Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
245a4c3
Spec: replace an upload's file, and say which file each version is
jeremy Aug 6, 2026
cae9ed4
Go: CreateVersion, a real UploadVersion, and a reachable description …
jeremy Aug 6, 2026
dde4d17
All six SDKs: a 507 is a limit, not a retryable server error
jeremy Aug 6, 2026
9290e67
Conformance: both uploads write paths, across all six runners
jeremy Aug 6, 2026
63286d1
Docs: the counts, the two breaks, and the divergence that narrowed
jeremy Aug 6, 2026
c093f65
Reconcile with #679's repin, and finish the 507 it left half-classified
jeremy Aug 6, 2026
15ed1a7
Address Codex review: keep listVersions typed in TS and Kotlin
jeremy Aug 7, 2026
923470f
Address Codex round 2: export the new TS types, and Python does break
jeremy Aug 7, 2026
d540e59
Address Codex round 3: map 507 in Go's raw request path too
jeremy Aug 7, 2026
bb91729
Address Codex round 4: filtering on blob_changed drops the original file
jeremy Aug 7, 2026
e32020e
Say what `current` actually is, without weakening it
jeremy Aug 7, 2026
3ec4db3
Address Codex round 5: the six READMEs are public error references too
jeremy Aug 7, 2026
228b851
Account for CreateUploadVersion on the grouped client (#682)
jeremy Aug 7, 2026
17ed903
Correct SPEC §2's retry.max distribution: 44 -> 45 at max 2
jeremy Aug 7, 2026
a72e79e
Reconcile the rest of the derived counts, by sweeping instead of patc…
jeremy Aug 7, 2026
6dc3788
List all eight 507 operations, not the four this PR touched
jeremy Aug 7, 2026
1797145
Sweep the whole tree for stale operation counts, not the named files
jeremy Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Paths are from the repository root, since that is where you will be working.
| **Kotlin** | Ktor via `BaseService` | `kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/*.kt` |
| **Python** | httpx via `HttpClient` | `python/src/basecamp/generated/services/*.py` |

All 249 operations across the ~50-service per-SDK layer are generated. Hand-written code is limited to infrastructure:
All 250 operations across the ~50-service per-SDK layer are generated. Hand-written code is limited to infrastructure:

| Purpose | Location |
|---------|----------|
Expand Down Expand Up @@ -91,7 +91,7 @@ Pull the andon cord when you see:

All new API coverage starts in `spec/basecamp.smithy`. Before writing SDK code, add operations and shapes to the spec.

`spec/basecamp.smithy` holds 249 worked operations. Copy the nearest one rather than
`spec/basecamp.smithy` holds 250 worked operations. Copy the nearest one rather than
working from a skeleton here: it shows the live conventions for naming, `@http` URIs,
pagination traits and shape reuse, and it cannot drift from itself.

Expand Down
14 changes: 14 additions & 0 deletions API-GAP-404.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

Addresses basecamp/basecamp-cli#404.

> **Resolved.** BC3 closed this with a dedicated route rather than by widening
> the update: `POST /uploads/{id}/versions.json` (basecamp/bc3#12555, input
> contract settled in #12565). The SDK absorbed it as `CreateUploadVersion`,
> alongside the read-side retype that closes basecamp-sdk#649.
>
> **The finding below still stands and is why the guard stays.** `PUT
> /uploads/{id}.json` still ignores `attachable_sgid` — the hypothesis this
> document tested is still false, and `TestUpdateUploadRequest_HasNoFileReplacementField`
> still asserts it. What changed is that the guard now pins a design choice
> rather than a missing feature; its positive counterpart is
> `TestCreateUploadVersionRequest_HasFileReplacementField`.
>
> Registry entry: [`spec/api-gaps/upload-new-version.md`](spec/api-gaps/upload-new-version.md).

## Question

basecamp-cli#404 asks the SDK to support uploading a **new version** of an
Expand Down
2 changes: 1 addition & 1 deletion COORDINATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ live BC5 by the #11629 tooling. The historical server-side audit lived on the

The SDK's conformance baseline is the pin in
[`spec/api-provenance.json`](spec/api-provenance.json) — `bc3` `master`
`7fe1c63ab3` as of the 2026-08-05 sync. <!-- @bc3-pin -->
`b5d8c9df8d` as of the 2026-08-05 sync. <!-- @bc3-pin -->
That file is the only authority; quote it here rather than a remembered SHA.
`make sync-api-version` now rewrites the marked line above from it, and
`make doc-constants-check` fails if the two disagree — this sentence sat two
Expand Down
135 changes: 135 additions & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,141 @@ what wrong behaviour you get if you ignore one. This file is that half.

---

# Unreleased

Breaking in Go and in the shape every SDK decodes from
`GET /uploads/{id}/versions.json`.

**Operation inventory: 249 → 250** — `CreateUploadVersion`. Derive both ends
rather than trusting either number:

```bash
git show v0.13.0:openapi.json | jq '[.paths[]|keys[]]|length'
jq '[.paths[]|keys[]]|length' openapi.json
```

### `ListUploadVersions` returns versions, not uploads (#649)

The endpoint has always returned **events**. The spec declared
`uploads: UploadList` anyway, and **11 of `Upload`'s 14 required members are
absent from every response** — which is why the CLI's versions command and the
MCP server's `list_upload_versions` printed blank fields rather than failing.
The output is now `versions: UploadVersionList`.

| SDK | was | now |
|---|---|---|
| Go | `UploadVersionListResult.Versions []Upload` | `[]UploadVersion` |
| TypeScript | `ListResult<Upload>` | `ListResult<UploadVersion>` |
| Swift | `ListResult<Upload>` | `ListResult<UploadVersion>` |
| Kotlin | `ListResult<Upload>` | `ListResult<UploadVersion>` |
| Ruby / Python | parsed body, unchanged at runtime | fields differ, see below |

The four typed SDKs keep their `ListResult` wrapper, so `.meta.totalCount` and
the pagination surface are untouched; only the element type changes. Member
access moves with it — `version.filename` becomes `version.upload?.filename`,
and the event's own `action`, `createdAt` and `creator` sit alongside.

**In Ruby and Python the compiler will not catch this.** Nothing changes in the
type; what changes is which keys are actually there. Code reading
`version["filename"]` was reading a key the server never sent and getting nil —
it now reads `version["upload"]["filename"]`, and the event's own metadata
(`action`, `created_at`, `creator`) is available where it previously looked like
a partly-empty upload.

A version carries `upload` only when its recordable still resolves; a deleted
file leaves the event behind with no `upload` at all. Check before dereferencing.
`action` is `created`, `active` (the publication) or `blob_changed` (a file
replacement). To list the file's **past** versions, take the entries that carry
an `upload` with `current == false` — not the ones with `action == "blob_changed"`,
which drops the original (it arrives as `created` or `active`) and keeps the
current file. The per-version `download_url` serves **that** version's bytes; the
upload's own always serves the latest.

### Go: `UpdateUploadRequest.Description` became `*string`

Tri-state, following `UpdateGaugeNeedleRequest.Description` (#560): nil leaves
it untouched, `basecamp.Ptr("")` clears it, `basecamp.Ptr(v)` sets it.
Previously a plain `string` behind a zero-value guard, so `""` read as *unset*
and clearing a description through `Update` was unreachable — the divergence
SPEC §5 documented.

```go
// Before — compiled, and silently did nothing to the description.
svc.Update(ctx, id, &UpdateUploadRequest{Description: ""})

// After — clears it.
svc.Update(ctx, id, &UpdateUploadRequest{Description: basecamp.Ptr("")})

// After — leaves it alone.
svc.Update(ctx, id, &UpdateUploadRequest{BaseName: "renamed"})
```

The compiler catches this one: `Description: "text"` no longer type-checks. Wrap
it in `basecamp.Ptr`.

`BaseName` is deliberately still a plain `string` on both this and
`CreateUploadVersionRequest`. `Upload#base_name=` guards on
`new_base_name.present?`, so `""` and absent are the same write server-side —
there is no third state for a pointer to express.

### New: `UploadsService.CreateVersion` and a 507 error code

Not breaking, but the reason for the above. `POST /uploads/{id}/versions.json`
Comment thread
jeremy marked this conversation as resolved.
replaces an upload's file in place, keeping the recording's id, URL and
comments, so a published link keeps working — which `CreateUpload` cannot do.

A `507 Insufficient Storage` now maps to the new `limit_exceeded` code (exit
code 10) instead of `api_error`. **If you branch on `api_error` to decide
whether to back off, a limit failure no longer lands in that branch** — which is
the point: it was reported as retryable, and no retry can satisfy a plan limit.

The mapping is by **status**, not by operation, so it reaches every 507 the spec
declares — all eight, across three different limits:

| Operations | Limit | Error shape |
|---|---|---|
| `CreateUpload`, `CreateUploadVersion`, `CreateAttachment`, `CreateCampfireUpload` | file storage | `StorageLimitError` (new) |
| `CreateProject`, `UnarchiveProject` | project count | `ProjectLimitError` (v0.13.0) |
| `CreateWebhook`, `UpdateWebhook` | webhook count | `WebhookLimitError` (pre-existing) |

Only the first row is new surface. The other four operations already returned
507 and already reported it as a retryable `api_error`; they are reclassified
here too, so **webhook and project callers need the same new branch even though
nothing about those endpoints changed**. Derive the list rather than trusting
it:

```bash
jq -r '.paths[]|to_entries[]|select(.value.responses."507")|.value.operationId' openapi.json
```

### The new error code is source-breaking in four SDKs

Adding a member to a closed type breaks exhaustive handling, so this is not
merely behavioural:

| SDK | what changed | how it breaks |
|---|---|---|
| TypeScript | `ErrorCode` union gains `"limit_exceeded"` | a `Record<ErrorCode, T>` map, or a `switch` the compiler checks for exhaustiveness, stops compiling until it has a branch |
| Swift | `BasecampError` gains `case limitExceeded` | a `switch` over the enum without a `default` stops compiling |
| Kotlin | `BasecampException` gains `LimitExceeded` | a `when` over the sealed class used as an expression stops compiling |
| Python | `ErrorCode` (a `StrEnum`) gains `LIMIT_EXCEEDED` | a `match` over it ending in `typing.assert_never` stops type-checking — mypy reports the new member as unhandled |

Python's break needs a type-checker to surface, not an interpreter: the module
imports and runs either way. If your CI runs mypy — this package does — it fails
there rather than at import, which makes it easier to miss in review and no less
of a break.

Go and Ruby take a new constant rather than a new variant, so neither breaks a
build — which is exactly why they need reading for: a `case` or `when` falling
through to a default arm now routes storage and project limits wherever that
default goes.

Add a `limit_exceeded` branch that surfaces the limit to the user and does not
retry. This SDK's own Kotlin test suite hit the compile error, which is what the
exhaustive `when` in `ErrorTest` exists to produce.

---

# v0.13.0

Breaking across all six SDKs — Go, TypeScript, Python, Ruby, Kotlin, Swift.
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ print(f"Headers: {safe}")
## Retry Behavior

Retry eligibility is decided per *operation*, not per HTTP method. `behavior-model.json` classifies
all 249 operations: the 125 GETs are retryable by method, and 83 mutations are flagged
all 250 operations: the 125 GETs are retryable by method, and 83 mutations are flagged
`idempotent: true` — all 52 PUTs, all 24 DELETEs, and 7 POSTs (`CompleteTodo`, `PauseQuestion`,
`SubscribeToCardColumn`, `Subscribe`, `EnableCardColumnOnHold`, `CreateBookmark`, `PrioritizeAssignment`). The other 41 POSTs are attempted exactly once. SPEC.md §7 specifies the
`SubscribeToCardColumn`, `Subscribe`, `EnableCardColumnOnHold`, `CreateBookmark`, `PrioritizeAssignment`). The other 42 POSTs are attempted exactly once. SPEC.md §7 specifies the
three-gate algorithm and the per-SDK divergences.

- **Reads (GET)**: retried with exponential backoff on 429/503 in every SDK. (HEAD is idempotent by method too, but Ruby's transport gates on `method == :get` specifically, so a HEAD would not retry there. The API surface has no HEAD operations today, so this is theoretical.)
Expand Down
Loading
Loading