Skip to content

Route corrections: nine flat routes bc3 only draws bucket-scoped, three operations removed - #619

Merged
jeremy merged 1 commit into
mainfrom
fix/route-corrections-v013
Aug 3, 2026
Merged

jeremy merged 1 commit into
mainfrom
fix/route-corrections-v013

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Eleven operations pointed at URLs bc3 does not serve; the twelfth called a real endpoint with the wrong semantics. All eleven were live 404s in shipped consumers. TrashTodo is the exception and is worth stating precisely: it reached DELETE /todos/:id, which bc3 really draws — but TodosController#destroy defaults destroy_status_param to "archived", so every caller has been archiving while calling a method named trash. Each fix is breaking, so they ride the v0.13.0 window together.

Nine routes are corrected, three operations are removed. Operation count 243 → 240.

Closes #583, closes #584, closes #585, closes #588.


What changed, and what a consumer must do

#588 — nine operations gain a required bucketId path label

bc3 draws none of these flat. Verified against config/routes.rb at the revision spec/api-provenance.json currently pins.

Operation Was (404) Now bc3 draw
ListChatbots GET /chats/{campfireId}/integrations.json GET /buckets/{bucketId}/chats/{campfireId}/integrations.json :956
GetChatbot GET /chats/{campfireId}/integrations/{chatbotId} GET /buckets/{bucketId}/chats/{campfireId}/integrations/{chatbotId} :956
CreateChatbot POST /chats/{campfireId}/integrations.json POST /buckets/{bucketId}/chats/{campfireId}/integrations.json :956
UpdateChatbot PUT /chats/{campfireId}/integrations/{chatbotId} PUT /buckets/{bucketId}/chats/{campfireId}/integrations/{chatbotId} :956
DeleteChatbot DELETE /chats/{campfireId}/integrations/{chatbotId} DELETE /buckets/{bucketId}/chats/{campfireId}/integrations/{chatbotId} :956
ListClientApprovals GET /client/approvals.json GET /buckets/{bucketId}/client/approvals.json :1278
ListClientCorrespondences GET /client/correspondences.json GET /buckets/{bucketId}/client/correspondences.json :1277
ListClientReplies GET /client/recordings/{recordingId}/replies.json GET /buckets/{bucketId}/client/recordings/{recordingId}/replies.json :1285
GetClientReply GET /client/recordings/{recordingId}/replies/{replyId} GET /buckets/{bucketId}/client/recordings/{recordingId}/replies/{replyId} :1285

The only integrations resource in bc3 is bucket-scoped (:956, inside :954 resources :chats inside :887 resources :buckets); the flat chats draw at :180 nests only lines and uploads.

The flat client namespace (:190-195) draws four show-only members. index exists only bucket-scoped, and semantically so: Clients::ApprovalsController#index resolves @bucket.project.client_board_recording, and a client board is per-project. bc3's own test/api/clients/correspondences_controller_api_test.rb:49 asserts ActiveRecord::RecordNotFound for a bucket with no client board. ListClientReplies/GetClientReply were worse than a wrong only: — the flat namespace has neither replies nor recordings, and Clients::RepliesController includes only BucketScoped, so @bucket is never assigned on a flat route.

GetClientApproval and GetClientCorrespondence are correct as flat and are deliberately untouched — bc3 draws them at :193-194, documents them flat, and tests them unscoped. Only the list operations were wrong.

Migration. Pass the project (bucket) id first, ahead of every other argument. It is the project that owns the campfire (chatbots) or the client board (client portal). Request and response bodies are unchanged; only the path and the signature move.

  // chatbots — campfires service
- await client.campfires.listChatbots(campfireId, options);
+ await client.campfires.listChatbots(bucketId, campfireId, options);

- await client.campfires.getChatbot(campfireId, chatbotId);
+ await client.campfires.getChatbot(bucketId, campfireId, chatbotId);

- await client.campfires.createChatbot(campfireId, req);
+ await client.campfires.createChatbot(bucketId, campfireId, req);

- await client.campfires.updateChatbot(campfireId, chatbotId, req);
+ await client.campfires.updateChatbot(bucketId, campfireId, chatbotId, req);

- await client.campfires.deleteChatbot(campfireId, chatbotId);
+ await client.campfires.deleteChatbot(bucketId, campfireId, chatbotId);

  // client portal — the four list/collection reads
- await client.clientApprovals.list(options);
+ await client.clientApprovals.list(bucketId, options);

- await client.clientCorrespondences.list(options);
+ await client.clientCorrespondences.list(bucketId, options);

- await client.clientReplies.list(recordingId, options);
+ await client.clientReplies.list(bucketId, recordingId, options);

- await client.clientReplies.get(recordingId, replyId);
+ await client.clientReplies.get(bucketId, recordingId, replyId);

  // unchanged — bc3 draws these flat and tests them unscoped
  await client.clientApprovals.get(approvalId);
  await client.clientCorrespondences.get(correspondenceId);

Same shape in every SDK, with each language's own spelling of the leading argument: Go Campfires().ListChatbots(ctx, bucketID, campfireID, nil), Ruby list_chatbots(bucket_id:, campfire_id:), Python list_chatbots(bucket_id=, campfire_id=), Kotlin listChatbots(bucketId, campfireId), Swift listChatbots(bucketId:campfireId:).

#584 — GetRecording removed

No correct path exists in any shape. config/routes.rb:258 is resources :recordings, only: [], so the flat show is not drawn. The bucket-scoped show cannot render on the API host either: api_request? keys on request.host == BC3.api_host and then restricts view paths to app/views/api, where recordings/ holds only partials (_category, _completion, _recording, _rich_text) — no show.json.jbuilder, no application/ fallback, no engine tree. There is no test/api/recordings_controller_api_test.rb at all. Same treatment #504 gave GetEverythingBoosts.

Migration. Use ListRecordings (GET /projects/recordings.json), which is bc3's only recordings read endpoint and which the SDK already models. It takes a required type and filters by bucket, status, sort, direction. If you were fetching one recording by id, fetch it through its own type's service (messages.get, todos.get, documents.get, …) — those are the routes bc3 renders.

#585 — TrashTodo removed

Not a routing defect: DELETE /todos/{todoId} is drawn (:58) and returns 204. The semantics were wrong. TodosController#destroy writes destroy_status_param, and

def destroy_status_param
  status_param || "archived"
end

TrashTodoInput carried no status, so every caller of TrashTodo was archiving. bc3's own test says so — test/api/todos_controller_api_test.rb asserts recording.reload.archived? after a bare DELETE, and needs status: "trashed" explicitly to trash.

Removal beats renaming to ArchiveTodo, and beats adding a status query param, for the same reason: both leave a silent behaviour change in place for anyone who does not read the changelog. A caller who upgrades and finds the method gone reads this note. A caller whose trashTodo quietly keeps archiving, or quietly starts trashing, does not.

Migration. Pick the one you meant:

- await client.todos.trash(todoId);
+ await client.recordings.trash(todoId);    // actually trashes — PUT /recordings/{id}/status/trashed.json
+ await client.recordings.archive(todoId);  // what trash() was really doing

recordings.trash / recordings.archive are the documented mechanism (doc/api/sections/recordings.md), they work on any recording type, and they already shipped.

#583 — CreateForwardReply removed

Decision, stated plainly because the brief left it conditional: the brief said add bucketId if bc3 has an API-host test for the bucket-scoped create, otherwise remove the operation. I looked, there is no such test, so I took the removal branch. The evidence is below.

The flat create was never drawn: config/routes.rb:150 is resources :replies, only: %i[index show]. Create exists only bucket-scoped at :1262.

The brief allowed adding bucketId if bc3 has an API-host test for the bucket-scoped create. It does not. At the pinned revision, test/api/inboxes/replies_controller_api_test.rb holds six tests — get replies, get reply, and four flat-route GET tests — and no create test at any path. A repo-wide search for Inbox::Reply under test/ finds only fixtures, a mailbox test, a model test, and one HTML integration test. doc/api/sections/inbox_replies.md documents only the two GETs. Shipping a route bc3 does not test is not an improvement over shipping one it does not route.

Migration. There is no supported replacement. If you need to reply to a forward, the API surface bc3 documents is read-only (forwards.listReplies, forwards.getReply). If the bucket-scoped create gets documented and covered upstream, the operation can come back as an additive change.


Red proofs

Both run against origin/main in a throwaway worktree, not by mutating the branch.

1. The gate fails the moment the waivers come off. Emptying sdk_routes_known_defective in a scratch copy of pre-fix main and running the gate:

==> bc3 route parity FAILED:
  CreateChatbot: SDK declares POST /chats/:id/integrations, which bc3 does not document. bc3 documents it only as /buckets/:id/chats/:id/integrations — if the flat form is really served, add a scope_aliases entry citing a routes.rb line and an API test.
  CreateForwardReply: SDK declares POST /inbox_forwards/:id/replies, which bc3 does not document. Fix the route, or allowlist it with a reason.
  DeleteChatbot: SDK declares DELETE /chats/:id/integrations/:id, which bc3 does not document. bc3 documents it only as /buckets/:id/chats/:id/integrations/:id — [...]
  GetChatbot: SDK declares GET /chats/:id/integrations/:id, which bc3 does not document. [...]
  GetClientReply: SDK declares GET /client/recordings/:id/replies/:id, which bc3 does not document. [...]
  GetRecording: SDK declares GET /recordings/:id, which bc3 does not document. Fix the route, or allowlist it with a reason.
  ListChatbots: SDK declares GET /chats/:id/integrations, which bc3 does not document. [...]
  ListClientApprovals: SDK declares GET /client/approvals, which bc3 does not document. [...]
  ListClientCorrespondences: SDK declares GET /client/correspondences, which bc3 does not document. [...]
  ListClientReplies: SDK declares GET /client/recordings/:id/replies, which bc3 does not document. [...]
  UpdateChatbot: SDK declares PUT /chats/:id/integrations/:id, which bc3 does not document. [...]
REAL_EXIT=1

([...] marks a repeated hint sentence elided; nothing else is trimmed.)

2. The nine new conformance fixtures fail on the pre-fix SDK, with the exact flat path that 404'd. Same fixture JSON, dispatched through pre-fix main's Ruby signatures:

  FAIL: ListChatbots is bucket-scoped, not flat /chats/{id}/integrations
        Expected first request path to contain "/buckets/2085958497/chats/1069478933/integrations.json", got "/999/chats/1069478933/integrations.json"; Expected request path "/999/buckets/2085958497/chats/1069478933/integrations.json" on request index 0, got "/999/chats/1069478933/integrations.json"
  FAIL: GetChatbot is bucket-scoped, not flat /chats/{id}/integrations/{id}
        Expected first request path to contain "/buckets/2085958497/chats/1069478933/integrations/1049715958", got "/999/chats/1069478933/integrations/1049715958"; [...]
  FAIL: CreateChatbot is bucket-scoped, not flat /chats/{id}/integrations
        Expected first request path to contain "/buckets/2085958497/chats/1069478933/integrations.json", got "/999/chats/1069478933/integrations.json"; [...]
  FAIL: UpdateChatbot is bucket-scoped, not flat /chats/{id}/integrations/{id}
        Expected first request path to contain "/buckets/2085958497/chats/1069478933/integrations/1049715958", got "/999/chats/1069478933/integrations/1049715958"; [...]
  FAIL: DeleteChatbot is bucket-scoped, not flat /chats/{id}/integrations/{id}
        Expected first request path to contain "/buckets/2085958497/chats/1069478933/integrations/1049715958", got "/999/chats/1069478933/integrations/1049715958"; [...]
  FAIL: ListClientApprovals is bucket-scoped, not flat /client/approvals
        Expected first request path to contain "/buckets/2085958500/client/approvals.json", got "/999/client/approvals.json"; [...]
  FAIL: ListClientCorrespondences is bucket-scoped, not flat /client/correspondences
        Expected first request path to contain "/buckets/2085958500/client/correspondences.json", got "/999/client/correspondences.json"; [...]
  FAIL: ListClientReplies is bucket-scoped, not flat /client/recordings/{id}/replies
        Expected first request path to contain "/buckets/2085958500/client/recordings/1069479566/replies.json", got "/999/client/recordings/1069479566/replies.json"; [...]
  FAIL: GetClientReply is bucket-scoped, not flat /client/recordings/{id}/replies/{id}
        Expected first request path to contain "/buckets/2085958500/client/recordings/1069479566/replies/1069479571", got "/999/client/recordings/1069479566/replies/1069479571"; [...]

Results: 145 passed, 9 failed, 11 skipped
REAL_EXIT=1

On this branch the same nine pass in all six runners — verified per-runner, no skips:

ListChatbots: PASS=5 SKIP=0 FAIL=0   (Go, Kotlin, Ruby, Python, Swift print PASS:)
… ×9 …
COUNT=9                              (TypeScript, vitest --reporter=verbose)

The countdown reached zero

