Skip to content

Model the bare field-map error bodies, then cloud_files and google_documents (#550, #551) - #629

Merged
jeremy merged 3 commits into
mainfrom
feat/bare-field-map-errors
Aug 4, 2026
Merged

jeremy merged 3 commits into
mainfrom
feat/bare-field-map-errors

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Three commits, dependent in that order. The first establishes an error shape; the second is the first resource family that needs it on arrival; the third closes three latent instances of a gap review found in the second.

Reports #550 and #551. Not closing them here — the orchestrator decides.

Sequencing — resolved

This was held as a draft behind the spec train, because one PR at a time may touch spec/basecamp.smithy + openapi.json + the six generated trees. The order that actually ran, ahead of this one:

#628 → #626 → the ScheduleEntries triad (#632) → #636 → #637 → #629

(The banner this description used to carry named a different order — #630 and #626 in the wrong places, the triad under its issue numbers rather than its PR. It has been replaced by the record above rather than corrected in place, since its job is done.) Rebased onto origin/main at 0fd25079c, regenerated from scratch, and every count below re-derived from behavior-model.json rather than carried forward.

Commit 1 — bare {field: [messages]} error bodies (#550)

Four bc3 controller families render Rails validation errors bare — render json: record.errors, no wrapper at all — and the spec described those bodies as the flat {error} shape, which they have never been.

Re-verified at the current pinned provenance revision, bc3 4dd2926f. (The claims were first made at 2c0dafba; the pin advanced while this was held, and none of the eleven bc3 files these two commits rest on — the six controllers, config/routes.rb, and the four cloud_files/google_documents jbuilders — changed in that range. The six commits between the two revisions touch schedule entries, the authentication cache, and user profiles.) bc3's own API tests pin the wire shape exactly:

Controller Status bc3 test assertion
webhooks_controller.rb:31,48 400 assert_equal({ "payload_url" => [ "must resolve to an active public IP", "must be HTTPS" ] }, response.parsed_body)
concerns/category_actions.rb:33,63 400 assert_equal({ "name" => [ "can't be blank" ] }, response.parsed_body)
lineup/markers_controller.rb:32,49 422 assert_equal({ "date" => [ "There's already a marker on that date" ] }, response.parsed_body)
chats/integrations_controller.rb:32,49 400 not modeled in the SDK — nothing to fix

Two new error shapes carry it. Each has a single member that is itself a map, so BareObjectResponseMapper's unwrap resolves the response content to FieldErrorMap directly — the bare wire shape. That is the deliberate contrast with FieldValidationError, whose single member is a structure (FieldKeyedErrors) and therefore keeps the errors wrapper. Confirmed in the built OpenAPI:

BareFieldBadRequestErrorResponseContent -> $ref FieldErrorMap
BareFieldValidationErrorResponseContent -> $ref FieldErrorMap
FieldValidationErrorResponseContent     -> $ref FieldKeyedErrors

Six operations re-declared. The message-type pair also had the wrong status: CategoryActions renders :bad_request, never :unprocessable_entity, so ValidationError (422) advertised a response bc3 does not send. (The turbo_stream branch three lines down does render 422 — that is the near-miss, and it is not the JSON path.)

Operation Was Now
CreateMessageType ValidationError (422) BareFieldBadRequestError (400)
UpdateMessageType ValidationError (422) BareFieldBadRequestError (400)
CreateWebhook BadRequestError BareFieldBadRequestError
UpdateWebhook BadRequestError BareFieldBadRequestError
CreateLineupMarker ValidationError BareFieldValidationError
UpdateLineupMarker ValidationError BareFieldValidationError

Contract truthfulness only, no transport change: all six SDKs already parse the bare map (SPEC §6 step 2, whose prose already names these same four controller families), and conformance already pins that parse. What was missing was the modelled contract, so a consumer reading openapi.json — or Go's typed JSON400 field — saw a shape the server never sends. BadRequestError keeps three other callers, so no shape is orphaned.

Commit 2 — cloud_files and google_documents (#551)

Six operations across the two vault-child recordable subtypes. They are field-keyed 422 emitters, so they declare FieldValidationError from the start rather than needing a retrofit — which is why #550 came first.

Route correction

The create path is nested under the vault, and bucket-scoped. Both the tracking issue's route list and this repo's own allowlist spelled it POST /vaults/:id/cloud_files — that is the coverage-ledger spelling (the parity gate's direction 2 collapses bucket scoping), not a URL bc3 serves.

config/routes.rb draws cloud_files/google_documents under resources :vaults only inside the resources :buckets scope. The flat top-level vault block nests documents, uploads, and vaults and nothing else — spec/bc3-routes.json carries exactly eight routes under a flat /vaults/:id, and none of them is a cloud file — so a flat POST /vaults/:id/cloud_files.json is a 404. bc3's own API tests drive bucket_vault_cloud_files_url / bucket_vault_google_documents_url, and the docs' captured fixtures are keyed on the bucket-scoped path.

The get/update pair is the reverse: flat and unscoped (resources :cloud_files, only: %i[ show update ] at the top level), which the docs call canonical, with the bucket-scoped spelling accepted as a documented legacy alias.

GetCloudFile          GET  /{accountId}/cloud_files/{cloudFileId}
UpdateCloudFile       PUT  /{accountId}/cloud_files/{cloudFileId}
CreateCloudFile       POST /{accountId}/buckets/{bucketId}/vaults/{vaultId}/cloud_files.json
GetGoogleDocument     GET  /{accountId}/google_documents/{googleDocumentId}
UpdateGoogleDocument  PUT  /{accountId}/google_documents/{googleDocumentId}
CreateGoogleDocument  POST /{accountId}/buckets/{bucketId}/vaults/{vaultId}/google_documents.json

Direction 1 of the parity gate is EXACT — a wrong spelling is a 404 — so it proves all six declared URIs resolve. The six bc3_routes_not_modeled dispositions are removed and the ledger drops 15 → 9. (The old description said 16 → 10; that was measured against this branch's original base. #626 has since retired five of those dispositions and re-entered five of them as modeled_as alias spellings, so the number against current main is different.) scripts/check-bc3-route-parity with the six gone:

==> bc3 route parity OK: 247 SDK routes vs 369 bc3 routes (137 live-marker-backed) at bc3 4dd2926f8a.
    Direction 1 is EXACT (a wrong spelling is a 404): 3 evidenced scope aliases, 2 documented-elsewhere waivers.
    Direction 2 collapses bucket scoping (coverage ledger): 9 dispositions (1 registry, 3 out_of_scope, 5 modeled_as).
REAL_EXIT=0

Wire quirk worth naming

url on both shapes is the external link, not the record's API URL. The jbuilder renders the shared recording partial and then json.(recording.recordable, :url, :service) — :url, :document_type for google_documents — which overwrites the recording's url key with the recordable's. app_url is still the Basecamp URL. Modelled and documented on both structures, and pinned by a Go test — decoding it as an API URL would be a silent data error for anyone following the link.

Update is a replace, not a patch

@basecampWriteSemantics(mode: "replace", clearsOmitted: true). bc3 builds a brand-new recordable from the permitted params and swaps it wholesale, so an omitted title or description is cleared. url + service (and url + document_type) carry validations, so they are required on the input rather than clearable — an omission is a 422, not a silent wipe.

Client wiring and cross-SDK tests

Kotlin and Swift wire new services themselves — their accessors are generated (ServiceAccessors.kt, AccountClient+Services.swift). Go, TypeScript, Ruby and Python are hand-wired, so all four got explicit accessors: client.cloudFiles / client.googleDocuments (Go, TS), client.cloud_files / client.google_documents (Ruby, and both the sync and async Python account clients), plus root re-exports from typescript/src/index.ts.

typescript/src/client.ts also carries a hand-maintained idMapping that turns a concrete request path back into its templated form for the PATH_TO_OPERATION lookup. Without cloud_files: "{cloudFileId}" and google_documents: "{googleDocumentId}" there, a request to /cloud_files/123 would not resolve to GetCloudFile, so per-operation retry metadata would have silently fallen through to the default while every test still passed. Both entries added.

Tests for all six operations in every mandated suite (AGENTS.md Completeness Bar, items 5-7), one file per service, each with a happy path and an error case:

Files
TypeScript tests/services/cloud-files.test.ts, google-documents.test.ts
Ruby test/basecamp/services/cloud_files_service_test.rb, google_documents_service_test.rb
Python tests/services/test_cloud_files_service.py, test_google_documents_service.py (sync + async classes)
Go pkg/basecamp/cloud_files_test.go

The assertions are deliberately specific rather than generic: the create stubs are anchored on the exact bucket-scoped, vault-nested URL (a regression to the flat spelling fails the way it would 404 in production); url is asserted to be the external link with app_url checked separately; and the 422 cases assert on field_errors/fieldErrors, not just the exception class — a class-only assertion would pass against the flat {error} shape too.

Also here

  • CloudFileService structure — the embedded service struct (CloudFile::Service#as_json). supporting_text is the one optional member; Services::Other declares none.

  • Files tag + a CloudFiles/GoogleDocuments service split in all five service generators. Without the tag the operations fell through to Untagged and generated a Miscellaneous service in every SDK — caught and fixed before commit.

  • Go ergonomic wrappers (go-check-drift demands one per generated op and keeps its exclusion list empty): 247/247 wrapped.

  • Shared fixtures + manifest coverage. Both structures emit description_attachments, and the fixture-completeness guard requires every rich-text emitter to be covered or excluded; these are covered, not excluded.

  • check-idempotency-parity counts bumped deliberately (the gate asks for this explicitly): +2 idempotent (79→81, the two @idempotent PUTs), +4 union (202→206, plus the two readonly GETs). The two creates are neither — verified against behavior-model.json, not assumed.

  • Client wiring and service tests (added in review, after chatgpt-codex-connector caught both). Kotlin, Swift and Go were already reachable because their accessors are generated or gate-enforced; TypeScript, Ruby and Python register services through hand-written top-level clients, so the generated classes were uncallable. Wired into typescript/src/client.ts (+ index.ts re-exports and the two normalizeUrlPath segments), ruby/lib/basecamp/client.rb, and python/src/basecamp/client.py / async_client.py. Six new test files — one per service per SDK — cover all six operations with a happy path and an error case, and pin the create-path scoping, the camelCase→snake_case body serialization, and url-is-the-external-link. Red proof: with the tests in place and client.ts reverted, 15/15 TypeScript cases fail and the runner exits 1.

    Worth naming since commit 1 is about exactly this distinction: these six operations render the wrapped form, not the bare one. cloud_files_controller.rb:22,37 and google_documents_controller.rb:25,40 are both render json: { errors: e.record.errors } at 422, as is the reject_invalid_document_types before_action at google_documents_controller.rb:49 — verified at the pinned revision. So they declare FieldValidationError, the same form stacks_controller.rb uses, and not the BareFieldValidationError commit 1 adds for Lineup::MarkersController.

  • spec/api-gaps/recordable-subtypes-doc.md truthed up. It keeps partial-coverage, but the split moved: the CloudFile/GoogleDocument half is absorbed and only Journal remains, which has no JSON contract to model. Its route list carried the same wrong create path, now corrected.

241 → 247 operations. SPEC.md's Operation Counts section, its two "all N operations use retry_on: [429, 503]" claims, and §7's per-operation-ceiling distributions are updated to match — verified against behavior-model.json, where all 247 carry retry_on: [429, 503]. SECURITY.md's and AGENTS.md's restatements move with them. No gate enforces those numbers, so they had gone stale silently; on a PR about contract truthfulness that seemed worth fixing.

Commit 3 — export the three entity types consumers could not name

Review (chatgpt-codex-connector, P2) pointed out that TypeScript consumers could call the two new services but could not name either returned entity. The immediate fix is in commit 2 — TYPE_ALIASES in typescript/scripts/generate-services.ts had no entry for either schema, so the generated methods returned components["schemas"]["…ResponseContent"] rather than a named type. Adding the entries makes the services emit export type CloudFile = components["schemas"]["CloudFile"] and collapses the signatures.

Chasing that turned up three pre-existing instances of the same gap. Notification, TimelineEvent and TimesheetEntry are real entities — MyNotificationsService, TimelineService/ReportsService and TimesheetsService return them — each had a TYPE_ALIASES entry, and none was re-exported from the root. typescript/package.json publishes an exhaustive exports map (. and ./oauth, nothing else), so a type index.ts omits is unreachable, not merely awkward to import.

Nothing caught it, which is why it lasted: check-typescript-service-drift.sh regenerates the generated tree and diffs it, and that tree is self-consistent either way; tsc passes because the type resolves fine inside the package. The failure appears only where an external caller stands, and no check in this repo stands there.

Purely additive — three type exports, no behavior change. TimelineEvent is re-exported once because both timeline.ts and reports.ts declare it and a second re-export would be a duplicate identifier.

The gate that would have caught all five is deliberately not in this PR

It lives on feat/ts-entity-export-gate and will arrive as its own PR.

That is a deliberate split, not an omission. The first version was a static scan of index.ts, and review took it apart twice: it read // export { type Notification } from "…"; as a live re-export (reproduced — gate exited 0 on an unreachable type, and tsc --noEmit exited 0 too), and after that was fixed with a string-aware comment strip, the same statement inside a string literal passed just as well. A text scan of source can always be spelled around; each round only buys the next spelling.

The version worth shipping stops scanning and asks the compiler. Generate a probe from TYPE_ALIASES that imports every alias from "@37signals/basecamp" through the published exports map, exactly as a consumer would, and typecheck it. Prototyped and proven against the case that beat the text scan:

--- text scan verdict ---
==> TypeScript entity exports OK: 54 generated schema aliases, all re-exported from src/index.ts.
TEXT_SCAN_EXIT=0
--- probe verdict ---
probe.ts(1,42): error TS2305: Module '"@37signals/basecamp"' has no exported member 'Notification'.
PROBE_EXIT=2

It also catches what a text scan structurally cannot: an alias present in index.ts but excluded by the exports map. Roughly five seconds to run.

Tying that to this PR served neither. This PR is about bc3's error shapes and two recordable subtypes, reviewed clean at three heads; the gate is unrelated late work that needs its own review rounds. Splitting it also takes this PR out of Makefile / .github/workflows/test.yml entirely — it now touches neither, so the gates train behind it has nothing to conflict with.

Verification

make check green at fae3ed2fd, REAL exit code written to a log and grepped back:

REAL_EXIT=0
==> All checks passed

SHA pinned around the run: PRE_SHA == POST_SHA == fae3ed2fd02df3df53c49f026e3f8606c2a49256, working tree clean on both sides.

Per-language execution confirmed (not SKIP lines, not cache hits):

Evidence
Go go test ./... -count=1 at this exact head → ok .../go/pkg/basecamp 9.007s plus oauth, otel, prometheus, types. Re-run with -count=1 because the in-check run served some packages from cache.
Ruby make rb-test → 1372 runs, 30452 assertions, 0 failures, 0 errors, 0 skips
Kotlin :basecamp-sdk:jvmTest --rerun-tasks at this exact head → 478 tests, 0 failures across 36 result classes. Forced because the in-check task reported UP-TO-DATE; nothing in this PR's final commit is Kotlin-visible, and a cache hit is not evidence.
Swift macOS-gated and it ran: Test Suite 'All tests' passed
TypeScript both suites: 82 files / 1408 tests, and 7 files / 221 tests (1 file, 2 tests skipped)
Python 1184 passed, 4 skipped
Conformance six runners, all of them: Go 177/0/2 · Kotlin 178/0/1 · TypeScript 7 files (1 skipped) · Ruby 168/0/11 · Python 179/0/0 · Swift 178/0/1

No new conformance fixtures, so no runner dispatch was needed in any of the six runners — nothing silently skips. The bare-field-map parse is already pinned by the existing error-mapping.json cases.

Review ledger

All review threads resolved. One suppressed-comment block was in a review body rather than a thread — copilot-pull-request-reviewer, SPEC.md:3269, flagging that §7's per-operation-ceiling distributions had been left at the pre-PR numbers while the Operation Counts section moved. It was correct and is fixed (§7's three figures, plus SECURITY.md's and AGENTS.md's restatements, all move with the total). Recording it here because a suppressed comment does not show up in an unresolved-thread count.

Bot review at head. chatgpt-codex-connector reviewed the post-rebase heads and opened two P2 threads, both on the static gate that has since been split out to feat/ts-entity-export-gate — commented-out exports read as live, then the same statement inside a string literal. Both were real, both reproduced before being addressed, and neither touches the spec commits, which Codex has now reviewed clean at every head. copilot-pull-request-reviewer errored on this head ("encountered an error and was unable to review this pull request") rather than reviewing it, and the review-request API rejects that bot as a non-collaborator, so it cannot be re-requested. Copilot's three earlier reviews were at efef78c72 and c8224e7fe; every finding from them is addressed above. Proceeding on Codex-at-head plus green CI, which is this repo's established disposition for a Copilot flake.

Gate count: unchanged at 37. With the static gate split out, this PR touches neither Makefile nor .github/workflows/test.yml, so it adds no gate and cannot conflict with anything queued on either file.

Copilot AI balanced review requested due to automatic review settings August 3, 2026 22:03
@jeremy jeremy added the enhancement New feature or request label Aug 3, 2026
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK go kotlin swift spec Changes to the Smithy spec or OpenAPI python Pull requests that update the Python SDK labels Aug 3, 2026
@jeremy
jeremy force-pushed the feat/bare-field-map-errors branch from 87a43d9 to 339d876 Compare August 3, 2026 22:05

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87a43d929c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread typescript/src/generated/services/index.ts
Comment thread spec/basecamp.smithy

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR advances the Smithy-first coverage work in two dependent commits. Commit 1 models the bare {field: [messages]} error bodies (#550) that four bc3 controller families render without an errors wrapper, introducing BareFieldBadRequestError/BareFieldValidationError shapes and re-declaring six operations to advertise the shape (and status) the server actually sends. Commit 2 models the cloud_files and google_documents vault-child recordables (#551) — six new operations (get/create/update each) — which need the bare error shape on arrival, taking the SDK from 240 → 246 operations. The changes flow from spec/basecamp.smithy through openapi.json into all six SDKs' generated service layers plus hand-written Go wrappers, fixtures, and parity-gate counts.

Changes:

  • Add two bare field-map error shapes and correct six operations' declared error contracts (status + shape) to match bc3.
  • Add CloudFiles and GoogleDocuments services (get/create/update) across all six SDKs, with a new Files tag and generator service split.
  • Correct create routes to the bucket-scoped vault path, update fixtures/manifests, and bump idempotency-parity counts (78→80 idempotent, 201→205 union).

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 21 out of 54 changed files in this pull request and generated no comments.

Show a summary per file
File Description
typescript/src/generated/services/index.ts Exports the two new services — but they are not wired into client.ts, so they are unreachable.
typescript/src/generated/services/cloud-files.ts New generated TS CloudFiles service (get/create/update).
typescript/src/generated/services/google-documents.ts New generated TS GoogleDocuments service (get/create/update).
python/src/basecamp/generated/services/__init__.py Exports the new Python services — not wired into client.py/async_client.py.
ruby/lib/basecamp/generated/services/cloud_files_service.rb New generated Ruby service — not wired into client.rb.

(Note: the PR also touches many generated files across all six SDKs plus spec/basecamp.smithy, openapi.json, fixtures, and parity scripts; the table highlights the files where review findings are anchored.)

Review findings: The new services are generated and exported in TypeScript, Ruby, and Python, but they are not wired into the hand-written top-level clients for those three SDKs (Go, Swift, and Kotlin were wired). Each of those clients registers services through explicit accessors with no dynamic fallback, so client.cloudFiles / client.cloud_files / etc. are unreachable — the six new operations cannot be invoked in half the SDKs. The same three SDKs also lack the per-service unit tests that every sibling Files service (vaults, documents, uploads) has; only Go includes tests. These gaps are flagged in the stored comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI review requested due to automatic review settings August 3, 2026 22:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 54 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 3, 2026 22:31
@jeremy
jeremy force-pushed the feat/bare-field-map-errors branch from 339d876 to efef78c Compare August 3, 2026 22:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: efef78c727

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread typescript/src/index.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 65 changed files in this pull request and generated no new comments.

Suppressed comments (1)

SPEC.md:3269

  • This PR updates the Operation Counts here (240→246, idempotent 78→80) and the two "all N operations use retry_on" claims, but the §7 "Per-operation retry ceiling" prose was left at the pre-PR 240-operation distributions and is now internally inconsistent with this section:
  • Line 111: "198 ops at 3, 42 at 2" — the 6 new ops add 4 at max:3 (Get/Update CloudFile + Get/Update GoogleDocument) and 2 at max:2 (the two creates), so this should read 202 at 3, 44 at 2 (246 total).
  • Line 113: "The other 190 retry-eligible ops are unaffected" — the 2 gets and 2 updates join the retry-eligible union, so this should be 194.
  • Line 114: "all 201 retry-eligible operations … (190 to 3, 11 to 2)" — should be 205 retry-eligible (194 to 3, 11 to 2), matching expected_union=205 in scripts/check-idempotency-parity.

Since these numbers aren't CI-gated, they'll silently drift otherwise. (For completeness, SECURITY.md:187 "all 240 operations … 123 GETs … 78 mutations" and AGENTS.md:29/:93 "240 operations" carry the same stale counts, though they're outside this PR's diff.)

- Total operations: 246
- Idempotent: 80 (flagged with `idempotent: true`)
- Non-idempotent: 166 (no `idempotent` field, or not present)

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Picking up the finding Copilot filed inside <details>Suppressed comments (1)</details> rather than as a thread, because it was the sharpest review comment on this PR and it would otherwise go unread.

It was right, and it caught something worse than a stale number: by updating SPEC.md's Operation Counts section and leaving §7 alone, I had made SPEC.md internally inconsistent with itself. §7's retry-ceiling distributions still described a 240-operation model while §3267 described a 246-operation one.

I re-derived every figure from behavior-model.json and openapi.json rather than taking the comment's arithmetic on faith. All of it checks out:

Claim Was Re-derived Now
SPEC §7 per-op retry.max spread 198 at 3, 42 at 2 {3: 202, 2: 44} 202 / 44
SPEC §7 "other N retry-eligible ops unaffected" 190 194 194
SPEC §7 retry-eligible total 201 (190 to 3, 11 to 2) 205 (194 to 3, 11 to 2) 205 (194 / 11)

The retry-eligible total now agrees with expected_union=205 in scripts/check-idempotency-parity, which this PR also bumped — those two numbers are the same quantity and had no business disagreeing.

I also fixed the two files the comment flagged as outside the diff, because this PR is what invalidates them — they were correct at 240 until these six operations landed:

  • SECURITY.md:187 — 240 → 246 operations, 123 → 125 GETs, 78 → 80 mutations, 48 → 50 PUTs, 39 → 41 non-idempotent POSTs. The seven idempotent POSTs it names are unchanged; I verified the set is byte-identical, not just the count.
  • AGENTS.md:29 and :93 — 240 → 246.

Every number above was re-derived a second time after rebasing onto 620c80be (#628, the flat-Todolist replacement), since that PR reshapes Todolist and I did not want to quote figures from before it. The counts are unchanged by it: #628 replaces shapes, not operations.

On the comment's closing point — "since these numbers aren't CI-gated, they'll silently drift otherwise" — that is exactly what happened here, and a doc-count gate is worth having. It is out of scope for this PR, but I'd rather it be tracked than mentioned once in a collapsed block, so I'm flagging it for whoever picks up the next docs pass.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/test.yml

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

Copilot AI review requested due to automatic review settings August 4, 2026 05:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@codex review

Rebased onto origin/main at 0fd25079c and fully regenerated, plus a third commit (142773dc1) that adds scripts/check-typescript-entity-exports.py — the gate for the P2 you raised on typescript/src/index.ts. It also re-exports three pre-existing unreachable aliases (Notification, TimelineEvent, TimesheetEntry) that the gate turned up.

Worth a look at the rebase seam specifically: the operation total moved 241 → 247 and every restated count in SPEC.md, SECURITY.md, AGENTS.md and scripts/check-idempotency-parity was re-derived from behavior-model.json rather than incremented.

(copilot-pull-request-reviewer errored on this head rather than reviewing it, and the API will not let me re-request it.)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 142773dc19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-typescript-entity-exports.py Outdated
Copilot AI review requested due to automatic review settings August 4, 2026 06:13
@jeremy
jeremy force-pushed the feat/bare-field-map-errors branch from 142773d to 75d971d Compare August 4, 2026 06:13
@jeremy

jeremy commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@codex review

Your P2 on scripts/check-typescript-entity-exports.py was right and is fixed in 75d971da2.

Reproduced before fixing, in the exact single-line shape you wrote: the gate printed 54 generated schema aliases, all re-exported and exited 0 while Notification was genuinely unreachable, and tsc --noEmit exited 0 as well. Comments are now stripped (string-aware, so a // inside a module specifier survives) on both the index.ts re-export scan and the generated-service alias scan, and a --self-test with nine cases is wired into make ts-check-entity-exports and the CI job. Revert the strip and the self-test reports 3/9 FAILED and exits 1.

Please re-review at 75d971da2 — the gate itself is the part I most want another pass on.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75d971da2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-typescript-entity-exports.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 70 changed files in this pull request and generated no new comments.

`typescript/package.json` publishes an exhaustive `exports` map — `.` and
`./oauth`, nothing else. There is no subpath for the generated modules even
though `src/generated` ships in `files`, so a type the root `index.ts` does
not re-export is one a consumer can *receive* but cannot *name*: the signature
reads `Promise<TimesheetEntry>` and `TimesheetEntry` is unwritable outside the
package.

`Notification`, `TimelineEvent` and `TimesheetEntry` were all in that state.
Each is a real entity — `MyNotificationsService`, `TimelineService` /
`ReportsService`, and `TimesheetsService` return them — and each had a
`TYPE_ALIASES` entry, so the generated services named the type in their
signatures while the entry point kept it private.

Nothing caught it, which is why it lasted. `check-typescript-service-drift.sh`
regenerates the generated tree and diffs it, and that tree is self-consistent
either way. `tsc` is happy because the type resolves fine *inside* the
package. The failure appears only when an external caller tries to write the
type down, and no check in this repo stands where that caller stands.

`TimelineEvent` is declared by both `timeline.ts` and `reports.ts`, so it is
re-exported once — a second re-export would be a duplicate identifier.

These three surfaced while reviewing the same gap on `CloudFile` and
`GoogleDocument` (this PR's own operations, fixed a commit earlier). Purely
additive: three type exports, no behavior change.

A check that would have caught all five is deliberately NOT here. It is on
`feat/ts-entity-export-gate`, where it can take the rounds it needs: a static
scan of `index.ts` can be spelled around — review found it reading commented
code as live, then string literals as live — so the version worth shipping
asks the compiler instead, importing each alias through the published
`exports` map exactly as a consumer would. That is a different question from
this PR's, and it should not gate it.
Copilot AI review requested due to automatic review settings August 4, 2026 06:35
@jeremy
jeremy force-pushed the feat/bare-field-map-errors branch from 75d971d to fae3ed2 Compare August 4, 2026 06:35
@github-actions github-actions Bot removed the github-actions Pull requests that update GitHub Actions label Aug 4, 2026
@jeremy

jeremy commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@codex review

The static gate you took apart twice is gone from this PR — lifted onto feat/ts-entity-export-gate, where it will be rebuilt as a tsc probe that imports each alias through the published exports map instead of scanning source. Your string-literal case is what settled that; details in the thread.

Head is now fae3ed2fd, three commits: the two spec commits unchanged, plus a purely additive commit re-exporting Notification, TimelineEvent and TimesheetEntry. This PR no longer touches Makefile, .github/workflows/test.yml, or scripts/.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: fae3ed2fd0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 67 changed files in this pull request and generated no new comments.

@jeremy
jeremy merged commit a373b00 into main Aug 4, 2026
47 checks passed
@jeremy
jeremy deleted the feat/bare-field-map-errors branch August 4, 2026 06:50
jeremy added a commit that referenced this pull request Aug 4, 2026
…cit-clear

* origin/main:
  Stop `make check` rewriting typescript/package-lock.json (#631)
  Model the bare field-map error bodies, then cloud_files and google_documents (#550, #551) (#629)
jeremy added a commit that referenced this pull request Aug 4, 2026
…uide

* origin/main:
  Stop `make check` rewriting typescript/package-lock.json (#631)
  Model the bare field-map error bodies, then cloud_files and google_documents (#550, #551) (#629)
jeremy added a commit that referenced this pull request Aug 4, 2026
…and cards

Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives
every count against main, and writes the cards due-date change.

The P2 was right, and it was a contradiction with this guide's own definition
rather than loose wording: "silent" was defined as "does not raise" and then
used to file nil-pointer panics. The section is now "Breaks your compiler will
not catch" — the property all of it actually shares — split into class A, no
signal at all, and class B, compiles then panics or raises but only when a
particular field is absent, so it passes every test where that field is
populated. Applying the definition consistently moved four entries, not the
three flagged: the three Go pointerization panics plus Ruby's
Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and
had the same defect. Two moved entries carry real no-signal residue, kept as
sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript
5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body
counts verified against the table by parsing the section, not by eye.

The Swift section claimed three new optional Todolist members and named one;
the other two are required. Now singular, matching TypeScript.

Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since
#629 merged. Added, removed and route-moved lists are computed from openapi.json
at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves
— and the Folders operations are flagged as drawn at /stacks, not /folders.

Cards get their own section. The half that matters most is true in production
today and is not caused by upgrading: every released SDK encodes "clear a card
due date" as omission, bc3 stopped treating omission as a clear, so that call is
a silent no-op right now. That is a reason to upgrade rather than a hazard of
it, so it sits in the operator checklist. The SDK-side change is read from
bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding,
UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes
away. The hook collapse is written as the inverse of the {Todolists,Update}
split because it fails the opposite way — allowlists do not start denying, but a
denylist on {Cards,Get} silently stops blocking the write it used to take down.
Removing the preservation GET also removes three named errorRaised kill cases
from cards_write.json; the class stays pinned on Todos, which still does a real
read-modify-write, so that is said rather than filed as a redundant-GET cleanup.
jeremy added a commit that referenced this pull request Aug 4, 2026
v0.13.0 breaks all six SDKs and 35 of those breaks are silent — no compile
error, no exception, no decoder failure. Label-generated release notes list
what merged; they cannot say what a consumer must react to or what wrong
behaviour they get if they ignore it. That had no home in this repo.

Adds MIGRATING.md at the root, linked from the root README and all six
per-SDK READMEs. Silent breaks lead the document, then one section per SDK
ordered by severity, plus an operator checklist, a "coverage: corrected and
re-scoped" section for what did not ship, and known gaps.

No CHANGELOG is reintroduced. The hand-maintained ones were deleted in #115
as superseded by auto-generated notes, and every release body since is
machine-built. CONTRIBUTING records the resulting rule: label-generated notes
say what merged, MIGRATING says what to do about it.

Corrections to the source drafts, each re-derived rather than repeated:

- TrashTodo was not a 404. bc3 draws `resources :todos, only: %i[show edit
  update destroy]`; DELETE /todos/:id returned 204 and set status to
  "archived", so every caller was archiving. It is the one #619 removal that
  takes away a working call, and it now carries its own carve-out.
- #619 removed three operations, not nine. Nine were re-pathed. Fusing the
  two sets is what made the blanket 404 reassurance look safe.
- Hook operation identity differs by SDK: Go and Ruby emit a short verb,
  the other four emit the wire operation ID, where the todolist pair kept
  its names — so an allowlist holding UpdateTodolistOrGroup passes the write
  and denies the new read.
- 238 -> 241 measured at the v0.12.0 tag and at c95d81c, not assumed.
- Kotlin binary compatibility is already disclaimed in kotlin/README.md;
  Swift has no written policy. Both are now stated rather than left unsaid.

recordings.get is documented as a known gap with a list-and-filter recipe
and its honest cost. The Go recipe compiles against this tree.

#637, #629 and #635/#641 were open at the time of writing and are recorded
under "Not in this release" rather than described as shipped.
jeremy added a commit that referenced this pull request Aug 4, 2026
…and cards

Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives
every count against main, and writes the cards due-date change.

The P2 was right, and it was a contradiction with this guide's own definition
rather than loose wording: "silent" was defined as "does not raise" and then
used to file nil-pointer panics. The section is now "Breaks your compiler will
not catch" — the property all of it actually shares — split into class A, no
signal at all, and class B, compiles then panics or raises but only when a
particular field is absent, so it passes every test where that field is
populated. Applying the definition consistently moved four entries, not the
three flagged: the three Go pointerization panics plus Ruby's
Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and
had the same defect. Two moved entries carry real no-signal residue, kept as
sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript
5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body
counts verified against the table by parsing the section, not by eye.

The Swift section claimed three new optional Todolist members and named one;
the other two are required. Now singular, matching TypeScript.

Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since
#629 merged. Added, removed and route-moved lists are computed from openapi.json
at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves
— and the Folders operations are flagged as drawn at /stacks, not /folders.

Cards get their own section. The half that matters most is true in production
today and is not caused by upgrading: every released SDK encodes "clear a card
due date" as omission, bc3 stopped treating omission as a clear, so that call is
a silent no-op right now. That is a reason to upgrade rather than a hazard of
it, so it sits in the operator checklist. The SDK-side change is read from
bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding,
UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes
away. The hook collapse is written as the inverse of the {Todolists,Update}
split because it fails the opposite way — allowlists do not start denying, but a
denylist on {Cards,Get} silently stops blocking the write it used to take down.
Removing the preservation GET also removes three named errorRaised kill cases
from cards_write.json; the class stays pinned on Todos, which still does a real
read-modify-write, so that is said rather than filed as a redundant-GET cleanup.
jeremy added a commit that referenced this pull request Aug 4, 2026
* MIGRATING.md: the v0.13.0 upgrade guide, silent breaks first

v0.13.0 breaks all six SDKs and 35 of those breaks are silent — no compile
error, no exception, no decoder failure. Label-generated release notes list
what merged; they cannot say what a consumer must react to or what wrong
behaviour they get if they ignore it. That had no home in this repo.

Adds MIGRATING.md at the root, linked from the root README and all six
per-SDK READMEs. Silent breaks lead the document, then one section per SDK
ordered by severity, plus an operator checklist, a "coverage: corrected and
re-scoped" section for what did not ship, and known gaps.

No CHANGELOG is reintroduced. The hand-maintained ones were deleted in #115
as superseded by auto-generated notes, and every release body since is
machine-built. CONTRIBUTING records the resulting rule: label-generated notes
say what merged, MIGRATING says what to do about it.

Corrections to the source drafts, each re-derived rather than repeated:

- TrashTodo was not a 404. bc3 draws `resources :todos, only: %i[show edit
  update destroy]`; DELETE /todos/:id returned 204 and set status to
  "archived", so every caller was archiving. It is the one #619 removal that
  takes away a working call, and it now carries its own carve-out.
- #619 removed three operations, not nine. Nine were re-pathed. Fusing the
  two sets is what made the blanket 404 reassurance look safe.
- Hook operation identity differs by SDK: Go and Ruby emit a short verb,
  the other four emit the wire operation ID, where the todolist pair kept
  its names — so an allowlist holding UpdateTodolistOrGroup passes the write
  and denies the new read.
- 238 -> 241 measured at the v0.12.0 tag and at c95d81c, not assumed.
- Kotlin binary compatibility is already disclaimed in kotlin/README.md;
  Swift has no written policy. Both are now stated rather than left unsaid.

recordings.get is documented as a known gap with a list-and-filter recipe
and its honest cost. The Go recipe compiles against this tree.

#637, #629 and #635/#641 were open at the time of writing and are recorded
under "Not in this release" rather than described as shipped.

* Fix the Go pagination advice, cut the raw-wire workaround, absorb #637/#643

Addresses both P1 review threads on #642 and folds in the two PRs that landed
since the first draft.

Pagination (P1). Cross-SDK item 1 claimed `page` was a starting offset in every
SDK and told readers to drop it to restore the old walk. For Go that was
actively harmful: `git show v0.12.0:go/pkg/basecamp/bookmarks.go` returns before
followPagination whenever page > 0, so a positive Page already meant one
request, and dropping it converts a bounded call into a full account-wide
traversal. The item is now scoped to the five SDKs where it holds — re-checked
at the tag rather than assumed, since the universal claim had already failed
once — with a Go subsection splitting the two real cases: services where the
page number was already honored (Bookmarks, Drafts, Everything*, request
unchanged) and the fourteen carrying the "not yet honored" doc, which sent no
page at all and returned page 1's rows. Gauges is in neither; it had no page.

Raw wire (P1). The Forwards().CreateReply example built a path with fmt.Sprintf
and called the raw AccountClient.Post against a route with no upstream
coverage, which is what AGENTS.md "Never Do These" 4 and 5 forbid. Removed
rather than softened, and replaced with a known-gap section stating what a
hand-built path gives up. Swept the document: the one other hit documents a real
change to the raw client's error codes, so it stays, but its fabricated path is
gone and it now says it is not a suggestion to reach for the escape hatch.

#643 landed, so basecamp.Ptr and basecamp.Deref replace the hand-rolled ptr
helper throughout, the Go section opens with the 300-pointer census and a
command that reproduces it, and ParticipantIDs *[]int64 gets its own note: nil
leaves participants alone, a pointer to an empty slice removes every one.

#637 landed and does NOT add a break to any SDK. color and comments_app_url did
not exist on Todolist at v0.12.0 in any of the six — both arrived with #628
earlier in this same release — so from the guide's baseline nothing turned from
optional to required. Counts stay 27/20/16/14/16/14. Documented where it bites:
color is required-and-nullable so explicit null decodes, comments_app_url
rejects null and absence alike.

Also: kotlin/README's append-only source-compat promise contradicted this
release repeatedly, so it now describes documented pre-1.0 breaking correctness
releases; the binary-compat disclaimer is kept and sharpened. release-github.yml
links MIGRATING.md from every release body, guarded on the file, so the link
cannot be forgotten at tag time. "Silent" is defined as source/runtime-silent
against a live server, since a suite pinning request paths does catch some.

Counts are stated as-of 51d0d86 with derivations inline, and each in-flight
change names the numbers it invalidates so the pre-tag pass is arithmetic.

* Split silent breaks into no-signal and fails-at-runtime; absorb #629 and cards

Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives
every count against main, and writes the cards due-date change.

The P2 was right, and it was a contradiction with this guide's own definition
rather than loose wording: "silent" was defined as "does not raise" and then
used to file nil-pointer panics. The section is now "Breaks your compiler will
not catch" — the property all of it actually shares — split into class A, no
signal at all, and class B, compiles then panics or raises but only when a
particular field is absent, so it passes every test where that field is
populated. Applying the definition consistently moved four entries, not the
three flagged: the three Go pointerization panics plus Ruby's
Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and
had the same defect. Two moved entries carry real no-signal residue, kept as
sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript
5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body
counts verified against the table by parsing the section, not by eye.

The Swift section claimed three new optional Todolist members and named one;
the other two are required. Now singular, matching TypeScript.

Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since
#629 merged. Added, removed and route-moved lists are computed from openapi.json
at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves
— and the Folders operations are flagged as drawn at /stacks, not /folders.

Cards get their own section. The half that matters most is true in production
today and is not caused by upgrading: every released SDK encodes "clear a card
due date" as omission, bc3 stopped treating omission as a clear, so that call is
a silent no-op right now. That is a reason to upgrade rather than a hazard of
it, so it sits in the operator checklist. The SDK-side change is read from
bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding,
UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes
away. The hook collapse is written as the inverse of the {Todolists,Update}
split because it fails the opposite way — allowlists do not start denying, but a
denylist on {Cards,Get} silently stops blocking the write it used to take down.
Removing the preservation GET also removes three named errorRaised kill cases
from cards_write.json; the class stays pinned on Todos, which still does a real
read-modify-write, so that is said rather than filed as a redundant-GET cleanup.

* Audit class A across all six SDKs; add Ruby's missing download retry

Fourth review round on #642. Four findings, all upheld.

The allowlist framing was wrong in the direction that matters. I wrote that
fewer hook events are safe for an allowlist. True only if the allowlist named
both operations: one that names UpdateCard and deliberately omits GetCard used
to reject cards.update at its read, and after the collapse permits it end to
end. Both policy shapes now carry the warning, labelled, plus the observation
that they are the same hole seen twice — in each, the thing stopping the write
was the read, expressed once as an omission and once as an entry.

The class-A counting was inconsistent across all six SDKs, not the two flagged.
Python and Kotlin excluded changes their own prose called "no signal
whatsoever"; auditing every SDK against the definition moved the totals to 47
class A and 4 class B. The counting policy is now stated in the document so it
can be checked against a rule rather than an impression: one entry per distinct
change per SDK, counted where it bites; class A if any ordinary call-site shape
stays silent even when another is compile-caught; second faces annotated as
residue and counted once; raises-only-on-malformed-response is class B.

Two things fell out that were not counting problems. Ruby's #563 was missing
from the guide entirely — no mention of download_url anywhere in the chapter —
verified against source rather than prose: v0.12.0 http.get_no_retry, which
sent Accept: application/json and did not retry, became get_download calling
request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now
has its own section. The same check confirmed Go's omission of #563 is correct,
because Go already retried at v0.12.0. Separately, the Go note claiming the
compiler catches only the pkg/generated half of Schedules().UpdateEntry was
false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp
call site that set a field fails to build.

The class-B definition described only half its own membership. It said the
trigger is an absent field, but Ruby's entry fires only when the field is
populated. It now says both, and says plainly that class B is a property of a
call plus a response rather than of the call — the same method against the
other shape is not a break at all. Class A has no such dependency.

Stale counts in the chapter intros are fixed. The Go intro still said eleven
silent and two panics, which is the first thing a #go link shows, and Swift
claimed the most no-signal breaks, which stopped being true at Go ten.

Also folds in #652 (projected-example gate, stacked on #648, takes check-targets
to 43), moves #648 out of draft at cb438ce, and records that #647 is being
reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn
is *types.Date and cannot express "". The consumer-facing card shape is
unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added,
5 removed and 11 same-ID route moves survives unchanged.

* Correct four claims in the v0.13.0 guide that do not match the source

The opening warning said the runtime failures need a payload where a field is
absent. That holds for the three Go entries; Ruby's single class-B entry has the
opposite trigger. Draft#scheduled_posting_at and MyNote#created_at/#updated_at
run through parse_datetime, which returns nil for nil and a Time otherwise, so
.start_with? and Time.parse raise only when the field is populated. A reader
following the old text builds the wrong fixture and concludes they are
unaffected. Both directions are now named, here and in the root README.

Class A was described as breaking on every response. Most of it does, but two
groups do not: the error-message and validation entries need an error status to
reach the code at all, and the field-map half needs a body of a particular
shape; downloadURL's hop-1 retry changes nothing until a network error or one of
429/502/503/504 occurs. Stated as preconditions rather than as a blanket claim.

The Go pointer example said only the field selector panics. types.Date.String
has a value receiver, so Go rewrites t.DueOn.String() to (*t.DueOn).String() and
the nil dereference panics before String is entered. The same holds for IsZero,
Before, After and Weekday on Date and for Format, Sub, Unix and Year on
time.Time. The summary bullet already said both panic; the example contradicted
it.

The Accept-header note credited only Python. Ruby dropped it on the same hop:
get_download passes accept: nil, and request_headers sets the header only when
accept is truthy. Both are named, with the observation that the other four never
sent it on that hop at v0.12.0 either.

No counts are touched.

* Re-derive every count against the final release commit

Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged
since the branch was last updated, not the seven that carried the breaking
label: #647 was on the "Not in this release" list and had landed.

Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51.
Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0,
Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17.
The body parses back to those numbers rather than agreeing with them by hand.
The root README's aggregate sentence is re-derived to match, and now states both
halves numerically instead of "most" and "a few". The operation inventory is
unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route
moves, computed from openapi.json at both ends. check-targets is 43, and the
derivation is inline where the gate count was previously only projected. The
release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce
both are embedded in the as-of block, with the note that a labelled PR is not
the same unit as an entry, which is why the per-SDK columns exceed 15.

#658 is class B, not class A. It does to five wrapper timestamps exactly what
#615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's
CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt
compile untouched through a value-receiver call and panic on nil. #615's own
check could not see them because it keyed on the omitempty tag and these five
did not carry one. The audit is ten fields, and the entry names the near-miss
siblings that did not move, ClientApproval's pair in particular.

#664 splits. The public CreateScheduleEntryRequest fields were already string
and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone,
a bare date now creates an all-day entry, and a malformed value reaches bc3
instead of failing locally. That is class A. The generated
CreateScheduleEntryRequestContent went time.Time to string, which is a compile
error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a
migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are
doc-comment only.

#647 is folded in as merged, with two corrections to what was written when it
was still a branch. It touches no schema, so the claim that it had to go
Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by
#560. And the v0.12.0 preservation GET was conditional, taken only when the
caller left due_on unaddressed, so the request-count table is scoped to that
path rather than presented as universal.

#648 adds no silent break anywhere. bc3's body is byte-identical before and
after, so nothing that was populated stops being so; the assignable's title was
never sent and is now spelled content. Every rename and retype is caught
statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python
and Ruby, so it is one compile-or-runtime entry per SDK.

Two corrections nobody asked for. The Go class list opened "Go carries every
class-B break in the release", which stopped being true when Ruby's decode
entry moved into class B; it now claims only the panic-shaped ones. And
todos_write.json carries three errorRaised cases, not two, because #660 added a
bare-scalar kill.

#660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient
means a present, populated, wrong-typed scalar throws SerializationException
where it used to coerce to a string, and no signature moved to announce it. It
throws in the response decode, so on a write the mutation has already landed,
and it is not a BasecampException outside todolists.

#656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET,
which means get_absolute and the Launchpad fetch rather than any operation
lacking a policy. Every other configuration is bit-identical.

Not in this release is now empty, and says so.

* State the schedule-entry clear value per field instead of universally

The Swift Behavioural bullet said an explicit "" clears any of the five
full-state fields. Only description does. "" on summary is accepted and
reads back "Untitled"; starts_at and ends_at are under
validates_presence_of in Schedule::Entry, so "" is rejected rather than
cleared; allDay is a boolean in every SDK, so "" does not typecheck at
all. The carve-out half grouped notify with the three clearable fields
even though it is a send directive with no state to clear.

* Re-derive the per-SDK README banners against the final class A/B table

The six SDK README banners still carried the counts from before the Go
reclassification and the recount that followed it, summing to 51 where
MIGRATING.md and the root README say 61. Each banner now matches its row
in the class A/B table: Go 12+4, Swift 10, TypeScript 9, Python 8, Ruby
10+1, Kotlin 6+1. Kotlin also gains the runtime clause it was missing,
since its one class B entry throws on a present field carrying a JSON
number or boolean where the model declares a string.

* Correct the merged-PR count and the two claims the reviewers caught

The release spans 55 merged pull requests, not 67. The 67 came from comparing
GitHub's Z-formatted mergedAt against a git timestamp formatted with a local
offset, using jq's string >, which is lexicographic rather than temporal; it
wrongly swept in twelve PRs merged in the hours before the v0.12.0 tag instant.
The derivation embedded in the guide taught that same broken comparison, so it
now uses %ct and fromdateiso8601 and says why. The breaking count of fifteen is
unchanged, since all fifteen merged after the tag, so the class A/B split, the
per-SDK tables and the six README banners are untouched.

The header no longer calls 2afc977 the commit the release is cut from. That
commit is the last of the release content and the baseline the counts were
measured against, but it predates this guide; the tag is cut from main after
this merges, on a tree that contains the file the release body links to.

The release-body teaser claimed the guide covers only breaks with no exception
and no decoder failure. The guide documents six breaks that do fail at runtime,
including Ruby and Kotlin raises and a Kotlin decoder failure, so the teaser now
names both the silent class and the runtime one.
jeremy added a commit that referenced this pull request Aug 6, 2026
Release prep for v0.13.0. Documentation only; no code.

Counts re-derived from origin/main by PR-merge-commit ancestry, not
incremented:

  58 -> 64 merged pull requests
  15 -> 16 labelled breaking (#678 earned it)
  238 -> 247 becomes 238 -> 249 (#679 added ArchiveProject/UnarchiveProject)
  14 added / 5 removed becomes 16 added / 5 removed; at capability level
  12 additions becomes 14. 11 route-moved is unchanged, and verified.
  Baseline `70d576bd8` -> `9a819e44d`, the last commit of release content.

The 61 = 55 + 6 split is unchanged, and reconciles across MIGRATING.md, the
root README and all six per-SDK banners (Go 12+4, TS 9, Ruby 10+1, Swift 10,
Kotlin 6+1, Python 8).

Two `247`s were deliberately NOT touched. Both are as-of facts about a
specific PR, true forever, and rewriting them would have made two correct
sentences false: #648 did leave the inventory at 247 on both sides, and #629
did take it from 241 to 247. Only #679 moved it to 249. Same distinction the
provenance-pin convention draws between a current-value claim and an as-of
one. The third `247`, "every one of the N operations in metadata.json
declares a retry block", IS a current-value claim and did move — verified
that all 249 still declare one rather than assuming it.

The derivation snippet embedded in the guide had the trap that produced a
wrong number here: `--limit 300`. `gh pr list` orders by CREATED, so a
long-open PR that merged late can fall off the end and go silently
uncounted. Raised to 1000 and documented as trap 3, alongside reading from
origin/main rather than HEAD.

SPEC §2 step 5 said Go returns an `error` on a bad `max_pages` and that
"Swift alone is not recoverable". Both false. Go panics
`"basecamp: max pages must be positive"`, and the generated client carries
no MaxPages at all, so there is no other Go path that could return one. Go
and Swift are both non-recoverable, each because the constructor taking the
cap cannot report a failure — Go's NewClient has no error return, Swift's
init is public and non-throwing. §3 step 5 already said Go panics on config
failure, so the paragraph contradicted the document around it. Every SDK's
behaviour was read out of source before this was rewritten; this was the
third factual error in that one paragraph.

Also records what #680 changed there: Python's bool exclusion, and the rule
that a guard feeding a `??` fallback must test `!= null` to match it. Codex
correctly pointed out on #680 that the spec never said whether an explicit
null counts as a supplied cap. Now it does.

MIGRATING.md gains a prose entry for the maxPages validation, stated as NOT
part of the 61: those are breaks a compiler will not catch, class A silent
and class B needing a particular server response, and this is neither — it
fails at construction, deterministically, before any request. The entry is
per-SDK because the six did not start level: Go and Ruby already rejected a
non-positive cap at v0.12.0 and move not at all, and Python's 0 and
negatives already raised, so only its type check is new.

The six PRs that landed after the guide's first draft are recorded rather
than left for a reviewer to reconcile against git log.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK spec Changes to the Smithy spec or OpenAPI swift typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants