Repin bc3 to 7fe1c63ab3 and absorb project archive/unarchive
Customer support asked for project archiving through the API. The routes always existed, but Projects::StatusController had no respond_to, so a JSON request got a 302 to an HTML URL and following it returned 406. BC3 #12550 added the JSON branch (head :no_content) plus 32 lines of doc/api/sections/projects.md; this absorbs it.
The repin and the absorption are one change on purpose, following the 9459083ea Folders precedent. doc/api/sections/projects.md gains "Archive a project" and "Unarchive a project" only in #12550, so at the old pin the two routes are absent from spec/bc3-routes.json entirely. Absorbing alone would model a contract the pin says isn't there; repinning alone would leave two documented routes with no operation behind them. Regenerating the table at the new pin is what lets direction 1 of bc3-route-parity pass on the truth instead of on a waiver — and it does, with zero new sdk_routes_absent_from_bc3_docs entries.
247 → 249 operations.
ArchiveProject PUT /{accountId}/projects/{projectId}/status/archived.json 204
UnarchiveProject PUT /{accountId}/projects/{projectId}/status/active.json 204
The repin: 4e34dc83eb..7fe1c63ab3, 71 commits
Upstream moved four times across planning and implementation — 4e34dc83eb → 6f4781bbd4 → a26c2e479f → 7fe1c63ab3 — every one a doc/api/ commit, and the last landed after the absorption was complete and committed. Hence two repin commits in the history rather than one; the final commit narrates the sequence.
Classifying all 71 commits by touched path — a filter on doc/api/ + config/routes.rb + app/views/api/ is a filter, not a certification — finds six contract-or-documentation changes. Three touch doc/api/; only two move the route table. The residual bucket is empty.
| Item | Disposition |
|---|---|
#12550 (6f4781bbd4) project status JSON | absorbed here |
#12555 (a26c2e479f) upload file replacement | registered, absorbed on upload-versions-api |
#12396 (98eb24b22f) sort_pings_first | registered — new brief, partial-coverage |
#9471 (eac8b2b476) OAuth 2.1 | two halves, two answers — see below |
5c0e774b0d + 3 siblings, completions denormalization | no SDK action, recorded |
#12566 (7fe1c63ab3) status read-only on update | registered — ratifies an omission we'd already made |
Two of the six are the reason the sweep is a classification and not a glob:
- The completions reordering has no
doc/api, noconfig/routes.rband noapp/views/apidiff at all, yet reroutesRecording.completed→completed_recently_first(reorder("completions.created_at DESC")), reordering three modelled operations:GetEverythingCompletedTodos,GetEverythingCompletedCards,GetMyCompletedAssignments. No field added, removed or retyped, and none of the three declares an ordering in its Smithy docs, so there is nothing to correct — but a path filter would have missed it entirely. The pairedmy/assigningsandmy/assignmentscontrollers are not a rename: both exist before and after, one line each. - #12566 touches
doc/apiwithout moving the route table — two lines of prose on an endpoint already documented. So "threedoc/apicommits" and "four added rows" are both correct; the triage now says so explicitly rather than leaving a reader to reconcile them.
api_request.rb is touched by #9471, but only to add a private app_url_options helper for OAuth's HTML redirects — api_request? and restrict_view_paths_to_api_root are untouched, so what counts as an API route is unchanged. #12475 registers Gallery as a dock_tool, which is additive because DockItem.name is a plain String and not an enum, and no app/views/api/galleries template exists. Full sweep in spec/api-gaps/README.md.
OAuth was the gate item, and it did not come back clean
Discovery matches, no action: bc3's new .well-known/oauth-authorization-server (RFC 8414) and .well-known/oauth-protected-resource (RFC 9728) are the exact paths the SDK's hand-written discovery already fetches — go/pkg/basecamp/oauth/discovery.go:24-25, typescript/src/oauth/discovery.ts:400,514, python/src/basecamp/oauth/discovery.py:223,301, ruby/lib/basecamp/oauth/discovery.rb:88.
The authorization document diverges, and it is reachable today. bc3 now draws resource :authorization, only: :show and renders app/views/api/authorizations/show.json.jbuilder, whose shape is not Launchpad's: identity carries only id, accounts carry resource but neither product nor app_href, and expires_at is integer epoch seconds rather than ISO-8601. Ruby's Http#get_authorization_document binds to the discovered BC5 issuer and fetches {issuer_origin}/authorization.json, so this is not hypothetical.
TypeScript is the exposed SDK: three required string fields become undefined, filterProduct silently matches nothing (breaking the documented "pick product: bc3" flow), and new Date(epochSeconds) reads the integer as milliseconds and yields a 1970 date instead of throwing. Go already tolerates the timestamp via FlexTime; Python and Ruby return untyped maps. Registered as spec/api-gaps/bc5-authorization-document-shape.md — OAuth is outside the OpenAPI spec by design, so nothing here drifted from Smithy.
The absorption
ArchiveProject/UnarchiveProject copy ArchiveRecording/UnarchiveRecording — same verbs, same URI suffixes, same 204 — with recordingId: RecordingId → projectId: ProjectId. Both @idempotent + @basecampIdempotent(natural: true): re-archiving an archived project is a no-op that still answers 204.
Error lists decided, not copied. Both take [NotFoundError, UnauthorizedError, ForbiddenError, InternalServerError] — matching TrashProject, the nearest project-status operation, and not ArchiveRecording's list, which carries a ValidationError these cannot raise because they have no request body. UnarchiveProject adds the 507.
403 on both is deliberate, and corrects the registration draft. forbid_clients is an unscoped before_action (app/controllers/concerns/permissions.rb:22-24), so it fires on active exactly as on archived. The asymmetry is in the cause — only ensure_can_archive_or_trash_project is only: %i[archived trashed] — not in reachability, and bc3's own tests split on exactly that line ("unarchive is permitted for a non-admin or creator").
ProjectLimitError is a new @httpError(507) shape wired into UnarchiveProject and CreateProject — the same body was already reachable from create and unmodelled, so one shape closes two gaps. Two shapes sharing 507 is safe because no SDK maps status → shape; all six switch on the status code into a fixed taxonomy. The invariant that matters holds: no operation's error list contains two shapes with the same status.
status is deliberately not modelled as writable on UpdateProject — it's absent from create_project_params, so bc3 silently drops it. #12566 has since made that bc3's documented contract, so the omission is now backed by upstream prose rather than a controller read.
Tests: all six SDKs
No generator emits tests. Happy path 204 → void/nil/None, plus two error cases each: 403 on archive and 507 on unarchive — the latter is the new shape's only behavioural evidence, surfacing as a generic api_error with http_status: 507 (SPEC §7), since no SDK gives 507 a named class. Assertions check status and error code, not just the exception class.
Python had no trash test to copy, so this establishes the pattern there across both the sync and async classes the generator emits. Go had none either; its table test covers Trash alongside the two new wrappers. Go wrappers are mandatory, not optional — EXCLUDED_OPS is empty by policy and go-check-drift flagged 247/249 until they landed.
Python's 507 test asserts retryable is False where Ruby's and Kotlin's assert true. That's not an inconsistency in the tests: Python's fallback arm builds a bare ApiError defaulting to retryable=False while the other five mark every unclassified 5xx retryable. Pre-existing divergence from SPEC §7, asserted as-is and left alone.
Nothing added to conformance/tests/live-my-surface.json — read-only live surface, and archiving is destructive.
Counts
Re-derived from behavior-model.json and openapi.json rather than incremented, and they cross-check: 52 PUTs + 24 DELETEs + 7 idempotent POSTs = 83 idempotent; 83 + 125 readonly = 208 union.
scripts/check-idempotency-parity is the one enforced count (81 → 83, 206 → 208). Prose: SPEC.md §Operation Counts and its two "all 247 operations" mentions, the easily-missed §2 retry distributions (203 → 205 at max:3; 195 → 197 unaffected at the default cap; 206 → 208 clamped above it), SECURITY.md (50 → 52 PUTs), and AGENTS.md. MIGRATING.md deliberately gets nothing — its four 247s are historical v0.13.0 measurements, and new operations break nobody.
Two traps worth knowing
Generation succeeds at the wrong count. Defining and tagging the operations regenerated cleanly at exit 0 and emitted 247 with ProjectsService still at 5 — the new operations must also join the Smithy service shape's operations: list, and nothing in the output names the omission. Check the operation count before trusting a clean regeneration.
One allowlist entry covers both upload spellings. Direction 2's failure named both the flat and bucket-scoped POST .../versions routes, so two entries looked right; the bucket-scoped one then hard-failed as matches nothing, because direction 2 collapses a leading /buckets/:id. Established by running the gate, and the comment records it.
Also: bc3-route-parity's staleness guard sits upstream of both directions, so there is no observable direction-1 failure between repinning and make bc3-routes — it compares nothing and just says the table is stale. The direction-1 proof is therefore the absence on the green run: zero new waivers, with the two PUTs arriving as additions in the spec/bc3-routes.json diff.
Verification
make is green locally (all 43 check-targets). CI run 31069828400 reports the following authoritative test totals:
- Go conformance: 182 passed, 0 failed, 2 skipped (184).
- TypeScript conformance: 226 passed, 0 failed, 2 skipped (228).
- Python unit tests: 1,186 passed, 4 skipped on each Python 3.11, 3.12, 3.13 and 3.14 job.
- Python conformance: 184 passed, 0 failed, 0 skipped (3.13 job).
- Ruby unit tests: 1,374 runs and 30,433 assertions, 0 failures on Ruby 3.2, 3.3, 3.4, 4.0 and head.
- Ruby conformance: 173 passed, 0 failed, 11 skipped (3.3 job).
- Kotlin conformance: 183 passed, 0 failed, 1 skipped (184).
- Swift conformance: 183 passed, 0 failed, 1 skipped (184).
The CodeQL Advanced Analyze (swift) job was still in progress when this description was updated; all other required CI checks were passing.
make bc3-routes-check is not in check-targets and not in CI because it needs BC3_REPO_PATH; it was run by hand at each repin and reports the table up to date at 7fe1c63ab3 (373 routes, 64 sections).
Nothing in CI makes a live HTTP call, so no green result here is evidence the endpoints work. The end-to-end proof is bc3's, split across two files that prove different things — cite both, neither covers the other's case:
test/api/projects/status_controller_api_test.rb— the 204s, the 403s, and the behaviour itself, including "unarchive restores a trashed project".gems/saas/test/api/projects/status_controller_api_test.rb— the only 507 proof: "unarchive enforces the project limit" on a free-plan account, asserting:insufficient_storageand the exact bodyProjectLimitErrormodels.
Summary by cubic
Adds project archive and unarchive to the Projects service across all six SDKs with idempotent PUT endpoints that return 204. Repins the BC3 API to 2026-08-05 and regenerates clients, tests, and docs; also ensures Go’s grouped generated client now exposes these methods.
-
New Features
- Projects service adds
archive(projectId)andunarchive(projectId)in Go, TypeScript, Python, Ruby, Swift, and Kotlin. - Routes: PUT
/projects/{projectId}/status/archived.jsonand PUT/projects/{projectId}/status/active.json(204 No Content). - Both operations are idempotent and retryable (max 3, exponential backoff on 429/503).
- Adds
ProjectLimitError(507) response; surfaced byCreateProjectandUnarchiveProject. - Tests added in all SDKs for happy paths and 403/507 cases.
- Projects service adds
-
Bug Fixes
- Go grouped client now emits
ArchiveandUnarchiveongenerated.Client.Projects(); template updated to include these methods.
- Go grouped client now emits