spec/bc3-route-allowlist.yml's sdk_routes_known_defective list is now declared and empty. Two other ledger moves came with it:

  • The DELETE /todos/:id entry under sdk_routes_absent_from_bc3_docs goes with TrashTodo. The gate hard-fails on an entry matching nothing, which is exactly how it told me.
  • The two bucket-scoped client collections join spec/bucket-scoped-allowlist.txt. check-bucket-flat-parity wants a flat counterpart for every bucket-scoped list GET; here a flat counterpart is precisely what bc3 refuses to serve, and the justification comment says why.

No spec/api-gaps/ entry accompanies the three removals: an api-gap records an upstream contract the SDK has not absorbed, and none of these three ever had one. GET /recordings/:id and POST /inbox_forwards/:id/replies are absent from bc3's docs entirely, so direction 2 of the gate reports nothing new; DELETE /todos/:id is undocumented because bc3 steers clients to the status PUTs the SDK already models.

Counts and gates

  • Operations 243 → 240; idempotent 79 → 78 (TrashTodo was a naturally-idempotent DELETE); readonly∪idempotent 203 → 201. Updated in AGENTS.md, SPEC.md, SECURITY.md, and scripts/check-idempotency-parity's pinned floors.
  • SECURITY.md's retry census: 124 GETs → 123, 24 DELETEs → 23, 40 non-idempotent POSTs → 39.
  • All six trees regenerated. ruby/lib/basecamp/generated/types.rb was timestamp-only churn and was restored; the two metadata files carry real content and are committed.

Verification at the merge head

