Route corrections: nine flat routes bc3 only draws bucket-scoped, three operations removed - #619
Conversation
There was a problem hiding this comment.
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@httpLabelto 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, andCreateForwardReply(unroutable/semantically-wrong/untested endpoints), empty thesdk_routes_known_defectiveallowlist, and move the two bucket-scoped client collections intospec/bucket-scoped-allowlist.txt. - Update operation-count references and the retry census in
AGENTS.md,SECURITY.md,SPEC.md, andscripts/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.
b657237 to
48d3e93
Compare
|
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 So typescript/package-lock.json should never have been in the diff. The "Files not reviewed (1)" line was the tell. Both folded into the existing commit rather than stacked, so the history stays one reviewable change. Re-verified at the new head:
|
|
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 ( SPEC.md §7 now reads Copilot is right that this prose is not CI-gated, which is why Also from that review: Verification for the current head is in the PR description under Verification at the merge head: |
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.
* 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.
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.
TrashTodois the exception and is worth stating precisely: it reachedDELETE /todos/:id, which bc3 really draws — butTodosController#destroydefaultsdestroy_status_paramto"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
bucketIdpath labelbc3 draws none of these flat. Verified against
config/routes.rbat the revisionspec/api-provenance.jsoncurrently pins.ListChatbotsGET /chats/{campfireId}/integrations.jsonGET /buckets/{bucketId}/chats/{campfireId}/integrations.json:956GetChatbotGET /chats/{campfireId}/integrations/{chatbotId}GET /buckets/{bucketId}/chats/{campfireId}/integrations/{chatbotId}:956CreateChatbotPOST /chats/{campfireId}/integrations.jsonPOST /buckets/{bucketId}/chats/{campfireId}/integrations.json:956UpdateChatbotPUT /chats/{campfireId}/integrations/{chatbotId}PUT /buckets/{bucketId}/chats/{campfireId}/integrations/{chatbotId}:956DeleteChatbotDELETE /chats/{campfireId}/integrations/{chatbotId}DELETE /buckets/{bucketId}/chats/{campfireId}/integrations/{chatbotId}:956ListClientApprovalsGET /client/approvals.jsonGET /buckets/{bucketId}/client/approvals.json:1278ListClientCorrespondencesGET /client/correspondences.jsonGET /buckets/{bucketId}/client/correspondences.json:1277ListClientRepliesGET /client/recordings/{recordingId}/replies.jsonGET /buckets/{bucketId}/client/recordings/{recordingId}/replies.json:1285GetClientReplyGET /client/recordings/{recordingId}/replies/{replyId}GET /buckets/{bucketId}/client/recordings/{recordingId}/replies/{replyId}:1285The only
integrationsresource in bc3 is bucket-scoped (:956, inside:954 resources :chatsinside:887 resources :buckets); the flatchatsdraw at:180nests onlylinesanduploads.The flat
clientnamespace (:190-195) draws four show-only members.indexexists only bucket-scoped, and semantically so:Clients::ApprovalsController#indexresolves@bucket.project.client_board_recording, and a client board is per-project. bc3's owntest/api/clients/correspondences_controller_api_test.rb:49assertsActiveRecord::RecordNotFoundfor a bucket with no client board.ListClientReplies/GetClientReplywere worse than a wrongonly:— the flat namespace has neitherrepliesnorrecordings, andClients::RepliesControllerincludes onlyBucketScoped, so@bucketis never assigned on a flat route.GetClientApprovalandGetClientCorrespondenceare 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.
Same shape in every SDK, with each language's own spelling of the leading argument: Go
Campfires().ListChatbots(ctx, bucketID, campfireID, nil), Rubylist_chatbots(bucket_id:, campfire_id:), Pythonlist_chatbots(bucket_id=, campfire_id=), KotlinlistChatbots(bucketId, campfireId), SwiftlistChatbots(bucketId:campfireId:).#584 —
GetRecordingremovedNo correct path exists in any shape.
config/routes.rb:258isresources :recordings, only: [], so the flat show is not drawn. The bucket-scoped show cannot render on the API host either:api_request?keys onrequest.host == BC3.api_hostand then restricts view paths toapp/views/api, whererecordings/holds only partials (_category,_completion,_recording,_rich_text) — noshow.json.jbuilder, noapplication/fallback, no engine tree. There is notest/api/recordings_controller_api_test.rbat all. Same treatment #504 gaveGetEverythingBoosts.Migration. Use
ListRecordings(GET /projects/recordings.json), which is bc3's only recordings read endpoint and which the SDK already models. It takes a requiredtypeand filters bybucket,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 —
TrashTodoremovedNot a routing defect:
DELETE /todos/{todoId}is drawn (:58) and returns 204. The semantics were wrong.TodosController#destroywritesdestroy_status_param, andTrashTodoInputcarried nostatus, so every caller ofTrashTodowas archiving. bc3's own test says so —test/api/todos_controller_api_test.rbassertsrecording.reload.archived?after a bareDELETE, and needsstatus: "trashed"explicitly to trash.Removal beats renaming to
ArchiveTodo, and beats adding astatusquery 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 whosetrashTodoquietly keeps archiving, or quietly starts trashing, does not.Migration. Pick the one you meant:
recordings.trash/recordings.archiveare the documented mechanism (doc/api/sections/recordings.md), they work on any recording type, and they already shipped.#583 —
CreateForwardReplyremovedDecision, stated plainly because the brief left it conditional: the brief said add
bucketIdif 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:150isresources :replies, only: %i[index show]. Create exists only bucket-scoped at:1262.The brief allowed adding
bucketIdif 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.rbholds six tests —get replies,get reply, and four flat-route GET tests — and no create test at any path. A repo-wide search forInbox::Replyundertest/finds only fixtures, a mailbox test, a model test, and one HTML integration test.doc/api/sections/inbox_replies.mddocuments 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/mainin a throwaway worktree, not by mutating the branch.1. The gate fails the moment the waivers come off. Emptying
sdk_routes_known_defectivein a scratch copy of pre-fix main and running the gate:(
[...]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:
On this branch the same nine pass in all six runners — verified per-runner, no skips:
The countdown reached zero
spec/bc3-route-allowlist.yml'ssdk_routes_known_defectivelist is now declared and empty. Two other ledger moves came with it:DELETE /todos/:identry undersdk_routes_absent_from_bc3_docsgoes withTrashTodo. The gate hard-fails on an entry matching nothing, which is exactly how it told me.spec/bucket-scoped-allowlist.txt.check-bucket-flat-paritywants 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/:idandPOST /inbox_forwards/:id/repliesare absent from bc3's docs entirely, so direction 2 of the gate reports nothing new;DELETE /todos/:idis undocumented because bc3 steers clients to the status PUTs the SDK already models.Counts and gates
TrashTodowas a naturally-idempotent DELETE); readonly∪idempotent 203 → 201. Updated inAGENTS.md,SPEC.md,SECURITY.md, andscripts/check-idempotency-parity's pinned floors.ruby/lib/basecamp/generated/types.rbwas timestamp-only churn and was restored; the twometadatafiles carry real content and are committed.Verification at the merge head
Re-run end to end on
48d3e936ce755c9194e7978d7607125eb5f07eee, rebased ontoorigin/mainata7354ae353de20e2a90952bafa316282b5fcad78(zero commits behind, one ahead — no conflicts to resolve, soMakefile'scheck:line and.github/workflows/test.ymlare byte-identical to main; 35 targets,bc3-route-parity/doc-constants-check/check-runner-test-reachability/check-replay-decoder-parity/check-readme-env-varsall present).Zero
KNOWN-DEFECTIVElines in the whole run — the banner is guarded byunless flagged.empty?andsdk_routes_known_defectiveis now[].Per-runner, no skips and no failures:
vitest -t "is bucket-scoped"→ 9 passed, 200 skippedSwift ran rather than printing its SKIP line (
Test Suite 'All tests' passed). Ruby is judged bymake rb-test: 1241 runs, 29706 assertions, 0 failures, 0 errors, 0 skips, 96.00% line coverage. Kotlin's:basecamp-sdk:jvmTestreportedUP-TO-DATEinsidemake checkoff a warm Gradle cache, so it was re-run under--rerun-tasksto 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 checkleavestypescript/package-lock.jsondirty on macOS (#612). Restored, not committed — the tree is clean at the head above.Removals are complete, not partial
GetRecording,TrashTodoandCreateForwardReplygrepped 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:GetRecordingspec/bc3-route-allowlist.ymlcommentsTrashTodotrashTodoregex hit is the unrelatedTrashTodolistcomment atspec/basecamp.smithy:1259)CreateForwardReplyspec/bc3-route-allowlist.ymlcommentsNo
recordings.get, notodos.trash, noforwards.createReplysurvives 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), andrecordings.trash(TrashRecording, the migration target).spec/fixtures/recordings/get.jsonalso stays — it is theRecordingschema representative inspec/fixtures/manifest.yaml, not aGetRecordingoperation fixture.Counts agree in all seven machine-readable places that carry them:
spec/basecamp.smithy240,openapi.json240, and the Ruby / Python / TypeScript / Kotlin / Swift metadata tables 240 each. (Go carries no operation count of its own —go/pkg/generated/client.gen.gois checked by regeneration undergo-check-generated-drift, andgo/pkg/basecamp/url-routes.jsonindexes 161 wrapper routes, not operations.) Derived from source rather than from the prose:behavior-model.jsonhas 240 operations withretry.maxdistributed 198 at3and 42 at2; idempotent is 78,@readonlyis 123, and the union is 201 — matchingscripts/check-idempotency-parity's pinned floors and SPEC.md §7. No stale243/79/203anywhere in the tree.Summary by cubic
Fixes 11 live 404s by scoping nine routes to projects (
bucketId) and removing two unserved operations, plus removesTrashTodo, which reached a real endpoint but archived rather than trashed. This is breaking (signature changes and removals) and targets v0.13.0.bucketId. Paths move to /buckets/{bucketId}/…:Written for commit 48d3e93. Summary will update on new commits.