From 245a4c3c85df7c833ada4e186b08a8be7d767fb3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 5 Aug 2026 21:52:45 -0700 Subject: [PATCH 01/17] Spec: replace an upload's file, and say which file each version is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BC3 #12555 added POST /uploads/{id}/versions.json — replace an upload's file in place, keeping the recording's id, URL and comments — and gave each version event a nested upload object. #12565 then settled the endpoint's input contract. Absorb both, and repin to the revision containing them. CreateUploadVersion models attachable_sgid, base_name, description, notify and subscriptions. notify and subscriptions are documented contracts as of audience arrives either through notify naming a mode or through a bare subscriptions array. visible_to_clients is deliberately absent — #12565 removed it from the endpoint's reachable surface, because it never set visibility, only widened the notification audience. The read side is the half that fixes #649. ListUploadVersionsOutput declared uploads: UploadList, but the endpoint returns events: 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 render blank fields. It now returns UploadVersion — an event plus the file it recorded — built from _version.json.jbuilder rather than from the Upload shape it was pretending to be. New shapes rather than EventList plus a member, for the reason bc3's own commit gives for using a purpose-built partial: upload fields would otherwise leak onto todo, message and card events. StorageLimitError declares the 507 that ensure_account_can_upload_files has always been able to raise. It fronts four modeled operations, not one, so all four take it; absorbing the contract for the new operation alone would leave three declaring a status they can return and don't model. WebhookLimitError is left alone — it has the right body shape, but hanging a webhook-named error on an upload is the same kind of typed lie as #649. SPEC §6 gains limit_exceeded, and the 507 step is ordered ahead of the 5xx catch-all. Until now a 507 fell through to `status >= 500` and surfaced as api_error with retryable: true — a plan limit no retry can satisfy, reported as a transient server error. Nothing retried it in practice, since no retryOn list names 507; what was wrong was what the caller was told. Repin triage, recorded in spec/api-gaps/README.md: eight of the range's 72 commits touch the API surface. Two are absorbed here. Three are registered — project status writes (#12550 finally made archive and unarchive answer 204 instead of a 302 that 406'd), the OAuth 2.1 stack (#9471), and the CreateUploadInput.subscriptions field the endpoint has never read. Two need no SDK change, and #12566 confirms existing modelling rather than exposing drift. Two have no wire effect. One item no gate could have caught: #9471 added `resource :authorization` to routes.rb, so bc3 now serves /authorization.json itself. Its waiver still matches, because the parity gate reads doc/api and doc/api still names Launchpad's host — but the waiver's justification has decayed, which is exactly how waiver lists rot. Registered rather than left. --- COORDINATION.md | 2 +- SPEC.md | 10 +- behavior-model.json | 11 + go/pkg/basecamp/api-provenance.json | 2 +- go/pkg/basecamp/url-routes.json | 3 +- go/pkg/generated/client.gen.go | 318 +++++++++++++++++- .../com/basecamp/sdk/generator/Config.kt | 3 +- .../com/basecamp/sdk/generated/Metadata.kt | 1 + .../basecamp/sdk/generated/services/Types.kt | 9 + .../sdk/generated/services/uploads.kt | 31 +- openapi.json | 292 +++++++++++++++- python/scripts/generate_services.py | 3 +- python/src/basecamp/generated/metadata.json | 11 + .../basecamp/generated/services/uploads.py | 48 +++ python/src/basecamp/generated/types.py | 34 ++ ruby/lib/basecamp/generated/metadata.json | 41 +-- .../generated/services/uploads_service.rb | 18 + ruby/lib/basecamp/generated/types.rb | 78 ++++- ruby/scripts/generate-services.rb | 3 +- spec/api-gaps/README.md | 34 +- spec/api-gaps/upload-create-subscriptions.md | 101 ++++++ spec/api-gaps/upload-new-version.md | 86 ++--- spec/api-provenance.json | 2 +- spec/basecamp.smithy | 124 ++++++- spec/bc3-route-allowlist.yml | 29 +- spec/bc3-routes.json | 2 +- spec/doc-constants.json | 2 +- spec/overlays/tags.smithy | 1 + .../Sources/Basecamp/Generated/Metadata.swift | 1 + .../Models/CreateUploadVersionRequest.swift | 24 ++ .../Generated/Models/UploadVersion.swift | 36 ++ .../Generated/Models/UploadVersionFile.swift | 27 ++ .../Generated/Services/UploadsService.swift | 12 +- .../BasecampGenerator/MethodNaming.swift | 1 + .../BasecampGenerator/ServiceGrouper.swift | 2 +- typescript/scripts/generate-services.ts | 3 +- typescript/src/generated/metadata.ts | 41 +-- .../src/generated/openapi-stripped.json | 281 +++++++++++++++- typescript/src/generated/path-mapping.ts | 1 + typescript/src/generated/schema.d.ts | 201 ++++++++++- typescript/src/generated/services/uploads.ts | 65 +++- 41 files changed, 1836 insertions(+), 158 deletions(-) create mode 100644 spec/api-gaps/upload-create-subscriptions.md create mode 100644 swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift create mode 100644 swift/Sources/Basecamp/Generated/Models/UploadVersion.swift create mode 100644 swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift diff --git a/COORDINATION.md b/COORDINATION.md index e7b4175ba7..0b8735b022 100644 --- a/COORDINATION.md +++ b/COORDINATION.md @@ -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. +`b5d8c9df8d` as of the 2026-08-05 sync. 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 diff --git a/SPEC.md b/SPEC.md index 1c27fcffc8..02ed457dc4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -520,10 +520,11 @@ Status-mapped codes are verified per the Verification column and are `[conforman | `ambiguous` | 8 | — | false | Multiple matches found (CLI disambiguation) | `[static]` | | `validation` | 9 | 422 | false | Request validation failed | `[conformance]` | | `validation` | 9 | 400 | false | Request validation failed | `[conformance]` | +| `limit_exceeded` | 10 | 507 | false | An account limit blocks the request (file storage, webhooks) | `[conformance]` | ### HTTP Status Mapping Algorithm -Each explicitly enumerated status mapping below (steps 1–10) is `[conformance]`-verified. The two catch-all fallback steps (11: general 5xx; 12: any other non-mapped status) have no dedicated conformance case and are `[static]`. +Each explicitly enumerated status mapping below (steps 1–11) is `[conformance]`-verified. The two catch-all fallback steps (12: general 5xx; 13: any other non-mapped status) have no dedicated conformance case and are `[static]`. Given an HTTP response with status code `status` and body `body`: @@ -537,8 +538,11 @@ Given an HTTP response with status code `status` and body `body`: 8. If `status == 502` → `BasecampError(code: "api_error", http_status: 502, retryable: true)`. 9. If `status == 503` → `BasecampError(code: "api_error", http_status: 503, retryable: true)`. 10. If `status == 504` → `BasecampError(code: "api_error", http_status: 504, retryable: true)`. -11. If `status >= 500` → `BasecampError(code: "api_error", http_status: status, retryable: true)`. `[static]` -12. Otherwise → `BasecampError(code: "api_error", http_status: status, retryable: false)`. `[static]` +11. If `status == 507` → `BasecampError(code: "limit_exceeded", http_status: 507, retryable: false)`. +12. If `status >= 500` → `BasecampError(code: "api_error", http_status: status, retryable: true)`. `[static]` +13. Otherwise → `BasecampError(code: "api_error", http_status: status, retryable: false)`. `[static]` + +Step 11 must precede the 5xx catch-all. A 507 is a *server* status carrying a *client* fact: the account is out of storage, or at its webhook ceiling. Retrying cannot satisfy it, so classifying it by its 5xx range alone would report a plan limit as a transient server error — indistinguishable, to a caller deciding whether to back off, from a 500. Ordering is what makes the distinction, since both steps match. In all cases, extract `request_id` from `X-Request-Id` response header if present. `[conformance]` diff --git a/behavior-model.json b/behavior-model.json index 8bcaa8cd37..bd7cbf7880 100644 --- a/behavior-model.json +++ b/behavior-model.json @@ -392,6 +392,17 @@ ] } }, + "CreateUploadVersion": { + "retry": { + "max": 2, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateVault": { "retry": { "max": 2, diff --git a/go/pkg/basecamp/api-provenance.json b/go/pkg/basecamp/api-provenance.json index cfd5e32b66..eb7a71835e 100644 --- a/go/pkg/basecamp/api-provenance.json +++ b/go/pkg/basecamp/api-provenance.json @@ -1,7 +1,7 @@ { "bc3": { "branch": "master", - "revision": "7fe1c63ab33be059605b58e3721cc5db02c9e59a", + "revision": "b5d8c9df8dd957e78bf2618807623d14b4704dc2", "date": "2026-08-05" }, "compatibility": { diff --git a/go/pkg/basecamp/url-routes.json b/go/pkg/basecamp/url-routes.json index 009f899351..fba04fbb61 100644 --- a/go/pkg/basecamp/url-routes.json +++ b/go/pkg/basecamp/url-routes.json @@ -2700,7 +2700,8 @@ "pattern": "/{accountId}/uploads/{uploadId}/versions", "resource": "Files", "operations": { - "GET": "ListUploadVersions" + "GET": "ListUploadVersions", + "POST": "CreateUploadVersion" }, "params": { "accountId": { diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index b8ec7d0841..e4af618caf 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -1014,6 +1014,30 @@ type CreateUploadRequestContent struct { // CreateUploadResponseContent defines model for CreateUploadResponseContent. type CreateUploadResponseContent = Upload +// CreateUploadVersionRequestContent defines model for CreateUploadVersionRequestContent. +type CreateUploadVersionRequestContent struct { + AttachableSgid string `json:"attachable_sgid"` + + // BaseName Omit to keep the uploaded file's own name. Sending "" also keeps it. + BaseName *string `json:"base_name,omitempty"` + + // Description Presence-aware: omit to carry the previous version's description forward, + // send "" to clear it, send a value to set it. + Description *string `json:"description,omitempty"` + + // Notify Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + // + // Omit both this and subscriptions to notify nobody. A subscriptions array sent + // without notify is read as "custom". + Notify *string `json:"notify,omitempty"` + + // Subscriptions People to notify about the replacement and subscribe to the upload. + Subscriptions *[]int64 `json:"subscriptions,omitempty"` +} + +// CreateUploadVersionResponseContent defines model for CreateUploadVersionResponseContent. +type CreateUploadVersionResponseContent = Upload + // CreateVaultRequestContent defines model for CreateVaultRequestContent. type CreateVaultRequestContent struct { Title string `json:"title"` @@ -1977,7 +2001,7 @@ type ListTodolistsResponseContent = []Todolist type ListTodosResponseContent = []Todo // ListUploadVersionsResponseContent defines model for ListUploadVersionsResponseContent. -type ListUploadVersionsResponseContent = []Upload +type ListUploadVersionsResponseContent = []UploadVersion // ListUploadsResponseContent defines model for ListUploadsResponseContent. type ListUploadsResponseContent = []Upload @@ -2968,6 +2992,16 @@ type SetClientVisibilityRequestContent struct { // SetClientVisibilityResponseContent defines model for SetClientVisibilityResponseContent. type SetClientVisibilityResponseContent = Recording +// StorageLimitErrorResponseContent The account has reached its file storage limit. +// +// Raised by ResourceLimits#ensure_account_can_upload_files ahead of any operation +// that stores new bytes. No retry can satisfy it: the account needs more storage, +// so this maps to `limit_exceeded` rather than a retryable server error. +type StorageLimitErrorResponseContent struct { + Error string `json:"error"` + Message *string `json:"message,omitempty"` +} + // SubscribeResponseContent defines model for SubscribeResponseContent. type SubscribeResponseContent = Subscription @@ -4036,6 +4070,45 @@ type Upload struct { Width *types.FlexInt `json:"width,omitempty"` } +// UploadVersion A version event for an upload, from GET /uploads/{id}/versions.json. +// +// `action` is one of `created`, `active` (the upload's publication) or `blob_changed` +// (a file replacement). A caller listing past versions of the file filters on +// `blob_changed`. This is an Event plus the file it recorded, rendered by its own +// partial rather than the shared event one, so upload fields don't leak onto todo, +// message and card events. +type UploadVersion struct { + Action string `json:"action"` + BoostsCount *int32 `json:"boosts_count,omitempty"` + BoostsUrl *string `json:"boosts_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + Creator Person `json:"creator"` + Details *EventDetails `json:"details,omitempty"` + Id int64 `json:"id"` + RecordingId int64 `json:"recording_id"` + + // Upload The file a version event recorded — a reduced projection, not an Upload. + Upload *UploadVersionFile `json:"upload,omitempty"` +} + +// UploadVersionFile The file a version event recorded — a reduced projection, not an Upload. +type UploadVersionFile struct { + AppDownloadUrl string `json:"app_download_url"` + ByteSize *int64 `json:"byte_size,omitempty"` + ContentType *string `json:"content_type,omitempty"` + + // Current True for the most recent version; exactly one element per response. This is the + // newest *version*, not necessarily the file the upload's own download_url serves — + // a metadata-only PUT swaps in a recordable carrying the same blob and emits no + // event, so after one no event references the upload's current recordable. + Current bool `json:"current"` + + // DownloadUrl Fetches THIS version's bytes. The upload's own download_url always serves the + // latest, which is the whole point of the feature. + DownloadUrl string `json:"download_url"` + Filename string `json:"filename"` +} + // ValidationErrorResponseContent defines model for ValidationErrorResponseContent. type ValidationErrorResponseContent struct { Error string `json:"error"` @@ -5041,6 +5114,9 @@ type CreateTodolistJSONRequestBody = CreateTodolistRequestContent // UpdateUploadJSONRequestBody defines body for UpdateUpload for application/json ContentType. type UpdateUploadJSONRequestBody = UpdateUploadRequestContent +// CreateUploadVersionJSONRequestBody defines body for CreateUploadVersion for application/json ContentType. +type CreateUploadVersionJSONRequestBody = CreateUploadVersionRequestContent + // UpdateVaultJSONRequestBody defines body for UpdateVault for application/json ContentType. type UpdateVaultJSONRequestBody = UpdateVaultRequestContent @@ -6406,6 +6482,11 @@ type ClientInterface interface { // ListUploadVersions request ListUploadVersions(ctx context.Context, accountId string, uploadId int64, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateUploadVersionWithBody request with any body + CreateUploadVersionWithBody(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateUploadVersion(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetVault request GetVault(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -9894,6 +9975,36 @@ func (c *Client) ListUploadVersions(ctx context.Context, accountId string, uploa } +// CreateUploadVersionWithBody executes the CreateUploadVersion operation. + +func (c *Client) CreateUploadVersionWithBody(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + + req, err := NewCreateUploadVersionRequestWithBody(c.Server, accountId, uploadId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) + +} + +func (c *Client) CreateUploadVersion(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + + req, err := NewCreateUploadVersionRequest(c.Server, accountId, uploadId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) + +} + // GetVault is marked as idempotent and will be retried on transient failures. func (c *Client) GetVault(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -23079,6 +23190,60 @@ func NewListUploadVersionsRequest(server string, accountId string, uploadId int6 return req, nil } +// NewCreateUploadVersionRequest calls the generic CreateUploadVersion builder with application/json body +func NewCreateUploadVersionRequest(server string, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateUploadVersionRequestWithBody(server, accountId, uploadId, "application/json", bodyReader) +} + +// NewCreateUploadVersionRequestWithBody generates requests for CreateUploadVersion with any type of body +func NewCreateUploadVersionRequestWithBody(server string, accountId string, uploadId int64, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "accountId", runtime.ParamLocationPath, accountId) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "uploadId", runtime.ParamLocationPath, uploadId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/%s/uploads/%s/versions.json", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetVaultRequest generates requests for GetVault func NewGetVaultRequest(server string, accountId string, vaultId int64) (*http.Request, error) { var err error @@ -23925,6 +24090,7 @@ var operationMetadata = map[string]OperationMetadata{ "GetUpload": {Idempotent: true, HasSensitiveParams: false}, "UpdateUpload": {Idempotent: true, HasSensitiveParams: false}, "ListUploadVersions": {Idempotent: true, HasSensitiveParams: false}, + "CreateUploadVersion": {Idempotent: false, HasSensitiveParams: false}, "GetVault": {Idempotent: true, HasSensitiveParams: false}, "UpdateVault": {Idempotent: true, HasSensitiveParams: false}, "ListDocuments": {Idempotent: true, HasSensitiveParams: false}, @@ -24183,6 +24349,7 @@ var operationRetryMax = map[string]int{ "GetUpload": 3, "UpdateUpload": 3, "ListUploadVersions": 3, + "CreateUploadVersion": 2, "GetVault": 3, "UpdateVault": 3, "ListDocuments": 3, @@ -24439,6 +24606,7 @@ var operationRetryOn = map[string][]int{ "GetUpload": {429, 503}, "UpdateUpload": {429, 503}, "ListUploadVersions": {429, 503}, + "CreateUploadVersion": {429, 503}, "GetVault": {429, 503}, "UpdateVault": {429, 503}, "ListDocuments": {429, 503}, @@ -26282,6 +26450,11 @@ type ClientWithResponsesInterface interface { // ListUploadVersionsWithResponse request ListUploadVersionsWithResponse(ctx context.Context, accountId string, uploadId int64, reqEditors ...RequestEditorFn) (*ListUploadVersionsResponse, error) + // CreateUploadVersionWithBodyWithResponse request with any body + CreateUploadVersionWithBodyWithResponse(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) + + CreateUploadVersionWithResponse(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) + // GetVaultWithResponse request GetVaultWithResponse(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*GetVaultResponse, error) @@ -26470,6 +26643,7 @@ type CreateAttachmentResponse struct { JSON422 *ValidationErrorResponseContent JSON429 *RateLimitErrorResponseContent JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent } // Status returns HTTPResponse.Status @@ -28602,6 +28776,7 @@ type CreateCampfireUploadResponse struct { JSON422 *ValidationErrorResponseContent JSON429 *RateLimitErrorResponseContent JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent } // Status returns HTTPResponse.Status @@ -34482,6 +34657,43 @@ func (r ListUploadVersionsResponse) ContentType() string { return "" } +type CreateUploadVersionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreateUploadVersionResponseContent + JSON401 *UnauthorizedErrorResponseContent + JSON403 *ForbiddenErrorResponseContent + JSON404 *NotFoundErrorResponseContent + JSON422 *ValidationErrorResponseContent + JSON429 *RateLimitErrorResponseContent + JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent +} + +// Status returns HTTPResponse.Status +func (r CreateUploadVersionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateUploadVersionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateUploadVersionResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetVaultResponse struct { Body []byte HTTPResponse *http.Response @@ -34663,6 +34875,7 @@ type CreateUploadResponse struct { JSON422 *ValidationErrorResponseContent JSON429 *RateLimitErrorResponseContent JSON500 *InternalServerErrorResponseContent + JSON507 *StorageLimitErrorResponseContent } // Status returns HTTPResponse.Status @@ -37635,6 +37848,23 @@ func (c *ClientWithResponses) ListUploadVersionsWithResponse(ctx context.Context return ParseListUploadVersionsResponse(rsp) } +// CreateUploadVersionWithBodyWithResponse request with arbitrary body returning *CreateUploadVersionResponse +func (c *ClientWithResponses) CreateUploadVersionWithBodyWithResponse(ctx context.Context, accountId string, uploadId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) { + rsp, err := c.CreateUploadVersionWithBody(ctx, accountId, uploadId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUploadVersionResponse(rsp) +} + +func (c *ClientWithResponses) CreateUploadVersionWithResponse(ctx context.Context, accountId string, uploadId int64, body CreateUploadVersionJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUploadVersionResponse, error) { + rsp, err := c.CreateUploadVersion(ctx, accountId, uploadId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUploadVersionResponse(rsp) +} + // GetVaultWithResponse request returning *GetVaultResponse func (c *ClientWithResponses) GetVaultWithResponse(ctx context.Context, accountId string, vaultId int64, reqEditors ...RequestEditorFn) (*GetVaultResponse, error) { rsp, err := c.GetVault(ctx, accountId, vaultId, reqEditors...) @@ -38023,6 +38253,12 @@ func ParseCreateAttachmentResponse(rsp *http.Response) (*CreateAttachmentRespons response.JSON500 = &dest } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + } return response, nil @@ -41289,6 +41525,12 @@ func ParseCreateCampfireUploadResponse(rsp *http.Response) (*CreateCampfireUploa response.JSON500 = &dest } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + } return response, nil @@ -50155,6 +50397,74 @@ func ParseListUploadVersionsResponse(rsp *http.Response) (*ListUploadVersionsRes return response, nil } +// ParseCreateUploadVersionResponse parses an HTTP response from a CreateUploadVersionWithResponse call +func ParseCreateUploadVersionResponse(rsp *http.Response) (*CreateUploadVersionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateUploadVersionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreateUploadVersionResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest UnauthorizedErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON401 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ForbiddenErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON403 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON404 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ValidationErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON422 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest RateLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON429 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON500 = &dest + } + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + + } + + return response, nil +} + // ParseGetVaultResponse parses an HTTP response from a GetVaultWithResponse call func ParseGetVaultResponse(rsp *http.Response) (*GetVaultResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -50468,6 +50778,12 @@ func ParseCreateUploadResponse(rsp *http.Response) (*CreateUploadResponse, error response.JSON500 = &dest } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 507: + var dest StorageLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err == nil { + response.JSON507 = &dest + } + } return response, nil diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt index bc09cf4323..dbdcfc9bbd 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt @@ -49,7 +49,7 @@ val SERVICE_SPLITS: Map>> = mapOf( ), "Files" to mapOf( "Attachments" to listOf("CreateAttachment"), - "Uploads" to listOf("GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"), + "Uploads" to listOf("GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"), "Vaults" to listOf("GetVault", "UpdateVault", "ListVaults", "CreateVault"), "Documents" to listOf("GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"), "CloudFiles" to listOf("GetCloudFile", "CreateCloudFile", "UpdateCloudFile"), @@ -260,6 +260,7 @@ val METHOD_NAME_OVERRIDES = mapOf( "ListUploads" to "list", "CreateUpload" to "create", "ListUploadVersions" to "listVersions", + "CreateUploadVersion" to "createVersion", "GetMessage" to "get", "UpdateMessage" to "update", "CreateMessage" to "create", diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt index dc3c881acf..9b059800a0 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt @@ -55,6 +55,7 @@ object Metadata { "CreateTodosetTodo" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "CreateTool" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateUpload" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), + "CreateUploadVersion" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateVault" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateWebhook" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), "CreateWormhole" to OperationConfig(false, RetryConfig(2, 1000L, "exponential", setOf(429, 503))), diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt index 00efb62643..0898a8f006 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt @@ -1165,6 +1165,15 @@ data class UpdateUploadBody( val baseName: String? = null ) +/** Request body for CreateUploadVersion. */ +data class CreateUploadVersionBody( + val attachableSgid: String, + val baseName: String? = null, + val description: String? = null, + val notify: String? = null, + val subscriptions: List? = null +) + /** Options for ListUploads. */ data class ListUploadsOptions( /** Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8. */ diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt index a7eddb76d5..ba2bd63ce0 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt @@ -61,7 +61,7 @@ open class UploadsService(client: AccountClient) : BaseService(client) { * @param uploadId The upload ID * @param options Optional query parameters and pagination control */ - suspend fun listVersions(uploadId: Long, options: PaginationOptions? = null): ListResult { + suspend fun listVersions(uploadId: Long, options: PaginationOptions? = null): ListResult { val info = OperationInfo( service = "Uploads", operation = "ListUploadVersions", @@ -73,7 +73,34 @@ open class UploadsService(client: AccountClient) : BaseService(client) { return requestPaginated(info, options, { httpGet("/uploads/${uploadId}/versions.json", operationName = info.operation) }) { body -> - json.decodeFromString>(body) + json.decodeFromString>(body) + } + } + + /** + * Replace an upload's file with a new version + * @param uploadId The upload ID + * @param body Request body + */ + suspend fun createVersion(uploadId: Long, body: CreateUploadVersionBody): Upload { + val info = OperationInfo( + service = "Uploads", + operation = "CreateUploadVersion", + resourceType = "upload_version", + isMutation = true, + projectId = null, + resourceId = uploadId, + ) + return request(info, { + httpPost("/uploads/${uploadId}/versions.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { + put("attachable_sgid", kotlinx.serialization.json.JsonPrimitive(body.attachableSgid)) + body.baseName?.let { put("base_name", kotlinx.serialization.json.JsonPrimitive(it)) } + body.description?.let { put("description", kotlinx.serialization.json.JsonPrimitive(it)) } + body.notify?.let { put("notify", kotlinx.serialization.json.JsonPrimitive(it)) } + body.subscriptions?.let { put("subscriptions", kotlinx.serialization.json.JsonArray(it.map { kotlinx.serialization.json.JsonPrimitive(it) })) } + }), operationName = info.operation) + }) { body -> + json.decodeFromString(body) } } diff --git a/openapi.json b/openapi.json index 45661b542e..8f3498bb8c 100644 --- a/openapi.json +++ b/openapi.json @@ -476,6 +476,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -7312,6 +7322,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -25371,6 +25391,136 @@ 503 ] } + }, + "post": { + "description": "Replace an upload's file with a new version\n\nThe recording keeps its id, its URL and its comments; the previous file becomes a\npast version. Use this instead of CreateUpload when publishing a new release of the\nsame file, so its published link keeps working.", + "operationId": "CreateUploadVersion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "uploadId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "CreateUploadVersion 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Files" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/{accountId}/vaults/{vaultId}": { @@ -25995,6 +26145,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -28974,6 +29134,40 @@ "CreateUploadResponseContent": { "$ref": "#/components/schemas/Upload" }, + "CreateUploadVersionRequestContent": { + "type": "object", + "properties": { + "attachable_sgid": { + "type": "string" + }, + "base_name": { + "type": "string", + "description": "Omit to keep the uploaded file's own name. Sending \"\" also keeps it." + }, + "description": { + "type": "string", + "description": "Presence-aware: omit to carry the previous version's description forward,\nsend \"\" to clear it, send a value to set it." + }, + "notify": { + "type": "string", + "description": "Who to notify: \"default\", \"everyone\", or \"custom\" (the people in subscriptions).\n\nOmit both this and subscriptions to notify nobody. A subscriptions array sent\nwithout notify is read as \"custom\"." + }, + "subscriptions": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "People to notify about the replacement and subscribe to the upload." + } + }, + "required": [ + "attachable_sgid" + ] + }, + "CreateUploadVersionResponseContent": { + "$ref": "#/components/schemas/Upload" + }, "CreateVaultRequestContent": { "type": "object", "properties": { @@ -31188,7 +31382,7 @@ "ListUploadVersionsResponseContent": { "type": "array", "items": { - "$ref": "#/components/schemas/Upload" + "$ref": "#/components/schemas/UploadVersion" } }, "ListUploadsResponseContent": { @@ -33616,6 +33810,21 @@ "SetClientVisibilityResponseContent": { "$ref": "#/components/schemas/Recording" }, + "StorageLimitErrorResponseContent": { + "type": "object", + "description": "The account has reached its file storage limit.\n\nRaised by ResourceLimits#ensure_account_can_upload_files ahead of any operation\nthat stores new bytes. No retry can satisfy it: the account needs more storage,\nso this maps to `limit_exceeded` rather than a retryable server error.", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, "SubscribeResponseContent": { "$ref": "#/components/schemas/Subscription" }, @@ -35622,6 +35831,87 @@ "visible_to_clients" ] }, + "UploadVersion": { + "type": "object", + "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). A caller listing past versions of the file filters on\n`blob_changed`. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "recording_id": { + "type": "integer", + "format": "int64" + }, + "action": { + "type": "string" + }, + "details": { + "$ref": "#/components/schemas/EventDetails" + }, + "created_at": { + "type": "string", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + } + }, + "creator": { + "$ref": "#/components/schemas/Person" + }, + "boosts_count": { + "type": "integer", + "format": "int32" + }, + "boosts_url": { + "type": "string" + }, + "upload": { + "$ref": "#/components/schemas/UploadVersionFile" + } + }, + "required": [ + "action", + "created_at", + "creator", + "id", + "recording_id" + ] + }, + "UploadVersionFile": { + "type": "object", + "description": "The file a version event recorded — a reduced projection, not an Upload.", + "properties": { + "content_type": { + "type": "string" + }, + "byte_size": { + "type": "integer", + "format": "int64" + }, + "filename": { + "type": "string" + }, + "download_url": { + "type": "string", + "description": "Fetches THIS version's bytes. The upload's own download_url always serves the\nlatest, which is the whole point of the feature.", + "x-basecamp-auth-routable-url": {} + }, + "app_download_url": { + "type": "string" + }, + "current": { + "type": "boolean", + "description": "True for the most recent version; exactly one element per response. This is the\nnewest *version*, not necessarily the file the upload's own download_url serves —\na metadata-only PUT swaps in a recordable carrying the same blob and emits no\nevent, so after one no event references the upload's current recordable." + } + }, + "required": [ + "app_download_url", + "current", + "download_url", + "filename" + ] + }, "ValidationErrorResponseContent": { "type": "object", "properties": { diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py index 3d2d4eba2b..5b6aa79c41 100644 --- a/python/scripts/generate_services.py +++ b/python/scripts/generate_services.py @@ -66,7 +66,7 @@ }, "Files": { "Attachments": ["CreateAttachment"], - "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"], + "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"], "Vaults": ["GetVault", "UpdateVault", "ListVaults", "CreateVault"], "Documents": ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"], "CloudFiles": ["GetCloudFile", "CreateCloudFile", "UpdateCloudFile"], @@ -223,6 +223,7 @@ "ListUploads": "list", "CreateUpload": "create", "ListUploadVersions": "list_versions", + "CreateUploadVersion": "create_version", "GetMessage": "get", "UpdateMessage": "update", "CreateMessage": "create", diff --git a/python/src/basecamp/generated/metadata.json b/python/src/basecamp/generated/metadata.json index ecaedd5af5..0d81effcf1 100644 --- a/python/src/basecamp/generated/metadata.json +++ b/python/src/basecamp/generated/metadata.json @@ -388,6 +388,17 @@ ] } }, + "CreateUploadVersion": { + "retry": { + "backoff": "exponential", + "base_delay_ms": 1000, + "max": 2, + "retry_on": [ + 429, + 503 + ] + } + }, "CreateVault": { "retry": { "backoff": "exponential", diff --git a/python/src/basecamp/generated/services/uploads.py b/python/src/basecamp/generated/services/uploads.py index b26df7492f..7207fe1d01 100644 --- a/python/src/basecamp/generated/services/uploads.py +++ b/python/src/basecamp/generated/services/uploads.py @@ -36,6 +36,30 @@ def list_versions(self, *, upload_id: int, max_items: int | None = None) -> List operation="ListUploadVersions", ) + def create_version( + self, + *, + upload_id: int, + attachable_sgid: str, + base_name: str | None = None, + description: str | None = None, + notify: str | None = None, + subscriptions: list[int] | None = None, + ) -> dict[str, Any]: + return self._request( + OperationInfo(service="uploads", operation="create_version", is_mutation=True, resource_id=upload_id), + "POST", + f"/uploads/{upload_id}/versions.json", + json_body=self._compact( + attachable_sgid=attachable_sgid, + base_name=base_name, + description=description, + notify=notify, + subscriptions=subscriptions, + ), + operation="CreateUploadVersion", + ) + def list(self, *, vault_id: int, page: int | None = None, max_items: int | None = None) -> ListResult: return self._request_paginated( OperationInfo(service="uploads", operation="list", is_mutation=False, resource_id=vault_id), @@ -98,6 +122,30 @@ async def list_versions(self, *, upload_id: int, max_items: int | None = None) - operation="ListUploadVersions", ) + async def create_version( + self, + *, + upload_id: int, + attachable_sgid: str, + base_name: str | None = None, + description: str | None = None, + notify: str | None = None, + subscriptions: list[int] | None = None, + ) -> dict[str, Any]: + return await self._request( + OperationInfo(service="uploads", operation="create_version", is_mutation=True, resource_id=upload_id), + "POST", + f"/uploads/{upload_id}/versions.json", + json_body=self._compact( + attachable_sgid=attachable_sgid, + base_name=base_name, + description=description, + notify=notify, + subscriptions=subscriptions, + ), + operation="CreateUploadVersion", + ) + async def list(self, *, vault_id: int, page: int | None = None, max_items: int | None = None) -> ListResult: return await self._request_paginated( OperationInfo(service="uploads", operation="list", is_mutation=False, resource_id=vault_id), diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index f3686e9022..f131b6cf57 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -628,6 +628,14 @@ class CreateUploadRequestContent(TypedDict): visible_to_clients: NotRequired[bool] +class CreateUploadVersionRequestContent(TypedDict): + attachable_sgid: str + base_name: NotRequired[str] + description: NotRequired[str] + notify: NotRequired[str] + subscriptions: NotRequired[list[int]] + + class CreateVaultRequestContent(TypedDict): title: str @@ -1654,6 +1662,11 @@ class SetClientVisibilityRequestContent(TypedDict): visible_to_clients: bool +class StorageLimitErrorResponseContent(TypedDict): + error: str + message: NotRequired[str] + + class Subscription(TypedDict): count: int subscribed: bool @@ -2163,6 +2176,27 @@ class Upload(TypedDict): width: NotRequired[int | float] +class UploadVersion(TypedDict): + action: str + boosts_count: NotRequired[int] + boosts_url: NotRequired[str] + created_at: str + creator: Person + details: NotRequired[EventDetails] + id: int + recording_id: int + upload: NotRequired[UploadVersionFile] + + +class UploadVersionFile(TypedDict): + app_download_url: str + byte_size: NotRequired[int] + content_type: NotRequired[str] + current: bool + download_url: str + filename: str + + class ValidationErrorResponseContent(TypedDict): error: str message: NotRequired[str] diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index f5c48ea12d..5fd2d9a21e 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-06T00:45:34Z", + "generated": "2026-08-06T04:48:02Z", "operations": { "GetAccount": { "retry": { @@ -1806,34 +1806,6 @@ "natural": true } }, - "UnarchiveProject": { - "retry": { - "maxAttempts": 3, - "baseDelayMs": 1000, - "backoff": "exponential", - "retryOn": [ - 429, - 503 - ] - }, - "idempotent": { - "natural": true - } - }, - "ArchiveProject": { - "retry": { - "maxAttempts": 3, - "baseDelayMs": 1000, - "backoff": "exponential", - "retryOn": [ - 429, - 503 - ] - }, - "idempotent": { - "natural": true - } - }, "GetProjectTimeline": { "retry": { "maxAttempts": 3, @@ -3148,6 +3120,17 @@ "maxPageSize": 50 } }, + "CreateUploadVersion": { + "retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, "GetVault": { "retry": { "maxAttempts": 3, diff --git a/ruby/lib/basecamp/generated/services/uploads_service.rb b/ruby/lib/basecamp/generated/services/uploads_service.rb index 5b3c3e50da..5ea59b4f6f 100644 --- a/ruby/lib/basecamp/generated/services/uploads_service.rb +++ b/ruby/lib/basecamp/generated/services/uploads_service.rb @@ -37,6 +37,24 @@ def list_versions(upload_id:, max_items: nil) end end + # Replace an upload's file with a new version + # @param upload_id [Integer] upload id ID + # @param attachable_sgid [String] attachable sgid + # @param base_name [String, nil] Omit to keep the uploaded file's own name. Sending "" also keeps it. + # @param description [String, nil] Presence-aware: omit to carry the previous version's description forward, + # send "" to clear it, send a value to set it. + # @param notify [String, nil] Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + # + # Omit both this and subscriptions to notify nobody. A subscriptions array sent + # without notify is read as "custom". + # @param subscriptions [Array, nil] People to notify about the replacement and subscribe to the upload. + # @return [Hash] response data + def create_version(upload_id:, attachable_sgid:, base_name: nil, description: nil, notify: nil, subscriptions: nil) + with_operation(service: "uploads", operation: "create_version", is_mutation: true, resource_id: upload_id) do + http_post("/uploads/#{upload_id}/versions.json", body: compact_params(attachable_sgid: attachable_sgid, base_name: base_name, description: description, notify: notify, subscriptions: subscriptions)).json + end + end + # List uploads in a vault # @param vault_id [Integer] vault id ID # @param page [Integer, nil] Page number for paginating through results. Defaults to 1. A positive value selects exactly that page, not a starting offset; see SPEC section 8. diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 67a35256d1..1f6f9ae940 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-08-06T00:45:34Z +# Generated: 2026-08-06T04:48:02Z require "json" require "time" @@ -5063,6 +5063,82 @@ def to_json(*args) end end + # UploadVersion + class UploadVersion + include TypeHelpers + attr_accessor :action, :created_at, :creator, :id, :recording_id, :boosts_count, :boosts_url, :details, :upload + + # @return [Array] + def self.required_fields + %i[action created_at creator id recording_id].freeze + end + + def initialize(data = {}) + @action = data["action"] + @created_at = parse_datetime(data["created_at"]) + @creator = parse_type(data["creator"], "Person") + @id = parse_integer(data["id"]) + @recording_id = parse_integer(data["recording_id"]) + @boosts_count = parse_integer(data["boosts_count"]) + @boosts_url = data["boosts_url"] + @details = parse_type(data["details"], "EventDetails") + @upload = parse_type(data["upload"], "UploadVersionFile") + end + + def to_h + { + "action" => @action, + "created_at" => @created_at, + "creator" => @creator, + "id" => @id, + "recording_id" => @recording_id, + "boosts_count" => @boosts_count, + "boosts_url" => @boosts_url, + "details" => @details, + "upload" => @upload, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + + # UploadVersionFile + class UploadVersionFile + include TypeHelpers + attr_accessor :app_download_url, :current, :download_url, :filename, :byte_size, :content_type + + # @return [Array] + def self.required_fields + %i[app_download_url current download_url filename].freeze + end + + def initialize(data = {}) + @app_download_url = data["app_download_url"] + @current = parse_boolean(data["current"]) + @download_url = data["download_url"] + @filename = data["filename"] + @byte_size = parse_integer(data["byte_size"]) + @content_type = data["content_type"] + end + + def to_h + { + "app_download_url" => @app_download_url, + "current" => @current, + "download_url" => @download_url, + "filename" => @filename, + "byte_size" => @byte_size, + "content_type" => @content_type, + }.compact + end + + def to_json(*args) + to_h.to_json(*args) + end + end + # Vault class Vault include TypeHelpers diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb index cdf51e437c..71eb8733d0 100644 --- a/ruby/scripts/generate-services.rb +++ b/ruby/scripts/generate-services.rb @@ -65,7 +65,7 @@ class ServiceGenerator }, 'Files' => { 'Attachments' => %w[CreateAttachment], - 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions], + 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions CreateUploadVersion], 'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault], 'Documents' => %w[GetDocument ReplaceDocument ListDocuments CreateDocument], 'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile], @@ -217,6 +217,7 @@ class ServiceGenerator 'ListUploads' => 'list', 'CreateUpload' => 'create', 'ListUploadVersions' => 'list_versions', + 'CreateUploadVersion' => 'create_version', 'GetMessage' => 'get', 'UpdateMessage' => 'update', 'CreateMessage' => 'create', diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index b020637c12..ff2b764add 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -52,7 +52,8 @@ making the absorption journey publicly auditable. | [todoset-direct-todo-create](todoset-direct-todo-create.md) | absorbed-in-sdk | post-train | medium | | [schedule-recurrence-writes](schedule-recurrence-writes.md) | addressed-in-bc3-pr-12359 | post-train | medium | | [dock-tool-create-contract](dock-tool-create-contract.md) | absorbed-in-sdk | launch | medium | -| [upload-new-version](upload-new-version.md) | addressed-in-bc3-pr-12555 | post-train | medium | +| [upload-new-version](upload-new-version.md) | absorbed-in-sdk | post-train | medium | +| [upload-create-subscriptions](upload-create-subscriptions.md) | partial-coverage | n/a | low | | [todolist-reposition](todolist-reposition.md) | absorbed-in-sdk | pre-BC5 | medium | | [rich-text-attachments-coverage](rich-text-attachments-coverage.md) | absorbed-in-sdk | n/a | medium | | [visible-to-clients-on-creates](visible-to-clients-on-creates.md) | absorbed-in-sdk | post-train | medium | @@ -97,13 +98,42 @@ making the absorption journey publicly auditable. > tracked in #12463) and the SDK's matching removal of `GetEverythingBoosts`; > its `no-json-contract` is literal — the feed has no JSON API today. > -> The provenance pin is `7fe1c63ab3` (2026-08-05). +> The provenance pin is `b5d8c9df8d` (2026-08-05). > That line is checked by `make doc-constants-check` and deliberately *not* > rewritten by `make sync-api-version`: this file is in > `spec/doc-constants.json` `.writerExcludes`, because the pin sentence heads > the range triage below and cannot advance without that triage advancing too. > The ranges themselves are settled history and stay unmarked. > +> The `7fe1c63ab3..b5d8c9df8d` range is **one commit**, and it is the companion +> to the absorption this repin carries: BC3 **#12565** (`b5d8c9df8d`) settles the +> input contract of the replacement endpoint #12555 shipped. It documents +> `notify` and `subscriptions` — `Subscribers#notify_param` defaults to +> `"custom"`, so an audience arrives either through `notify` naming a mode or +> through a bare `subscriptions` array, and the web form already relied on the +> second — and it removes `visible_to_clients` from the endpoint's reachable +> surface. That parameter never set the recording's visibility; it only widened +> `Subscribers`' audience, so a request could announce a client-invisible file to +> a project's clients. It also pins `""` as a description clear on both the +> replacement and `PUT /uploads/{id}.json`, which is the spelling the SDKs can +> express — five of six strip nulls structurally before the wire. +> +> Absorbed here as `CreateUploadVersion`, `UploadVersion` / `UploadVersionFile` +> and `StorageLimitError`, closing [`upload-new-version`](upload-new-version.md) +> and basecamp-sdk#649. The `POST /uploads/:id/versions` waivers the previous +> repin added to `spec/bc3-route-allowlist.yml` are **deleted** by that +> absorption, which is what the gate demands — a waiver matching nothing is a +> hard failure, not a shrug. +> +> One correction to the previous range's disposition, not a new finding: that +> triage recorded the 507 as needing "its own error shape", which it got +> (`StorageLimitError`, distinct from `ProjectLimitError` as required). What it +> did not record is that **neither** shape was classified correctly. SPEC §6 had +> no 507 step, so both fell through to `status >= 500` and surfaced as +> `api_error` with `retryable: true` — a plan limit reported as a transient +> server error. §6 now maps 507 to `limit_exceeded`, non-retryable, ahead of the +> 5xx catch-all, which fixes `ProjectLimitError` as well as the new shape. +> > The `4e34dc83eb..7fe1c63ab3` range (71 commits) contains exactly **six** > API-contract or API-documentation changes. Three touch `doc/api`; the other > three change the wire, or a payload's backing field, without a documentation or diff --git a/spec/api-gaps/upload-create-subscriptions.md b/spec/api-gaps/upload-create-subscriptions.md new file mode 100644 index 0000000000..fd7dfa367a --- /dev/null +++ b/spec/api-gaps/upload-create-subscriptions.md @@ -0,0 +1,101 @@ +--- +gap: upload-create-subscriptions +status: partial-coverage +detected: 2026-08-05 +sdk_demand: low +bc3_refs: + routes: + - POST /:account_id/vaults/:vault_id/uploads.json + - POST /:account_id/vaults/:vault_id/uploads/publish.json + controllers: + - app/controllers/uploads_controller.rb + - app/controllers/vaults/uploads_controller.rb + related_existing_api: + - CreateUpload + - CreateUploadVersion +--- + +# `CreateUploadInput.subscriptions` is modeled but never consumed + +## What's missing + +Nothing is missing from BC3 — the defect is on the SDK side. `CreateUploadInput` +models an input the endpoint ignores, so what's missing is either the server +behaviour the field promises or the field's removal. Details below. + +## What's wrong + +`CreateUploadInput` declares `subscriptions: PersonIdList` +(`spec/basecamp.smithy`), so every SDK offers it on create. The endpoint ignores +it. + +`CreateUpload` is `POST /{accountId}/vaults/{vaultId}/uploads.json`, which +`config/routes.rb` routes to `UploadsController#create`. That controller does +**not** include the `Subscribers` concern, and its `create` calls +`@bucket.record(@new_upload.recordable, parent:, status:, visible_to_clients:)` +— no subscribers argument anywhere in the path. + +`subscriptions` *is* consumed for uploads, but by a different controller on a +different route: `Vaults::UploadsController#publish` +(`POST /vaults/:vault_id/uploads/publish.json`), which passes +`subscribers: find_subscribers` into `Upload::Publisher.publish`. An API-created +upload never reaches it — `Upload::Creation#status` returns `active` for an +sgid-backed upload, so there is no draft left to publish. + +`doc/api/sections/uploads.md` is consistent with the code: the "Create an upload" +section documents `attachable_sgid`, `description`, `base_name` and +`visible_to_clients`, and never mentions `subscriptions`. + +## Why it matters + +This is the same class of defect as basecamp-sdk#649, in the structure right +next to the one that fixed it. A caller who passes `subscriptions` to +`uploads.create` gets no error and no subscribers — the field is accepted, +serialized, sent, and dropped. Silent no-ops are worse than absent features, +because nothing tells the caller to go looking. + +It is scoped `low` only because the workaround is easy once known +(`SubscriptionsService` after the create), not because the lie is mild. + +## Contrast with `CreateUploadVersion` + +`CreateUploadVersion` models `notify` and `subscriptions` and both are live — +`Uploads::VersionsController` includes `Subscribers`, and basecamp/bc3#12565 +documented and tested all four input shapes. So the two operations in the same +service now disagree about whether `subscriptions` does anything, which is +exactly the mixed-shape footgun to resolve rather than leave. + +## Suggested API shape + +Either the field goes, or BC3 grows the behaviour it names: + +1. **Remove `subscriptions` from `CreateUploadInput`.** Honest, and a breaking + change to the generated surface in six SDKs — needs its own `MIGRATING.md` + entry. Preferred. +2. **Make BC3 honor it** by including `Subscribers` in `UploadsController#create` + and threading `find_subscribers` through, matching the versions controller. + Turns the lie into a feature, and would make create consistent with + `CreateUploadVersion`, where both `notify` and `subscriptions` are live. + +## Implementation notes for BC3 + +Only needed if option 2 is chosen. `UploadsController#create` would include +`Subscribers` and pass `subscribers: find_subscribers` into the record call, and +`doc/api/sections/uploads.md` would gain `notify` and `subscriptions` bullets on +"Create an upload" mirroring the ones basecamp/bc3#12565 added to "Create an +upload version". Note the upload is already `active` at create for an +sgid-backed upload, so the publish path is not where this would live. + +If option 1 is chosen, BC3 needs no change at all — the docs are already correct. + +## SDK absorption plan when this lands + +Deliberately not bundled into the `CreateUploadVersion` absorption: removing an +existing input member is breaking, and belongs in a change whose title says so. + +- Option 1: drop the member from `CreateUploadInput`, regenerate, and add a + `MIGRATING.md` breaking entry alongside the other uploads-surface entries. + Check the CLI and MCP server for callers first — a silent no-op has no + compile-time users to find, so grep rather than trusting the build. +- Option 2: nothing to change in the SDK; the field starts working, and this + entry closes as `absorbed-in-sdk` with a test asserting subscribers land. diff --git a/spec/api-gaps/upload-new-version.md b/spec/api-gaps/upload-new-version.md index 7c9ce39c0c..59ee54f09c 100644 --- a/spec/api-gaps/upload-new-version.md +++ b/spec/api-gaps/upload-new-version.md @@ -1,9 +1,14 @@ --- gap: upload-new-version -status: addressed-in-bc3-pr-12555 +status: absorbed-in-sdk detected: 2026-07-22 sdk_demand: medium bc3_pr: 12555 +smithy_refs: + - CreateUploadVersion + - CreateUploadVersionInput + - UploadVersion + - UploadVersionFile bc3_refs: introduced_in: BC3 #12555, inside the range the SDK triaged when it registered this routes: @@ -19,34 +24,6 @@ bc3_refs: - ListUploadVersions --- -> **Status: shipped in BC3, not yet absorbed by the SDK.** BC3 **#12555** -> ("Expose upload file replacement over the API") added the write side this brief -> asked for. It chose the **second** of the two options proposed below — a -> dedicated `POST /uploads/:id/versions.json` returning **201**, drawn flat and -> bucket-scoped (`resources :versions, only: %i[ index create ], controller: -> "uploads/versions"`) — and **not** the `PUT /uploads/{id}.json` shape -> basecamp-cli#404 hypothesized and the analysis below disproved. The new version -> payload carries a `current` flag among its fields. -> -> It also answers **507** when the account is over its storage allowance. That -> 507 needs **its own error shape**: it is the *storage* limit ("The storage limit -> for this account has been reached.", -> `app/controllers/concerns/resource_limits.rb`), a different resource with a -> different message from the project limit. `ProjectLimitError` — added for -> `CreateProject`/`UnarchiveProject` in the absorption recorded in -> [`project-archive-unarchive.md`](project-archive-unarchive.md) — is **not** it -> and must not be reused. -> -> Absorption belongs to the `upload-versions-api` branch, not to the repin that -> registered this. Until that lands the SDK models **no** operation for these -> routes, which is why `spec/bc3-route-allowlist.yml` carries a -> `registry: spec/api-gaps/upload-new-version.md` disposition for -> `POST /uploads/:id/versions` — one entry, because direction 2 collapses the -> leading `/buckets/:id` and both documented spellings normalize to that key. -> -> Everything below predates #12555 and is preserved as the analysis that -> established what bc3 did *not* do; read it as history, not as current state. - # Upload a new version of an existing file (write side) ## What's missing @@ -121,15 +98,42 @@ reflect the new blob (`byte_size`, `content_type`, `filename`, `download_url`). ## SDK absorption plan when this lands -- Model the write operation in `spec/basecamp.smithy` (extend - `UpdateUploadInput` with `attachable_sgid`, or add a `CreateUploadVersion` - operation), then `make smithy-build` and regenerate. -- Map the new field/operation in `UploadsService` (`go/pkg/basecamp/vaults.go`) - and the peer SDKs; add `AttachableSGID` to `UpdateUploadRequest` only once the - server honors it. -- Add a canary fixture exercising a real file replacement and confirm the - version list grows, against a live account, before the CLI (basecamp-cli#404) - exposes an "upload new version" command. -- Replace the reflection guard - (`TestUpdateUploadRequest_HasNoFileReplacementField`) with a positive - assertion that the field is now sent. +**Done.** Landed in basecamp/bc3#12555 + #12565 and absorbed here; the plan below +is kept as the record of what was decided. + +BC3 took the **second** option in "Suggested API shape": a dedicated +`POST /uploads/{id}/versions.json`, shipped in basecamp/bc3#12555. `PUT +/uploads/{id}.json` was deliberately left alone, so the hypothesis +basecamp-cli#404 started from stays false — the update still permits only +`base_name` and `description`. + +That decision inverts one bullet of the old absorption plan. The reflection +guard `TestUpdateUploadRequest_HasNoFileReplacementField` is **not** replaced +with "the field is now sent"; it is still true and still worth holding, because +it now pins a design choice rather than a missing feature. It keeps its name, +gains a comment pointing at the sanctioned path, and is joined by a positive +counterpart asserting `CreateUploadVersionRequest` carries `AttachableSGID`. + +Absorbed as: + +- `CreateUploadVersion` — the write, tagged `Files`, grouped into `Uploads`. +- `UploadVersion` / `UploadVersionFile` — the read side. `ListUploadVersionsOutput` + previously declared `uploads: UploadList`, which was a typed lie: the endpoint + returns *events*, and 11 of `Upload`'s 14 `@required` members are absent from + every response. That is basecamp-sdk#649, fixed here rather than merely + corrected, because #12555 also added the nested `upload` object that gives the + version list a filename to report. +- `StorageLimitError` — the `507 Insufficient Storage` contract, which + `ensure_account_can_upload_files` also fronts on three operations the SDK + already modeled (`CreateUpload`, `CreateAttachment`, `CreateCampfireUpload`). + All four now declare it, and SPEC §6 maps 507 to `limit_exceeded` / + non-retryable instead of letting it fall through to the retryable 5xx + catch-all. + +Input contract settled in basecamp/bc3#12565: `notify` and `subscriptions` are +documented and tested, and `visible_to_clients` was removed from the endpoint's +reachable surface (it never set visibility — it only widened the notification +audience, and could announce a client-invisible file to a project's clients). + +Still open: the live-account canary, and basecamp-cli#404's "upload new version" +command. diff --git a/spec/api-provenance.json b/spec/api-provenance.json index cfd5e32b66..eb7a71835e 100644 --- a/spec/api-provenance.json +++ b/spec/api-provenance.json @@ -1,7 +1,7 @@ { "bc3": { "branch": "master", - "revision": "7fe1c63ab33be059605b58e3721cc5db02c9e59a", + "revision": "b5d8c9df8dd957e78bf2618807623d14b4704dc2", "date": "2026-08-05" }, "compatibility": { diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index 9f42028228..c611df9c9b 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -115,6 +115,7 @@ service Basecamp { CreateUpload, UpdateUpload, ListUploadVersions, + CreateUploadVersion, GetCloudFile, CreateCloudFile, UpdateCloudFile, @@ -476,6 +477,19 @@ structure WebhookLimitError { message: String } +/// The account has reached its file storage limit. +/// +/// Raised by ResourceLimits#ensure_account_can_upload_files ahead of any operation +/// that stores new bytes. No retry can satisfy it: the account needs more storage, +/// so this maps to `limit_exceeded` rather than a retryable server error. +@error("server") +@httpError(507) +structure StorageLimitError { + @required + error: String + message: String +} + /// The account has reached its project limit. Raised by CreateProject and by /// UnarchiveProject, both of which add to the active project count. @error("server") @@ -2653,7 +2667,7 @@ structure GetUploadOutput { operation CreateUpload { input: CreateUploadInput output: CreateUploadOutput - errors: [ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] + errors: [ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] } structure CreateUploadInput { @@ -2681,6 +2695,53 @@ structure CreateUploadOutput { upload: Upload } +/// Replace an upload's file with a new version +/// +/// The recording keeps its id, its URL and its comments; the previous file becomes a +/// past version. Use this instead of CreateUpload when publishing a new release of the +/// same file, so its published link keeps working. +@basecampRetry(maxAttempts: 2, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) +@http(method: "POST", uri: "/{accountId}/uploads/{uploadId}/versions.json", code: 201) +operation CreateUploadVersion { + input: CreateUploadVersionInput + output: CreateUploadVersionOutput + errors: [NotFoundError, ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] +} + +structure CreateUploadVersionInput { + @required + @httpLabel + accountId: AccountId + + @required + @httpLabel + uploadId: UploadId + + @required + attachable_sgid: AttachableSgid + + /// Omit to keep the uploaded file's own name. Sending "" also keeps it. + base_name: UploadBaseName + + /// Presence-aware: omit to carry the previous version's description forward, + /// send "" to clear it, send a value to set it. + description: UploadDescription + + /// Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + /// + /// Omit both this and subscriptions to notify nobody. A subscriptions array sent + /// without notify is read as "custom". + notify: String + + /// People to notify about the replacement and subscribe to the upload. + subscriptions: PersonIdList +} + +structure CreateUploadVersionOutput { + + upload: Upload +} + /// Update an existing upload @idempotent @basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) @@ -2738,7 +2799,7 @@ structure ListUploadVersionsInput { structure ListUploadVersionsOutput { - uploads: UploadList + versions: UploadVersionList } // ===== Cloud File Operations ===== @@ -3012,7 +3073,7 @@ structure UpdateGoogleDocumentOutput { operation CreateAttachment { input: CreateAttachmentInput output: CreateAttachmentOutput - errors: [ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] + errors: [ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] } structure CreateAttachmentInput { @@ -4529,7 +4590,7 @@ structure ListCampfireUploadsOutput { operation CreateCampfireUpload { input: CreateCampfireUploadInput output: CreateCampfireUploadOutput - errors: [ValidationError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] + errors: [ValidationError, StorageLimitError, UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] } structure CreateCampfireUploadInput { @@ -7202,6 +7263,61 @@ structure EventDetails { notified_recipient_ids: PersonIdList } +list UploadVersionList { + member: UploadVersion +} + +/// A version event for an upload, from GET /uploads/{id}/versions.json. +/// +/// `action` is one of `created`, `active` (the upload's publication) or `blob_changed` +/// (a file replacement). A caller listing past versions of the file filters on +/// `blob_changed`. This is an Event plus the file it recorded, rendered by its own +/// partial rather than the shared event one, so upload fields don't leak onto todo, +/// message and card events. +structure UploadVersion { + @required + id: EventId + @required + recording_id: RecordingId + @required + action: String + details: EventDetails + @required + created_at: ISO8601Timestamp + @required + creator: Person + boosts_count: Integer + boosts_url: String + + /// The file this version recorded. Absent when the recordable no longer resolves. + upload: UploadVersionFile +} + +/// The file a version event recorded — a reduced projection, not an Upload. +structure UploadVersionFile { + content_type: String + byte_size: Long + + @required + filename: String + + /// Fetches THIS version's bytes. The upload's own download_url always serves the + /// latest, which is the whole point of the feature. + @required + @basecampAuthRoutableUrl + download_url: String + + @required + app_download_url: String + + /// True for the most recent version; exactly one element per response. This is the + /// newest *version*, not necessarily the file the upload's own download_url serves — + /// a metadata-only PUT swaps in a recordable carrying the same blob and emits no + /// event, so after one no event references the upload's current recordable. + @required + current: Boolean +} + // ===== Recording Shapes ===== @documentation("Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault") diff --git a/spec/bc3-route-allowlist.yml b/spec/bc3-route-allowlist.yml index e2d7ce7a60..b1cba7f340 100644 --- a/spec/bc3-route-allowlist.yml +++ b/spec/bc3-route-allowlist.yml @@ -235,34 +235,6 @@ bc3_routes_not_modeled: path: /dock/doors registry: spec/api-gaps/external-links-doors.md - # -- upload file replacement (BC3 #12555, arrived with the repin that registered it) -- - # - # Registered, not waived: bc3 now documents creating a new version of an - # upload, which closes the write side spec/api-gaps/upload-new-version.md has - # described since basecamp-cli#404 hypothesized the wrong shape for it. bc3 - # chose POST .../versions.json (201), not the PUT /uploads/{id}.json that - # brief disproved. - # - # ONE entry covers both spellings. bc3 documents the flat - # POST /uploads/:id/versions and the bucket-scoped - # POST /buckets/:id/uploads/:id/versions, and the gate's direction-2 failure - # named both — but direction 2 collapses a leading /buckets/:id, so the two - # rows normalize to the same key and the flat entry satisfies both. Adding a - # second entry for the bucket-scoped spelling is a HARD FAILURE ("matches - # nothing"), which is how this was established rather than assumed. - # - # Absorption is NOT this repin's job and is not implied by these entries: it - # belongs to the `upload-versions-api` branch, which also has to model the - # 507 this endpoint returns. That 507 is a **storage** limit ("The storage - # limit for this account has been reached.", app/controllers/concerns/ - # resource_limits.rb) and must get its own error shape — ProjectLimitError, - # added here for CreateProject and UnarchiveProject, is a different resource - # with a different message and must not be reused for it. - - - method: POST - path: /uploads/:id/versions - registry: spec/api-gaps/upload-new-version.md - # -- gauge needle show/update/destroy (#581) -- # # Not a coverage gap: bc3 draws these three actions twice, once nested under @@ -312,3 +284,4 @@ bc3_routes_not_modeled: path: /projects/:id/recordings/:id/timesheet/entries modeled_as: CreateTimesheetEntry routes_rb: "config/routes.rb:268 draws the flat /recordings/:id/timesheet/entries; :747 draws the project-scoped form, inside `resources :recordings, only: []` at :744, inside `resources :projects` at :676. Both name controller \"timesheets/entries\", action create. Beware the near-miss: :741 draws entries under the PROJECT timesheet at :740, which is /projects/:id/timesheet/entries and is `only: %i[ show edit update destroy ]` — no create, and a different parent. bc3's API tests cover THIS pair on both sides: \"create entry on project timesheet\" (test/api/timesheets/entries_controller_api_test.rb:11) hits the scoped form, \"create entry via flat recording route\" (:139) the flat one. Read at the pinned revision 2c0dafba13." + diff --git a/spec/bc3-routes.json b/spec/bc3-routes.json index 9c0839d9de..45dd7c3560 100644 --- a/spec/bc3-routes.json +++ b/spec/bc3-routes.json @@ -5,7 +5,7 @@ "source": { "repo": "basecamp/bc3", "branch": "master", - "revision": "7fe1c63ab33be059605b58e3721cc5db02c9e59a", + "revision": "b5d8c9df8dd957e78bf2618807623d14b4704dc2", "date": "2026-08-05", "extracted_from": [ "doc/api/sections/*.md" diff --git a/spec/doc-constants.json b/spec/doc-constants.json index 8bb480b722..dced718198 100644 --- a/spec/doc-constants.json +++ b/spec/doc-constants.json @@ -48,7 +48,7 @@ "unmarkedPinCitations": { "spec/api-gaps/README.md": { "count": 2, - "reason": "the two ways a range triage names its own endpoint, which is by definition the pin that repin set: the 4e34dc83eb..7fe1c63ab3 range itself, and BC3 #12566 cited as the commit that shipped the note documenting a project's status as read-only on update. Both are bound to that triage and stay true after the next repin. Note the count survived a repin WITHIN this triage without changing meaning: the earlier a26c2e479f endpoint and its #12555 citation stopped being checked the moment the pin advanced past them, and the two citations granted here are the new endpoint's. The pin sentence is separately marked and is the only class-A claim in the file" + "reason": "the two ways a range triage names its own endpoint, which is by definition the pin that repin set: the 7fe1c63ab3..b5d8c9df8d range itself, and BC3 #12565 cited as the commit that settled the upload replacement's input contract. Both are bound to that triage and stay true after the next repin. The count has now survived two repins without changing meaning, which is the point of stating it as a count rather than a list: each repin's two citations stop being checked the moment the pin advances past them, and the two granted here are the new endpoint's. The pin sentence is separately marked and is the only class-A claim in the file" } } } diff --git a/spec/overlays/tags.smithy b/spec/overlays/tags.smithy index 076002a4ae..4280ca83fc 100644 --- a/spec/overlays/tags.smithy +++ b/spec/overlays/tags.smithy @@ -64,6 +64,7 @@ apply GetUpload @tags(["Files"]) apply CreateUpload @tags(["Files"]) apply UpdateUpload @tags(["Files"]) apply ListUploadVersions @tags(["Files"]) +apply CreateUploadVersion @tags(["Files"]) apply GetCloudFile @tags(["Files"]) apply CreateCloudFile @tags(["Files"]) apply UpdateCloudFile @tags(["Files"]) diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift index 3cfcfc60ab..4418087745 100644 --- a/swift/Sources/Basecamp/Generated/Metadata.swift +++ b/swift/Sources/Basecamp/Generated/Metadata.swift @@ -38,6 +38,7 @@ enum Metadata { "CreateTodosetTodo": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateTool": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateUpload": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), + "CreateUploadVersion": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateVault": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateWebhook": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "CreateWormhole": RetryConfig(maxAttempts: 2, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), diff --git a/swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift b/swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift new file mode 100644 index 0000000000..7c10429800 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/CreateUploadVersionRequest.swift @@ -0,0 +1,24 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct CreateUploadVersionRequest: Codable, Sendable { + public let attachableSgid: String + public var baseName: String? + public var description: String? + public var notify: String? + public var subscriptions: [Int]? + + public init( + attachableSgid: String, + baseName: String? = nil, + description: String? = nil, + notify: String? = nil, + subscriptions: [Int]? = nil + ) { + self.attachableSgid = attachableSgid + self.baseName = baseName + self.description = description + self.notify = notify + self.subscriptions = subscriptions + } +} diff --git a/swift/Sources/Basecamp/Generated/Models/UploadVersion.swift b/swift/Sources/Basecamp/Generated/Models/UploadVersion.swift new file mode 100644 index 0000000000..fc2a61dbee --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/UploadVersion.swift @@ -0,0 +1,36 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct UploadVersion: Codable, Sendable { + public let action: String + public let createdAt: String + public let creator: Person + public let id: Int + public let recordingId: Int + public var boostsCount: Int32? + public var boostsUrl: String? + public var details: EventDetails? + public var upload: UploadVersionFile? + + public init( + action: String, + createdAt: String, + creator: Person, + id: Int, + recordingId: Int, + boostsCount: Int32? = nil, + boostsUrl: String? = nil, + details: EventDetails? = nil, + upload: UploadVersionFile? = nil + ) { + self.action = action + self.createdAt = createdAt + self.creator = creator + self.id = id + self.recordingId = recordingId + self.boostsCount = boostsCount + self.boostsUrl = boostsUrl + self.details = details + self.upload = upload + } +} diff --git a/swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift b/swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift new file mode 100644 index 0000000000..8b8c8d53f2 --- /dev/null +++ b/swift/Sources/Basecamp/Generated/Models/UploadVersionFile.swift @@ -0,0 +1,27 @@ +// @generated from OpenAPI spec — do not edit directly +import Foundation + +public struct UploadVersionFile: Codable, Sendable { + public let appDownloadUrl: String + public let current: Bool + public let downloadUrl: String + public let filename: String + public var byteSize: Int? + public var contentType: String? + + public init( + appDownloadUrl: String, + current: Bool, + downloadUrl: String, + filename: String, + byteSize: Int? = nil, + contentType: String? = nil + ) { + self.appDownloadUrl = appDownloadUrl + self.current = current + self.downloadUrl = downloadUrl + self.filename = filename + self.byteSize = byteSize + self.contentType = contentType + } +} diff --git a/swift/Sources/Basecamp/Generated/Services/UploadsService.swift b/swift/Sources/Basecamp/Generated/Services/UploadsService.swift index d012767e65..80f65248fe 100644 --- a/swift/Sources/Basecamp/Generated/Services/UploadsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/UploadsService.swift @@ -32,6 +32,16 @@ public final class UploadsService: BaseService, @unchecked Sendable { ) } + public func createVersion(uploadId: Int, req: CreateUploadVersionRequest) async throws -> Upload { + return try await request( + OperationInfo(service: "Uploads", operation: "CreateUploadVersion", resourceType: "upload_version", isMutation: true, resourceId: uploadId), + method: "POST", + path: "/uploads/\(uploadId)/versions.json", + body: req, + retryConfig: Metadata.retryConfig(for: "CreateUploadVersion") + ) + } + public func get(uploadId: Int) async throws -> Upload { return try await request( OperationInfo(service: "Uploads", operation: "GetUpload", resourceType: "upload", isMutation: false, resourceId: uploadId), @@ -41,7 +51,7 @@ public final class UploadsService: BaseService, @unchecked Sendable { ) } - public func listVersions(uploadId: Int, options: ListVersionsUploadOptions? = nil) async throws -> ListResult { + public func listVersions(uploadId: Int, options: ListVersionsUploadOptions? = nil) async throws -> ListResult { return try await requestPaginated( OperationInfo(service: "Uploads", operation: "ListUploadVersions", resourceType: "upload_version", isMutation: false, resourceId: uploadId), path: "/uploads/\(uploadId)/versions.json", diff --git a/swift/Sources/BasecampGenerator/MethodNaming.swift b/swift/Sources/BasecampGenerator/MethodNaming.swift index 5dfc4cb511..861fcf8bbd 100644 --- a/swift/Sources/BasecampGenerator/MethodNaming.swift +++ b/swift/Sources/BasecampGenerator/MethodNaming.swift @@ -124,6 +124,7 @@ let methodNameOverrides: [String: String] = [ "ListUploads": "list", "CreateUpload": "create", "ListUploadVersions": "listVersions", + "CreateUploadVersion": "createVersion", "GetMessage": "get", "UpdateMessage": "update", "CreateMessage": "create", diff --git a/swift/Sources/BasecampGenerator/ServiceGrouper.swift b/swift/Sources/BasecampGenerator/ServiceGrouper.swift index 88601489c8..f52c39b58a 100644 --- a/swift/Sources/BasecampGenerator/ServiceGrouper.swift +++ b/swift/Sources/BasecampGenerator/ServiceGrouper.swift @@ -48,7 +48,7 @@ let serviceSplits: [String: [String: [String]]] = [ ], "Files": [ "Attachments": ["CreateAttachment"], - "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"], + "Uploads": ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"], "Vaults": ["GetVault", "UpdateVault", "ListVaults", "CreateVault"], "Documents": ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"], "CloudFiles": ["GetCloudFile", "CreateCloudFile", "UpdateCloudFile"], diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 0924ad046a..40a3ff2882 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -208,7 +208,7 @@ const SERVICE_SPLITS: Record> = { }, Files: { Attachments: ["CreateAttachment"], - Uploads: ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions"], + Uploads: ["GetUpload", "UpdateUpload", "ListUploads", "CreateUpload", "ListUploadVersions", "CreateUploadVersion"], Vaults: ["GetVault", "UpdateVault", "ListVaults", "CreateVault"], Documents: ["GetDocument", "ReplaceDocument", "ListDocuments", "CreateDocument"], CloudFiles: ["GetCloudFile", "CreateCloudFile", "UpdateCloudFile"], @@ -416,6 +416,7 @@ const METHOD_NAME_OVERRIDES: Record = { ListUploads: "list", CreateUpload: "create", ListUploadVersions: "listVersions", + CreateUploadVersion: "createVersion", GetMessage: "get", UpdateMessage: "update", CreateMessage: "create", diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 8b990a6430..17c322439e 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-06T00:45:32.690Z", + "generated": "2026-08-06T04:48:00.939Z", "operations": { "GetAccount": { "retry": { @@ -1842,34 +1842,6 @@ const metadata: MetadataOutput = { "natural": true } }, - "UnarchiveProject": { - "retry": { - "maxAttempts": 3, - "baseDelayMs": 1000, - "backoff": "exponential", - "retryOn": [ - 429, - 503 - ] - }, - "idempotent": { - "natural": true - } - }, - "ArchiveProject": { - "retry": { - "maxAttempts": 3, - "baseDelayMs": 1000, - "backoff": "exponential", - "retryOn": [ - 429, - 503 - ] - }, - "idempotent": { - "natural": true - } - }, "GetProjectTimeline": { "retry": { "maxAttempts": 3, @@ -3185,6 +3157,17 @@ const metadata: MetadataOutput = { "maxPageSize": 50 } }, + "CreateUploadVersion": { + "retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + }, "GetVault": { "retry": { "maxAttempts": 3, diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index eee27ac01e..aa6b00eb94 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -417,6 +417,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -6571,6 +6581,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -22663,6 +22683,125 @@ 503 ] } + }, + "post": { + "description": "Replace an upload's file with a new version\n\nThe recording keeps its id, its URL and its comments; the previous file becomes a\npast version. Use this instead of CreateUpload when publishing a new release of the\nsame file, so its published link keeps working.", + "operationId": "CreateUploadVersion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "uploadId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "CreateUploadVersion 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadVersionResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Files" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/vaults/{vaultId}": { @@ -23221,6 +23360,16 @@ } } } + }, + "507": { + "description": "StorageLimitError 507 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageLimitErrorResponseContent" + } + } + } } }, "tags": [ @@ -26145,6 +26294,40 @@ "CreateUploadResponseContent": { "$ref": "#/components/schemas/Upload" }, + "CreateUploadVersionRequestContent": { + "type": "object", + "properties": { + "attachable_sgid": { + "type": "string" + }, + "base_name": { + "type": "string", + "description": "Omit to keep the uploaded file's own name. Sending \"\" also keeps it." + }, + "description": { + "type": "string", + "description": "Presence-aware: omit to carry the previous version's description forward,\nsend \"\" to clear it, send a value to set it." + }, + "notify": { + "type": "string", + "description": "Who to notify: \"default\", \"everyone\", or \"custom\" (the people in subscriptions).\n\nOmit both this and subscriptions to notify nobody. A subscriptions array sent\nwithout notify is read as \"custom\"." + }, + "subscriptions": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "People to notify about the replacement and subscribe to the upload." + } + }, + "required": [ + "attachable_sgid" + ] + }, + "CreateUploadVersionResponseContent": { + "$ref": "#/components/schemas/Upload" + }, "CreateVaultRequestContent": { "type": "object", "properties": { @@ -28359,7 +28542,7 @@ "ListUploadVersionsResponseContent": { "type": "array", "items": { - "$ref": "#/components/schemas/Upload" + "$ref": "#/components/schemas/UploadVersion" } }, "ListUploadsResponseContent": { @@ -30787,6 +30970,21 @@ "SetClientVisibilityResponseContent": { "$ref": "#/components/schemas/Recording" }, + "StorageLimitErrorResponseContent": { + "type": "object", + "description": "The account has reached its file storage limit.\n\nRaised by ResourceLimits#ensure_account_can_upload_files ahead of any operation\nthat stores new bytes. No retry can satisfy it: the account needs more storage,\nso this maps to `limit_exceeded` rather than a retryable server error.", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error" + ] + }, "SubscribeResponseContent": { "$ref": "#/components/schemas/Subscription" }, @@ -32793,6 +32991,87 @@ "visible_to_clients" ] }, + "UploadVersion": { + "type": "object", + "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). A caller listing past versions of the file filters on\n`blob_changed`. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "recording_id": { + "type": "integer", + "format": "int64" + }, + "action": { + "type": "string" + }, + "details": { + "$ref": "#/components/schemas/EventDetails" + }, + "created_at": { + "type": "string", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + } + }, + "creator": { + "$ref": "#/components/schemas/Person" + }, + "boosts_count": { + "type": "integer", + "format": "int32" + }, + "boosts_url": { + "type": "string" + }, + "upload": { + "$ref": "#/components/schemas/UploadVersionFile" + } + }, + "required": [ + "action", + "created_at", + "creator", + "id", + "recording_id" + ] + }, + "UploadVersionFile": { + "type": "object", + "description": "The file a version event recorded — a reduced projection, not an Upload.", + "properties": { + "content_type": { + "type": "string" + }, + "byte_size": { + "type": "integer", + "format": "int64" + }, + "filename": { + "type": "string" + }, + "download_url": { + "type": "string", + "description": "Fetches THIS version's bytes. The upload's own download_url always serves the\nlatest, which is the whole point of the feature.", + "x-basecamp-auth-routable-url": {} + }, + "app_download_url": { + "type": "string" + }, + "current": { + "type": "boolean", + "description": "True for the most recent version; exactly one element per response. This is the\nnewest *version*, not necessarily the file the upload's own download_url serves —\na metadata-only PUT swaps in a recordable carrying the same blob and emits no\nevent, so after one no event references the upload's current recordable." + } + }, + "required": [ + "app_download_url", + "current", + "download_url", + "filename" + ] + }, "ValidationErrorResponseContent": { "type": "object", "properties": { diff --git a/typescript/src/generated/path-mapping.ts b/typescript/src/generated/path-mapping.ts index 815be35016..94c32c0180 100644 --- a/typescript/src/generated/path-mapping.ts +++ b/typescript/src/generated/path-mapping.ts @@ -193,6 +193,7 @@ export const PATH_TO_OPERATION: Record = { "GET:/{accountId}/uploads/{uploadId}": "GetUpload", "PUT:/{accountId}/uploads/{uploadId}": "UpdateUpload", "GET:/{accountId}/uploads/{uploadId}/versions.json": "ListUploadVersions", + "POST:/{accountId}/uploads/{uploadId}/versions.json": "CreateUploadVersion", "GET:/{accountId}/vaults/{vaultId}": "GetVault", "PUT:/{accountId}/vaults/{vaultId}": "UpdateVault", "GET:/{accountId}/vaults/{vaultId}/documents.json": "ListDocuments", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 979f049db1..f50490a833 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -3307,7 +3307,14 @@ export interface paths { */ get: operations["ListUploadVersions"]; put?: never; - post?: never; + /** + * @description Replace an upload's file with a new version + * + * The recording keeps its id, its URL and its comments; the previous file becomes a + * past version. Use this instead of CreateUpload when publishing a new release of the + * same file, so its published link keeps working. + */ + post: operations["CreateUploadVersion"]; delete?: never; options?: never; head?: never; @@ -4214,6 +4221,26 @@ export interface components { visible_to_clients?: boolean; }; CreateUploadResponseContent: components["schemas"]["Upload"]; + CreateUploadVersionRequestContent: { + attachable_sgid: string; + /** @description Omit to keep the uploaded file's own name. Sending "" also keeps it. */ + base_name?: string; + /** + * @description Presence-aware: omit to carry the previous version's description forward, + * send "" to clear it, send a value to set it. + */ + description?: string; + /** + * @description Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + * + * Omit both this and subscriptions to notify nobody. A subscriptions array sent + * without notify is read as "custom". + */ + notify?: string; + /** @description People to notify about the replacement and subscribe to the upload. */ + subscriptions?: number[]; + }; + CreateUploadVersionResponseContent: components["schemas"]["Upload"]; CreateVaultRequestContent: { title: string; }; @@ -4915,7 +4942,7 @@ export interface components { ListTodolistGroupsResponseContent: components["schemas"]["Todolist"][]; ListTodolistsResponseContent: components["schemas"]["Todolist"][]; ListTodosResponseContent: components["schemas"]["Todo"][]; - ListUploadVersionsResponseContent: components["schemas"]["Upload"][]; + ListUploadVersionsResponseContent: components["schemas"]["UploadVersion"][]; ListUploadsResponseContent: components["schemas"]["Upload"][]; ListVaultsResponseContent: components["schemas"]["Vault"][]; ListWebhooksResponseContent: components["schemas"]["Webhook"][]; @@ -5896,6 +5923,17 @@ export interface components { visible_to_clients: boolean; }; SetClientVisibilityResponseContent: components["schemas"]["Recording"]; + /** + * @description The account has reached its file storage limit. + * + * Raised by ResourceLimits#ensure_account_can_upload_files ahead of any operation + * that stores new bytes. No retry can satisfy it: the account needs more storage, + * so this maps to `limit_exceeded` rather than a retryable server error. + */ + StorageLimitErrorResponseContent: { + error: string; + message?: string; + }; SubscribeResponseContent: components["schemas"]["Subscription"]; Subscription: { subscribed: boolean; @@ -6796,6 +6834,49 @@ export interface components { boosts_count?: number; boosts_url?: string; }; + /** + * @description A version event for an upload, from GET /uploads/{id}/versions.json. + * + * `action` is one of `created`, `active` (the upload's publication) or `blob_changed` + * (a file replacement). A caller listing past versions of the file filters on + * `blob_changed`. This is an Event plus the file it recorded, rendered by its own + * partial rather than the shared event one, so upload fields don't leak onto todo, + * message and card events. + */ + UploadVersion: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + recording_id: number; + action: string; + details?: components["schemas"]["EventDetails"]; + created_at: string; + creator: components["schemas"]["Person"]; + /** Format: int32 */ + boosts_count?: number; + boosts_url?: string; + upload?: components["schemas"]["UploadVersionFile"]; + }; + /** @description The file a version event recorded — a reduced projection, not an Upload. */ + UploadVersionFile: { + content_type?: string; + /** Format: int64 */ + byte_size?: number; + filename: string; + /** + * @description Fetches THIS version's bytes. The upload's own download_url always serves the + * latest, which is the whole point of the feature. + */ + download_url: string; + app_download_url: string; + /** + * @description True for the most recent version; exactly one element per response. This is the + * newest *version*, not necessarily the file the upload's own download_url serves — + * a metadata-only PUT swaps in a recordable carrying the same blob and emits no + * event, so after one no event references the upload's current recordable. + */ + current: boolean; + }; ValidationErrorResponseContent: { error: string; message?: string; @@ -7259,6 +7340,15 @@ export interface operations { "application/json": components["schemas"]["InternalServerErrorResponseContent"]; }; }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; }; }; GetBoost: { @@ -11362,6 +11452,15 @@ export interface operations { "application/json": components["schemas"]["InternalServerErrorResponseContent"]; }; }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; }; }; GetEverythingCheckins: { @@ -22280,6 +22379,95 @@ export interface operations { }; }; }; + CreateUploadVersion: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateUploadVersionRequestContent"]; + }; + }; + responses: { + /** @description CreateUploadVersion 201 response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateUploadVersionResponseContent"]; + }; + }; + /** @description UnauthorizedError 401 response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedErrorResponseContent"]; + }; + }; + /** @description ForbiddenError 403 response */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenErrorResponseContent"]; + }; + }; + /** @description NotFoundError 404 response */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NotFoundErrorResponseContent"]; + }; + }; + /** @description ValidationError 422 response */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValidationErrorResponseContent"]; + }; + }; + /** @description RateLimitError 429 response */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RateLimitErrorResponseContent"]; + }; + }; + /** @description InternalServerError 500 response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalServerErrorResponseContent"]; + }; + }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; + }; + }; GetVault: { parameters: { query?: never; @@ -22671,6 +22859,15 @@ export interface operations { "application/json": components["schemas"]["InternalServerErrorResponseContent"]; }; }; + /** @description StorageLimitError 507 response */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageLimitErrorResponseContent"]; + }; + }; }; }; ListVaults: { diff --git a/typescript/src/generated/services/uploads.ts b/typescript/src/generated/services/uploads.ts index 6324a47454..759b539c40 100644 --- a/typescript/src/generated/services/uploads.ts +++ b/typescript/src/generated/services/uploads.ts @@ -33,6 +33,26 @@ export interface UpdateUploadRequest { export interface ListVersionsUploadOptions extends PaginationOptions { } +/** + * Request parameters for createVersion. + */ +export interface CreateVersionUploadRequest { + /** Attachable sgid */ + attachableSgid: string; + /** Omit to keep the uploaded file's own name. Sending "" also keeps it. */ + baseName?: string; + /** Presence-aware: omit to carry the previous version's description forward, +send "" to clear it, send a value to set it. */ + description?: string; + /** Who to notify: "default", "everyone", or "custom" (the people in subscriptions). + +Omit both this and subscriptions to notify nobody. A subscriptions array sent +without notify is read as "custom". */ + notify?: string; + /** People to notify about the replacement and subscribe to the upload. */ + subscriptions?: number[]; +} + /** * Options for list. */ @@ -136,14 +156,14 @@ export class UploadsService extends BaseService { * List versions of an upload * @param uploadId - The upload ID * @param options - Optional query parameters - * @returns All Upload across all pages, with .meta.totalCount + * @returns All results across all pages, with .meta.totalCount * * @example * ```ts * const result = await client.uploads.listVersions(123); * ``` */ - async listVersions(uploadId: number, options?: ListVersionsUploadOptions): Promise> { + async listVersions(uploadId: number, options?: ListVersionsUploadOptions): Promise { return this.requestPaginated( { service: "Uploads", @@ -162,6 +182,47 @@ export class UploadsService extends BaseService { ); } + /** + * Replace an upload's file with a new version + * @param uploadId - The upload ID + * @param req - Upload_version creation parameters + * @returns The Upload + * @throws {BasecampError} If required fields are missing or invalid + * + * @example + * ```ts + * const result = await client.uploads.createVersion(123, { attachableSgid: "example" }); + * ``` + */ + async createVersion(uploadId: number, req: CreateVersionUploadRequest): Promise { + if (!req.attachableSgid) { + throw Errors.validation("Attachable sgid is required"); + } + const response = await this.request( + { + service: "Uploads", + operation: "CreateUploadVersion", + resourceType: "upload_version", + isMutation: true, + resourceId: uploadId, + }, + () => + this.client.POST("/uploads/{uploadId}/versions.json", { + params: { + path: { uploadId }, + }, + body: { + attachable_sgid: req.attachableSgid, + base_name: req.baseName, + description: req.description, + notify: req.notify, + subscriptions: req.subscriptions, + }, + }) + ); + return response; + } + /** * List uploads in a vault * @param vaultId - The vault ID From cae9ed40f652b26d22154e185bc33b855ee71718 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 5 Aug 2026 21:52:56 -0700 Subject: [PATCH 02/17] Go: CreateVersion, a real UploadVersion, and a reachable description clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListVersions returned []Upload, decoding an event payload into a struct whose required fields it does not carry. It now returns []UploadVersion, with the nested file as UploadVersionFile. Details is carried through with the same present-empty semantics Event uses — the versions partial renders details via recordings/events/_event, which emits "details": {} for an event with no membership changes, so mapping that to nil would lose the difference between "no changes recorded" and "no details at all". Description becomes *string on both CreateUploadVersionRequest and UpdateUploadRequest. BC3 reads description with key?, so omitted carries forward and "" clears; behind a plain string and omitzero() the clear was unreachable, which is the divergence SPEC.md:369 documents. Fixing only the new request type would have put one request that can clear and one that silently cannot inside the same service. BaseName stays a plain string on both, and says why in its doc comment: 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 — the asymmetry is a verified server fact, not an oversight. TestUpdateUploadRequest_HasNoFileReplacementField stays. #12555 added a dedicated route rather than widening PUT /uploads/{id}.json, so the guard now pins a design choice rather than a missing feature; its comment names CreateVersion as the sanctioned path, and a positive counterpart asserts CreateUploadVersionRequest carries the field the update refuses. Each new assertion was run against the un-fixed code first. The tri-state test fails with "the clear spelling must reach the wire"; the details test fails with "a present but empty details object must survive as non-nil". --- go/pkg/basecamp/optional_presence_test.go | 162 ++++++++++++++++++ go/pkg/basecamp/vaults.go | 193 ++++++++++++++++++++-- go/pkg/basecamp/vaults_test.go | 21 ++- 3 files changed, 360 insertions(+), 16 deletions(-) diff --git a/go/pkg/basecamp/optional_presence_test.go b/go/pkg/basecamp/optional_presence_test.go index e602cf15d3..0dc2adc4cb 100644 --- a/go/pkg/basecamp/optional_presence_test.go +++ b/go/pkg/basecamp/optional_presence_test.go @@ -7,6 +7,7 @@ import ( "math" "net/http" "net/http/httptest" + "reflect" "testing" "time" @@ -333,3 +334,164 @@ func TestEventFromGenerated_PresentEmptyDetailsSurvives(t *testing.T) { t.Error("an absent details object must stay nil") } } + +// captureUploadBody runs fn against a server that records the JSON body it sent. +func captureUploadBody(t *testing.T, status int, respBody string, fn func(*UploadsService) error) map[string]any { + t.Helper() + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(respBody)) + })) + t.Cleanup(srv.Close) + + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + svc := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}).ForAccount("99999").Uploads() + if err := fn(svc); err != nil { + t.Fatalf("request: %v", err) + } + return got +} + +// CreateUploadVersion's Description is presence-aware server-side: omitted carries +// the previous version's description forward, "" clears it. A plain string behind +// omitzero() could not express the clear, because "" would read as unset. +func TestCreateUploadVersionRequest_DescriptionIsTriState(t *testing.T) { + capture := func(desc *string) (any, bool) { + t.Helper() + body := captureUploadBody(t, http.StatusCreated, `{"id":1,"filename":"a.png"}`, func(svc *UploadsService) error { + _, err := svc.CreateVersion(context.Background(), 1, &CreateUploadVersionRequest{ + AttachableSGID: "sgid", Description: desc, + }) + return err + }) + v, present := body["description"] + return v, present + } + + if _, present := capture(nil); present { + t.Error("nil Description must be omitted so the previous version's description carries forward") + } + + v, present := capture(ptr("")) + if !present { + t.Fatal(`Description: ptr("") must reach the wire to clear the description`) + } + if v != "" { + t.Errorf(`expected description "" on the wire, got %#v`, v) + } + + v, present = capture(ptr("
Set
")) + if !present || v != "
Set
" { + t.Errorf("expected the supplied description on the wire, got %#v (present=%v)", v, present) + } +} + +// UpdateUpload lands on the same serialized ActionText attribute as +// CreateUploadVersion, so its clear has to be reachable too. Leaving it a plain +// string would put one request type that can clear and one that silently cannot +// inside the same service. +func TestUpdateUploadRequest_DescriptionIsTriState(t *testing.T) { + capture := func(desc *string) (any, bool) { + t.Helper() + body := captureUploadBody(t, http.StatusOK, `{"id":1,"filename":"a.png"}`, func(svc *UploadsService) error { + _, err := svc.Update(context.Background(), 1, &UpdateUploadRequest{Description: desc}) + return err + }) + v, present := body["description"] + return v, present + } + + if _, present := capture(nil); present { + t.Error("nil Description must be omitted so the current description is left alone") + } + + v, present := capture(ptr("")) + if !present { + t.Fatal(`Description: ptr("") must reach the wire to clear the description`) + } + if v != "" { + t.Errorf(`expected description "" on the wire, got %#v`, v) + } +} + +// BaseName stays a plain string on both request types, deliberately. +// Upload#base_name= guards on new_base_name.present?, so "" and absent are the +// same write server-side — there is no third state a pointer could express. +func TestUploadRequests_BaseNameHasNoClearState(t *testing.T) { + for _, tc := range []struct { + name string + send func(*UploadsService) error + }{ + {"CreateVersion", func(svc *UploadsService) error { + _, err := svc.CreateVersion(context.Background(), 1, &CreateUploadVersionRequest{ + AttachableSGID: "sgid", BaseName: "", + }) + return err + }}, + {"Update", func(svc *UploadsService) error { + _, err := svc.Update(context.Background(), 1, &UpdateUploadRequest{BaseName: ""}) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + status := http.StatusCreated + if tc.name == "Update" { + status = http.StatusOK + } + body := captureUploadBody(t, status, `{"id":1,"filename":"a.png"}`, tc.send) + if _, present := body["base_name"]; present { + t.Error(`an empty BaseName must stay off the wire; "" and absent are the same server write`) + } + }) + } +} + +// CreateUploadVersion carries the file reference that UpdateUpload deliberately +// does not — the positive counterpart to +// TestUpdateUploadRequest_HasNoFileReplacementField. +func TestCreateUploadVersionRequest_HasFileReplacementField(t *testing.T) { + f, ok := reflect.TypeOf(CreateUploadVersionRequest{}).FieldByName("AttachableSGID") + if !ok { + t.Fatal("CreateUploadVersionRequest must carry AttachableSGID: it is the sanctioned file-replacement path") + } + if got := f.Tag.Get("json"); got != "attachable_sgid" { + t.Errorf(`expected json tag "attachable_sgid", got %q`, got) + } +} + +// The versions partial renders details through the shared +// recordings/events/_event partial, which emits "details": {} for an event with +// no membership changes. UploadVersion has to keep the same present-empty +// distinction Event does, and must not drop the field outright. +func TestUploadVersionFromGenerated_DetailsSurvives(t *testing.T) { + present := uploadVersionFromGenerated(generated.UploadVersion{ + Details: &generated.EventDetails{}, + }) + if present.Details == nil { + t.Error("a present but empty details object must survive as non-nil") + } + + if absent := uploadVersionFromGenerated(generated.UploadVersion{}); absent.Details != nil { + t.Error("an absent details object must stay nil") + } + + populated := uploadVersionFromGenerated(generated.UploadVersion{ + Details: &generated.EventDetails{ + AddedPersonIds: []int64{1, 2}, + NotifiedRecipientIds: []int64{3}, + }, + }) + if populated.Details == nil { + t.Fatal("expected details") + } + if len(populated.Details.AddedPersonIDs) != 2 { + t.Errorf("added_person_ids must survive, got %v", populated.Details.AddedPersonIDs) + } + if len(populated.Details.NotifiedRecipientIDs) != 1 { + t.Errorf("notified_recipient_ids must survive, got %v", populated.Details.NotifiedRecipientIDs) + } +} diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index 7bf8383b40..44ebf089c2 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -216,9 +216,15 @@ type CreateDocumentRequest struct { // UpdateUploadRequest specifies the parameters for updating an upload. type UpdateUploadRequest struct { - // Description is the upload description. - Description string `json:"description,omitempty"` + // Description is the upload description, and is presence-aware. + // nil omits the field, leaving the current description alone. + // Ptr("") clears it. Ptr(v) sets it. + Description *string `json:"description,omitempty"` // BaseName is the filename without extension. + // + // A plain string rather than a pointer, unlike Description: 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. BaseName string `json:"base_name,omitempty"` } @@ -831,9 +837,8 @@ func (s *UploadsService) Update(ctx context.Context, uploadID int64, req *Update return nil, err } - body := generated.UpdateUploadJSONRequestBody{} - if req.Description != "" { - body.Description = &req.Description + body := generated.UpdateUploadJSONRequestBody{ + Description: req.Description, } if req.BaseName != "" { body.BaseName = &req.BaseName @@ -902,6 +907,85 @@ func (s *UploadsService) Create(ctx context.Context, vaultID int64, req *CreateU return &upload, nil } +// CreateUploadVersionRequest specifies the parameters for replacing an upload's file. +type CreateUploadVersionRequest struct { + // AttachableSGID is the signed global ID for an uploaded attachment (required). + // See the Create Attachment endpoint for how to upload files. + AttachableSGID string `json:"attachable_sgid"` + // BaseName is the filename without extension (optional). Omit it to keep the + // name of the file you uploaded. + // + // A plain string rather than a pointer, unlike Description: 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. + BaseName string `json:"base_name,omitempty"` + // Description is the upload description in HTML, and is presence-aware. + // nil omits the field, carrying the previous version's description forward. + // Ptr("") clears it. Ptr(v) sets it. + Description *string `json:"description,omitempty"` + // Notify selects who to notify: "default", "everyone", or "custom" (the + // people in Subscriptions). nil omits the field. + // + // Leave both this and Subscriptions nil to notify nobody. A Subscriptions + // list sent without Notify is read as "custom". + Notify *string `json:"notify,omitempty"` + // Subscriptions are the people to notify about the replacement and subscribe + // to the upload. nil omits the field. + Subscriptions *[]int64 `json:"subscriptions,omitempty"` +} + +// CreateVersion replaces an upload's file with a new version. +// The attachable_sgid must be obtained from the Create Attachment endpoint. +// +// The recording keeps its id, its URL and its comments; the previous file +// becomes a past version. Use this instead of Create when publishing a new +// release of the same file, so its published link keeps working. +// +// Returns the upload with its new file. +func (s *UploadsService) CreateVersion(ctx context.Context, uploadID int64, req *CreateUploadVersionRequest) (result *Upload, err error) { + op := OperationInfo{ + Service: "Uploads", Operation: "CreateVersion", + ResourceType: "upload", IsMutation: true, + ResourceID: uploadID, + } + if gater, ok := s.client.parent.hooks.(GatingHooks); ok { + if ctx, err = gater.OnOperationGate(ctx, op); err != nil { + return + } + } + start := time.Now() + ctx = s.client.parent.hooks.OnOperationStart(ctx, op) + defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }() + + if req == nil || req.AttachableSGID == "" { + err = ErrUsage("upload version attachable_sgid is required") + return nil, err + } + + body := generated.CreateUploadVersionJSONRequestBody{ + AttachableSgid: req.AttachableSGID, + BaseName: omitzero(req.BaseName), + Description: req.Description, + Notify: req.Notify, + Subscriptions: req.Subscriptions, + } + + resp, err := s.client.parent.gen.CreateUploadVersionWithResponse(ctx, s.client.accountID, uploadID, body) + if err != nil { + return nil, err + } + if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil { + return nil, err + } + if resp.JSON201 == nil { + err = fmt.Errorf("unexpected empty response") + return nil, err + } + + upload := uploadFromGenerated(*resp.JSON201) + return &upload, nil +} + // Trash moves an upload to the trash. // Trashed uploads can be recovered from the trash. func (s *UploadsService) Trash(ctx context.Context, uploadID int64) (err error) { @@ -939,9 +1023,94 @@ type UploadVersionListOptions struct { } // UploadVersionListResult contains the results from listing upload versions. +// UploadVersion is a version event for an upload, plus the file it recorded. +// +// Action is one of "created", "active" (the upload's publication) or +// "blob_changed" (a file replacement). To list past versions of the file, +// filter on "blob_changed". +type UploadVersion struct { + // ID is the event ID, not the upload's. + ID int64 + // RecordingID is the upload recording this version belongs to. + RecordingID int64 + // Action is "created", "active" or "blob_changed". + Action string + // CreatedAt is when the version was recorded. + CreatedAt time.Time + // Creator is the person who recorded it. + Creator Person + // Details carries the event's membership changes, when it has any. + // + // Presence is the pointer, matching Event: the versions partial renders + // details through the shared recordings/events/_event partial, which emits + // "details": {} for an event with no membership changes. Mapping that + // present-empty object to nil would lose the distinction between "no changes + // recorded" and "this event carries no details at all". + Details *EventDetails + // BoostsCount is the number of boosts (nil when the event isn't boostable). + BoostsCount *int32 + // BoostsURL links the event's boosts (nil when the event isn't boostable). + BoostsURL *string + // Upload is the file this version recorded. Nil when the recordable no + // longer resolves — a deleted file leaves its version event behind. + Upload *UploadVersionFile +} + +// UploadVersionFile is the file a version event recorded. It is a reduced +// projection, not an Upload: the versions endpoint renders its own partial and +// emits only these fields. +type UploadVersionFile struct { + // Filename is the name of this version's file. + Filename string + // ContentType is the blob's MIME type (nil when the upload has no blob). + ContentType *string + // ByteSize is the blob's size (nil when the upload has no blob). + ByteSize *int64 + // DownloadURL fetches THIS version's bytes. The upload's own download URL + // always serves the latest, which is the point of the feature. + DownloadURL string + // AppDownloadURL is the same file on the storage host. + AppDownloadURL string + // Current is true for the most recent version; exactly one element per + // response has it. This is the newest version, not necessarily the file the + // upload's own download URL serves — a metadata-only update swaps in a + // recordable carrying the same blob and emits no event. + Current bool +} + +func uploadVersionFromGenerated(g generated.UploadVersion) UploadVersion { + v := UploadVersion{ + ID: g.Id, + RecordingID: g.RecordingId, + Action: g.Action, + CreatedAt: g.CreatedAt, + Creator: personFromGenerated(g.Creator), + BoostsCount: g.BoostsCount, + BoostsURL: g.BoostsUrl, + } + if g.Details != nil { + v.Details = &EventDetails{ + AddedPersonIDs: g.Details.AddedPersonIds, + RemovedPersonIDs: g.Details.RemovedPersonIds, + NotifiedRecipientIDs: g.Details.NotifiedRecipientIds, + } + } + if g.Upload != nil { + v.Upload = &UploadVersionFile{ + Filename: g.Upload.Filename, + ContentType: g.Upload.ContentType, + ByteSize: g.Upload.ByteSize, + DownloadURL: g.Upload.DownloadUrl, + AppDownloadURL: g.Upload.AppDownloadUrl, + Current: g.Upload.Current, + } + } + return v +} + type UploadVersionListResult struct { // Versions is the list of upload versions returned. - Versions []Upload + Versions []UploadVersion // Meta contains pagination metadata (total count, etc.). Meta ListMeta } @@ -984,10 +1153,10 @@ func (s *UploadsService) ListVersions(ctx context.Context, uploadID int64, opts totalCount := parseTotalCount(resp.HTTPResponse) // Parse first page - var versions []Upload + var versions []UploadVersion if resp.JSON200 != nil { - for _, gu := range *resp.JSON200 { - versions = append(versions, uploadFromGenerated(gu)) + for _, gv := range *resp.JSON200 { + versions = append(versions, uploadVersionFromGenerated(gv)) } } @@ -1016,11 +1185,11 @@ func (s *UploadsService) ListVersions(ctx context.Context, uploadID int64, opts // Parse additional pages for _, raw := range rawMore { - var gu generated.Upload - if err := json.Unmarshal(raw, &gu); err != nil { + var gv generated.UploadVersion + if err := json.Unmarshal(raw, &gv); err != nil { return nil, fmt.Errorf("failed to parse upload version: %w", err) } - versions = append(versions, uploadFromGenerated(gu)) + versions = append(versions, uploadVersionFromGenerated(gv)) } return &UploadVersionListResult{Versions: versions, Meta: ListMeta{TotalCount: totalCount, Truncated: truncated}}, nil diff --git a/go/pkg/basecamp/vaults_test.go b/go/pkg/basecamp/vaults_test.go index 83f80a28cf..5766d306c4 100644 --- a/go/pkg/basecamp/vaults_test.go +++ b/go/pkg/basecamp/vaults_test.go @@ -588,6 +588,13 @@ func TestUpload_UnmarshalList(t *testing.T) { } } +func equalStringPtr(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + func TestUpdateUploadRequest_Marshal(t *testing.T) { data := loadUploadsFixture(t, "update-request.json") @@ -596,8 +603,8 @@ func TestUpdateUploadRequest_Marshal(t *testing.T) { t.Fatalf("failed to unmarshal update-request.json: %v", err) } - if req.Description != "Updated description for the file" { - t.Errorf("expected description 'Updated description for the file', got %q", req.Description) + if req.Description == nil || *req.Description != "Updated description for the file" { + t.Errorf("expected description 'Updated description for the file', got %v", req.Description) } if req.BaseName != "new_filename" { t.Errorf("expected base_name 'new_filename', got %q", req.BaseName) @@ -614,7 +621,7 @@ func TestUpdateUploadRequest_Marshal(t *testing.T) { t.Fatalf("failed to unmarshal round-trip: %v", err) } - if roundtrip.Description != req.Description || roundtrip.BaseName != req.BaseName { + if !equalStringPtr(roundtrip.Description, req.Description) || roundtrip.BaseName != req.BaseName { t.Error("round-trip mismatch") } } @@ -625,6 +632,12 @@ func TestUpdateUploadRequest_Marshal(t *testing.T) { // basecamp/bc3 @ ba105ba7 — see /API-GAP-404.md), so the SDK must not offer it // as an upload-update field. // +// This stayed true when file replacement shipped. basecamp/bc3#12555 added a +// dedicated POST /uploads/{id}/versions.json rather than widening the update, so +// the guard now pins a design choice rather than a missing feature: the +// sanctioned path is UploadsService.CreateVersion, and its positive counterpart +// is TestCreateUploadVersionRequest_HasFileReplacementField. +// // This is asserted over the request type, not the wire: an omitempty field left // unset is simply absent from the body, so a wire-body check could not catch a // newly added field. Adding an attachable_sgid field to UpdateUploadRequest @@ -676,7 +689,7 @@ func TestUploadsService_Update_SendsDocumentedFields(t *testing.T) { ac := client.ForAccount("12345") _, err := ac.Uploads().Update(context.Background(), 1069479400, &UpdateUploadRequest{ - Description: "Updated description", + Description: Ptr("Updated description"), BaseName: "renamed", }) if err != nil { From dde4d1759fe3fc1e0137cd0681b3cb7456f92c06 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 6 Aug 2026 11:23:31 -0700 Subject: [PATCH 03/17] All six SDKs: a 507 is a limit, not a retryable server error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §6 gained limit_exceeded with the spec commit; this is the six implementations plus the tests that drove them. Every SDK mapped 507 through its 5xx catch-all, so an account out of file storage got api_error with retryable: true — indistinguishable, to a caller deciding whether to back off, from a 500. Each mapper now decides 507 before that catch-all, since both arms match and only order separates them. Nothing retried a 507 in practice (no retryOn list names it), so this changes what the caller is told, not what the client does. Ruby gains LimitExceededError, Python LimitExceededError (exported), Swift a .limitExceeded case, Kotlin a LimitExceeded class, Go CodeLimitExceeded, TS a "limit_exceeded" ErrorCode. Exit code 10 across all six. Kotlin's exhaustive `when` caught the omission in ErrorTest at compile time, which is the whole reason that test enumerates every subclass. The uploads tests came first and failed as intended — TypeScript reported "expected 'api_error' to be 'limit_exceeded'" before the mapper changed. Also here: the shared versions fixture, built from _version.json.jbuilder over recordings/events/_event.json.jbuilder rather than invented. Three elements covering all three actions, exactly one "current": true (the renderer passes current_event: @events.first), and one element with no upload object at all — its recordable no longer resolves, which is the optionality UploadVersion.upload declares. That element is deliberately not the one covering UploadVersionFile, since the manifest's concrete-instance rule needs a non-null representative. The TS, Ruby and Python listVersions tests were all asserting against Upload-shaped stubs the endpoint has never returned. They now read the fixture and assert on filename, byte_size, current, and the per-version download URL — the fields #649 was filed about. --- go/pkg/basecamp/errors.go | 6 + go/pkg/basecamp/helpers.go | 5 + .../com/basecamp/sdk/BasecampException.kt | 21 ++ .../kotlin/com/basecamp/sdk/ErrorTest.kt | 2 + python/src/basecamp/__init__.py | 2 + python/src/basecamp/errors.py | 20 ++ python/tests/services/test_uploads.py | 128 ++++++++++++- ruby/lib/basecamp.rb | 4 + ruby/lib/basecamp/error.rb | 1 + ruby/lib/basecamp/error_code.rb | 1 + ruby/lib/basecamp/exit_code.rb | 1 + ruby/lib/basecamp/http.rb | 6 + ruby/lib/basecamp/limit_exceeded_error.rb | 22 +++ .../basecamp/services/uploads_service_test.rb | 104 +++++++++- spec/fixtures/manifest.yaml | 27 +++ spec/fixtures/uploads/versions.json | 109 +++++++++++ swift/Sources/Basecamp/BasecampError.swift | 18 ++ typescript/src/errors.ts | 14 +- typescript/tests/services/uploads.test.ts | 180 +++++++++++++++--- 19 files changed, 643 insertions(+), 28 deletions(-) create mode 100644 ruby/lib/basecamp/limit_exceeded_error.rb create mode 100644 spec/fixtures/uploads/versions.json diff --git a/go/pkg/basecamp/errors.go b/go/pkg/basecamp/errors.go index 9ec09f92e0..c94a85bd31 100644 --- a/go/pkg/basecamp/errors.go +++ b/go/pkg/basecamp/errors.go @@ -27,6 +27,9 @@ const ( CodeAPI = "api_error" CodeValidation = "validation" CodeAmbiguous = "ambiguous" + // CodeLimitExceeded is a 507: an account limit blocks the request (file + // storage, webhooks). Not retryable — no backoff can satisfy a plan limit. + CodeLimitExceeded = "limit_exceeded" ) // Exit codes for CLI tools. @@ -41,6 +44,7 @@ const ( ExitAPI = 7 // Server returned error ExitAmbiguous = 8 // Multiple matches for name ExitValidation = 9 // Validation error (422) + ExitLimit = 10 // Account limit reached (507) ) // requestIDHeader is the response header carrying the server-issued request ID. @@ -114,6 +118,8 @@ func ExitCodeFor(code string) int { return ExitValidation case CodeAmbiguous: return ExitAmbiguous + case CodeLimitExceeded: + return ExitLimit default: return ExitAPI } diff --git a/go/pkg/basecamp/helpers.go b/go/pkg/basecamp/helpers.go index ff07b2c752..f1ed406466 100644 --- a/go/pkg/basecamp/helpers.go +++ b/go/pkg/basecamp/helpers.go @@ -76,6 +76,11 @@ func checkResponse(resp *http.Response, body []byte) error { return &Error{Code: CodeNotFound, Message: msgOrDefault(serverMsg, "resource not found"), Hint: serverHint, HTTPStatus: 404, RequestID: requestID} case http.StatusTooManyRequests: return &Error{Code: CodeRateLimit, Message: msgOrDefault(serverMsg, "rate limited - try again later"), Hint: serverHint, HTTPStatus: 429, Retryable: true, RequestID: requestID} + case http.StatusInsufficientStorage: + // A 5xx status carrying a client fact: the account is out of storage, or + // at its webhook ceiling. Retrying cannot satisfy it, so this must be + // decided before the 5xx catch-all below. + return &Error{Code: CodeLimitExceeded, Message: msgOrDefault(serverMsg, "account limit reached"), Hint: serverHint, HTTPStatus: 507, Retryable: false, RequestID: requestID} default: retryable := resp.StatusCode >= 500 && resp.StatusCode < 600 return &Error{Code: CodeAPI, Message: msgOrDefault(serverMsg, fmt.Sprintf("API error: %s", resp.Status)), Hint: serverHint, HTTPStatus: resp.StatusCode, Retryable: retryable, RequestID: requestID} diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt index 750b6b047c..6d4e07356f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampException.kt @@ -119,6 +119,20 @@ sealed class BasecampException( val fieldErrors: Map>? = null, ) : BasecampException(message, CODE_VALIDATION, hint, httpStatus, false, requestId) + /** + * An account limit blocks the request (507) — file storage exhausted, or a + * webhook ceiling reached. + * + * Never retryable: no amount of backoff frees storage or raises a plan + * limit. Distinct from [Api] for exactly that reason, since a 507 would + * otherwise land there as a retryable 5xx. + */ + class LimitExceeded( + message: String = "Account limit reached", + hint: String? = null, + requestId: String? = null, + ) : BasecampException(message, CODE_LIMIT_EXCEEDED, hint, 507, false, requestId) + /** Ambiguous match error (multiple resources match a name/identifier). */ class Ambiguous( /** The type of resource that was ambiguous. */ @@ -204,6 +218,7 @@ sealed class BasecampException( const val CODE_VALIDATION = "validation" const val CODE_AMBIGUOUS = "ambiguous" const val CODE_USAGE = "usage" + const val CODE_LIMIT_EXCEEDED = "limit_exceeded" // RFC 8628 device-flow reasons (see [DeviceFlow]). const val DEVICE_ACCESS_DENIED = "access_denied" @@ -242,6 +257,7 @@ sealed class BasecampException( private const val EXIT_API = 7 private const val EXIT_AMBIGUOUS = 8 private const val EXIT_VALIDATION = 9 + private const val EXIT_LIMIT_EXCEEDED = 10 /** Maps an error code to a CLI exit code. */ fun exitCodeFor(code: String): Int = when (code) { @@ -254,6 +270,7 @@ sealed class BasecampException( CODE_API -> EXIT_API CODE_AMBIGUOUS -> EXIT_AMBIGUOUS CODE_VALIDATION -> EXIT_VALIDATION + CODE_LIMIT_EXCEEDED -> EXIT_LIMIT_EXCEEDED else -> EXIT_API } @@ -291,6 +308,10 @@ sealed class BasecampException( 404 -> NotFound(msg, hint, requestId) 429 -> RateLimit(retryAfterSeconds, msg, hint, requestId) 400, 422 -> Validation(msg, hint, httpStatus, requestId, fieldErrors) + // A 5xx status carrying a client fact: the account is out of + // storage, or at its webhook ceiling. Matched before the else + // arm, which would make it a retryable Api. + 507 -> LimitExceeded(msg, hint, requestId) else -> Api(msg, httpStatus, hint, httpStatus in 500..599, requestId) } } diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt index 258deaa39e..83730c857d 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ErrorTest.kt @@ -184,6 +184,7 @@ class ErrorTest { BasecampException.Api("error", 500), BasecampException.Ambiguous("project"), BasecampException.Validation("invalid"), + BasecampException.LimitExceeded(), BasecampException.Usage("bad arg"), BasecampException.DiscoverySelection("ambiguous_issuers", "ambiguous"), BasecampException.DeviceFlow(BasecampException.DEVICE_ACCESS_DENIED), @@ -200,6 +201,7 @@ class ErrorTest { is BasecampException.Api -> "api" is BasecampException.Ambiguous -> "ambiguous" is BasecampException.Validation -> "validation" + is BasecampException.LimitExceeded -> "limit_exceeded" is BasecampException.Usage -> "usage" is BasecampException.DiscoverySelection -> "discovery_selection" is BasecampException.DeviceFlow -> "device_flow" diff --git a/python/src/basecamp/__init__.py b/python/src/basecamp/__init__.py index 738b9737cc..dd6bfc2dde 100644 --- a/python/src/basecamp/__init__.py +++ b/python/src/basecamp/__init__.py @@ -14,6 +14,7 @@ from basecamp.download import DownloadResult from basecamp.errors import ( AmbiguousError, + LimitExceededError, ApiError, AuthError, BasecampError, @@ -43,6 +44,7 @@ "NetworkError", "ApiError", "AmbiguousError", + "LimitExceededError", "UsageError", "ErrorCode", "ExitCode", diff --git a/python/src/basecamp/errors.py b/python/src/basecamp/errors.py index 9dba575e6a..177646336f 100644 --- a/python/src/basecamp/errors.py +++ b/python/src/basecamp/errors.py @@ -16,6 +16,7 @@ class ErrorCode(StrEnum): API = "api_error" AMBIGUOUS = "ambiguous" VALIDATION = "validation" + LIMIT_EXCEEDED = "limit_exceeded" class ExitCode(IntEnum): @@ -28,6 +29,7 @@ class ExitCode(IntEnum): API = 7 AMBIGUOUS = 8 VALIDATION = 9 + LIMIT_EXCEEDED = 10 _EXIT_CODE_MAP = { @@ -40,6 +42,7 @@ class ExitCode(IntEnum): ErrorCode.API: ExitCode.API, ErrorCode.AMBIGUOUS: ExitCode.AMBIGUOUS, ErrorCode.VALIDATION: ExitCode.VALIDATION, + ErrorCode.LIMIT_EXCEEDED: ExitCode.LIMIT_EXCEEDED, } @@ -108,6 +111,18 @@ def __init__(self, message: str = "API error", *, retryable: bool = False, **kwa super().__init__(message, code=ErrorCode.API, retryable=retryable, **kwargs) +class LimitExceededError(BasecampError): + """An account limit blocks the request (HTTP 507). + + File storage exhausted, or a webhook ceiling reached. Never retryable: no + amount of backoff frees storage or raises a plan limit. That is why this is + not an ApiError, which a 507 would otherwise become via the 5xx catch-all. + """ + + def __init__(self, message: str = "Account limit reached", **kwargs: Any): + super().__init__(message, code=ErrorCode.LIMIT_EXCEEDED, retryable=False, **kwargs) + + class AmbiguousError(BasecampError): def __init__(self, message: str = "Ambiguous match", *, matches: list[Any] | None = None, **kwargs: Any): super().__init__(message, code=ErrorCode.AMBIGUOUS, **kwargs) @@ -239,6 +254,11 @@ def error_from_response(status: int, body: str | bytes | None, headers: dict[str # otherwise; truncated after flattening so the tail is capped too. message = f"{message} ({flat})" if message else flat err = ValidationError(_truncate(message or "Validation failed"), http_status=status, field_errors=field_errors) + elif status == 507: + # A 5xx status carrying a client fact: the account is out of storage, or + # at its webhook ceiling. Retrying cannot satisfy it, so this is decided + # before the 5xx arms below. + err = LimitExceededError(_truncate(message or "Account limit reached"), http_status=507) elif status == 500: err = ApiError("Server error (500)", retryable=True, http_status=500) elif status in (502, 503, 504): diff --git a/python/tests/services/test_uploads.py b/python/tests/services/test_uploads.py index 1af3c9f281..47a4e2a104 100644 --- a/python/tests/services/test_uploads.py +++ b/python/tests/services/test_uploads.py @@ -10,7 +10,7 @@ import respx from basecamp import AsyncClient, Client -from basecamp.errors import UsageError +from basecamp.errors import LimitExceededError, UsageError _FIXTURES = Path(__file__).resolve().parents[3] / "spec" / "fixtures" @@ -215,3 +215,129 @@ def test_get_preserves_dimension_float_and_none(self): # NotRequired[Optional[int | float]] width/height type. assert attachments[1]["width"] is None assert attachments[1]["height"] is None + + +class TestListVersions: + """The endpoint returns EVENTS, not Uploads — the retype that closes #649.""" + + @respx.mock + def test_versions_carry_the_file_each_one_recorded(self): + versions = json.loads((_FIXTURES / "uploads" / "versions.json").read_text(encoding="utf-8")) + respx.get("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(200, json=versions) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.list_versions(upload_id=77) + + assert len(result) == 3 + assert result[0]["action"] == "blob_changed" + assert result[0]["upload"]["filename"] == "company-logo.png" + assert result[0]["upload"]["byte_size"] == 184829 + + @respx.mock + def test_exactly_one_version_is_current(self): + versions = json.loads((_FIXTURES / "uploads" / "versions.json").read_text(encoding="utf-8")) + respx.get("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(200, json=versions) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.list_versions(upload_id=77) + + assert sum(1 for v in result if v.get("upload", {}).get("current")) == 1 + assert result[0]["upload"]["current"] is True + + @respx.mock + def test_tolerates_a_version_whose_recordable_is_gone(self): + versions = json.loads((_FIXTURES / "uploads" / "versions.json").read_text(encoding="utf-8")) + respx.get("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(200, json=versions) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.list_versions(upload_id=77) + + assert result[2]["action"] == "created" + assert "upload" not in result[2] + + +class TestCreateVersion: + @respx.mock + def test_posts_the_attachable_sgid(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77, "filename": "company-logo.png"}) + ) + + c = Client(access_token="test-token") + result = c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc") + + assert result["id"] == 77 + assert json.loads(route.calls[0].request.content)["attachable_sgid"] == "sgid-abc" + + # Presence-aware: omitted carries the previous description forward, "" + # clears. _compact strips None only, so "" survives to the wire — which is + # exactly why "" is the SDK's clear spelling rather than None. + @respx.mock + def test_omits_an_unaddressed_description(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77}) + ) + + c = Client(access_token="test-token") + c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc") + + body = json.loads(route.calls[0].request.content) + assert "description" not in body + assert "base_name" not in body + + @respx.mock + def test_sends_an_explicit_blank_description_to_clear_it(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77}) + ) + + c = Client(access_token="test-token") + c.for_account("12345").uploads.create_version( + upload_id=77, attachable_sgid="sgid-abc", description="" + ) + + body = json.loads(route.calls[0].request.content) + assert "description" in body + assert body["description"] == "" + + @respx.mock + def test_passes_notify_and_subscriptions(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response(201, json={"id": 77}) + ) + + c = Client(access_token="test-token") + c.for_account("12345").uploads.create_version( + upload_id=77, attachable_sgid="sgid-abc", notify="custom", subscriptions=[1049715915] + ) + + body = json.loads(route.calls[0].request.content) + assert body["notify"] == "custom" + assert body["subscriptions"] == [1049715915] + + # A replacement copies bytes into a new blob and keeps the old one, so it + # always grows recorded storage. 507 is a limit, never a transient failure. + @respx.mock + def test_storage_limit_is_limit_exceeded_and_not_retried(self): + route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( + return_value=httpx.Response( + 507, json={"error": "The storage limit for this account has been reached."} + ) + ) + + c = Client(access_token="test-token") + with pytest.raises(LimitExceededError) as excinfo: + c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc") + + err = excinfo.value + assert err.code == "limit_exceeded" + assert err.exit_code == 10 + assert err.retryable is False + assert "storage limit" in str(err) + assert route.call_count == 1 diff --git a/ruby/lib/basecamp.rb b/ruby/lib/basecamp.rb index 10ff69aef6..5e40077ae3 100644 --- a/ruby/lib/basecamp.rb +++ b/ruby/lib/basecamp.rb @@ -132,6 +132,10 @@ def self.error_from_response(status, body = nil, retry_after: nil) NotFoundError.new(message: message) when 429 RateLimitError.new(retry_after: retry_after) + when 507 + # Decided before the 5xx arms: a 507 is an account limit, not a + # transient server failure, and no retry can satisfy it. + LimitExceededError.new(Security.truncate(message)) when 500 ApiError.new("Server error (500)", http_status: 500, retryable: true) when 502, 503, 504 diff --git a/ruby/lib/basecamp/error.rb b/ruby/lib/basecamp/error.rb index b02ec16d70..0584624531 100644 --- a/ruby/lib/basecamp/error.rb +++ b/ruby/lib/basecamp/error.rb @@ -79,6 +79,7 @@ def self.exit_code_for(code) when ErrorCode::API then ExitCode::API when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS when ErrorCode::VALIDATION then ExitCode::VALIDATION + when ErrorCode::LIMIT_EXCEEDED then ExitCode::LIMIT_EXCEEDED else ExitCode::API end end diff --git a/ruby/lib/basecamp/error_code.rb b/ruby/lib/basecamp/error_code.rb index 6c66ca6cd1..367936ed49 100644 --- a/ruby/lib/basecamp/error_code.rb +++ b/ruby/lib/basecamp/error_code.rb @@ -12,5 +12,6 @@ module ErrorCode API = "api_error" AMBIGUOUS = "ambiguous" VALIDATION = "validation" + LIMIT_EXCEEDED = "limit_exceeded" end end diff --git a/ruby/lib/basecamp/exit_code.rb b/ruby/lib/basecamp/exit_code.rb index f52beb15a7..c15f27eae4 100644 --- a/ruby/lib/basecamp/exit_code.rb +++ b/ruby/lib/basecamp/exit_code.rb @@ -13,5 +13,6 @@ module ExitCode API = 7 AMBIGUOUS = 8 VALIDATION = 9 + LIMIT_EXCEEDED = 10 end end diff --git a/ruby/lib/basecamp/http.rb b/ruby/lib/basecamp/http.rb index 1b9276491c..84d686ac3f 100644 --- a/ruby/lib/basecamp/http.rb +++ b/ruby/lib/basecamp/http.rb @@ -632,6 +632,12 @@ def handle_error(error, refresh_on_401: true) Basecamp.compose_validation_message(Basecamp.parse_error_message(body), field_errors) || "Validation failed" ) Basecamp::ValidationError.new(message, http_status: status, field_errors: field_errors) + when 507 + # A 5xx status carrying a client fact: the account is out of storage, or + # at its webhook ceiling. Retrying cannot satisfy it, so this is decided + # before the 5xx arms below. + message = Security.truncate(Basecamp.parse_error_message(body) || "Account limit reached") + Basecamp::LimitExceededError.new(message) when 500 Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true) when 502, 503, 504 diff --git a/ruby/lib/basecamp/limit_exceeded_error.rb b/ruby/lib/basecamp/limit_exceeded_error.rb new file mode 100644 index 0000000000..cb045b57e4 --- /dev/null +++ b/ruby/lib/basecamp/limit_exceeded_error.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Basecamp + # Raised when an account limit blocks the request (HTTP 507) — file storage + # exhausted, or a webhook ceiling reached. + # + # Never retryable: no amount of backoff frees storage or raises a plan limit. + # That is the whole reason this is not an ApiError, which a 507 would + # otherwise become through the 5xx catch-all. + class LimitExceededError < Error + def initialize(message = "Account limit reached", hint: nil, cause: nil) + super( + code: ErrorCode::LIMIT_EXCEEDED, + message: message, + hint: hint, + http_status: 507, + retryable: false, + cause: cause + ) + end + end +end diff --git a/ruby/test/basecamp/services/uploads_service_test.rb b/ruby/test/basecamp/services/uploads_service_test.rb index 018d423049..8353fa59de 100644 --- a/ruby/test/basecamp/services/uploads_service_test.rb +++ b/ruby/test/basecamp/services/uploads_service_test.rb @@ -69,14 +69,112 @@ def test_update assert_equal "Updated description", result["description"] end + # The endpoint returns EVENTS, not Uploads — the retype that closes #649. def test_list_versions - response = [ { "id" => 1, "version" => 1, "description_attachments" => [] }, { "id" => 2, "version" => 2, "description_attachments" => [] } ] + stub_request(:get, %r{https://3\.basecampapi\.com/12345/uploads/\d+/versions\.json}) + .to_return(status: 200, body: load_fixture("uploads/versions.json").to_json, + headers: { "Content-Type" => "application/json" }) + + result = @account.uploads.list_versions(upload_id: 2).to_a + + assert_equal 3, result.length + assert_equal "blob_changed", result.first["action"] + assert_equal "company-logo.png", result.first["upload"]["filename"] + assert_equal 184829, result.first["upload"]["byte_size"] + end + def test_list_versions_marks_exactly_one_current stub_request(:get, %r{https://3\.basecampapi\.com/12345/uploads/\d+/versions\.json}) - .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + .to_return(status: 200, body: load_fixture("uploads/versions.json").to_json, + headers: { "Content-Type" => "application/json" }) result = @account.uploads.list_versions(upload_id: 2).to_a - assert_equal 2, result.length + + assert_equal 1, result.count { |v| v.dig("upload", "current") } + assert result.first.dig("upload", "current") + end + + # A version whose recordable no longer resolves omits the upload object + # entirely; the partial's `if upload = uploads[...]` is false. + def test_list_versions_tolerates_a_missing_recordable + stub_request(:get, %r{https://3\.basecampapi\.com/12345/uploads/\d+/versions\.json}) + .to_return(status: 200, body: load_fixture("uploads/versions.json").to_json, + headers: { "Content-Type" => "application/json" }) + + result = @account.uploads.list_versions(upload_id: 2).to_a + + assert_equal "created", result.last["action"] + assert_nil result.last["upload"] + end + + def test_create_version_posts_the_attachable_sgid + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2, "filename" => "company-logo.png" }.to_json, + headers: { "Content-Type" => "application/json" }) + + result = @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc") + + assert_equal 2, result["id"] + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + JSON.parse(req.body)["attachable_sgid"] == "sgid-abc" + end + end + + # Presence-aware: omitted carries the previous description forward, "" clears. + def test_create_version_omits_an_unaddressed_description + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2 }.to_json, headers: { "Content-Type" => "application/json" }) + + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc") + + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + body = JSON.parse(req.body) + !body.key?("description") && !body.key?("base_name") + end + end + + def test_create_version_sends_an_explicit_blank_description_to_clear_it + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2 }.to_json, headers: { "Content-Type" => "application/json" }) + + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc", description: "") + + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + body = JSON.parse(req.body) + body.key?("description") && body["description"] == "" + end + end + + def test_create_version_passes_notify_and_subscriptions + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 201, body: { "id" => 2 }.to_json, headers: { "Content-Type" => "application/json" }) + + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc", + notify: "custom", subscriptions: [ 1049715915 ]) + + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") do |req| + body = JSON.parse(req.body) + body["notify"] == "custom" && body["subscriptions"] == [ 1049715915 ] + end + end + + # A replacement copies bytes into a new blob and keeps the old one, so it + # always grows recorded storage. 507 is a limit, never a transient failure. + def test_create_version_reports_a_storage_limit_as_limit_exceeded + stub_request(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json") + .to_return(status: 507, + body: { "error" => "The storage limit for this account has been reached." }.to_json, + headers: { "Content-Type" => "application/json" }) + + error = assert_raises(Basecamp::LimitExceededError) do + @account.uploads.create_version(upload_id: 2, attachable_sgid: "sgid-abc") + end + + assert_equal "limit_exceeded", error.code + assert_equal 10, error.exit_code + refute error.retryable? + assert_match(/storage limit/, error.message) + assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json", times: 1) end def test_download_delegates_through_download_url diff --git a/spec/fixtures/manifest.yaml b/spec/fixtures/manifest.yaml index 1e4d12a99e..d8b89fc854 100644 --- a/spec/fixtures/manifest.yaml +++ b/spec/fixtures/manifest.yaml @@ -119,6 +119,31 @@ targets: fixture: uploads/get.json pointer: "" schema: Upload + + # GET /uploads/{id}/versions.json returns EVENTS, not Uploads — the reason + # ListUploadVersionsOutput was retyped (basecamp-sdk#649). Built from + # app/views/api/uploads/versions/_version.json.jbuilder over + # recordings/events/_event.json.jbuilder. + # + # Element 0 is the blob_changed replacement and the only one with + # "current": true — the renderer passes current_event: @events.first, so + # exactly one element per response carries it. Element 2 has NO upload object + # at all: its recordable no longer resolves, so the partial's + # `if upload = uploads[event.recordable_gid]` is false. That element pins the + # optionality and is deliberately not the one covering UploadVersionFile, + # since the concrete-instance rule needs a non-null representative. + - id: upload-version-0 + fixture: uploads/versions.json + pointer: "/0" + schema: UploadVersion + - id: upload-version-file-0 + fixture: uploads/versions.json + pointer: "/0/upload" + schema: UploadVersionFile + - id: upload-version-recordable-gone + fixture: uploads/versions.json + pointer: "/2" + schema: UploadVersion - id: cloud-file-get fixture: cloud_files/get.json pointer: "" @@ -271,6 +296,8 @@ covered_schemas: Card: [card-get] Todolist: [todolist-get, todolist-group-get] Upload: [upload-get] + UploadVersion: [upload-version-0, upload-version-recordable-gone] + UploadVersionFile: [upload-version-file-0] CloudFile: [cloud-file-get] GoogleDocument: [google-document-get] ScheduleEntry: [schedule-entry-get] diff --git a/spec/fixtures/uploads/versions.json b/spec/fixtures/uploads/versions.json new file mode 100644 index 0000000000..40075f78ea --- /dev/null +++ b/spec/fixtures/uploads/versions.json @@ -0,0 +1,109 @@ +[ + { + "id": 1069479501, + "recording_id": 1069479400, + "action": "blob_changed", + "details": {}, + "created_at": "2022-12-04T16:41:12.114Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "boosts_count": 2, + "boosts_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479400/events/1069479501/boosts.json", + "upload": { + "content_type": "image/png", + "byte_size": 184829, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "current": true + } + }, + { + "id": 1069479500, + "recording_id": 1069479400, + "action": "active", + "details": {}, + "created_at": "2022-11-28T09:02:44.301Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + }, + "upload": { + "content_type": "image/png", + "byte_size": 172294, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "current": false + } + }, + { + "id": 1069479499, + "recording_id": 1069479400, + "action": "created", + "details": {}, + "created_at": "2022-11-22T08:23:58.523Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + } + } +] diff --git a/swift/Sources/Basecamp/BasecampError.swift b/swift/Sources/Basecamp/BasecampError.swift index 86d56a38cf..f08cf9b646 100644 --- a/swift/Sources/Basecamp/BasecampError.swift +++ b/swift/Sources/Basecamp/BasecampError.swift @@ -50,6 +50,13 @@ public enum BasecampError: Error, Sendable, LocalizedError { message: String, httpStatus: Int, hint: String?, requestId: String?, fieldErrors: [String: [String]]?) + /// An account limit blocks the request (HTTP 507) — file storage + /// exhausted, or a webhook ceiling reached. Never retryable: no amount of + /// backoff frees storage or raises a plan limit. Distinct from `.api` for + /// exactly that reason, since a 507 would otherwise land there as a + /// retryable 5xx. + case limitExceeded(message: String, hint: String?, requestId: String?) + /// Multiple matches found for a name or identifier. case ambiguous(resource: String, matches: [String], hint: String?) @@ -64,6 +71,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit: true case .network: true case .api(_, let status, _, _): status.map { $0 >= 500 } ?? false + case .limitExceeded: false case .ambiguous: false default: false } @@ -78,6 +86,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit: 429 case .validation(_, let status, _, _, _): status case .api(_, let status, _, _): status + case .limitExceeded: 507 case .ambiguous: nil case .network: nil case .usage: nil @@ -96,6 +105,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .api: 7 case .ambiguous: 8 case .validation: 9 + case .limitExceeded: 10 } } @@ -108,6 +118,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit(_, _, let hint, _): hint case .network: "Check your network connection" case .api(_, _, let hint, _): hint + case .limitExceeded(_, let hint, _): hint case .ambiguous(_, _, let hint): hint case .validation(_, _, let hint, _, _): hint case .usage(_, let hint): hint @@ -123,6 +134,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .rateLimit(let msg, _, _, _): msg case .network(let msg, _): msg case .api(let msg, _, _, _): msg + case .limitExceeded(let msg, _, _): msg case .ambiguous(let resource, _, _): "Ambiguous \(resource)" case .validation(let msg, _, _, _, _): msg case .usage(let msg, _): msg @@ -137,6 +149,7 @@ public enum BasecampError: Error, Sendable, LocalizedError { case .notFound(_, _, let id): id case .rateLimit(_, _, _, let id): id case .api(_, _, _, let id): id + case .limitExceeded(_, _, let id): id case .ambiguous: nil case .validation(_, _, _, let id, _): id case .network: nil @@ -209,6 +222,11 @@ public enum BasecampError: Error, Sendable, LocalizedError { message: validationMessage, httpStatus: status, hint: hint, requestId: requestId, fieldErrors: fieldErrors ) + case 507: + // A 5xx status carrying a client fact: the account is out of + // storage, or at its webhook ceiling. Decided before the default + // arm, which would make it a retryable .api. + return .limitExceeded(message: message, hint: hint, requestId: requestId) default: return .api( message: message, httpStatus: status, diff --git a/typescript/src/errors.ts b/typescript/src/errors.ts index f02585ddfd..6d974b2c96 100644 --- a/typescript/src/errors.ts +++ b/typescript/src/errors.ts @@ -51,7 +51,8 @@ export type ErrorCode = | "ambiguous" | "network" | "api_error" - | "usage"; + | "usage" + | "limit_exceeded"; /** * Options for creating a BasecampError. @@ -87,6 +88,7 @@ const EXIT_CODES: Record = { api_error: 7, // API error ambiguous: 8, // Multiple matches found validation: 9, // Validation error (HTTP 400/422) + limit_exceeded: 10, // Account limit reached (HTTP 507) }; /** @@ -363,6 +365,16 @@ export function errorFromParsedBody( case 400: case 422: return new BasecampError("validation", message, { httpStatus, hint, requestId, fieldErrors }); + case 507: + // A 5xx status carrying a client fact: the account is out of storage, or + // at its webhook ceiling. Retrying cannot satisfy it, so this must be + // decided before the 5xx catch-all below. + return new BasecampError("limit_exceeded", message, { + httpStatus, + retryable: false, + hint, + requestId, + }); default: // 5xx errors are retryable const retryable = httpStatus >= 500 && httpStatus < 600; diff --git a/typescript/tests/services/uploads.test.ts b/typescript/tests/services/uploads.test.ts index b4bb4b731f..ea7805b731 100644 --- a/typescript/tests/services/uploads.test.ts +++ b/typescript/tests/services/uploads.test.ts @@ -10,6 +10,8 @@ import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { BasecampError } from "../../src/errors.js"; import { createBasecampClient } from "../../src/client.js"; +// Sourced from the shared, coverage-guarded fixture (spec/fixtures/manifest.yaml) +import versionsFixture from "../../../spec/fixtures/uploads/versions.json"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -255,37 +257,62 @@ describe("UploadsService", () => { }); describe("listVersions", () => { - it("should return upload versions", async () => { - const uploads = [ - { - id: 7001, - filename: "file_v3.pdf", - created_at: "2024-03-15T10:00:00Z", - description_attachments: [], - }, - { - id: 7001, - filename: "file_v2.pdf", - created_at: "2024-02-10T10:00:00Z", - description_attachments: [], - }, - { - id: 7001, - filename: "file_v1.pdf", - created_at: "2024-01-05T10:00:00Z", - description_attachments: [], - }, - ]; - + // The endpoint returns EVENTS, not Uploads — the retype that closes #649. + it("should return upload versions carrying the file each one recorded", async () => { server.use( http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { - return HttpResponse.json(uploads); + return HttpResponse.json(versionsFixture); }), ); const result = await service.listVersions(7001); expect(result).toHaveLength(3); + expect(result[0].action).toBe("blob_changed"); + expect(result[0].upload?.filename).toBe("company-logo.png"); + expect(result[0].upload?.content_type).toBe("image/png"); + expect(result[0].upload?.byte_size).toBe(184829); + }); + + it("marks exactly one version current", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + expect(result.filter((v) => v.upload?.current)).toHaveLength(1); + expect(result[0].upload?.current).toBe(true); + }); + + // The per-version URL serves THAT version's bytes; the upload's own always + // serves the latest, which is the whole point of the feature. + it("gives each version its own download URL", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + expect(result[0].upload?.download_url).not.toBe(result[1].upload?.download_url); + expect(result[0].upload?.download_url).toContain("/versions/1069479501/"); + }); + + it("tolerates a version whose recordable no longer resolves", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + expect(result[2].action).toBe("created"); + expect(result[2].upload).toBeUndefined(); }); it("should return empty array when no versions", async () => { @@ -301,6 +328,113 @@ describe("UploadsService", () => { }); }); + describe("createVersion", () => { + it("posts the attachable sgid and returns the updated upload", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json( + { id: 7001, filename: "company-logo.png", description_attachments: [] }, + { status: 201 }, + ); + }), + ); + + const result = await service.createVersion(7001, { attachableSgid: "sgid-abc" }); + + expect(result.id).toBe(7001); + expect(capturedBody?.attachable_sgid).toBe("sgid-abc"); + }); + + // Presence-aware: omitted carries the previous description forward, "" + // clears. Both spellings have to be distinguishable on the wire. + it("omits description when it is not addressed", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 7001, description_attachments: [] }, { status: 201 }); + }), + ); + + await service.createVersion(7001, { attachableSgid: "sgid-abc" }); + + expect(capturedBody).not.toHaveProperty("description"); + expect(capturedBody).not.toHaveProperty("base_name"); + }); + + it("sends an explicit empty description to clear it", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 7001, description_attachments: [] }, { status: 201 }); + }), + ); + + await service.createVersion(7001, { attachableSgid: "sgid-abc", description: "" }); + + expect(capturedBody).toHaveProperty("description"); + expect(capturedBody?.description).toBe(""); + }); + + it("passes notify and subscriptions through", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ id: 7001, description_attachments: [] }, { status: 201 }); + }), + ); + + await service.createVersion(7001, { + attachableSgid: "sgid-abc", + notify: "custom", + subscriptions: [1049715915, 1049715916], + }); + + expect(capturedBody?.notify).toBe("custom"); + expect(capturedBody?.subscriptions).toEqual([1049715915, 1049715916]); + }); + + it("rejects a missing attachable sgid before hitting the wire", async () => { + await expect(service.createVersion(7001, { attachableSgid: "" })).rejects.toThrow( + BasecampError, + ); + }); + + // A replacement copies bytes into a new blob and keeps the old one, so it + // always grows recorded storage. 507 is a limit, never a transient failure. + it("reports a storage limit as limit_exceeded and does not retry", async () => { + let requestCount = 0; + + server.use( + http.post(`${BASE_URL}/uploads/7001/versions.json`, () => { + requestCount += 1; + return HttpResponse.json( + { error: "The storage limit for this account has been reached." }, + { status: 507 }, + ); + }), + ); + + const error = await service + .createVersion(7001, { attachableSgid: "sgid-abc" }) + .catch((e) => e as BasecampError); + + expect(error).toBeInstanceOf(BasecampError); + expect(error.code).toBe("limit_exceeded"); + expect(error.retryable).toBe(false); + expect(error.message).toContain("storage limit"); + expect(requestCount).toBe(1); + }); + }); + // Note: trash() is on RecordingsService, not UploadsService (spec-conformant) // Use client.recordings.trash(uploadId) instead From 9290e678cbd0a82eaefb8435daccd67435be168d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 6 Aug 2026 11:36:54 -0700 Subject: [PATCH 04/17] Conformance: both uploads write paths, across all six runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named for the surface rather than the operation, because it has to cover both writes: CreateUploadVersion and UpdateUpload land on the same serialized ActionText attribute, and only one of them had its presence semantics pinned anywhere. The uploads surface had zero requestBodyAbsent assertions before this. Each one rides with a requestBody presence assertion in the same case, following schedule_entries_write: a requestBodyAbsent alone is satisfied by an empty body, so it would pass while the SDK sent nothing at all. Seven cases — the three presence states for the replacement, the two UpdateUpload states its changed Go semantics require and no existing case covered, the read-side decode, and the 507. The 507 case is the one that had to go red first, and did: every SDK reported api_error with retryable: true before the taxonomy change. It pins requestCount at 1 as well, so a future retryOn list that added 507 fails here rather than silently burning an account's retry budget on a limit no backoff can lift. Two things the runners needed. The versions endpoint returns an ARRAY and every runner resolves a responseBody path as a top-level key only, so each flattens to the summary shape summarize_upcoming established rather than teaching six path resolvers to index arrays. And Swift's strict decoder rejected hand-rolled Upload bodies for missing `bucket` — the mock bodies are now built from spec/fixtures/uploads/get.json, so every byte a runner decodes traces to a coverage-guarded fixture. Swift's conformanceCode needed the new case, and knownErrorTypes with it: that set is documented as having to stay in sync, since a member missing from it silently forbids a real error rather than catching a typo. --- conformance/runner/go/main.go | 96 ++ conformance/runner/python/runner.py | 53 + conformance/runner/ruby/runner.rb | 48 + .../ConformanceRunner/Assertions.swift | 3 +- .../Sources/ConformanceRunner/Dispatch.swift | 56 ++ conformance/runner/typescript/runner.test.ts | 55 ++ conformance/tests/uploads_write.json | 915 ++++++++++++++++++ .../com/basecamp/sdk/conformance/Main.kt | 69 ++ 8 files changed, 1294 insertions(+), 1 deletion(-) create mode 100644 conformance/tests/uploads_write.json diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go index 86cdd8bf84..fd1188b3cd 100644 --- a/conformance/runner/go/main.go +++ b/conformance/runner/go/main.go @@ -1165,6 +1165,77 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T } return operationResult{err: nil} + case "CreateUploadVersion": + // Presence-bearing, like ReplaceScheduleEntry: a key the fixture omits + // stays nil and never reaches the wire, so an unaddressed description + // carries forward while Ptr("") is sent and clears. + uploadID := getInt64Param(tc.PathParams, "uploadId") + req := &basecamp.CreateUploadVersionRequest{ + AttachableSGID: getStringParam(tc.RequestBody, "attachable_sgid"), + Description: optionalStringFrom(tc.RequestBody, "description"), + Notify: optionalStringFrom(tc.RequestBody, "notify"), + } + if _, ok := tc.RequestBody["base_name"]; ok { + req.BaseName = getStringParam(tc.RequestBody, "base_name") + } + if raw, ok := tc.RequestBody["subscriptions"]; ok { + ids := toInt64Slice(raw) + req.Subscriptions = &ids + } + _, err := account.Uploads().CreateVersion(ctx, uploadID, req) + return operationResult{err: err} + + case "UpdateUpload": + uploadID := getInt64Param(tc.PathParams, "uploadId") + req := &basecamp.UpdateUploadRequest{ + Description: optionalStringFrom(tc.RequestBody, "description"), + } + if _, ok := tc.RequestBody["base_name"]; ok { + req.BaseName = getStringParam(tc.RequestBody, "base_name") + } + _, err := account.Uploads().Update(ctx, uploadID, req) + return operationResult{err: err} + + case "ListUploadVersions": + // The endpoint returns an ARRAY and a responseBody path resolves as a + // top-level key only, so flatten to scalars the way GetUpcomingSchedule + // does. Built from the DECODED models, which is where the retype that + // closes #649 is actually enforced. + uploadID := getInt64Param(tc.PathParams, "uploadId") + result, err := account.Uploads().ListVersions(ctx, uploadID, nil) + if err != nil { + return operationResult{err: err} + } + if result == nil { + return operationResult{err: fmt.Errorf("ListVersions returned no result")} + } + currentCount := 0 + for _, v := range result.Versions { + if v.Upload != nil && v.Upload.Current { + currentCount++ + } + } + summary := map[string]interface{}{ + "versions_count": len(result.Versions), + "current_count": currentCount, + } + if len(result.Versions) > 0 { + first := result.Versions[0] + summary["first_action"] = first.Action + if first.Upload != nil { + summary["first_filename"] = first.Upload.Filename + summary["first_content_type"] = first.Upload.ContentType + summary["first_byte_size"] = first.Upload.ByteSize + summary["first_current"] = first.Upload.Current + } + last := result.Versions[len(result.Versions)-1] + summary["last_action"] = last.Action + // A version whose recordable no longer resolves omits the upload + // object entirely — the optionality UploadVersion.Upload declares. + summary["last_has_upload"] = last.Upload != nil + } + return operationResult{err: nil, result: summary} + case "UploadsDownload": uploadID := getInt64Param(tc.PathParams, "uploadId") result, err := account.Uploads().Download(ctx, uploadID) @@ -1905,6 +1976,31 @@ type scheduleEntryWrite struct { // Testing v != "" would collapse the two and let an explicit-clear fixture pass // as an omission — which is the whole distinction BC3's preserve-on-omission // carve-out makes. +// optionalStringFrom returns a pointer only when the fixture carries the key, +// so an unaddressed member stays nil and off the wire while an explicit "" is +// sent verbatim. The whole point of the *string members it feeds. +func optionalStringFrom(body map[string]interface{}, key string) *string { + if _, ok := body[key]; !ok { + return nil + } + s := getStringParam(body, key) + return &s +} + +func toInt64Slice(raw interface{}) []int64 { + items, ok := raw.([]interface{}) + if !ok { + return []int64{} + } + out := make([]int64, 0, len(items)) + for _, item := range items { + if f, isNum := item.(float64); isNum { + out = append(out, int64(f)) + } + } + return out +} + func scheduleEntryWriteFrom(body map[string]interface{}) scheduleEntryWrite { str := func(key string) *string { if _, ok := body[key]; !ok { diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 42560aea49..ab3a408a06 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -209,6 +209,40 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: UPCOMING_WINDOW_END = "2026-06-30" +# attachable_sgid is passed explicitly by the dispatch (it is required), so it +# is deliberately absent here — this list is only the presence-bearing members, +# where "sent as empty" and "not sent" are different writes. +_UPLOAD_VERSION_WRITE_FIELDS = ("base_name", "description", "notify", "subscriptions") + + +def _summarize_upload_versions(versions: list) -> dict: + """Flatten the versions array into top-level scalars. + + GET /uploads/{id}/versions.json returns an ARRAY, and a responseBody path + resolves as a top-level key only, so the assertions cannot walk into it. + Same shape as _summarize_upcoming, for the same reason. + """ + summary: dict[str, Any] = { + "versions_count": len(versions), + "current_count": sum(1 for v in versions if (v.get("upload") or {}).get("current")), + } + if versions: + first = versions[0] + first_upload = first.get("upload") or {} + summary["first_action"] = first["action"] + summary["first_filename"] = first_upload.get("filename") + summary["first_content_type"] = first_upload.get("content_type") + summary["first_byte_size"] = first_upload.get("byte_size") + summary["first_current"] = first_upload.get("current") + + last = versions[-1] + summary["last_action"] = last["action"] + # A version whose recordable no longer resolves omits the upload object + # entirely — the optionality UploadVersion.upload declares. + summary["last_has_upload"] = last.get("upload") is not None + return summary + + def _summarize_upcoming(envelope: dict) -> dict: """Flatten the upcoming-schedule envelope into top-level scalars. @@ -543,6 +577,25 @@ def __call__( return self._account.tools.enable(tool_id=path_params["toolId"]) case "UploadsDownload": return self._account.uploads.download(upload_id=path_params["uploadId"]) + case "CreateUploadVersion": + # Presence-bearing, like ReplaceScheduleEntry: a key the fixture + # omits is never passed, so an unaddressed description stays off + # the wire while an explicit "" survives _compact (which strips + # None only) and clears. + return self._account.uploads.create_version( + upload_id=path_params["uploadId"], + attachable_sgid=body["attachable_sgid"], + **{k: body[k] for k in _UPLOAD_VERSION_WRITE_FIELDS if k in body}, + ) + case "UpdateUpload": + return self._account.uploads.update( + upload_id=path_params["uploadId"], + **{k: body[k] for k in _UPLOAD_VERSION_WRITE_FIELDS if k in body}, + ) + case "ListUploadVersions": + return _summarize_upload_versions( + self._account.uploads.list_versions(upload_id=path_params["uploadId"]) + ) case "GetEverythingMessages": return self._account.everything.get_everything_messages() case "GetEverythingComments": diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 6b9f0060ec..a3e9f54c15 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -353,6 +353,19 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "", max_ ) when "UploadsDownload" @account.uploads.download(upload_id: path_params["uploadId"]) + when "CreateUploadVersion" + # Presence-bearing, like ReplaceScheduleEntry: only keys the fixture + # carries are passed, so an unaddressed description stays off the wire + # while an explicit "" survives compact_params (which strips only nil). + @account.uploads.create_version( + upload_id: path_params["uploadId"], + attachable_sgid: body["attachable_sgid"], + **upload_version_write_kwargs(body) + ) + when "UpdateUpload" + @account.uploads.update(upload_id: path_params["uploadId"], **upload_version_write_kwargs(body)) + when "ListUploadVersions" + summarize_upload_versions(@account.uploads.list_versions(upload_id: path_params["uploadId"]).to_a) when "UpdateTodo" @account.todos.update( todo_id: path_params["todoId"], @@ -652,6 +665,41 @@ def schedule_entry_write_kwargs(body) .to_h { |key| [key.to_sym, body[key]] } end + # attachable_sgid is passed positionally by the dispatch (it is required), so + # it is deliberately absent here — this list is only the presence-bearing + # members, where "sent as empty" and "not sent" are different writes. + UPLOAD_VERSION_WRITE_KEYS = %w[base_name description notify subscriptions].freeze + + def upload_version_write_kwargs(body) + UPLOAD_VERSION_WRITE_KEYS.select { |key| (body || {}).key?(key) } \ + .to_h { |key| [key.to_sym, body[key]] } + end + + # GET /uploads/{id}/versions.json returns an ARRAY, and the assertion path + # resolvers walk objects, not array indices. Flatten to the same summary shape + # summarize_upcoming uses so the fixture can assert on it. + def summarize_upload_versions(versions) + summary = { + "versions_count" => versions.length, + "current_count" => versions.count { |v| v.dig("upload", "current") } + } + if versions.any? + first = versions.first + summary["first_action"] = first["action"] + summary["first_filename"] = first.dig("upload", "filename") + summary["first_content_type"] = first.dig("upload", "content_type") + summary["first_byte_size"] = first.dig("upload", "byte_size") + summary["first_current"] = first.dig("upload", "current") + + last = versions.last + summary["last_action"] = last["action"] + # A version whose recordable no longer resolves omits the upload object + # entirely — the optionality UploadVersion.upload declares. + summary["last_has_upload"] = !last["upload"].nil? + end + summary + end + CARD_WRITE_KEYS = %w[title content due_on assignee_ids].freeze def card_write_kwargs(body) diff --git a/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift b/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift index 86c67cb334..39a0ce067f 100644 --- a/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift +++ b/conformance/runner/swift/Sources/ConformanceRunner/Assertions.swift @@ -36,6 +36,7 @@ func conformanceCode(_ error: BasecampError) -> String { case .network: "network" case .usage: "usage" case .ambiguous: "ambiguous" + case .limitExceeded: "limit_exceeded" } } @@ -44,7 +45,7 @@ func conformanceCode(_ error: BasecampError) -> String { /// catch a typo'd error type silently forbids a real one instead. private let knownErrorTypes: Set = [ "not_found", "auth_required", "forbidden", "rate_limit", - "validation", "api_error", "usage", "network", "ambiguous", + "validation", "api_error", "usage", "network", "ambiguous", "limit_exceeded", ] /// Compares an expected fixture value against an actual JSON value, diff --git a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift index f876e7c4cc..f6fe292ebe 100644 --- a/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift +++ b/conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift @@ -159,6 +159,35 @@ private func summarizeProjects(_ projects: [Project]) -> JSON { ]) } +/// Flattens the versions array into top-level scalars. +/// +/// GET /uploads/{id}/versions.json returns an ARRAY and a responseBody path +/// resolves as a top-level key only. Every value comes off the DECODED model, +/// which is what makes this a decode test of the retype that closes #649 rather +/// than a transport test. +private func summarizeUploadVersions(_ versions: [UploadVersion]) -> JSON { + var summary: [String: JSON] = [ + "versions_count": .int(Int64(versions.count)), + "current_count": .int(Int64(versions.filter { $0.upload?.current == true }.count)), + ] + if let first = versions.first { + summary["first_action"] = .string(first.action) + if let file = first.upload { + summary["first_filename"] = .string(file.filename) + if let contentType = file.contentType { summary["first_content_type"] = .string(contentType) } + if let byteSize = file.byteSize { summary["first_byte_size"] = .int(Int64(byteSize)) } + summary["first_current"] = .bool(file.current) + } + } + if let last = versions.last { + summary["last_action"] = .string(last.action) + // A version whose recordable no longer resolves omits the upload object + // entirely — the optionality UploadVersion.upload declares. + summary["last_has_upload"] = .bool(last.upload != nil) + } + return .object(summary) +} + /// Dispatches the test operation against the SDK and returns observed metadata. /// Direct port of the Kotlin dispatch table. func dispatchOperation(_ tc: TestCase, _ account: AccountClient) async throws -> DispatchResult { @@ -732,6 +761,33 @@ func dispatchOperation(_ tc: TestCase, _ account: AccountClient) async throws -> _ = try await account.uploads.download(uploadId: pathParams.longParam("uploadId")) return DispatchResult() + // Presence-bearing, like ReplaceScheduleEntry: optString yields nil for a + // key the fixture omits, and encodeIfPresent then keeps it off the wire, so + // an unaddressed description carries forward while an explicit "" clears. + case "CreateUploadVersion": + _ = try await account.uploads.createVersion( + uploadId: pathParams.longParam("uploadId"), + req: CreateUploadVersionRequest( + attachableSgid: rb.stringParam("attachable_sgid"), + baseName: rb.optString("base_name"), + description: rb.optString("description"), + notify: rb.optString("notify"), + subscriptions: rb.intArray("subscriptions"))) + return DispatchResult() + + case "UpdateUpload": + _ = try await account.uploads.update( + uploadId: pathParams.longParam("uploadId"), + req: UpdateUploadRequest( + baseName: rb.optString("base_name"), + description: rb.optString("description"))) + return DispatchResult() + + case "ListUploadVersions": + let versions = try await account.uploads.listVersions( + uploadId: pathParams.longParam("uploadId")) + return DispatchResult(resultJSON: summarizeUploadVersions(versions.items)) + // Pins the `inbox_forwards` collection segment. The shipped path said // `forwards`, which bc3 does not route, so the fixture is a wire assertion // on the segment rather than on any response shape. diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 4c30148d31..d476e4dbce 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -278,6 +278,34 @@ function summarizeProjects( last_project_id: projects.length > 0 ? projects[projects.length - 1]!.id : 0, }; } +/** + * GET /uploads/{id}/versions.json returns an ARRAY, and a responseBody path + * resolves as a top-level key only. Flatten to the same shape summarizeUpcoming + * uses so the fixture's assertions stay portable across all six runners. + */ +function summarizeUploadVersions( + versions: Awaited>, +): Record { + const summary: Record = { + versions_count: versions.length, + current_count: versions.filter((v) => v.upload?.current).length, + }; + if (versions.length > 0) { + const first = versions[0]; + summary.first_action = first.action; + summary.first_filename = first.upload?.filename; + summary.first_content_type = first.upload?.content_type; + summary.first_byte_size = first.upload?.byte_size; + summary.first_current = first.upload?.current; + + const last = versions[versions.length - 1]; + summary.last_action = last.action; + // A version whose recordable no longer resolves omits the upload object + // entirely — the optionality UploadVersion.upload declares. + summary.last_has_upload = last.upload !== undefined && last.upload !== null; + } + return summary; +} /** * Executes the appropriate SDK method for the given operation name. @@ -736,6 +764,33 @@ async function executeOperation( await client.tools.enable(Number(params.toolId)); break; + // Presence-bearing, like ReplaceScheduleEntry: a key the fixture omits + // never reaches the request object, so an unaddressed description stays + // off the wire while an explicit "" is sent and clears it. + case "CreateUploadVersion": + await client.uploads.createVersion(Number(params.uploadId), { + attachableSgid: String(body.attachable_sgid), + ...(body.base_name !== undefined ? { baseName: String(body.base_name) } : {}), + ...(body.description !== undefined ? { description: String(body.description) } : {}), + ...(body.notify !== undefined ? { notify: String(body.notify) } : {}), + ...(body.subscriptions !== undefined + ? { subscriptions: body.subscriptions as number[] } + : {}), + }); + break; + + case "UpdateUpload": + await client.uploads.update(Number(params.uploadId), { + ...(body.base_name !== undefined ? { baseName: String(body.base_name) } : {}), + ...(body.description !== undefined ? { description: String(body.description) } : {}), + }); + break; + + case "ListUploadVersions": { + const versions = await client.uploads.listVersions(Number(params.uploadId)); + return { result: summarizeUploadVersions(versions) }; + } + case "UploadsDownload": { const result = await client.uploads.download(Number(params.uploadId)); // Drain the stream so the socket can be reused and we don't leak. diff --git a/conformance/tests/uploads_write.json b/conformance/tests/uploads_write.json new file mode 100644 index 0000000000..ca85d37c8d --- /dev/null +++ b/conformance/tests/uploads_write.json @@ -0,0 +1,915 @@ +[ + { + "name": "create-version-unaddressed: description and base_name stay off the wire", + "description": "BC3 reads description with key?, so an omitted key carries the previous version's description forward and a present one replaces it. When the caller addresses neither description nor base_name, neither key may appear in the body \u2014 a compactor that emitted null, or a default that emitted \"\", would clear a description the server is holding for us. The requestBody assertion on attachable_sgid rides along deliberately: a requestBodyAbsent alone is satisfied by an empty body, which would pass while sending nothing at all.", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201" + }, + "mockResponses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "
The original
", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "POST", + "index": 0 + }, + { + "type": "requestPath", + "expected": "/999/uploads/1069479400/versions.json", + "index": 0 + }, + { + "type": "requestBody", + "path": "attachable_sgid", + "expected": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "description", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "base_name", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "body-compaction" + ] + }, + { + "name": "create-version-clear: an explicit empty description reaches the wire", + "description": "The clear spelling is \"\", not null: SPEC \u00a718 body compaction forbids {\"field\": null}, and five of six SDKs strip nulls structurally before the wire (Python _compact, Ruby compact_params, Kotlin ?.let, Swift encodeIfPresent, Go omitempty). Only an empty string survives compaction in every language, so it is the only clear a caller can actually express. Pinned by BC3's own test at basecamp/bc3#12565.", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "description": "" + }, + "mockResponses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestBody", + "path": "attachable_sgid", + "expected": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "index": 0 + }, + { + "type": "requestBody", + "path": "description", + "expected": "", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "clear" + ] + }, + { + "name": "create-version-set: a supplied description reaches the wire verbatim", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201", + "description": "
The replacement
", + "base_name": "company-logo" + }, + "mockResponses": [ + { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "
The replacement
", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestBody", + "path": "description", + "expected": "
The replacement
", + "index": 0 + }, + { + "type": "requestBody", + "path": "base_name", + "expected": "company-logo", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware" + ] + }, + { + "name": "update-unaddressed: an unaddressed description stays off the wire", + "description": "UpdateUpload lands on the same serialized ActionText attribute as CreateUploadVersion. Go's UpdateUploadRequest.Description was a plain string behind omitzero(), so \"\" read as unset and the clear was unreachable \u2014 the divergence SPEC.md \u00a75 documented. It is a pointer now, and these two cases are what keep both request types honest about the same three states.", + "operation": "UpdateUpload", + "method": "PUT", + "path": "/uploads/{uploadId}", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "base_name": "renamed" + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "
The original
", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "renamed.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "PUT", + "index": 0 + }, + { + "type": "requestBody", + "path": "base_name", + "expected": "renamed", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "description", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "body-compaction" + ] + }, + { + "name": "update-clear: an explicit empty description reaches the wire", + "operation": "UpdateUpload", + "method": "PUT", + "path": "/uploads/{uploadId}", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "description": "" + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479400, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-11-22T08:35:00.000Z", + "updated_at": "2022-11-22T08:35:00.000Z", + "title": "logo.png", + "inherits_status": true, + "type": "Upload", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/uploads/1069479400.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/uploads/1069479400", + "bookmark_url": "https://3.basecampapi.com/999999999/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--abcd1234.json", + "subscription_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/subscription.json", + "comments_count": 1, + "comments_url": "https://3.basecampapi.com/999999999/buckets/2085958500/recordings/1069479400/comments.json", + "position": 1, + "parent": { + "id": 1069479098, + "title": "Docs & Files", + "type": "Vault", + "url": "https://3.basecampapi.com/999999999/buckets/2085958500/vaults/1069479098.json", + "app_url": "https://3.basecamp.com/999999999/buckets/2085958500/vaults/1069479098" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "description": "", + "content_type": "image/png", + "byte_size": 245678, + "width": 1024.0, + "height": 768.0, + "download_url": "https://3.basecampapi.com/999999999/blobs/abcd1234/download/logo.png", + "filename": "logo.png", + "description_attachments": [ + { + "id": 1069480020, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01", + "filename": "brand-guide.png", + "content_type": "image/png", + "byte_size": 512000, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/download/brand-guide.png", + "width": 1024.0, + "height": 768, + "previewable": true, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/previews/brand-guide.png", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMAY6BkVU--upl0ad01/thumbnails/brand-guide.png" + }, + { + "id": 1069480021, + "sgid": "BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02", + "filename": "specs.pdf", + "content_type": "application/pdf", + "byte_size": 2097152, + "download_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/download/specs.pdf", + "width": null, + "height": null, + "previewable": false, + "preview_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/previews/specs.pdf", + "thumbnail_url": "https://3.basecampapi.com/999999999/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvMTA2OTQ4MDAyMQY6BkVU--upl0ad02/thumbnails/specs.pdf" + } + ] + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestBody", + "path": "description", + "expected": "", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "write", + "presence-aware", + "clear" + ] + }, + { + "name": "list-versions decodes the version payload, filename and current included", + "description": "ListUploadVersionsOutput declared uploads: UploadList, but the endpoint returns events \u2014 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 rendered blank fields (basecamp-sdk#649). The body here is the shared spec/fixtures/uploads/versions.json, so this case and the per-SDK unit tests cannot drift apart. The runners flatten the array into a summary object \u2014 the same shape summarize_upcoming uses \u2014 because the assertion path resolvers walk objects, not array indices.", + "operation": "ListUploadVersions", + "method": "GET", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": [ + { + "id": 1069479501, + "recording_id": 1069479400, + "action": "blob_changed", + "details": {}, + "created_at": "2022-12-04T16:41:12.114Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abcd1234", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.000Z", + "updated_at": "2022-11-22T08:23:21.000Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--5520caeec1845b5a20c5b41d2c9de6578bcbb83d/avatar?v=1", + "can_manage_projects": true, + "can_manage_people": true + }, + "boosts_count": 2, + "boosts_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479400/events/1069479501/boosts.json", + "upload": { + "content_type": "image/png", + "byte_size": 184829, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479501/download/company-logo.png", + "current": true + } + }, + { + "id": 1069479500, + "recording_id": 1069479400, + "action": "active", + "details": {}, + "created_at": "2022-11-28T09:02:44.301Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + }, + "upload": { + "content_type": "image/png", + "byte_size": 172294, + "filename": "company-logo.png", + "download_url": "https://3.basecampapi.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "app_download_url": "https://storage.3.basecamp.com/195539477/buckets/2085958500/uploads/1069479400/versions/1069479500/download/company-logo.png", + "current": false + } + }, + { + "id": 1069479499, + "recording_id": 1069479400, + "action": "created", + "details": {}, + "created_at": "2022-11-22T08:23:58.523Z", + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd1e798e7c0e2b40cd88ed93c0b8", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkz4=--e609ef146e39f9ca5e4bb7f8bf7b7fecfff6e302/avatar?v=1", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + } + } + ] + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "responseBody", + "path": "versions_count", + "expected": 3 + }, + { + "type": "responseBody", + "path": "first_action", + "expected": "blob_changed" + }, + { + "type": "responseBody", + "path": "first_filename", + "expected": "company-logo.png" + }, + { + "type": "responseBody", + "path": "first_content_type", + "expected": "image/png" + }, + { + "type": "responseBody", + "path": "first_byte_size", + "expected": 184829 + }, + { + "type": "responseBody", + "path": "first_current", + "expected": true + }, + { + "type": "responseBody", + "path": "current_count", + "expected": 1 + }, + { + "type": "responseBody", + "path": "last_action", + "expected": "created" + }, + { + "type": "responseBody", + "path": "last_has_upload", + "expected": false + }, + { + "type": "noError" + } + ], + "tags": [ + "uploads", + "read", + "issue-649" + ] + }, + { + "name": "create-version storage limit is limit_exceeded, not a retryable api_error", + "description": "A replacement copies bytes into a new blob and keeps the old one, so it always grows recorded storage; ensure_account_can_upload_files guards it. SPEC \u00a76 step 11 decides 507 before the 5xx catch-all, because both match and only order separates them. Against un-fixed code every SDK reported api_error with retryable: true \u2014 a plan limit no backoff can satisfy, indistinguishable from a 500. requestCount pins that nothing retried it, so a future retryOn list that added 507 would fail here.", + "operation": "CreateUploadVersion", + "method": "POST", + "path": "/uploads/{uploadId}/versions.json", + "pathParams": { + "uploadId": 1069479400 + }, + "requestBody": { + "attachable_sgid": "BAh2CEkiCGdpZAY6BkVUSSIsZ2lkOi7vYmMzL0F0dGFjaG1lbnQvNzM4NDcyNj8=--13982201" + }, + "mockResponses": [ + { + "status": 507, + "headers": { + "Content-Type": "application/json", + "X-Request-Id": "req-507-storage" + }, + "body": { + "error": "The storage limit for this account has been reached." + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "errorCode", + "expected": "limit_exceeded" + }, + { + "type": "errorField", + "path": "httpStatus", + "expected": 507 + }, + { + "type": "errorField", + "path": "retryable", + "expected": false + }, + { + "type": "errorMessage", + "expected": "storage limit" + } + ], + "tags": [ + "uploads", + "write", + "error-mapping", + "limit-exceeded" + ] + } +] diff --git a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt index 6eb03e0027..a7c1f63fc6 100644 --- a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt +++ b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt @@ -79,6 +79,41 @@ private fun summarizeProjects(projects: List): JsonElement = buildJsonO put("last_project_id", projects.lastOrNull()?.id ?: 0L) } +/** + * Flattens the versions array into top-level scalars. + * + * GET /uploads/{id}/versions.json returns an ARRAY and a responseBody path + * resolves as a top-level key only. listVersions hands back ListResult + * — the generator's shape for a bare-array response, as with gauges, checkins and + * search — so this reads the wire keys directly. + */ +private fun summarizeUploadVersions(versions: List): JsonElement = buildJsonObject { + put("versions_count", versions.size) + put( + "current_count", + versions.count { v -> + (v as? JsonObject)?.get("upload")?.jsonObject?.get("current")?.jsonPrimitive?.booleanOrNull == true + }, + ) + versions.firstOrNull()?.let { first -> + val obj = first.jsonObject + put("first_action", obj["action"]!!.jsonPrimitive.content) + obj["upload"]?.jsonObject?.let { file -> + put("first_filename", file["filename"]!!.jsonPrimitive.content) + file["content_type"]?.jsonPrimitive?.contentOrNull?.let { put("first_content_type", it) } + file["byte_size"]?.jsonPrimitive?.longOrNull?.let { put("first_byte_size", it) } + put("first_current", file["current"]!!.jsonPrimitive.boolean) + } + } + versions.lastOrNull()?.let { last -> + val obj = last.jsonObject + put("last_action", obj["action"]!!.jsonPrimitive.content) + // A version whose recordable no longer resolves omits the upload object + // entirely — the optionality UploadVersion.upload declares. + put("last_has_upload", obj["upload"] != null) + } +} + fun main() { val testsDir = File("../conformance/tests") @@ -1399,6 +1434,40 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis DispatchResult() } + // Presence-bearing, like ReplaceScheduleEntry: a key the fixture omits + // stays null and `?.let` keeps it off the wire, so an unaddressed + // description carries forward while an explicit "" is sent and clears. + "CreateUploadVersion" -> { + val uploadId = tc.pathParams.longParam("uploadId") + val rb = tc.requestBody + account.uploads.createVersion(uploadId, CreateUploadVersionBody( + attachableSgid = tc.requestBody.stringParam("attachable_sgid"), + baseName = rb?.get("base_name")?.jsonPrimitive?.contentOrNull, + description = rb?.get("description")?.jsonPrimitive?.contentOrNull, + notify = rb?.get("notify")?.jsonPrimitive?.contentOrNull, + subscriptions = rb?.get("subscriptions")?.jsonArray + ?.map { element -> element.jsonPrimitive.long }, + )) + DispatchResult() + } + + "UpdateUpload" -> { + val uploadId = tc.pathParams.longParam("uploadId") + val rb = tc.requestBody + account.uploads.update(uploadId, UpdateUploadBody( + baseName = rb?.get("base_name")?.jsonPrimitive?.contentOrNull, + description = rb?.get("description")?.jsonPrimitive?.contentOrNull, + )) + DispatchResult() + } + + "ListUploadVersions" -> { + val uploadId = tc.pathParams.longParam("uploadId") + val result = account.uploads.listVersions(uploadId) + // ListResult delegates to List, so it IS the item list. + DispatchResult(resultJson = summarizeUploadVersions(result)) + } + "ListForwards" -> { val inboxId = tc.pathParams.longParam("inboxId") val result = account.forwards.list(inboxId) From 63286d18f98eb20ba7806c560d9d039a1b3e0240 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 6 Aug 2026 11:58:03 -0700 Subject: [PATCH 05/17] Docs: the counts, the two breaks, and the divergence that narrowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operation count 247 -> 248 in the four places it is restated. SPEC §5 gains uploads to the presence-aware list, naming both controllers and the BC3 tests that pin them, and says why base_name is deliberately NOT presence-bearing: Upload#base_name= guards on .present?, so "" and absent are the same server write and there is no third state to model. Saying so keeps the asymmetry legible as a verified fact rather than an oversight someone will "fix" later. The Go "use Edit or Replace to clear" divergence is narrowed rather than deleted — it still holds for UpdateTodolistRequest and UpdateDocumentRequest, and now explicitly does not for uploads. §10 One Renderer, One Schema gains UploadVersion beside the UpcomingScheduleEntry worked example. It is the second instance of the same failure and the sharper one: 11 of Upload's 14 required members are absent from every versions response. It also demonstrates the section's first corollary from the other direction — a reduced projection carrying a member (`upload`) no other event projection has. Appendix D gains four rows for uploads_write.json. MIGRATING gets an Unreleased section rather than edits to v0.13.0, whose counts are measurements at a fixed commit. Two breaks: the ListUploadVersions retype, which is a compiler-silent break in Ruby, Python and Kotlin because the type does not change — only which keys are actually present — and Go's Description pointer, which the compiler does catch. The 507 reclassification is flagged there too: not breaking, but a caller branching on api_error to decide whether to back off will no longer see storage limits in that branch, which is the entire point. API-GAP-404.md gets a resolution note that keeps its finding intact. PUT /uploads/{id}.json still ignores attachable_sgid — the hypothesis that document tested is still false, which is why the guard survives. Gate fixes from the full run: JSON tags on the new Go wrapper structs (the wrapper-drift checker maps fields by tag, and untagged ones read as dropped), a doc comment that merged with its neighbour's, gofmt, one rubocop refute->assert_not, and ruff import order and formatting. --- AGENTS.md | 4 +- API-GAP-404.md | 14 ++++ MIGRATING.md | 77 +++++++++++++++++++ SPEC.md | 49 +++++++++++- go/pkg/basecamp/errors.go | 20 ++--- go/pkg/basecamp/vaults.go | 32 ++++---- python/src/basecamp/__init__.py | 2 +- python/tests/services/test_uploads.py | 8 +- .../basecamp/services/uploads_service_test.rb | 2 +- 9 files changed, 169 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0ce632da0d..96276df299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | |---------|----------| @@ -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. diff --git a/API-GAP-404.md b/API-GAP-404.md index a70391dddc..d2174c6564 100644 --- a/API-GAP-404.md +++ b/API-GAP-404.md @@ -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 diff --git a/MIGRATING.md b/MIGRATING.md index eb034f33c8..0a7298dc0a 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -11,6 +11,83 @@ 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`. + +### `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 | `Upload[]` | `UploadVersion[]` | +| Swift | `ListResult` | `ListResult` | +| Kotlin | `ListResult` | unchanged — bare-array responses were already untyped here | +| Ruby / Python | parsed body, unchanged at runtime | fields differ, see below | + +**This is one of the breaks a compiler will not catch in Ruby and Python**, and +in Kotlin. 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) — filter on `blob_changed` to list past versions of the 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` +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 storage-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. This affects `CreateUpload`, `CreateAttachment`, +`CreateCampfireUpload` and `CreateUploadVersion`. + +--- + # v0.13.0 Breaking across all six SDKs — Go, TypeScript, Python, Ruby, Kotlin, Swift. diff --git a/SPEC.md b/SPEC.md index 02ed457dc4..da02cf5d8b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -338,6 +338,20 @@ Card **steps** share the contract: `title` is optional on update, an omitted key `"assignee_ids": []` removes everyone, and an assignee-only body is a valid partial update where it used to 400. `UpdateStepRequest.DueOn` is presence-bearing for the same reason as the card's. +**Uploads** share it too, on both write paths. `Uploads::VersionsController#create` reads +`description` with `key?`, so an omitted key carries the previous version's description forward and +`""` clears; `UploadsController#update` reaches the same `serialize(:description, coder: +ActionText::Content)` attribute through `@upload.changing`, with no blank-cast in between. Both are +pinned by BC3 server tests (basecamp/bc3#12565), so `""` cannot regress to a no-op on either. +`CreateUploadVersionRequest.Description` and `UpdateUploadRequest.Description` are therefore +presence-bearing in every SDK — including Go, which uses `*string` here rather than the zero-value +guard described under Todolists below. + +`base_name` is deliberately **not** presence-bearing on either: `Upload#base_name=` guards on +`new_base_name.present?`, so `""` and absent are the same server write and there is no third state +to model. Stating that is what keeps the asymmetry legible as a verified server fact rather than an +oversight. + ### Merge-Safe Write Surface (Todos) The `PUT /{accountId}/todos/{todoId}` endpoint is **full replace, omission clears** (spec operation `ReplaceTodo`, `content` required, declared via `x-basecamp-write-semantics: {mode: "replace", clearsOmitted: true}` and the `write` clause in `behavior-model.json`). Every SDK exposes a three-method, two-state surface over it: @@ -368,6 +382,14 @@ Every SDK exposes the same three-method, two-state surface over it: **Go is the exception, and this bites in practice.** Its request struct uses zero-value guards (`if req.Description != ""`), so `""` *is* the unset marker: `Update` with an empty description does **nothing to that field** rather than clearing it. **To clear a field in Go, use `Edit` or `Replace` — not `Update`.** + This still holds for `UpdateTodolistRequest` and `UpdateDocumentRequest`. It no + longer holds for uploads: `UpdateUploadRequest.Description` and + `CreateUploadVersionRequest.Description` are `*string`, following the + gauge-needle precedent, so `Ptr("")` clears and `nil` leaves the field alone. + `BaseName` stays a plain `string` on both — `Upload#base_name=` guards on + `new_base_name.present?`, so `""` and absent are the same write server-side and + there is no third state a pointer could express. + ```go // Does NOT clear the description — "" reads as "unaddressed". svc.Update(ctx, id, &UpdateTodolistRequest{Description: ""}) @@ -826,7 +848,7 @@ END ### behavior-model.json Retry Patterns -All 249 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: +All 250 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: - `(2, 1000)` — most create operations - `(3, 1000)` — most read/update/delete operations - `(3, 2000)` — `CreateAttachment`, `CreateCampfireUpload` (file uploads) @@ -1123,6 +1145,23 @@ Two corollaries worth stating, because both were violated here: with tests passing. Conformance cases and `spec/fixtures/` bodies are read out of the partial. +A second instance, absorbed the same way: `ListUploadVersions`. BC3 renders it +through `app/views/api/uploads/versions/_version.json.jbuilder` over the shared +`recordings/events/_event.json.jbuilder`, not through `uploads/_upload`. The +output declared `uploads: UploadList` anyway, which was a typed lie of the +sharpest kind — **11 of `Upload`'s 14 `@required` members are absent from every +response**, so the CLI's versions command and the MCP server's +`list_upload_versions` rendered blank fields (basecamp-sdk#649). It is now +`UploadVersion` / `UploadVersionFile`. + +The reason not to widen `Event` instead is stated by bc3's own commit for the +partial: doing so "would leak upload fields onto todo, message, and card +events". `UploadVersion` also demonstrates the first corollary above from the +other direction — it is an Event *plus* a member no other event projection +carries (`upload`), which is exactly why plain `EventList` would have needed +growing again the moment anyone wanted the filename, the only reason the +endpoint is called at all. + ### Integer Precision `[conformance]` All integer IDs must use at least 64 bits of precision (e.g., Go `int64`, Kotlin `Long`, Swift `Int` on 64-bit platforms). Note: Kotlin `Int` is 32-bit and must not be used for IDs — use `Long`. IDs up to 2^53 + 1 (`9007199254740993`) must survive JSON round-trip without precision loss. @@ -1376,7 +1415,7 @@ END ### Hop-1 Retry `[conformance]` -The authenticated first hop retries on **network errors plus {429, 502, 503, 504}** — never 500. The set is declared here rather than inherited from anywhere else, and it matches neither of the two sets an SDK already has to hand: it is broader than the per-operation `retry_on` in `behavior-model.json` (`{429, 503}` for all 249 operations, which never governs `DownloadURL` because it has no entry there), and narrower than the error taxonomy's "all 5xx retryable" flag, which would sweep in the 500 this policy deliberately excludes. It is the gateway-error set Go's hand-written `singleRequest` already uses for GETs. Backoff is exponential from a 1-second base with jitter; `Retry-After` is honored on 429. The second hop is exempt: no retry, no auth. +The authenticated first hop retries on **network errors plus {429, 502, 503, 504}** — never 500. The set is declared here rather than inherited from anywhere else, and it matches neither of the two sets an SDK already has to hand: it is broader than the per-operation `retry_on` in `behavior-model.json` (`{429, 503}` for all 250 operations, which never governs `DownloadURL` because it has no entry there), and narrower than the error taxonomy's "all 5xx retryable" flag, which would sweep in the 500 this policy deliberately excludes. It is the gateway-error set Go's hand-written `singleRequest` already uses for GETs. Backoff is exponential from a 1-second base with jitter; `Retry-After` is honored on 429. The second hop is exempt: no retry, no auth. "Network error" means a transport failure, with one carve-out that SDKs inherit from their main GET loop rather than restate: an attempt that exhausted the caller's entire per-attempt time budget (a request timeout) is not retried. The timeout is per attempt, so a retry spends another full budget on the same slowness rather than riding out a blip. Kotlin implements this explicitly; SDKs whose transports surface timeouts indistinguishably from other connection failures retry them. @@ -3388,6 +3427,10 @@ account, attachments, automation, boosts, campfires, cardColumns, cardSteps, car | `network-retry.json` | Network error on an idempotent POST is retried then succeeds | §7 (Gate 2) | | `uploads_download.json` | UploadsDownload delegates through DownloadURL primitive | §14, §18 | | `uploads_download.json` | UploadsDownload errors when upload has no download_url | §14, §18 | +| `uploads_write.json` | create-version presence states (unaddressed / clear / set) | §5 (Cards, Uploads), §18 | +| `uploads_write.json` | update presence states (unaddressed / clear) | §5 (Cards, Uploads), §18 | +| `uploads_write.json` | list-versions decodes the version payload | §10 (One Renderer, One Schema) | +| `uploads_write.json` | 507 → limit_exceeded, not retried | §6 | | `todos_write.json` | update-merge / edit-clear / replace-omission-clears | §5 (Todos), §18 | | `todolists_write.json` | update-merge / update-group / edit-clear / replace-omission-clears | §5 (Todolists), §18 | | `todolists_read.json` | list-read / group-read / group-list-read (one flat shape decodes for both variants) | §5 (Todolists) | @@ -3440,7 +3483,7 @@ Every operation has a `retry` block, including non-idempotent POSTs. For non-ide ### Operation Counts -- Total operations: 249 +- Total operations: 250 - Idempotent: 83 (flagged with `idempotent: true`) - Non-idempotent: 166 (no `idempotent` field, or not present) - All operations use `retry_on: [429, 503]` diff --git a/go/pkg/basecamp/errors.go b/go/pkg/basecamp/errors.go index c94a85bd31..b949329175 100644 --- a/go/pkg/basecamp/errors.go +++ b/go/pkg/basecamp/errors.go @@ -34,16 +34,16 @@ const ( // Exit codes for CLI tools. const ( - ExitOK = 0 // Success - ExitUsage = 1 // Invalid arguments or flags - ExitNotFound = 2 // Resource not found - ExitAuth = 3 // Not authenticated - ExitForbidden = 4 // Access denied (scope issue) - ExitRateLimit = 5 // Rate limited (429) - ExitNetwork = 6 // Connection/DNS/timeout error - ExitAPI = 7 // Server returned error - ExitAmbiguous = 8 // Multiple matches for name - ExitValidation = 9 // Validation error (422) + ExitOK = 0 // Success + ExitUsage = 1 // Invalid arguments or flags + ExitNotFound = 2 // Resource not found + ExitAuth = 3 // Not authenticated + ExitForbidden = 4 // Access denied (scope issue) + ExitRateLimit = 5 // Rate limited (429) + ExitNetwork = 6 // Connection/DNS/timeout error + ExitAPI = 7 // Server returned error + ExitAmbiguous = 8 // Multiple matches for name + ExitValidation = 9 // Validation error (422) ExitLimit = 10 // Account limit reached (507) ) diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index 44ebf089c2..8b62a3e9cd 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -1022,7 +1022,6 @@ type UploadVersionListOptions struct { Page int } -// UploadVersionListResult contains the results from listing upload versions. // UploadVersion is a version event for an upload, plus the file it recorded. // // Action is one of "created", "active" (the upload's publication) or @@ -1030,15 +1029,15 @@ type UploadVersionListOptions struct { // filter on "blob_changed". type UploadVersion struct { // ID is the event ID, not the upload's. - ID int64 + ID int64 `json:"id"` // RecordingID is the upload recording this version belongs to. - RecordingID int64 + RecordingID int64 `json:"recording_id"` // Action is "created", "active" or "blob_changed". - Action string + Action string `json:"action"` // CreatedAt is when the version was recorded. - CreatedAt time.Time + CreatedAt time.Time `json:"created_at"` // Creator is the person who recorded it. - Creator Person + Creator Person `json:"creator"` // Details carries the event's membership changes, when it has any. // // Presence is the pointer, matching Event: the versions partial renders @@ -1046,14 +1045,14 @@ type UploadVersion struct { // "details": {} for an event with no membership changes. Mapping that // present-empty object to nil would lose the distinction between "no changes // recorded" and "this event carries no details at all". - Details *EventDetails + Details *EventDetails `json:"details,omitempty"` // BoostsCount is the number of boosts (nil when the event isn't boostable). - BoostsCount *int32 + BoostsCount *int32 `json:"boosts_count,omitempty"` // BoostsURL links the event's boosts (nil when the event isn't boostable). - BoostsURL *string + BoostsURL *string `json:"boosts_url,omitempty"` // Upload is the file this version recorded. Nil when the recordable no // longer resolves — a deleted file leaves its version event behind. - Upload *UploadVersionFile + Upload *UploadVersionFile `json:"upload,omitempty"` } // UploadVersionFile is the file a version event recorded. It is a reduced @@ -1061,21 +1060,21 @@ type UploadVersion struct { // emits only these fields. type UploadVersionFile struct { // Filename is the name of this version's file. - Filename string + Filename string `json:"filename"` // ContentType is the blob's MIME type (nil when the upload has no blob). - ContentType *string + ContentType *string `json:"content_type,omitempty"` // ByteSize is the blob's size (nil when the upload has no blob). - ByteSize *int64 + ByteSize *int64 `json:"byte_size,omitempty"` // DownloadURL fetches THIS version's bytes. The upload's own download URL // always serves the latest, which is the point of the feature. - DownloadURL string + DownloadURL string `json:"download_url"` // AppDownloadURL is the same file on the storage host. - AppDownloadURL string + AppDownloadURL string `json:"app_download_url"` // Current is true for the most recent version; exactly one element per // response has it. This is the newest version, not necessarily the file the // upload's own download URL serves — a metadata-only update swaps in a // recordable carrying the same blob and emits no event. - Current bool + Current bool `json:"current"` } func uploadVersionFromGenerated(g generated.UploadVersion) UploadVersion { @@ -1108,6 +1107,7 @@ func uploadVersionFromGenerated(g generated.UploadVersion) UploadVersion { return v } +// UploadVersionListResult contains the results from listing upload versions. type UploadVersionListResult struct { // Versions is the list of upload versions returned. Versions []UploadVersion diff --git a/python/src/basecamp/__init__.py b/python/src/basecamp/__init__.py index dd6bfc2dde..7815278306 100644 --- a/python/src/basecamp/__init__.py +++ b/python/src/basecamp/__init__.py @@ -14,13 +14,13 @@ from basecamp.download import DownloadResult from basecamp.errors import ( AmbiguousError, - LimitExceededError, ApiError, AuthError, BasecampError, ErrorCode, ExitCode, ForbiddenError, + LimitExceededError, NetworkError, NotFoundError, RateLimitError, diff --git a/python/tests/services/test_uploads.py b/python/tests/services/test_uploads.py index 47a4e2a104..2dc2814bda 100644 --- a/python/tests/services/test_uploads.py +++ b/python/tests/services/test_uploads.py @@ -298,9 +298,7 @@ def test_sends_an_explicit_blank_description_to_clear_it(self): ) c = Client(access_token="test-token") - c.for_account("12345").uploads.create_version( - upload_id=77, attachable_sgid="sgid-abc", description="" - ) + c.for_account("12345").uploads.create_version(upload_id=77, attachable_sgid="sgid-abc", description="") body = json.loads(route.calls[0].request.content) assert "description" in body @@ -326,9 +324,7 @@ def test_passes_notify_and_subscriptions(self): @respx.mock def test_storage_limit_is_limit_exceeded_and_not_retried(self): route = respx.post("https://3.basecampapi.com/12345/uploads/77/versions.json").mock( - return_value=httpx.Response( - 507, json={"error": "The storage limit for this account has been reached."} - ) + return_value=httpx.Response(507, json={"error": "The storage limit for this account has been reached."}) ) c = Client(access_token="test-token") diff --git a/ruby/test/basecamp/services/uploads_service_test.rb b/ruby/test/basecamp/services/uploads_service_test.rb index 8353fa59de..70dffa4e12 100644 --- a/ruby/test/basecamp/services/uploads_service_test.rb +++ b/ruby/test/basecamp/services/uploads_service_test.rb @@ -172,7 +172,7 @@ def test_create_version_reports_a_storage_limit_as_limit_exceeded assert_equal "limit_exceeded", error.code assert_equal 10, error.exit_code - refute error.retryable? + assert_not error.retryable? assert_match(/storage limit/, error.message) assert_requested(:post, "https://3.basecampapi.com/12345/uploads/2/versions.json", times: 1) end From c093f655a929d7c09681a5cba5ebb80d77ead37b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 6 Aug 2026 12:30:56 -0700 Subject: [PATCH 06/17] Reconcile with #679's repin, and finish the 507 it left half-classified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #679 landed while this branch was in flight. It repinned to 7fe1c63ab3 and absorbed project archive/unarchive, and it explicitly left the upload replacement to this branch — registering #12555 and parking its routes behind `registry:` waivers in the allowlist. So this repin's range is not the 72 commits it was when the branch started. 7fe1c63ab3 already contains #12555, and the range is now exactly one commit: #12565, the input contract. The triage is rewritten to say that, and the two waivers #679 parked are DELETED — absorption is what removes them, and a waiver matching nothing is a hard failure rather than a shrug. Three of the entries this branch wrote are gone because #679 wrote better ones from the same evidence: project-status-writes duplicated project-archive-unarchive, oauth-21-stack duplicated bc5-authorization-document-shape (which also splits #9471 into a discovery half that already matches and an authorization-document half that does not — a distinction this branch missed), and the pings preference is notifications-sort-pings-first. What did NOT duplicate is the taxonomy. #679 added ProjectLimitError for the project-limit 507 and correctly refused to reuse it for the storage one — but neither shape was classified. SPEC §6 had no 507 step, so both fell through `status >= 500` to api_error/retryable, and five SDK tests pinned that as intended behaviour, each with a comment saying "No SDK gives 507 a named class". They do now, and those tests assert limit_exceeded and non-retryable instead. Python's comment there recorded a real cross-SDK divergence: its fallback arm produced retryable=False while the other five marked every unclassified 5xx retryable, "asserted here as-is rather than fixed in passing". That divergence is gone — all six agree on False, and False is now the answer the spec gives rather than an accident of which arm caught the status. Operation count 249 -> 250: #679's two plus CreateUploadVersion. --- go/pkg/basecamp/projects_test.go | 12 +++++--- .../com/basecamp/sdk/ProjectsServiceTest.kt | 9 +++--- python/tests/services/test_projects.py | 25 ++++++++-------- ruby/lib/basecamp/generated/metadata.json | 30 ++++++++++++++++++- ruby/lib/basecamp/generated/types.rb | 2 +- .../services/projects_service_test.rb | 11 +++---- typescript/src/generated/metadata.ts | 30 ++++++++++++++++++- typescript/tests/services/projects.test.ts | 11 +++---- 8 files changed, 96 insertions(+), 34 deletions(-) diff --git a/go/pkg/basecamp/projects_test.go b/go/pkg/basecamp/projects_test.go index 141035b89c..551650cd5e 100644 --- a/go/pkg/basecamp/projects_test.go +++ b/go/pkg/basecamp/projects_test.go @@ -445,8 +445,9 @@ func TestProjectsService_ArchiveForbidden(t *testing.T) { } } -// The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named -// class, so it surfaces as a generic api_error carrying the status (SPEC.md §7). +// The only behavioural evidence for ProjectLimitError. A 507 is an account limit, +// so it maps to limit_exceeded and is NOT retryable — no backoff frees a project +// slot (SPEC.md §6, step 11, which is ordered ahead of the 5xx catch-all). func TestProjectsService_UnarchiveAtProjectLimit(t *testing.T) { svc := testProjectsServer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -463,12 +464,15 @@ func TestProjectsService_UnarchiveAtProjectLimit(t *testing.T) { if !errors.As(err, &bcErr) { t.Fatalf("error is not *basecamp.Error: %T", err) } - if bcErr.Code != CodeAPI { - t.Errorf("code = %q, want %q", bcErr.Code, CodeAPI) + if bcErr.Code != CodeLimitExceeded { + t.Errorf("code = %q, want %q", bcErr.Code, CodeLimitExceeded) } if bcErr.HTTPStatus != 507 { t.Errorf("http status = %d, want 507", bcErr.HTTPStatus) } + if bcErr.Retryable { + t.Error("a project limit must not be retryable") + } } // The low-level grouped client surface (generated.Client.Projects()) is emitted diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt index f390680646..00445fe673 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ProjectsServiceTest.kt @@ -272,8 +272,9 @@ class ProjectsServiceTest { client.close() } - // The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named - // class, so it lands in the generic Api arm carrying the status (SPEC.md §7). + // The only behavioural evidence for ProjectLimitError. A 507 is an account + // limit, so it maps to limit_exceeded and is NOT retryable — no backoff frees + // a project slot (SPEC.md §6, step 11). @Test fun unarchiveProjectAtProjectLimitThrows() = runTest { val client = mockClient { _ -> @@ -288,10 +289,10 @@ class ProjectsServiceTest { try { account.projects.unarchive(projectId = 42) assertTrue(false, "Should have thrown") - } catch (e: BasecampException.Api) { + } catch (e: BasecampException.LimitExceeded) { assertEquals(507, e.httpStatus) assertEquals("The project limit for this account has been reached.", e.message) - assertTrue(e.retryable, "Kotlin marks every unclassified 5xx retryable") + assertTrue(!e.retryable, "an account limit is never retryable") } client.close() diff --git a/python/tests/services/test_projects.py b/python/tests/services/test_projects.py index 773fe1700b..71d4ff3bc7 100644 --- a/python/tests/services/test_projects.py +++ b/python/tests/services/test_projects.py @@ -6,7 +6,7 @@ from basecamp import AsyncClient from basecamp.client import Client -from basecamp.errors import ApiError, ErrorCode, ForbiddenError +from basecamp.errors import ErrorCode, ForbiddenError, LimitExceededError BASE = "https://3.basecampapi.com/12345" @@ -109,15 +109,14 @@ def test_archive_project_forbidden(self): assert excinfo.value.http_status == 403 - # The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named - # class, so it falls into the generic api_error arm carrying the status - # (SPEC.md §7). + # The only behavioural evidence for ProjectLimitError. A 507 is an account + # limit, so it maps to limit_exceeded and is NOT retryable — no backoff frees + # a project slot (SPEC.md §6, step 11). # - # NOTE the deliberate `retryable is False`. Python's fallback arm builds a bare - # ApiError, whose default is retryable=False, while the other five SDKs mark - # every unclassified 5xx retryable. That is a pre-existing divergence from - # SPEC §7 (python/src/basecamp/errors.py) and is asserted here as-is rather - # than fixed in passing. + # The divergence this comment used to record is gone. Python's fallback arm + # produced retryable=False here while the other five marked every + # unclassified 5xx retryable; now all six classify 507 the same way, and + # False is the agreed answer rather than an accident of which arm caught it. @respx.mock def test_unarchive_project_at_project_limit(self): respx.put(f"{BASE}/projects/42/status/active.json").mock( @@ -125,12 +124,12 @@ def test_unarchive_project_at_project_limit(self): ) client, account = make_account() - with pytest.raises(ApiError) as excinfo: + with pytest.raises(LimitExceededError) as excinfo: account.projects.unarchive(project_id=42) client.close() assert excinfo.value.http_status == 507 - assert excinfo.value.code == ErrorCode.API + assert excinfo.value.code == ErrorCode.LIMIT_EXCEEDED assert excinfo.value.retryable is False @@ -174,9 +173,9 @@ async def test_unarchive_project_at_project_limit(self): ) account = AsyncClient(access_token="test-token").for_account("12345") - with pytest.raises(ApiError) as excinfo: + with pytest.raises(LimitExceededError) as excinfo: await account.projects.unarchive(project_id=42) assert excinfo.value.http_status == 507 - assert excinfo.value.code == ErrorCode.API + assert excinfo.value.code == ErrorCode.LIMIT_EXCEEDED assert excinfo.value.retryable is False diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 5fd2d9a21e..181df0f8b9 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-06T04:48:02Z", + "generated": "2026-08-06T19:03:03Z", "operations": { "GetAccount": { "retry": { @@ -1806,6 +1806,34 @@ "natural": true } }, + "UnarchiveProject": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "idempotent": { + "natural": true + } + }, + "ArchiveProject": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "idempotent": { + "natural": true + } + }, "GetProjectTimeline": { "retry": { "maxAttempts": 3, diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 1f6f9ae940..1c725b4cf9 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-08-06T04:48:02Z +# Generated: 2026-08-06T19:03:03Z require "json" require "time" diff --git a/ruby/test/basecamp/services/projects_service_test.rb b/ruby/test/basecamp/services/projects_service_test.rb index fca39a9826..3b6b42d41e 100644 --- a/ruby/test/basecamp/services/projects_service_test.rb +++ b/ruby/test/basecamp/services/projects_service_test.rb @@ -113,8 +113,9 @@ def test_archive_project_forbidden assert_equal 403, error.http_status end - # The only behavioural evidence for ProjectLimitError. No SDK gives 507 a named - # class, so it lands in the generic ApiError arm carrying the status (SPEC.md §7). + # The only behavioural evidence for ProjectLimitError. A 507 is an account + # limit, so it maps to limit_exceeded and is NOT retryable — no backoff frees + # a project slot (SPEC.md §6, step 11). def test_unarchive_project_at_project_limit stub_put( "/12345/projects/123/status/active.json", @@ -122,12 +123,12 @@ def test_unarchive_project_at_project_limit status: 507 ) - error = assert_raises(Basecamp::ApiError) do + error = assert_raises(Basecamp::LimitExceededError) do @account.projects.unarchive(project_id: 123) end assert_equal 507, error.http_status - assert_equal Basecamp::ErrorCode::API, error.code - assert error.retryable, "Ruby's from_status marks every 5xx retryable" + assert_equal Basecamp::ErrorCode::LIMIT_EXCEEDED, error.code + assert_not error.retryable, "an account limit is never retryable" end end diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 17c322439e..718e8ae176 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-06T04:48:00.939Z", + "generated": "2026-08-06T19:03:01.503Z", "operations": { "GetAccount": { "retry": { @@ -1842,6 +1842,34 @@ const metadata: MetadataOutput = { "natural": true } }, + "UnarchiveProject": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "idempotent": { + "natural": true + } + }, + "ArchiveProject": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "idempotent": { + "natural": true + } + }, "GetProjectTimeline": { "retry": { "maxAttempts": 3, diff --git a/typescript/tests/services/projects.test.ts b/typescript/tests/services/projects.test.ts index 5b9c014698..7b6f9950fe 100644 --- a/typescript/tests/services/projects.test.ts +++ b/typescript/tests/services/projects.test.ts @@ -175,10 +175,10 @@ describe("ProjectsService", () => { await expect(client.projects.unarchive(42)).resolves.toBeUndefined(); }); - // The only behavioural evidence for ProjectLimitError. No SDK gives 507 a - // named class, so it surfaces as a generic api_error carrying the status - // (SPEC.md §7). - it("should surface the 507 project limit as a generic api_error", async () => { + // The only behavioural evidence for ProjectLimitError. A 507 is an account + // limit, so it maps to limit_exceeded and is NOT retryable — no backoff + // frees a project slot (SPEC.md §6, step 11). + it("should surface the 507 project limit as a non-retryable limit_exceeded", async () => { server.use( http.put(`${BASE_URL}/projects/42/status/active.json`, () => { return HttpResponse.json( @@ -189,8 +189,9 @@ describe("ProjectsService", () => { ); await expect(client.projects.unarchive(42)).rejects.toMatchObject({ - code: "api_error", + code: "limit_exceeded", httpStatus: 507, + retryable: false, }); }); }); From 15ed1a724d43e73c3b2f70abefe6ef2b2cca0592 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 03:01:15 -0700 Subject: [PATCH 07/17] Address Codex review: keep listVersions typed in TS and Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex was right on all three, and the second one caught a claim I had verified backwards. Registering UploadVersion in the TypeScript and Kotlin generators' TYPE_ALIASES — not patching the generated files, which AGENTS.md forbids and which the next regeneration would undo. TypeScript went from Promise> to the raw response-schema type. The runtime still returned a ListResult, so .meta.totalCount kept working while no longer type-checking — a silent break my own tests missed because they only index the array. It is ListResult now. Kotlin went from ListResult to ListResult, a real downgrade to untyped. MIGRATING claimed that row was "unchanged — bare-array responses were already untyped here"; that was wrong, and wrong because I grepped the file AFTER the change and read the result as the prior state. Checking origin/main was the whole job. Kotlin now emits UploadVersion and UploadVersionFile models and returns ListResult, so the conformance summarizer reads decoded models — which makes that case a decode test in Kotlin as it already was in Swift, since kotlinx.serialization rejects a body missing any non-nullable member. Third: the new error code is source-breaking in three SDKs and MIGRATING called the whole 507 change non-breaking. Adding to TypeScript's ErrorCode union, Swift's enum and Kotlin's sealed class each breaks exhaustive handling — this repo's own Kotlin ErrorTest failed to compile on exactly that, which is what that test exists to produce. Documented per SDK, with the reason Go/Ruby/Python still need reading for: a default arm now catches storage and project limits. Also corrected there: the 507 reclassification reaches CreateProject and UnarchiveProject, which shipped in v0.13.0 as retryable api_error. The mapping is by status, not by operation. --- MIGRATING.md | 46 +++++++++++++++---- .../com/basecamp/sdk/conformance/Main.kt | 34 ++++++-------- .../com/basecamp/sdk/generator/Config.kt | 1 + .../sdk/generated/models/UploadVersion.kt | 24 ++++++++++ .../sdk/generated/models/UploadVersionFile.kt | 21 +++++++++ .../sdk/generated/services/uploads.kt | 4 +- ruby/lib/basecamp/generated/metadata.json | 2 +- ruby/lib/basecamp/generated/types.rb | 2 +- typescript/scripts/generate-services.ts | 1 + typescript/src/generated/metadata.ts | 2 +- typescript/src/generated/services/uploads.ts | 6 ++- 11 files changed, 107 insertions(+), 36 deletions(-) create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt diff --git a/MIGRATING.md b/MIGRATING.md index 0a7298dc0a..2e4e82f4ca 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -27,17 +27,22 @@ The output is now `versions: UploadVersionList`. | SDK | was | now | |---|---|---| | Go | `UploadVersionListResult.Versions []Upload` | `[]UploadVersion` | -| TypeScript | `Upload[]` | `UploadVersion[]` | +| TypeScript | `ListResult` | `ListResult` | | Swift | `ListResult` | `ListResult` | -| Kotlin | `ListResult` | unchanged — bare-array responses were already untyped here | +| Kotlin | `ListResult` | `ListResult` | | Ruby / Python | parsed body, unchanged at runtime | fields differ, see below | -**This is one of the breaks a compiler will not catch in Ruby and Python**, and -in Kotlin. 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. +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. @@ -84,7 +89,30 @@ code 10) instead of `api_error`. **If you branch on `api_error` to decide whether to back off, a storage-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. This affects `CreateUpload`, `CreateAttachment`, -`CreateCampfireUpload` and `CreateUploadVersion`. +`CreateCampfireUpload` and `CreateUploadVersion`, and — because the mapping is +by status, not by operation — the project-limit 507 on `CreateProject` and +`UnarchiveProject` that shipped in v0.13.0 as a retryable `api_error`. + +### The new error code is source-breaking in three 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` 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 | + +Go and Ruby take a new constant rather than a new variant, and Python a new +`ErrorCode` member plus a `LimitExceededError` class, so none of the three +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. --- diff --git a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt index a7c1f63fc6..9ab6410a12 100644 --- a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt +++ b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt @@ -83,37 +83,31 @@ private fun summarizeProjects(projects: List): JsonElement = buildJsonO * Flattens the versions array into top-level scalars. * * GET /uploads/{id}/versions.json returns an ARRAY and a responseBody path - * resolves as a top-level key only. listVersions hands back ListResult - * — the generator's shape for a bare-array response, as with gauges, checkins and - * search — so this reads the wire keys directly. + * resolves as a top-level key only. Every value comes off the DECODED model, so + * this is a decode test of the retype that closes #649 and not a transport test + * — kotlinx.serialization rejects a body missing any non-nullable member. */ -private fun summarizeUploadVersions(versions: List): JsonElement = buildJsonObject { +private fun summarizeUploadVersions(versions: List): JsonElement = buildJsonObject { put("versions_count", versions.size) - put( - "current_count", - versions.count { v -> - (v as? JsonObject)?.get("upload")?.jsonObject?.get("current")?.jsonPrimitive?.booleanOrNull == true - }, - ) + put("current_count", versions.count { it.upload?.current == true }) versions.firstOrNull()?.let { first -> - val obj = first.jsonObject - put("first_action", obj["action"]!!.jsonPrimitive.content) - obj["upload"]?.jsonObject?.let { file -> - put("first_filename", file["filename"]!!.jsonPrimitive.content) - file["content_type"]?.jsonPrimitive?.contentOrNull?.let { put("first_content_type", it) } - file["byte_size"]?.jsonPrimitive?.longOrNull?.let { put("first_byte_size", it) } - put("first_current", file["current"]!!.jsonPrimitive.boolean) + put("first_action", first.action) + first.upload?.let { file -> + put("first_filename", file.filename) + file.contentType?.let { put("first_content_type", it) } + file.byteSize?.let { put("first_byte_size", it) } + put("first_current", file.current) } } versions.lastOrNull()?.let { last -> - val obj = last.jsonObject - put("last_action", obj["action"]!!.jsonPrimitive.content) + put("last_action", last.action) // A version whose recordable no longer resolves omits the upload object // entirely — the optionality UploadVersion.upload declares. - put("last_has_upload", obj["upload"] != null) + put("last_has_upload", last.upload != null) } } + fun main() { val testsDir = File("../conformance/tests") diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt index dbdcfc9bbd..58aed40ecb 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt @@ -330,6 +330,7 @@ val TYPE_ALIASES = mapOf( "Vault" to "Vault", "Document" to "Document", "Upload" to "Upload", + "UploadVersion" to "UploadVersion", "Schedule" to "Schedule", "ScheduleEntry" to "ScheduleEntry", "Recording" to "Recording", diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt new file mode 100644 index 0000000000..a5d60a4318 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersion.kt @@ -0,0 +1,24 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * UploadVersion entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class UploadVersion( + val id: Long, + @SerialName("recording_id") val recordingId: Long, + val action: String, + @SerialName("created_at") val createdAt: String, + val creator: Person, + val details: EventDetails? = null, + @SerialName("boosts_count") val boostsCount: Int? = null, + @SerialName("boosts_url") val boostsUrl: String? = null, + val upload: UploadVersionFile? = null +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt new file mode 100644 index 0000000000..3a54a53ca5 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/UploadVersionFile.kt @@ -0,0 +1,21 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * UploadVersionFile entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class UploadVersionFile( + val filename: String, + @SerialName("download_url") val downloadUrl: String, + @SerialName("app_download_url") val appDownloadUrl: String, + val current: Boolean, + @SerialName("content_type") val contentType: String? = null, + @SerialName("byte_size") val byteSize: Long? = null +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt index ba2bd63ce0..99818d32a7 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/uploads.kt @@ -61,7 +61,7 @@ open class UploadsService(client: AccountClient) : BaseService(client) { * @param uploadId The upload ID * @param options Optional query parameters and pagination control */ - suspend fun listVersions(uploadId: Long, options: PaginationOptions? = null): ListResult { + suspend fun listVersions(uploadId: Long, options: PaginationOptions? = null): ListResult { val info = OperationInfo( service = "Uploads", operation = "ListUploadVersions", @@ -73,7 +73,7 @@ open class UploadsService(client: AccountClient) : BaseService(client) { return requestPaginated(info, options, { httpGet("/uploads/${uploadId}/versions.json", operationName = info.operation) }) { body -> - json.decodeFromString>(body) + json.decodeFromString>(body) } } diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 181df0f8b9..0421216984 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-06T19:03:03Z", + "generated": "2026-08-07T09:49:20Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 1c725b4cf9..875afc1678 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-08-06T19:03:03Z +# Generated: 2026-08-07T09:49:20Z require "json" require "time" diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 40a3ff2882..f96d7e5733 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -475,6 +475,7 @@ const TYPE_ALIASES: Record Vault: ["Vault", "entity"], Document: ["Document", "entity"], Upload: ["Upload", "entity"], + UploadVersion: ["UploadVersion", "entity"], CloudFile: ["CloudFile", "entity"], GoogleDocument: ["GoogleDocument", "entity"], Schedule: ["Schedule", "entity"], diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 718e8ae176..6ccc9cb312 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-06T19:03:01.503Z", + "generated": "2026-08-07T09:49:19.574Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/services/uploads.ts b/typescript/src/generated/services/uploads.ts index 759b539c40..1e28b9c961 100644 --- a/typescript/src/generated/services/uploads.ts +++ b/typescript/src/generated/services/uploads.ts @@ -16,6 +16,8 @@ import { Errors } from "../../errors.js"; /** Upload entity from the Basecamp API. */ export type Upload = components["schemas"]["Upload"]; +/** UploadVersion entity from the Basecamp API. */ +export type UploadVersion = components["schemas"]["UploadVersion"]; /** * Request parameters for update. @@ -156,14 +158,14 @@ export class UploadsService extends BaseService { * List versions of an upload * @param uploadId - The upload ID * @param options - Optional query parameters - * @returns All results across all pages, with .meta.totalCount + * @returns All UploadVersion across all pages, with .meta.totalCount * * @example * ```ts * const result = await client.uploads.listVersions(123); * ``` */ - async listVersions(uploadId: number, options?: ListVersionsUploadOptions): Promise { + async listVersions(uploadId: number, options?: ListVersionsUploadOptions): Promise> { return this.requestPaginated( { service: "Uploads", From 923470fd6716f3461f326ad8c8c7584c704c775c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 03:16:10 -0700 Subject: [PATCH 08/17] Address Codex round 2: export the new TS types, and Python does break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both correct. typescript/src/index.ts exported Upload, CreateUploadRequest and UpdateUploadRequest but none of the new types, so UploadVersion and CreateVersionUploadRequest appeared in public method signatures with no supported import through which a consumer could name them. That is the client-wiring step of this work, and I checked the method was exported without checking its types were. And MIGRATING said Python could not break a build. ErrorCode is a StrEnum, so a consumer matching it exhaustively and closing with typing.assert_never gets a mypy failure on the new member — a break that needs a type-checker to surface rather than an interpreter, which makes it easier to miss than the three that stop a compiler, not less real. Four SDKs now, with the distinction spelled out. --- MIGRATING.md | 17 +++++++++++------ typescript/src/index.ts | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index 2e4e82f4ca..f9b18a6fbb 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -93,7 +93,7 @@ plan limit. This affects `CreateUpload`, `CreateAttachment`, by status, not by operation — the project-limit 507 on `CreateProject` and `UnarchiveProject` that shipped in v0.13.0 as a retryable `api_error`. -### The new error code is source-breaking in three SDKs +### 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: @@ -103,12 +103,17 @@ merely behavioural: | TypeScript | `ErrorCode` union gains `"limit_exceeded"` | a `Record` 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 | -Go and Ruby take a new constant rather than a new variant, and Python a new -`ErrorCode` member plus a `LimitExceededError` class, so none of the three -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. +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 diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 4757278f84..bd16e2fdaa 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -385,8 +385,10 @@ export { export { UploadsService } from "./services/uploads-extensions.js"; export { type Upload, + type UploadVersion, type CreateUploadRequest, type UpdateUploadRequest, + type CreateVersionUploadRequest, } from "./generated/services/uploads.js"; // Schedule & Time services From d540e59fec77c970309634a469b2b39228340f79 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 03:30:38 -0700 Subject: [PATCH 09/17] Address Codex round 3: map 507 in Go's raw request path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go has two response handlers and I only changed one. checkResponse covers the generated service layer; doRequest backs the raw Client.Get/Post/Put/ Delete escape hatch, and its default arm was still returning api_error for a 507. The 400/422 arm sitting just above the new one exists because exactly this happened before — its comment says the raw path "used to fall through to the default arm and report api_error with the field-keyed detail dropped". Same shape, same fix, and the arm is ordered ahead of the 5xx cases for the same reason as everywhere else. The test fails against the un-mapped path with `code = "api_error", want "limit_exceeded"`. --- go/pkg/basecamp/client.go | 16 +++++++++ go/pkg/basecamp/optional_presence_test.go | 40 +++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/go/pkg/basecamp/client.go b/go/pkg/basecamp/client.go index 22ba70f52c..37d8275709 100644 --- a/go/pkg/basecamp/client.go +++ b/go/pkg/basecamp/client.go @@ -832,6 +832,22 @@ func (c *Client) singleRequest(ctx context.Context, method, url string, body any serverMsg, serverHint, fieldErrors := parseErrorBody(respBody) return nil, validationError(serverMsg, serverHint, fieldErrors, resp.StatusCode, requestID) + case http.StatusInsufficientStorage: // 507 + // Same reason the 400/422 arm above exists: the generated service layer + // maps this through checkResponse, and the raw escape hatch would + // otherwise fall through to the default arm and report an account limit + // as a generic api_error. Decided before the 5xx arms — a limit is not a + // transient failure, and no retry can satisfy it (SPEC §6, step 11). + respBody, _ := limitedReadAll(resp.Body, MaxErrorBodyBytes) + serverMsg, serverHint, _ := parseErrorBody(respBody) + return nil, (&Error{ + Code: CodeLimitExceeded, + Message: msgOrDefault(serverMsg, "account limit reached"), + Hint: serverHint, + HTTPStatus: 507, + Retryable: false, + }).withRequestID(requestID) + case http.StatusInternalServerError: // 500 return nil, ErrAPI(500, "Server error (500)").withRequestID(requestID) diff --git a/go/pkg/basecamp/optional_presence_test.go b/go/pkg/basecamp/optional_presence_test.go index 0dc2adc4cb..68324b227b 100644 --- a/go/pkg/basecamp/optional_presence_test.go +++ b/go/pkg/basecamp/optional_presence_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "testing" "time" @@ -495,3 +496,42 @@ func TestUploadVersionFromGenerated_DetailsSurvives(t *testing.T) { t.Errorf("notified_recipient_ids must survive, got %v", populated.Details.NotifiedRecipientIDs) } } + +// Go has two response handlers: checkResponse for the generated service layer, +// and doRequest for the raw Client.Get/Post/Put/Delete escape hatch. A status +// mapped in one and not the other is a real divergence — the 400/422 arm in +// doRequest exists because exactly that happened with field-keyed errors. +func TestRawClientRequest_MapsInsufficientStorage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInsufficientStorage) + _, _ = w.Write([]byte(`{"error":"The storage limit for this account has been reached."}`)) + })) + t.Cleanup(srv.Close) + + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}) + + _, err := client.Get(context.Background(), "/99999/uploads/1") + if err == nil { + t.Fatal("expected an error on 507") + } + + var bcErr *Error + if !errors.As(err, &bcErr) { + t.Fatalf("error is not *basecamp.Error: %T", err) + } + if bcErr.Code != CodeLimitExceeded { + t.Errorf("code = %q, want %q", bcErr.Code, CodeLimitExceeded) + } + if bcErr.HTTPStatus != 507 { + t.Errorf("http status = %d, want 507", bcErr.HTTPStatus) + } + if bcErr.Retryable { + t.Error("an account limit must not be retryable") + } + if !strings.Contains(bcErr.Message, "storage limit") { + t.Errorf("server message must survive, got %q", bcErr.Message) + } +} From bb917294aaeecaa873231bbe2181d2e2b1c71f27 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 04:02:15 -0700 Subject: [PATCH 10/17] Address Codex round 4: filtering on blob_changed drops the original file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guidance was backwards, and the fixture in this PR shows it. An upload's ORIGINAL file is recorded by the created or active event; only replacements are blob_changed. So "filter on blob_changed to list past versions" returns the replacements — which excludes the original and INCLUDES the current file, the exact inverse of what it reads like. bc3's own test names it: "versions carry the file each version recorded" destructures `replacement, original` and asserts original["action"] is "created". Past versions are the entries carrying an upload whose current is false, which is what the Smithy doc, the Go doc comment and MIGRATING now say. Pinned by a test over the shared fixture, which has exactly the shape that makes the two selections differ: current-false picks the active entry, and the blob_changed shortcut picks the current file and no history at all. --- MIGRATING.md | 8 +++++--- go/pkg/basecamp/vaults.go | 9 +++++++-- go/pkg/generated/client.gen.go | 6 ++++-- openapi.json | 2 +- ruby/lib/basecamp/generated/metadata.json | 2 +- ruby/lib/basecamp/generated/types.rb | 2 +- spec/basecamp.smithy | 6 ++++-- typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 2 +- typescript/src/generated/schema.d.ts | 6 ++++-- typescript/tests/services/uploads.test.ts | 20 +++++++++++++++++++ 11 files changed, 49 insertions(+), 16 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index f9b18a6fbb..0f66c2a671 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -47,9 +47,11 @@ 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) — filter on `blob_changed` to list past versions of the file. The -per-version `download_url` serves **that** version's bytes; the upload's own -always serves the latest. +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` diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index 8b62a3e9cd..e4213d238e 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -1025,8 +1025,13 @@ type UploadVersionListOptions struct { // UploadVersion is a version event for an upload, plus the file it recorded. // // Action is one of "created", "active" (the upload's publication) or -// "blob_changed" (a file replacement). To list past versions of the file, -// filter on "blob_changed". +// "blob_changed" (a file replacement). +// +// To list the file's PAST versions, select entries whose Upload is non-nil and +// whose Upload.Current is false. Filtering on "blob_changed" is the tempting +// shortcut and the wrong one: the original file is recorded by the "created" or +// "active" event, so that filter drops the original and keeps the current file +// — the opposite of what it reads like. type UploadVersion struct { // ID is the event ID, not the upload's. ID int64 `json:"id"` diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index e4af618caf..a1cd618c86 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -4073,8 +4073,10 @@ type Upload struct { // UploadVersion A version event for an upload, from GET /uploads/{id}/versions.json. // // `action` is one of `created`, `active` (the upload's publication) or `blob_changed` -// (a file replacement). A caller listing past versions of the file filters on -// `blob_changed`. This is an Event plus the file it recorded, rendered by its own +// (a file replacement). To list the file's PAST versions, select entries that carry +// an `upload` whose `current` is false — the original file arrives as `created` or +// `active`, never `blob_changed`, so filtering on that action drops the original +// and keeps the current file instead. This is an Event plus the file it recorded, rendered by its own // partial rather than the shared event one, so upload fields don't leak onto todo, // message and card events. type UploadVersion struct { diff --git a/openapi.json b/openapi.json index 8f3498bb8c..7742cb630b 100644 --- a/openapi.json +++ b/openapi.json @@ -35833,7 +35833,7 @@ }, "UploadVersion": { "type": "object", - "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). A caller listing past versions of the file filters on\n`blob_changed`. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", + "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). To list the file's PAST versions, select entries that carry\nan `upload` whose `current` is false — the original file arrives as `created` or\n`active`, never `blob_changed`, so filtering on that action drops the original\nand keeps the current file instead. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", "properties": { "id": { "type": "integer", diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 0421216984..876e75817f 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-07T09:49:20Z", + "generated": "2026-08-07T10:54:41Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 875afc1678..3e2c4050e7 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-08-07T09:49:20Z +# Generated: 2026-08-07T10:54:41Z require "json" require "time" diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index c611df9c9b..3db8f02b75 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -7270,8 +7270,10 @@ list UploadVersionList { /// A version event for an upload, from GET /uploads/{id}/versions.json. /// /// `action` is one of `created`, `active` (the upload's publication) or `blob_changed` -/// (a file replacement). A caller listing past versions of the file filters on -/// `blob_changed`. This is an Event plus the file it recorded, rendered by its own +/// (a file replacement). To list the file's PAST versions, select entries that carry +/// an `upload` whose `current` is false — the original file arrives as `created` or +/// `active`, never `blob_changed`, so filtering on that action drops the original +/// and keeps the current file instead. This is an Event plus the file it recorded, rendered by its own /// partial rather than the shared event one, so upload fields don't leak onto todo, /// message and card events. structure UploadVersion { diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 6ccc9cb312..947c35a545 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-07T09:49:19.574Z", + "generated": "2026-08-07T10:54:40.458Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index aa6b00eb94..d5ba33c004 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -32993,7 +32993,7 @@ }, "UploadVersion": { "type": "object", - "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). A caller listing past versions of the file filters on\n`blob_changed`. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", + "description": "A version event for an upload, from GET /uploads/{id}/versions.json.\n\n`action` is one of `created`, `active` (the upload's publication) or `blob_changed`\n(a file replacement). To list the file's PAST versions, select entries that carry\nan `upload` whose `current` is false — the original file arrives as `created` or\n`active`, never `blob_changed`, so filtering on that action drops the original\nand keeps the current file instead. This is an Event plus the file it recorded, rendered by its own\npartial rather than the shared event one, so upload fields don't leak onto todo,\nmessage and card events.", "properties": { "id": { "type": "integer", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index f50490a833..7aed62ab89 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -6838,8 +6838,10 @@ export interface components { * @description A version event for an upload, from GET /uploads/{id}/versions.json. * * `action` is one of `created`, `active` (the upload's publication) or `blob_changed` - * (a file replacement). A caller listing past versions of the file filters on - * `blob_changed`. This is an Event plus the file it recorded, rendered by its own + * (a file replacement). To list the file's PAST versions, select entries that carry + * an `upload` whose `current` is false — the original file arrives as `created` or + * `active`, never `blob_changed`, so filtering on that action drops the original + * and keeps the current file instead. This is an Event plus the file it recorded, rendered by its own * partial rather than the shared event one, so upload fields don't leak onto todo, * message and card events. */ diff --git a/typescript/tests/services/uploads.test.ts b/typescript/tests/services/uploads.test.ts index ea7805b731..edb9ece23c 100644 --- a/typescript/tests/services/uploads.test.ts +++ b/typescript/tests/services/uploads.test.ts @@ -302,6 +302,26 @@ describe("UploadsService", () => { expect(result[0].upload?.download_url).toContain("/versions/1069479501/"); }); + // Selecting past versions by action is the tempting shortcut and the wrong + // one: the original file arrives as `created`/`active`, so `blob_changed` + // drops it and keeps the CURRENT file instead. Select on current === false. + it("selects past versions by current, not by action", async () => { + server.use( + http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { + return HttpResponse.json(versionsFixture); + }), + ); + + const result = await service.listVersions(7001); + + const past = result.filter((v) => v.upload && v.upload.current === false); + expect(past.map((v) => v.action)).toEqual(["active"]); + + // The shortcut returns the current file and none of the history. + const byAction = result.filter((v) => v.action === "blob_changed"); + expect(byAction.map((v) => v.upload?.current)).toEqual([true]); + }); + it("tolerates a version whose recordable no longer resolves", async () => { server.use( http.get(`${BASE_URL}/uploads/7001/versions.json`, () => { From e32020e4b279560c21da06ccf53fc1c24403c7a6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 04:19:33 -0700 Subject: [PATCH 11/17] Say what `current` actually is, without weakening it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex read the two sentences on `current` as contradictory and concluded the exactly-one guarantee was false. The guarantee holds; the prose was what misled. `current` is `event == @events.first` over a reverse-chronological list — positional, so exactly one element of any non-empty response carries it, never zero and never plural. bc3 pins the specific case the old wording made sound impossible, in a test named "exactly one version is current after a metadata-only update", asserting the count is 1. What the caveat was actually about is a different question: `current` does not mean "the file the upload's own download_url serves". A metadata-only PUT swaps in a recordable carrying the same blob and emits no event, so afterwards no event references the upload's current recordable — and exactly one element is still current. Both docs now separate the two claims instead of running them together. Not taking the suggested fix. Describing `current` as "whether this event references the current recordable" and allowing zero matches would document an invariant weaker than the server's, and push every consumer to handle a case that cannot occur — the conformance case asserting current_count == 1 would have to go with it. --- go/pkg/basecamp/vaults.go | 14 ++++++++++---- go/pkg/generated/client.gen.go | 16 ++++++++++++---- openapi.json | 2 +- ruby/lib/basecamp/generated/metadata.json | 2 +- ruby/lib/basecamp/generated/types.rb | 2 +- spec/basecamp.smithy | 16 ++++++++++++---- typescript/src/generated/metadata.ts | 2 +- typescript/src/generated/openapi-stripped.json | 2 +- typescript/src/generated/schema.d.ts | 16 ++++++++++++---- 9 files changed, 51 insertions(+), 21 deletions(-) diff --git a/go/pkg/basecamp/vaults.go b/go/pkg/basecamp/vaults.go index e4213d238e..31f6949f77 100644 --- a/go/pkg/basecamp/vaults.go +++ b/go/pkg/basecamp/vaults.go @@ -1075,10 +1075,16 @@ type UploadVersionFile struct { DownloadURL string `json:"download_url"` // AppDownloadURL is the same file on the storage host. AppDownloadURL string `json:"app_download_url"` - // Current is true for the most recent version; exactly one element per - // response has it. This is the newest version, not necessarily the file the - // upload's own download URL serves — a metadata-only update swaps in a - // recordable carrying the same blob and emits no event. + // Current is true for the newest version event, and for exactly one element + // of any non-empty response. It is computed positionally by the renderer + // (event == @events.first over a reverse-chronological list), so it is a + // property of ordering rather than of what the upload points at, and is + // never zero or plural. + // + // It does NOT mean "the file the upload's own download URL serves". A + // metadata-only update swaps in a recordable carrying the same blob and + // emits no event, so afterwards no event references the upload's current + // recordable — and exactly one element is still Current. Current bool `json:"current"` } diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index a1cd618c86..a8344bfe32 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -4099,10 +4099,18 @@ type UploadVersionFile struct { ByteSize *int64 `json:"byte_size,omitempty"` ContentType *string `json:"content_type,omitempty"` - // Current True for the most recent version; exactly one element per response. This is the - // newest *version*, not necessarily the file the upload's own download_url serves — - // a metadata-only PUT swaps in a recordable carrying the same blob and emits no - // event, so after one no event references the upload's current recordable. + // Current True for the newest version *event*, and for exactly one element of any + // non-empty response. The renderer computes it positionally — `event == + // @events.first` over a reverse-chronological list — so it is a property of + // ordering, not of what the upload currently points at, and it cannot be + // zero or plural. + // + // That distinction is the whole caveat: `current` does NOT mean "this is the + // file the upload's own download_url serves". A metadata-only PUT swaps in a + // recordable carrying the same blob and emits no event, so afterwards no + // event references the upload's current recordable — and this flag still + // marks exactly one element, the newest event. bc3 pins that case by name in + // "exactly one version is current after a metadata-only update". Current bool `json:"current"` // DownloadUrl Fetches THIS version's bytes. The upload's own download_url always serves the diff --git a/openapi.json b/openapi.json index 7742cb630b..bf59b74e58 100644 --- a/openapi.json +++ b/openapi.json @@ -35902,7 +35902,7 @@ }, "current": { "type": "boolean", - "description": "True for the most recent version; exactly one element per response. This is the\nnewest *version*, not necessarily the file the upload's own download_url serves —\na metadata-only PUT swaps in a recordable carrying the same blob and emits no\nevent, so after one no event references the upload's current recordable." + "description": "True for the newest version *event*, and for exactly one element of any\nnon-empty response. The renderer computes it positionally — `event ==\n@events.first` over a reverse-chronological list — so it is a property of\nordering, not of what the upload currently points at, and it cannot be\nzero or plural.\n\nThat distinction is the whole caveat: `current` does NOT mean \"this is the\nfile the upload's own download_url serves\". A metadata-only PUT swaps in a\nrecordable carrying the same blob and emits no event, so afterwards no\nevent references the upload's current recordable — and this flag still\nmarks exactly one element, the newest event. bc3 pins that case by name in\n\"exactly one version is current after a metadata-only update\"." } }, "required": [ diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 876e75817f..ab2c5f4894 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-07T10:54:41Z", + "generated": "2026-08-07T11:12:00Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 3e2c4050e7..7cb463cc95 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-08-07T10:54:41Z +# Generated: 2026-08-07T11:12:00Z require "json" require "time" diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index 3db8f02b75..3fa094c59c 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -7312,10 +7312,18 @@ structure UploadVersionFile { @required app_download_url: String - /// True for the most recent version; exactly one element per response. This is the - /// newest *version*, not necessarily the file the upload's own download_url serves — - /// a metadata-only PUT swaps in a recordable carrying the same blob and emits no - /// event, so after one no event references the upload's current recordable. + /// True for the newest version *event*, and for exactly one element of any + /// non-empty response. The renderer computes it positionally — `event == + /// @events.first` over a reverse-chronological list — so it is a property of + /// ordering, not of what the upload currently points at, and it cannot be + /// zero or plural. + /// + /// That distinction is the whole caveat: `current` does NOT mean "this is the + /// file the upload's own download_url serves". A metadata-only PUT swaps in a + /// recordable carrying the same blob and emits no event, so afterwards no + /// event references the upload's current recordable — and this flag still + /// marks exactly one element, the newest event. bc3 pins that case by name in + /// "exactly one version is current after a metadata-only update". @required current: Boolean } diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 947c35a545..74f766e1aa 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-08-07T10:54:40.458Z", + "generated": "2026-08-07T11:11:59.965Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index d5ba33c004..b56c44aef5 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -33062,7 +33062,7 @@ }, "current": { "type": "boolean", - "description": "True for the most recent version; exactly one element per response. This is the\nnewest *version*, not necessarily the file the upload's own download_url serves —\na metadata-only PUT swaps in a recordable carrying the same blob and emits no\nevent, so after one no event references the upload's current recordable." + "description": "True for the newest version *event*, and for exactly one element of any\nnon-empty response. The renderer computes it positionally — `event ==\n@events.first` over a reverse-chronological list — so it is a property of\nordering, not of what the upload currently points at, and it cannot be\nzero or plural.\n\nThat distinction is the whole caveat: `current` does NOT mean \"this is the\nfile the upload's own download_url serves\". A metadata-only PUT swaps in a\nrecordable carrying the same blob and emits no event, so afterwards no\nevent references the upload's current recordable — and this flag still\nmarks exactly one element, the newest event. bc3 pins that case by name in\n\"exactly one version is current after a metadata-only update\"." } }, "required": [ diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 7aed62ab89..460b4aaaf0 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -6872,10 +6872,18 @@ export interface components { download_url: string; app_download_url: string; /** - * @description True for the most recent version; exactly one element per response. This is the - * newest *version*, not necessarily the file the upload's own download_url serves — - * a metadata-only PUT swaps in a recordable carrying the same blob and emits no - * event, so after one no event references the upload's current recordable. + * @description True for the newest version *event*, and for exactly one element of any + * non-empty response. The renderer computes it positionally — `event == + * @events.first` over a reverse-chronological list — so it is a property of + * ordering, not of what the upload currently points at, and it cannot be + * zero or plural. + * + * That distinction is the whole caveat: `current` does NOT mean "this is the + * file the upload's own download_url serves". A metadata-only PUT swaps in a + * recordable carrying the same blob and emits no event, so afterwards no + * event references the upload's current recordable — and this flag still + * marks exactly one element, the newest event. bc3 pins that case by name in + * "exactly one version is current after a metadata-only update". */ current: boolean; }; From 3ec4db3c6c58aa7c6047c4b4ba1d7cb466874035 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 04:36:27 -0700 Subject: [PATCH 12/17] Address Codex round 5: the six READMEs are public error references too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six error tables omitted limit_exceeded / exit code 10, and the Swift and Kotlin READMEs print exhaustive examples — a `switch` with no default and a `when` over the sealed class — that would no longer compile if a reader pasted them. Those are the same break MIGRATING now documents, shipped in the docs that teach it. Both examples gain a LimitExceeded arm, each saying the thing a caller needs at the point of handling: 507 is an account limit, not a transient failure, so do not retry. Also corrected while in there: the TypeScript, Swift and Kotlin tables said api_error covers "5xx", which stopped being true the moment 507 got its own code. They now name the statuses. --- go/README.md | 1 + kotlin/README.md | 6 +++++- python/README.md | 1 + ruby/README.md | 1 + swift/README.md | 6 +++++- typescript/README.md | 3 ++- 6 files changed, 15 insertions(+), 3 deletions(-) diff --git a/go/README.md b/go/README.md index c74e2295a7..e4c0bd9877 100644 --- a/go/README.md +++ b/go/README.md @@ -664,6 +664,7 @@ if err != nil { | `api_error` | Server error | 7 | | `ambiguous` | Multiple matches found | 8 | | `validation` | Validation error (400, 422) | 9 | +| `limit_exceeded` | Account limit reached (507) — never retryable | 10 | ### Validation Errors diff --git a/kotlin/README.md b/kotlin/README.md index 6e56d230f1..e17a69bc12 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -659,6 +659,9 @@ try { is BasecampException.NotFound -> println("Not found: ${e.message}") is BasecampException.RateLimit -> println("Retry in ${e.retryAfterSeconds}s") is BasecampException.Validation -> println("Invalid input: ${e.message}") + is BasecampException.LimitExceeded -> + // 507. An account limit, not a transient failure — do not retry. + println("Limit reached: ${e.message}") is BasecampException.Ambiguous -> println("Ambiguous: ${e.message}") is BasecampException.Network -> println("Network error: ${e.message}") is BasecampException.Api -> println("Server error (${e.httpStatus}): ${e.message}") @@ -685,9 +688,10 @@ try { | `NotFound` | 404 | 2 | Resource not found | | `RateLimit` | 429 | 5 | Rate limit exceeded (retryable) | | `Network` | - | 6 | Network error (retryable) | -| `Api` | 5xx | 7 | Server error | +| `Api` | 500, 502, 503, 504, other 5xx | 7 | Server error | | `Ambiguous` | - | 8 | Multiple matches found | | `Validation` | 400, 422 | 9 | Invalid request data | +| `LimitExceeded` | 507 | 10 | Account limit reached (file storage, projects, webhooks) — never retryable | | `Usage` | - | 1 | Configuration or argument error | | `DiscoverySelection` | - | 7 or 9 | OAuth discovery selection failed (code derived from `reason`) | | `DeviceFlow` | - | 1, 3, 6, or 9 | Device authorization grant failed (code derived from `reason`) | diff --git a/python/README.md b/python/README.md index 694b9e38e2..e6f319b769 100644 --- a/python/README.md +++ b/python/README.md @@ -626,6 +626,7 @@ All exceptions inherit from `BasecampError`: | `ApiError` | `api_error` | 5xx, other | Yes for 500/502/503/504; No otherwise | | `AmbiguousError` | `ambiguous` | - | No | | `ValidationError` | `validation` | 400, 422 | No | +| `LimitExceededError` | `limit_exceeded` | 507 | No | Every `BasecampError` provides: - `code` - `ErrorCode` enum value diff --git a/ruby/README.md b/ruby/README.md index fb22ece1b5..2984c6041e 100644 --- a/ruby/README.md +++ b/ruby/README.md @@ -480,6 +480,7 @@ end | `ValidationError` | Invalid request data (400, 422) | | `RateLimitError` | Rate limit exceeded (429) | | `NetworkError` | Connection failures | +| `LimitExceededError` | Account limit reached (507) — file storage, projects, webhooks | ### Validation Errors diff --git a/swift/README.md b/swift/README.md index 799c1f6b38..caea10972e 100644 --- a/swift/README.md +++ b/swift/README.md @@ -361,6 +361,9 @@ do { fieldErrors?.forEach { field, messages in messages.forEach { print(" \(field) \($0)") } } + case .limitExceeded(let message, _, _): + // 507. An account limit, not a transient failure — do not retry. + print("Limit reached: \(message)") case .ambiguous(let resource, _, _): print("Ambiguous \(resource)") case .usage(let message, _): @@ -385,9 +388,10 @@ do { | `.notFound` | 404 | 2 | Resource not found | | `.rateLimit` | 429 | 5 | Rate limit exceeded (retryable) | | `.network` | - | 6 | Network error (retryable) | -| `.api` | 5xx | 7 | Server error | +| `.api` | 500, 502, 503, 504, other 5xx | 7 | Server error | | `.ambiguous` | - | 8 | Multiple matches found | | `.validation` | 400, 422 | 9 | Invalid request data | +| `.limitExceeded` | 507 | 10 | Account limit reached (file storage, projects, webhooks) — never retryable | | `.usage` | - | 1 | Configuration or argument error | ### Validation Errors diff --git a/typescript/README.md b/typescript/README.md index 27daa4e3e5..1cd8d29771 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -634,9 +634,10 @@ try { | `not_found` | 404 | 2 | Resource not found | | `rate_limit` | 429 | 5 | Rate limit exceeded (retryable) | | `network` | - | 6 | Network error (retryable) | -| `api_error` | 5xx | 7 | Server error | +| `api_error` | 500, 502, 503, 504, other 5xx | 7 | Server error | | `ambiguous` | - | 8 | Multiple matches found | | `validation` | 400, 422 | 9 | Invalid request data | +| `limit_exceeded` | 507 | 10 | Account limit reached (file storage, projects, webhooks) — never retryable | | `usage` | - | 1 | Configuration or argument error | ### Validation Errors From 228b8512e455f8d572297d83759a57e79b42f4d3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 05:34:10 -0700 Subject: [PATCH 13/17] Account for CreateUploadVersion on the grouped client (#682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #682's new gate caught this branch, which is what it was built for: the inventory's invariant is that every operationId appears exactly once across `grouped:` or `not_grouped:`, so a new operation lands in neither and fails loudly rather than shipping missing — the way ArchiveProject and UnarchiveProject did through two green `make` runs. Filed under `not_grouped:`, next to `ListUploadVersions`. The grouped surface is the low-level generated client and is deliberately curated rather than exhaustive; the versions read is already off it, and putting the write on while its sibling read stays off would be the inconsistency the inventory exists to prevent. The ergonomic surface for this is UploadsService.CreateVersion in go/pkg/basecamp, which is unaffected. --- go/grouped-client-inventory.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/go/grouped-client-inventory.yml b/go/grouped-client-inventory.yml index 01d05c9f67..21823a9810 100644 --- a/go/grouped-client-inventory.yml +++ b/go/grouped-client-inventory.yml @@ -202,6 +202,7 @@ not_grouped: - CreateTodolistGroup - CreateTodosetTodo - CreateTool + - CreateUploadVersion - CreateWormhole - DeleteBookmark - DeleteBoost From 17ed903de0992245fc20e7454ec019d4c96cf953 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 06:06:23 -0700 Subject: [PATCH 14/17] =?UTF-8?q?Correct=20SPEC=20=C2=A72's=20retry.max=20?= =?UTF-8?q?distribution:=2044=20->=2045=20at=20max=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateUploadVersion carries retry.max 2, so the behavior-model breakdown is 205/45. §2 still said 205/44, which summed to 249 against a document that says 250 nine hundred lines later — a self-inconsistency an operation-count audit would read as drift. Derived from behavior-model.json rather than incremented by hand: `Counter((o['retry'] or {})['max'] for o in operations)` gives {3: 205, 2: 45}. Only that count moves. The 11 / 197 / 208 figures in the two bullets below it are about IDEMPOTENT operations — the ones that get retried at all — and CreateUploadVersion is a non-idempotent POST like CreateUpload, so it joins neither the named eleven nor the 208. Checked rather than assumed: behavior-model marks Subscribe and MarkAsRead idempotent and both CreateUpload and CreateUploadVersion not. --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index da02cf5d8b..c73698bc31 100644 --- a/SPEC.md +++ b/SPEC.md @@ -108,7 +108,7 @@ END **Naming note:** `max_retries` means total attempts (including the initial request), not the number of retries after the first attempt. With `max_retries = 3`, the transport makes at most 3 attempts total (1 initial + 2 retries). This name is inherited from the shipping Ruby SDK; the behavior-model.json uses `retry.max` with identical semantics. -**Per-operation retry ceiling.** Each operation carries a per-op `retry.max` in behavior-model.json (205 ops at `3`, 44 at `2`). **TypeScript and Swift** drive their retry loops directly from this per-op value, which is unambiguous there because neither exposes a numeric client-wide cap — only an on/off (`enableRetry`). Generated Go, Python, Kotlin (`BasecampConfig.maxRetries`), and Ruby's governed GET path (`config.max_retries`) expose a numeric client cap *and* honor the per-op value as a **ceiling**: `effective_attempts = min(client_cap, op_max)`. The ceiling can only reduce attempts below the client cap, never raise them, so a client that lowered its cap (e.g. to `1` to disable retries) is still honored. In Go, Python, and Ruby's governed path the cap is floored at one attempt before the ceiling applies (`min(max(1, cap), op_max)`), so a cap of `0` yields a single attempt rather than none whether or not the operation declares a retry block. Kotlin computes a different expression — `min(max(1, cap), op_max ?: cap)` — which is `0` for an ungoverned operation at a cap of `0`, but it lands on the same single attempt anyway because its loop consults the budget only after the first request has already gone out. Because every op's `max` is ≤ the default cap of `3`, a default or raised client makes exactly the per-op number of attempts in every capped SDK — matching TS/Swift. Observable changes from the former client-wide behavior, by client configuration: +**Per-operation retry ceiling.** Each operation carries a per-op `retry.max` in behavior-model.json (205 ops at `3`, 45 at `2`). **TypeScript and Swift** drive their retry loops directly from this per-op value, which is unambiguous there because neither exposes a numeric client-wide cap — only an on/off (`enableRetry`). Generated Go, Python, Kotlin (`BasecampConfig.maxRetries`), and Ruby's governed GET path (`config.max_retries`) expose a numeric client cap *and* honor the per-op value as a **ceiling**: `effective_attempts = min(client_cap, op_max)`. The ceiling can only reduce attempts below the client cap, never raise them, so a client that lowered its cap (e.g. to `1` to disable retries) is still honored. In Go, Python, and Ruby's governed path the cap is floored at one attempt before the ceiling applies (`min(max(1, cap), op_max)`), so a cap of `0` yields a single attempt rather than none whether or not the operation declares a retry block. Kotlin computes a different expression — `min(max(1, cap), op_max ?: cap)` — which is `0` for an ungoverned operation at a cap of `0`, but it lands on the same single attempt anyway because its loop consults the budget only after the first request has already gone out. Because every op's `max` is ≤ the default cap of `3`, a default or raised client makes exactly the per-op number of attempts in every capped SDK — matching TS/Swift. Observable changes from the former client-wide behavior, by client configuration: - **Default client (`max_retries = 3`):** only the **11 idempotent `max:2` operations** (account/gauge/preference writes plus two subscription-style POSTs: `UpdateAccountName`, `UpdateAccountLogo`, `RemoveAccountLogo`, `UpdateMyPreferences`, `DisableOutOfOffice`, `MarkAsRead`, `ToggleGauge`, `UpdateGaugeNeedle`, `DestroyGaugeNeedle`, `Subscribe`, `EnableCardColumnOnHold`) change — they now retry at most twice instead of three times. The other 197 retry-eligible ops are unaffected (`min(3, 3) = 3`). - **Client that raised its cap above 3:** **all 208 retry-eligible operations** are now clamped to their per-op `max` (197 to `3`, 11 to `2`) instead of retrying up to the raised cap. This is the intended meaning of a per-op ceiling and brings Go/Python into line with TS/Swift/Kotlin, which never retry beyond the per-op `max`. Go, Python, Kotlin, and Ruby's governed path all equally honor a caller who wants *fewer* attempts than the operation declares. From a72e79e5586033171241f5cc3404716ad9a24162 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 06:22:54 -0700 Subject: [PATCH 15/17] Reconcile the rest of the derived counts, by sweeping instead of patching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-idempotent 166 -> 167 in §2's Operation Counts. My last commit said "only that count moves" and was wrong: I checked the two bullets next to the number I was fixing and not the section nine hundred lines down that derives from the same file. So this time the whole set was derived from behavior-model.json and compared against every stated figure, rather than the one Codex pointed at: total 250, idempotent 83, non-idempotent 167, retry.max {3: 205, 2: 45}. All now reconcile, and 83 + 167 = 250 the way 83 + 166 did not. MIGRATING's Unreleased section gains the inventory line the file's own convention asks for — 249 -> 250, with the two commands to derive each end rather than trust either. Its earlier 247/249 figures are untouched: those are as-of facts about shipped releases, not claims about today. --- MIGRATING.md | 8 ++++++++ SPEC.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/MIGRATING.md b/MIGRATING.md index 0f66c2a671..c6ee00ce2b 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -16,6 +16,14 @@ what wrong behaviour you get if you ignore one. This file is that half. 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 diff --git a/SPEC.md b/SPEC.md index c73698bc31..d16f4c3010 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3485,7 +3485,7 @@ Every operation has a `retry` block, including non-idempotent POSTs. For non-ide - Total operations: 250 - Idempotent: 83 (flagged with `idempotent: true`) -- Non-idempotent: 166 (no `idempotent` field, or not present) +- Non-idempotent: 167 (no `idempotent` field, or not present) - All operations use `retry_on: [429, 503]` --- From 6dc37883126465337a709bb266e2b1891cf37c3e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 06:39:35 -0700 Subject: [PATCH 16/17] List all eight 507 operations, not the four this PR touched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reclassification is keyed on status, so it reaches every operation the spec gives a 507 — including CreateWebhook and UpdateWebhook, which have declared WebhookLimitError since long before this branch and were reporting it as a retryable api_error the whole time. Webhook and project callers therefore need the same new branch even though nothing about those endpoints changed, which is exactly the kind of thing a migration guide exists to say and mine did not. The list is now a table of all eight across the three limits, derived from openapi.json rather than recalled, with the query to re-derive it. That is also what makes the omission embarrassing rather than subtle: the six README tables this PR already updated say "file storage, projects, webhooks" — I knew the set and then wrote a shorter one here. --- MIGRATING.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index c6ee00ce2b..e676ed40e4 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -96,12 +96,27 @@ 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 storage-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. This affects `CreateUpload`, `CreateAttachment`, -`CreateCampfireUpload` and `CreateUploadVersion`, and — because the mapping is -by status, not by operation — the project-limit 507 on `CreateProject` and -`UnarchiveProject` that shipped in v0.13.0 as a retryable `api_error`. +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 From 179714564ad55143406ddff815d7b955450c56f7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 7 Aug 2026 06:57:12 -0700 Subject: [PATCH 17/17] Sweep the whole tree for stale operation counts, not the named files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third round on this class, so this time the sweep is the fix rather than the two references Codex pointed at. SECURITY.md said 249 operations and 41 non-idempotent POSTs; both move to 250 and 42. The rest of that paragraph checks out and is untouched — 125 GETs, 83 idempotent mutations, 52 PUTs, 24 DELETEs, 7 flagged POSTs, and the seven named are exactly the seven behavior-model flags. Verified rather than assumed, since the whole point of the last two rounds is that I patched what I was shown and not what was true. scripts/check-grouped-client-coverage's floor comment cited 249 as the real value. Not load-bearing — MIN_OPERATIONS is 200 and catches extraction collapse, not drift — but it is a current-value claim in prose and it was wrong. The sweep: every tracked file except MIGRATING (as-of history), generated output and lockfiles, scanned for 247/248/249 within ninety characters of "operation", "POST", "route" or "surface". Zero remaining. --- SECURITY.md | 4 ++-- scripts/check-grouped-client-coverage | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index e0d38d7384..35a6d8082f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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.) diff --git a/scripts/check-grouped-client-coverage b/scripts/check-grouped-client-coverage index ce3f0edba7..f4ce97f119 100755 --- a/scripts/check-grouped-client-coverage +++ b/scripts/check-grouped-client-coverage @@ -61,7 +61,7 @@ GENERATED_PATH = ENV["GROUPED_CLIENT_GENERATED"] || File.join(ROOT, "go/pkg/gene # Extraction floors. Every input here is parsed with a regex, and a regex that # stops matching returns an empty set rather than an error — which reads as "all # clear" and is how a gate silently stops gating. A floor turns that into a -# failure. The numbers are deliberately far below the real values (249 operations, +# failure. The numbers are deliberately far below the real values (250 operations, # 63 arms, 15 services): they catch total extraction collapse, not drift. MIN_OPERATIONS = 200 MIN_ARMS = 40