Re-run end to end on 48d3e936ce755c9194e7978d7607125eb5f07eee, rebased onto origin/main at a7354ae353de20e2a90952bafa316282b5fcad78 (zero commits behind, one ahead — no conflicts to resolve, so Makefile's check: line and .github/workflows/test.yml are byte-identical to main; 35 targets, bc3-route-parity / doc-constants-check / check-runner-test-reachability / check-replay-decoder-parity / check-readme-env-vars all present).

==> bc3 route parity OK: 240 SDK routes vs 369 bc3 routes (137 live-marker-backed) at bc3 2c0dafba13.
==> All checks passed
REAL_EXIT=0
PRE_SHA=48d3e936ce755c9194e7978d7607125eb5f07eee
POST_SHA=48d3e936ce755c9194e7978d7607125eb5f07eee
SHA_MATCH=yes

Zero KNOWN-DEFECTIVE lines in the whole run — the banner is guarded by unless flagged.empty? and sdk_routes_known_defective is now [].

Per-runner, no skips and no failures:

Runner Conformance The nine new bucket-scoped cases
Go 163 passed, 0 failed, 2 skipped 9 PASS
Kotlin 164 passed, 0 failed, 1 skipped 9 PASS
Ruby 154 passed, 0 failed, 11 skipped 9 PASS
Python 165 passed, 0 failed, 0 skipped 9 PASS
Swift 164 passed, 0 failed, 1 skipped 9 PASS
TypeScript 207 passed, 2 skipped vitest -t "is bucket-scoped" → 9 passed, 200 skipped

Swift ran rather than printing its SKIP line (Test Suite 'All tests' passed). Ruby is judged by make rb-test: 1241 runs, 29706 assertions, 0 failures, 0 errors, 0 skips, 96.00% line coverage. Kotlin's :basecamp-sdk:jvmTest reported UP-TO-DATE inside make check off a warm Gradle cache, so it was re-run under --rerun-tasks to get a real execution: 5 actionable tasks: 5 executed, BUILD SUCCESSFUL, and the XML results say 451 tests / 0 failures / 0 errors / 0 skipped.

make check leaves typescript/package-lock.json dirty on macOS (#612). Restored, not committed — the tree is clean at the head above.

Removals are complete, not partial

GetRecording, TrashTodo and CreateForwardReply grepped repo-wide in every casing (PascalCase, camelCase, snake_case, SCREAMING_SNAKE) across the spec, all six generated trees, the hand-written wrappers, the tests, and the conformance fixtures:

Operation Live references Remaining hits
GetRecording 0 4 — all prose in spec/bc3-route-allowlist.yml comments
TrashTodo 0 0 (the one trashTodo regex hit is the unrelated TrashTodolist comment at spec/basecamp.smithy:1259)
CreateForwardReply 0 2 — all prose in spec/bc3-route-allowlist.yml comments

No recordings.get, no todos.trash, no forwards.createReply survives in any of the six service layers — checked method-by-method, not just by operation name, since each SDK spells them generically. What does survive and is correct: forwards.get (GetForward), todos.get (GetTodo), and recordings.trash (TrashRecording, the migration target). spec/fixtures/recordings/get.json also stays — it is the Recording schema representative in spec/fixtures/manifest.yaml, not a GetRecording operation fixture.

Counts agree in all seven machine-readable places that carry them: spec/basecamp.smithy 240, openapi.json 240, and the Ruby / Python / TypeScript / Kotlin / Swift metadata tables 240 each. (Go carries no operation count of its own — go/pkg/generated/client.gen.go is checked by regeneration under go-check-generated-drift, and go/pkg/basecamp/url-routes.json indexes 161 wrapper routes, not operations.) Derived from source rather than from the prose: behavior-model.json has 240 operations with retry.max distributed 198 at 3 and 42 at 2; idempotent is 78, @readonly is 123, and the union is 201 — matching scripts/check-idempotency-parity's pinned floors and SPEC.md §7. No stale 243 / 79 / 203 anywhere in the tree.


Summary by cubic

Fixes 11 live 404s by scoping nine routes to projects (bucketId) and removing two unserved operations, plus removes TrashTodo, which reached a real endpoint but archived rather than trashed. This is breaking (signature changes and removals) and targets v0.13.0.

  • Migration
    • Chatbots and client portal list/reply endpoints now require a bucketId. Paths move to /buckets/{bucketId}/…:
      • Chatbots: List/Get/Create/Update/Delete
      • Client portal: ListClientApprovals, ListClientCorrespondences, ListClientReplies, GetClientReply
      • Examples: client.campfires.listChatbots(bucketId, campfireId); client.clientApprovals.list(bucketId); client.clientReplies.get(bucketId, recordingId, replyId)
    • GetRecording removed. Use ListRecordings (with filters) or the specific type’s get (e.g., messages.get, todos.get, documents.get).
    • TrashTodo removed. Use recordings.trash(todoId) to trash, or recordings.archive(todoId) to archive. The old DELETE endpoint actually archived.
    • CreateForwardReply removed. No supported replacement.

Written for commit 48d3e93. Summary will update on new commits.

Review in cubic

Copilot AI review requested due to automatic review settings August 3, 2026 18:31
@jeremy jeremy added the breaking Breaking change to public API 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 conformance Conformance test suite python Pull requests that update the Python SDK labels Aug 3, 2026

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 corrects a batch of route-parity defects in the Basecamp SDK (a six-language SDK generated from a Smithy spec). It fixes twelve operations that pointed at URLs bc3 never serves — all live 404s in shipped consumers. Nine operations gain a required bucketId path label (5 chatbot + 4 client‑portal list/get operations that bc3 only draws bucket-scoped), and three operations are removed (GetRecording, TrashTodo, CreateForwardReply) because no correct/supported path exists. Operation count drops 243 → 240. All changes are breaking and target the v0.13.0 window. It closes #583, #584, #585, and #588.

Changes:

  • Add bucketId @httpLabel to 9 chatbot/client‑portal operations in the Smithy spec and regenerate all six SDK service layers, generated clients, url-routes, and conformance fixtures.
  • Remove GetRecording, TrashTodo, and CreateForwardReply (unroutable/semantically-wrong/untested endpoints), empty the sdk_routes_known_defective allowlist, and move the two bucket-scoped client collections into spec/bucket-scoped-allowlist.txt.
  • Update operation-count references and the retry census in AGENTS.md, SECURITY.md, SPEC.md, and scripts/check-idempotency-parity.

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.

One thing worth flagging for the author (not attachable as a line comment because it falls outside this PR's changed regions in SPEC.md): the §7 "Per-operation retry ceiling" prose at SPEC.md:111/113/114 still carries the pre-PR distributions — "200 ops at 3, 43 at 2" (sums to 243) and "all 203 retry-eligible operations … (192 to 3, 11 to 2)". After removing GetRecording (max 3), TrashTodo (max 3), and CreateForwardReply (max 2), these should read 198 at 3 / 42 at 2, and 201 retry-eligible (190 to 3, 11 to 2). These counts are not CI-gated, so they weren't caught by make check.

Reviewed changes

Copilot reviewed 52 out of 102 changed files in this pull request and generated no comments.

Show a summary per file
File Description
spec/basecamp.smithy Source change: adds bucketId label to 9 ops; removes GetRecording, TrashTodo, CreateForwardReply.
spec/bc3-route-allowlist.yml Empties sdk_routes_known_defective; drops the DELETE /todos/:id entry; adds countdown/context comments.
spec/bucket-scoped-allowlist.txt Adds client/approvals.json and client/correspondences.json with justification for the flat-parity gate.
scripts/check-idempotency-parity Lowers pinned floors: idempotent 79→78, union 203→201.
SPEC.md Updates operation-count references (772, 1208, 2257–2259) to 240/78/162 (but not §7 lines 111/113/114).
SECURITY.md Updates retry census (243→240, 124→123 GETs, 24→23 DELETEs, POSTs 40→39, idempotent 79→78).
AGENTS.md Updates operation count 243→240.
conformance/tests/paths.json + runners Adds/updates 9 bucket-scoped fixtures; removes fixtures for the 3 deleted ops.
go/pkg/basecamp/url-routes.json Regenerated route table reflecting new bucket-scoped paths and removals.
Generated service layers (Go/TS/Ruby/Swift/Kotlin/Python) Regenerated chatbot/client-portal signatures with bucketId; removed methods for deleted ops.
TS/Ruby metadata + python/.../generated/types.py Regenerated metadata/type churn for moved/removed operations.
SDK tests (e.g. Kotlin RecordingsServiceTest) Updated to exercise list-based reads instead of the removed GetRecording.
Files not reviewed (1)
  • typescript/package-lock.json: Generated file

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

…ee operations removed

Every operation touched here pointed at a URL bc3 does not serve. All were
live 404s. Breaking, so they ride the v0.13.0 window together.

#588 — nine operations gain a required `bucketId` path label.
ListChatbots/GetChatbot/CreateChatbot/UpdateChatbot/DeleteChatbot declared
/chats/{campfireId}/integrations…; bc3's only `integrations` resource is
bucket-scoped (config/routes.rb:956, inside :954 resources :chats inside :887
resources :buckets), and the flat `chats` draw (:180) nests only lines and
uploads. ListClientApprovals and ListClientCorrespondences declared flat
collections; bc3's flat `client` namespace (:190-195) draws show-only members,
and `index` lives at :1277/:1278 — semantically so, since
Clients::{Approvals,Correspondences}Controller#index resolves
@bucket.project.client_board_recording, which is per-project.
ListClientReplies and GetClientReply declared paths in a namespace that has
neither `replies` nor `recordings`; the collection is at :1284-1285.

GetClientApproval and GetClientCorrespondence are correct as flat — bc3 draws
them at :193-194 and tests them unscoped — and are deliberately untouched.

#584 — GetRecording is removed. No correct path exists in any shape:
config/routes.rb:258 is `resources :recordings, only: []`, and the
bucket-scoped show cannot render on the API host, since app/views/api/recordings/
holds only partials (no show.json.jbuilder, no application/ fallback). bc3 has
no test/api/recordings_controller_api_test.rb at all. ListRecordings already
covers bc3's only recordings read endpoint. Same treatment #504 gave
GetEverythingBoosts.

#585 — TrashTodo is removed. The route is real and returns 204, but
TodosController#destroy writes `destroy_status_param`, which defaults to
"archived"; bc3's own test asserts `archived?` after a bare DELETE. Every
caller of TrashTodo was archiving. Removal beats renaming because a rename
would leave the silent behaviour mismatch in place for anyone who does not read
the changelog: `recordings.trash` trashes, `recordings.archive` archives, and
both say what they do.

#583 — CreateForwardReply is removed. The flat create was never drawn
(config/routes.rb:150 is `only: %i[index show]`). Create exists only
bucket-scoped at :1262, and that route is undocumented with zero test/api
coverage — checked at the pinned revision, where
test/api/inboxes/replies_controller_api_test.rb has four GET tests and no
create test at any path. Shipping a route bc3 does not test is not an
improvement over shipping one it does not route.

Operation count 243 -> 240; idempotent 79 -> 78 (TrashTodo was a
naturally-idempotent DELETE), readonly/idempotent union 203 -> 201. Counts
updated in AGENTS.md, SPEC.md, SECURITY.md and check-idempotency-parity.

spec/bc3-route-allowlist.yml's `sdk_routes_known_defective` list reaches zero
and stays declared-and-empty. The `DELETE /todos/:id`
sdk_routes_absent_from_bc3_docs entry goes with TrashTodo — an entry matching
nothing is a hard gate failure, by design. The two bucket-scoped client
collections join spec/bucket-scoped-allowlist.txt: a flat counterpart is what
bc3 refuses to serve here.

Nine conformance path fixtures pin the corrected wire paths, dispatched in all
six runners. Against the pre-fix SDK they fail with the exact flat path that
404'd, e.g. expected /999/buckets/2085958500/client/approvals.json, got
/999/client/approvals.json.
@jeremy
jeremy force-pushed the fix/route-corrections-v013 branch from b657237 to 48d3e93 Compare August 3, 2026 18:59
Copilot AI review requested due to automatic review settings August 3, 2026 18:59
@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Addressed the Copilot review's one finding, plus a self-caught defect it flagged obliquely.

SPEC.md §7 per-operation retry ceiling — real, fixed. The three distributions at SPEC.md:111/113/114 were pre-PR. Recomputed from behavior-model.json rather than by arithmetic on the review:

all ops by retry.max: {3: 198, 2: 42} total 240
retry-eligible: 201 by max: {3: 190, 2: 11}

So 200 ops at 3, 43 at 2 → 198 / 42; other 192 retry-eligible ops → 190; all 203 retry-eligible operations … (192 to 3, 11 to 2) → 201 … (190 to 3, 11 to 2). The eleven idempotent max:2 operations named in the same paragraph are unchanged — none of the three removals is in that set. Copilot's numbers were right; the derivation above is the evidence, since these counts are not CI-gated and that is exactly why they went stale.

typescript/package-lock.json should never have been in the diff. The "Files not reviewed (1)" line was the tell. make conformance runs npm install in typescript/, which on macOS strips the platform-conditional libc arrays a Linux CI run wrote (#612) — 24 deletions, no dependency change, and specifically not an undo of #616's brace-expansion override floor. Restored to origin/main's bytes; the branch no longer touches the file at all.

Both folded into the existing commit rather than stacked, so the history stays one reviewable change. Re-verified at the new head:

PRE_SHA=48d3e936ce755c9194e7978d7607125eb5f07eee
REAL_EXIT=0
POST_SHA=48d3e936ce755c9194e7978d7607125eb5f07eee

make check (which includes make conformance) green, PRE_SHA == POST_SHA, working tree clean.

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 52 out of 101 changed files in this pull request and generated no new comments.

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Answering the one finding that arrived in a review body rather than as a thread, so it does not get lost — the thread ledger reads 0/0 and that is not the whole review.

Copilot, on SPEC.md §7: the per-operation retry-ceiling prose still carried the pre-PR distributions (200 ops at 3, 43 at 2, and 203 retry-eligible … (192 to 3, 11 to 2)). Correct at the time it was written — that review ran against b6572379a. It is fixed in the current head 48d3e936c, and the numbers are right:

$ python3 -c "…count behavior-model.json…"
total ops in behavior-model: 240
retry.max distribution: {3: 198, 2: 42}
idempotent: 78   readonly: 123   union: 201

SPEC.md §7 now reads 198 ops at 3, 42 at 2, the other 190 retry-eligible ops, and all 201 retry-eligible operations … (190 to 3, 11 to 2). Those are derived from behavior-model.json and the @readonly traits in the spec, not copied from the old prose, and they match scripts/check-idempotency-parity's pinned floors (expected_idempotent=78, expected_union=201).

Copilot is right that this prose is not CI-gated, which is why make check stayed green across the stale version. Worth a gate; out of scope here, and it would need the §7 numbers to become derived rather than authored.

Also from that review: typescript/package-lock.json appeared in the file list at b657. It is the known macOS-only churn from #612 and is not in 48d3e936c — the commit is 101 files, and the working tree is clean after the verification run.

Verification for the current head is in the PR description under Verification at the merge head: make check REAL_EXIT=0 with PRE_SHA == POST_SHA == 48d3e936ce755c9194e7978d7607125eb5f07eee, bc3 route parity OK: 240 SDK routes, zero KNOWN-DEFECTIVE lines, and the nine new bucket-scoped conformance cases passing in all six runners with no skips.

@jeremy
jeremy merged commit ded0d88 into main Aug 3, 2026
47 checks passed
@jeremy
jeremy deleted the fix/route-corrections-v013 branch August 3, 2026 19:29
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
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API conformance Conformance test suite 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

2 participants