diff --git a/AGENTS.md b/AGENTS.md
index 5e07bc15c..ae435ce97 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,10 +9,10 @@
| **TypeScript SDK** | Production-ready | 46 generated services, openapi-fetch based |
| **Ruby SDK** | Production-ready | 46 generated services |
| **Swift SDK** | Production-ready | 46 generated services, URLSession-based |
-| **Kotlin SDK** | Production-ready | 46 accessors over 46 generated service classes (`todos` exposes a composite subclass of the generated `TodosService`), Ktor/KMP-based |
+| **Kotlin SDK** | Production-ready | 46 accessors over 46 generated service classes (`todos` and `cards` expose composite subclasses of their generated services), Ktor/KMP-based |
| **Python SDK** | Production-ready | 46 generated services, httpx-based |
-All six SDKs share the same architecture: **Smithy spec -> OpenAPI -> Generated services**. All wire operations are generated. The only hand-written runtime API methods are sanctioned composites that call generated wire methods exclusively (today: the merge-safe Todos `update`/`edit`) — see SPEC.md §18 "Hand-Written Composite Methods" for the rules they must satisfy.
+All six SDKs share the same architecture: **Smithy spec -> OpenAPI -> Generated services**. All wire operations are generated. The only hand-written runtime API methods are sanctioned composites that call generated wire methods exclusively (today: the merge-safe Todos `update`/`edit` and Cards `update`) — see SPEC.md §18 "Hand-Written Composite Methods" for the rules they must satisfy.
---
@@ -38,6 +38,7 @@ All 226 operations across the ~46-service per-SDK layer are generated. Hand-writ
| HTTP helpers, pagination, hooks | `src/services/base.ts` | `lib/basecamp/services/base_service.rb` | `Sources/Basecamp/Services/BaseService.swift` | `sdk/.../services/BaseService.kt` | `src/basecamp/generated/services/_base.py` |
| OAuth flows (not in OpenAPI spec) | `src/services/authorization.ts` | `lib/basecamp/services/authorization_service.rb` | — | `sdk/.../oauth/*.kt` | `src/basecamp/services/authorization.py` |
| Merge-safe Todos composites (update/edit over generated get+replace; SPEC.md §18) | `src/services/todos-extensions.ts` | `lib/basecamp/services/todos_extensions.rb` | `Sources/Basecamp/TodosServiceExtensions.swift` | `sdk/.../services/TodosService.kt` | `src/basecamp/services/todos.py` |
+| Merge-safe Cards composite (update over generated get+updateVerbatim; SPEC.md §18) | `src/services/cards-extensions.ts` | `lib/basecamp/services/cards_extensions.rb` | `Sources/Basecamp/CardsServiceExtensions.swift` | `sdk/.../services/CardsService.kt` | `src/basecamp/services/cards.py` |
Hand-written service files in `src/services/` (TS) and `lib/basecamp/services/` (Ruby) beyond the tables above are NOT loaded at runtime. They exist only as reference implementations.
diff --git a/SPEC.md b/SPEC.md
index 4e704a770..af1e7b9a7 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -275,6 +275,32 @@ account, attachments, automation, boosts, campfires, cardColumns, cardSteps, car
The OpenAPI spec uses 12 coarse tags (e.g., `Automation`, `Todos`, `Files`). The service generators split these into 46 fine-grained services using a two-table mapping: `TAG_TO_SERVICE` (tag → default service name) and `SERVICE_SPLITS` (tag → {service → [operationIds]}). For example, the `Todos` tag splits into `Todos`, `Todolists`, `Todosets`, `TodolistGroups`; the `Files` tag splits into `Attachments`, `Uploads`, `Vaults`, `Documents`. These mappings are defined in each language's generator script and produce identical service sets across SDKs.
+### Merge-Safe Write Surface (Cards)
+
+BC3 builds a card's update params as `{ due_on: nil }.merge(card_params)`
+(`kanban/cards_controller.rb`), so **any** update whose body omits `due_on` erases the card's due
+date. A sparse PUT — the natural thing to write, and what every generated SDK produced — is
+therefore destructive.
+
+- **`update`** — merge-safe. GETs the card and resends the existing `due_on` when the caller left it
+ unaddressed, then PUTs. The extra GET is paid for only in that case; naming the due date
+ explicitly skips it. `due_on` is tri-state: unaddressed preserves, an explicit empty clears, a
+ date sets. Clearing is encoded by **omitting** `due_on` — never by sending null (§18).
+- **`updateVerbatim`** — the raw single PUT, no read-before-write. Sharp by construction: omitting
+ `due_on` clears it.
+
+The composite deliberately does **not** resend everything. BC3 filters incoming assignee IDs through
+`reachable_people`, so echoing assignees back would silently unassign anyone who has since lost board
+access; only the caller's own `title`/`content`/`assignee_ids` go out, plus `due_on`.
+
+Not atomic: a concurrent due-date change landing between the GET and the PUT is overwritten with the
+value the call read. The window is one round-trip.
+
+Presence detection is language-native: Go `*string` (nil preserves, pointer-to-empty clears),
+TypeScript `dueOn?: string | null`, Ruby/Python `nil`/`None` kwarg defaults with `""` to clear,
+Kotlin nullable parameters with `""` to clear, Swift a `DueDate` enum (`.preserve`/`.clear`/`.on`)
+because an optional cannot carry three states.
+
### 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:
@@ -1270,9 +1296,14 @@ All wire operations are generated (rubric 1A.6). One narrow exception is sanctio
2. **Composition, not substitution.** It composes existing generated operations (e.g. GET → overlay → full PUT); it never introduces a wire operation the spec lacks — fix the spec and regenerate instead.
3. **Native hook identities.** Hooks observe the constituent wire operations under their normal per-language identities; composites never mint synthetic operation names.
4. **Conformance-covered.** The composite's behavior is encoded in `conformance/tests/` fixtures run by every runner (with native test mirrors where a runner does not exist yet, e.g. Swift).
-5. **Declared placement.** The composite lives in the language's designated hand-written extension point (Kotlin generator `EXTENSIBLE_SERVICES`/`HAND_WRITTEN_SERVICES`, TS `src/services/todos-extensions.ts` wired in `client.ts`, Ruby zeitwerk `prepend` module, Python service subclass re-exported by the client, Swift same-module extension) so regeneration can never silently drop or fork it.
+5. **Declared placement.** The composite lives in the language's designated hand-written extension point (Kotlin generator `EXTENSIBLE_SERVICES`/`HAND_WRITTEN_SERVICES`, TS `src/services/*-extensions.ts` wired in `client.ts`, Ruby zeitwerk `prepend` module, Python service subclass re-exported by the client, Swift same-module extension) so regeneration can never silently drop or fork it.
+6. **The raw operation stays reachable.** When a composite takes over the plain method name, the generated single-request method is renamed (via `METHOD_NAME_OVERRIDES`) rather than hidden, and gets its own conformance case asserting it makes exactly one request with no read-before-write. Without that second case, later generator drift could silently turn both public methods into composite behavior and nothing would notice.
+
+Current composites:
+- **Todos** `update` (merge-safe) and `edit` (read-modify-write) — see §5 "Merge-Safe Write Surface (Todos)".
+- **Cards** `update` (merge-safe) — see §5 "Merge-Safe Write Surface (Cards)". The raw path is `updateVerbatim`.
-Current composites: Todos `update` (merge-safe) and `edit` (read-modify-write) — see §5 "Merge-Safe Write Surface (Todos)".
+**Body compaction is not relaxed for composites.** A composite never sends `{"field": null}` to express "clear" (§18 rule). Where the server treats an omitted key as a clear — as BC3 does for `due_on` — omission *is* the clear encoding, and it is the only one all six SDKs can express identically: five strip nulls structurally before the wire (Python `_compact`, Ruby `compact_params`, Kotlin `?.let`, TypeScript's `JSON.stringify` dropping `undefined`, Swift `encodeIfPresent`).
---
diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go
index 7930b9bcd..d06d35fa9 100644
--- a/conformance/runner/go/main.go
+++ b/conformance/runner/go/main.go
@@ -538,6 +538,16 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T
_, err := account.Todos().Update(ctx, todoID, req)
return operationResult{err: err}
+ case "UpdateCard":
+ // Merge-safe composite: GET then PUT, resending the fetched due_on.
+ _, err := account.Cards().Update(ctx, getInt64Param(tc.PathParams, "cardId"), cardUpdateRequest(tc.RequestBody))
+ return operationResult{err: err}
+
+ case "UpdateCardVerbatim":
+ // Raw single PUT, no read-before-write.
+ _, err := account.Cards().UpdateVerbatim(ctx, getInt64Param(tc.PathParams, "cardId"), cardUpdateRequest(tc.RequestBody))
+ return operationResult{err: err}
+
case "EditTodo":
// Synthetic scenario key (not a wire operation): drives the SDK's
// edit closure, assigning each fixture requestBody key onto the
@@ -1293,6 +1303,30 @@ func getBoolParam(params map[string]interface{}, key string) bool {
// getInt64SliceParam extracts an []int64 parameter, reporting whether the key
// was present: a present-but-empty array returns (non-nil empty slice, true)
// so explicit-empty (a clear) is distinguishable from absent (untouched).
+// cardUpdateRequest builds an UpdateCardRequest from a fixture body using
+// PRESENCE, not non-emptiness. UpdateCardRequest's scalars are pointers exactly
+// so "explicitly set to empty" differs from "not set"; testing v != "" would
+// collapse the two and let an explicit-clear fixture pass as an omission.
+func cardUpdateRequest(body map[string]interface{}) *basecamp.UpdateCardRequest {
+ req := &basecamp.UpdateCardRequest{}
+ if v, ok := body["title"]; ok {
+ s, _ := v.(string)
+ req.Title = &s
+ }
+ if v, ok := body["content"]; ok {
+ s, _ := v.(string)
+ req.Content = &s
+ }
+ if v, ok := body["due_on"]; ok {
+ s, _ := v.(string)
+ req.DueOn = &s
+ }
+ if ids, ok := getInt64SliceParam(body, "assignee_ids"); ok {
+ req.AssigneeIDs = ids
+ }
+ return req
+}
+
func getInt64SliceParam(params map[string]interface{}, key string) ([]int64, bool) {
val, ok := params[key]
if !ok {
diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py
index 5a3f6cfd8..7a98cd503 100644
--- a/conformance/runner/python/runner.py
+++ b/conformance/runner/python/runner.py
@@ -26,6 +26,7 @@
# Wire keys for todo write operations; identical to the Python kwarg /
# edit-attribute names, so fixtures map onto the SDK surface directly.
_TODO_WRITE_FIELDS = ("content", "description", "assignee_ids", "completion_subscriber_ids", "due_on", "starts_on", "notify")
+_CARD_WRITE_FIELDS = ("title", "content", "due_on", "assignee_ids")
# Sentinel distinguishing "key absent from the JSON body" from a present None.
_MISSING = object()
@@ -101,6 +102,18 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod
todo_id=path_params["todoId"],
**{k: body[k] for k in _TODO_WRITE_FIELDS if k in body},
)
+ case "UpdateCard":
+ # Merge-safe composite: GET then PUT, resending the fetched due_on.
+ return self._account.cards.update(
+ card_id=path_params["cardId"],
+ **{k: body[k] for k in _CARD_WRITE_FIELDS if k in body},
+ )
+ case "UpdateCardVerbatim":
+ # Raw single PUT, no read-before-write.
+ return self._account.cards.update_verbatim(
+ card_id=path_params["cardId"],
+ **{k: body[k] for k in _CARD_WRITE_FIELDS if k in body},
+ )
case "EditTodo":
# Synthetic scenario key (not a wire op): drive the edit
# context manager, assigning each fixture requestBody key
diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb
index fdb97d177..1385821d4 100644
--- a/conformance/runner/ruby/runner.rb
+++ b/conformance/runner/ruby/runner.rb
@@ -156,6 +156,12 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "")
todo_id: path_params["todoId"],
**todo_write_kwargs(body)
)
+ when "UpdateCard"
+ # Merge-safe composite: GET then PUT, resending the fetched due_on.
+ @account.cards.update(card_id: path_params["cardId"], **card_write_kwargs(body))
+ when "UpdateCardVerbatim"
+ # Raw single PUT, no read-before-write.
+ @account.cards.update_verbatim(card_id: path_params["cardId"], **card_write_kwargs(body))
when "EditTodo"
@account.todos.edit(todo_id: path_params["todoId"]) do |t|
(body || {}).each { |key, value| t.public_send("#{key}=", value) }
@@ -211,6 +217,13 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "")
content description assignee_ids completion_subscriber_ids due_on starts_on notify
].freeze
+ CARD_WRITE_KEYS = %w[title content due_on assignee_ids].freeze
+
+ def card_write_kwargs(body)
+ CARD_WRITE_KEYS.select { |key| (body || {}).key?(key) } \
+ .to_h { |key| [key.to_sym, body[key]] }
+ end
+
def todo_write_kwargs(body)
TODO_WRITE_KEYS.select { |key| (body || {}).key?(key) } \
.to_h { |key| [key.to_sym, body[key]] }
diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts
index b63cad8c6..4f19df9fc 100644
--- a/conformance/runner/typescript/runner.test.ts
+++ b/conformance/runner/typescript/runner.test.ts
@@ -234,6 +234,32 @@ async function executeOperation(
await client.todos.update(Number(params.todoId), mapTodoWireFields(body));
break;
+ case "UpdateCard":
+ // Merge-safe composite: GET then PUT, resending the fetched due_on.
+ await client.cards.update(Number(params.cardId), {
+ ...(body.title !== undefined ? { title: String(body.title) } : {}),
+ ...(body.content !== undefined ? { content: String(body.content) } : {}),
+ ...(body.due_on !== undefined
+ ? { dueOn: body.due_on === "" ? null : String(body.due_on) }
+ : {}),
+ ...(body.assignee_ids !== undefined
+ ? { assigneeIds: body.assignee_ids as number[] }
+ : {}),
+ });
+ break;
+
+ case "UpdateCardVerbatim":
+ // Raw single PUT, no read-before-write.
+ await client.cards.updateVerbatim(Number(params.cardId), {
+ ...(body.title !== undefined ? { title: String(body.title) } : {}),
+ ...(body.content !== undefined ? { content: String(body.content) } : {}),
+ ...(body.due_on !== undefined ? { dueOn: String(body.due_on) } : {}),
+ ...(body.assignee_ids !== undefined
+ ? { assigneeIds: body.assignee_ids as number[] }
+ : {}),
+ });
+ break;
+
case "EditTodo":
// Synthetic scenario key: read-modify-write via the edit callback,
// assigning each fixture-present key onto the mapped TodoFields member.
diff --git a/conformance/tests/cards_write.json b/conformance/tests/cards_write.json
new file mode 100644
index 000000000..259699309
--- /dev/null
+++ b/conformance/tests/cards_write.json
@@ -0,0 +1,1130 @@
+[
+ {
+ "name": "update-preserves-due-on: composite refetches and resends the due date",
+ "description": "BC3 builds card_update_params as `{ due_on: nil }.merge(card_params)`, so a sparse PUT erases the card's due date. The merge-safe update GETs the card first and resends the existing due_on when the caller did not address it. Two requests: GET then PUT. assignee_ids stays absent \u2014 BC3 filters incoming ids through reachable_people, so echoing them back could unassign someone who lost board access.",
+ "operation": "UpdateCard",
+ "method": "PUT",
+ "path": "/card_tables/cards/{cardId}",
+ "pathParams": {
+ "cardId": 1069479350
+ },
+ "requestBody": {
+ "title": "Renamed card"
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 1069479350,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:00:00.000-06:00",
+ "updated_at": "2024-01-20T15:30:00.000-06:00",
+ "title": "Implement user authentication",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/card1.json",
+ "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/subscription.json",
+ "position": 1,
+ "content": "
Add OAuth2 login flow with Google and GitHub providers
",
+ "description": "Add OAuth2 login flow with Google and GitHub providers",
+ "due_on": "2024-02-01",
+ "completed": false,
+ "comments_count": 2,
+ "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/comments.json",
+ "completion_url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350/completion.json",
+ "parent": {
+ "id": 1069479347,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069479347.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069479347"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--xyz456",
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "personable_type": "User",
+ "title": "Chief Strategist",
+ "bio": "Leading product strategy",
+ "location": "Chicago, IL",
+ "created_at": "2024-01-01T08:00:00.000-06:00",
+ "updated_at": "2024-01-15T10:00:00.000-06:00",
+ "admin": true,
+ "owner": true,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/123.jpg",
+ "can_ping": true,
+ "can_manage_projects": true,
+ "can_manage_people": true
+ },
+ "assignees": [
+ {
+ "id": 1049715915,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abc789",
+ "name": "Annie Bryan",
+ "email_address": "annie@honchodesign.com",
+ "personable_type": "User",
+ "title": "Designer",
+ "admin": false,
+ "owner": false,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/456.jpg"
+ }
+ ],
+ "completion_subscribers": [],
+ "steps": [
+ {
+ "id": 1069479360,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:05:00.000-06:00",
+ "updated_at": "2024-01-18T11:00:00.000-06:00",
+ "title": "Set up OAuth providers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479360.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479360",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step1.json",
+ "position": 1,
+ "completed": true,
+ "completed_at": "2024-01-18T11:00:00.000-06:00",
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "completer": {
+ "id": 1049715915,
+ "name": "Annie Bryan"
+ },
+ "assignees": []
+ },
+ {
+ "id": 1069479361,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:06:00.000-06:00",
+ "updated_at": "2024-01-16T10:06:00.000-06:00",
+ "title": "Implement callback handlers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479361.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479361",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step2.json",
+ "position": 2,
+ "completed": false,
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "assignees": []
+ }
+ ],
+ "description_attachments": []
+ }
+ },
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 1069479350,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:00:00.000-06:00",
+ "updated_at": "2024-01-20T15:30:00.000-06:00",
+ "title": "Implement user authentication",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/card1.json",
+ "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/subscription.json",
+ "position": 1,
+ "content": "Add OAuth2 login flow with Google and GitHub providers
",
+ "description": "Add OAuth2 login flow with Google and GitHub providers",
+ "due_on": "2024-02-01",
+ "completed": false,
+ "comments_count": 2,
+ "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/comments.json",
+ "completion_url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350/completion.json",
+ "parent": {
+ "id": 1069479347,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069479347.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069479347"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--xyz456",
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "personable_type": "User",
+ "title": "Chief Strategist",
+ "bio": "Leading product strategy",
+ "location": "Chicago, IL",
+ "created_at": "2024-01-01T08:00:00.000-06:00",
+ "updated_at": "2024-01-15T10:00:00.000-06:00",
+ "admin": true,
+ "owner": true,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/123.jpg",
+ "can_ping": true,
+ "can_manage_projects": true,
+ "can_manage_people": true
+ },
+ "assignees": [
+ {
+ "id": 1049715915,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abc789",
+ "name": "Annie Bryan",
+ "email_address": "annie@honchodesign.com",
+ "personable_type": "User",
+ "title": "Designer",
+ "admin": false,
+ "owner": false,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/456.jpg"
+ }
+ ],
+ "completion_subscribers": [],
+ "steps": [
+ {
+ "id": 1069479360,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:05:00.000-06:00",
+ "updated_at": "2024-01-18T11:00:00.000-06:00",
+ "title": "Set up OAuth providers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479360.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479360",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step1.json",
+ "position": 1,
+ "completed": true,
+ "completed_at": "2024-01-18T11:00:00.000-06:00",
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "completer": {
+ "id": 1049715915,
+ "name": "Annie Bryan"
+ },
+ "assignees": []
+ },
+ {
+ "id": 1069479361,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:06:00.000-06:00",
+ "updated_at": "2024-01-16T10:06:00.000-06:00",
+ "title": "Implement callback handlers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479361.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479361",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step2.json",
+ "position": 2,
+ "completed": false,
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "assignees": []
+ }
+ ],
+ "description_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 2
+ },
+ {
+ "type": "requestMethod",
+ "expected": "GET",
+ "index": 0
+ },
+ {
+ "type": "requestMethod",
+ "expected": "PUT",
+ "index": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "title",
+ "expected": "Renamed card",
+ "index": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "due_on",
+ "expected": "2024-02-01",
+ "index": 1
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "assignee_ids",
+ "index": 1
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "content",
+ "index": 1
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "cards",
+ "merge-safe",
+ "due-on"
+ ]
+ },
+ {
+ "name": "update-verbatim-omits-due-on: raw update sends one PUT with no GET",
+ "description": "UpdateCardVerbatim is the server-native single PUT: exactly one request, no read-before-write, and a field omitted from the request is omitted from the body. This case exists so later generator drift cannot silently turn both public methods into composite behaviour \u2014 without it, a regression that made verbatim refetch would go unnoticed.",
+ "operation": "UpdateCardVerbatim",
+ "method": "PUT",
+ "path": "/card_tables/cards/{cardId}",
+ "pathParams": {
+ "cardId": 1069479350
+ },
+ "requestBody": {
+ "title": "Renamed card"
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 1069479350,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:00:00.000-06:00",
+ "updated_at": "2024-01-20T15:30:00.000-06:00",
+ "title": "Implement user authentication",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/card1.json",
+ "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/subscription.json",
+ "position": 1,
+ "content": "Add OAuth2 login flow with Google and GitHub providers
",
+ "description": "Add OAuth2 login flow with Google and GitHub providers",
+ "due_on": "2024-02-01",
+ "completed": false,
+ "comments_count": 2,
+ "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/comments.json",
+ "completion_url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350/completion.json",
+ "parent": {
+ "id": 1069479347,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069479347.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069479347"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--xyz456",
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "personable_type": "User",
+ "title": "Chief Strategist",
+ "bio": "Leading product strategy",
+ "location": "Chicago, IL",
+ "created_at": "2024-01-01T08:00:00.000-06:00",
+ "updated_at": "2024-01-15T10:00:00.000-06:00",
+ "admin": true,
+ "owner": true,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/123.jpg",
+ "can_ping": true,
+ "can_manage_projects": true,
+ "can_manage_people": true
+ },
+ "assignees": [
+ {
+ "id": 1049715915,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abc789",
+ "name": "Annie Bryan",
+ "email_address": "annie@honchodesign.com",
+ "personable_type": "User",
+ "title": "Designer",
+ "admin": false,
+ "owner": false,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/456.jpg"
+ }
+ ],
+ "completion_subscribers": [],
+ "steps": [
+ {
+ "id": 1069479360,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:05:00.000-06:00",
+ "updated_at": "2024-01-18T11:00:00.000-06:00",
+ "title": "Set up OAuth providers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479360.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479360",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step1.json",
+ "position": 1,
+ "completed": true,
+ "completed_at": "2024-01-18T11:00:00.000-06:00",
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "completer": {
+ "id": 1049715915,
+ "name": "Annie Bryan"
+ },
+ "assignees": []
+ },
+ {
+ "id": 1069479361,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:06:00.000-06:00",
+ "updated_at": "2024-01-16T10:06:00.000-06:00",
+ "title": "Implement callback handlers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479361.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479361",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step2.json",
+ "position": 2,
+ "completed": false,
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "assignees": []
+ }
+ ],
+ "description_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 1
+ },
+ {
+ "type": "requestMethod",
+ "expected": "PUT",
+ "index": 0
+ },
+ {
+ "type": "requestBody",
+ "path": "title",
+ "expected": "Renamed card",
+ "index": 0
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "due_on",
+ "index": 0
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "cards",
+ "verbatim",
+ "due-on"
+ ]
+ },
+ {
+ "name": "update-explicit-clear: an explicit empty due_on clears it by omission, with no GET",
+ "description": "Addressing due_on explicitly means the composite has nothing to preserve, so it skips the read entirely. Clearing is encoded by OMITTING due_on from the body: BC3 merges over `{ due_on: nil }`, so an absent key IS the clear. A literal null would violate body compaction (SPEC section 18) and five of the six SDKs strip nulls before the wire anyway.",
+ "operation": "UpdateCard",
+ "method": "PUT",
+ "path": "/card_tables/cards/{cardId}",
+ "pathParams": {
+ "cardId": 1069479350
+ },
+ "requestBody": {
+ "due_on": ""
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 1069479350,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:00:00.000-06:00",
+ "updated_at": "2024-01-20T15:30:00.000-06:00",
+ "title": "Implement user authentication",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/card1.json",
+ "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/subscription.json",
+ "position": 1,
+ "content": "Add OAuth2 login flow with Google and GitHub providers
",
+ "description": "Add OAuth2 login flow with Google and GitHub providers",
+ "due_on": "2024-02-01",
+ "completed": false,
+ "comments_count": 2,
+ "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/comments.json",
+ "completion_url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350/completion.json",
+ "parent": {
+ "id": 1069479347,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069479347.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069479347"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--xyz456",
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "personable_type": "User",
+ "title": "Chief Strategist",
+ "bio": "Leading product strategy",
+ "location": "Chicago, IL",
+ "created_at": "2024-01-01T08:00:00.000-06:00",
+ "updated_at": "2024-01-15T10:00:00.000-06:00",
+ "admin": true,
+ "owner": true,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/123.jpg",
+ "can_ping": true,
+ "can_manage_projects": true,
+ "can_manage_people": true
+ },
+ "assignees": [
+ {
+ "id": 1049715915,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abc789",
+ "name": "Annie Bryan",
+ "email_address": "annie@honchodesign.com",
+ "personable_type": "User",
+ "title": "Designer",
+ "admin": false,
+ "owner": false,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/456.jpg"
+ }
+ ],
+ "completion_subscribers": [],
+ "steps": [
+ {
+ "id": 1069479360,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:05:00.000-06:00",
+ "updated_at": "2024-01-18T11:00:00.000-06:00",
+ "title": "Set up OAuth providers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479360.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479360",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step1.json",
+ "position": 1,
+ "completed": true,
+ "completed_at": "2024-01-18T11:00:00.000-06:00",
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "completer": {
+ "id": 1049715915,
+ "name": "Annie Bryan"
+ },
+ "assignees": []
+ },
+ {
+ "id": 1069479361,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:06:00.000-06:00",
+ "updated_at": "2024-01-16T10:06:00.000-06:00",
+ "title": "Implement callback handlers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479361.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479361",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step2.json",
+ "position": 2,
+ "completed": false,
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "assignees": []
+ }
+ ],
+ "description_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 1
+ },
+ {
+ "type": "requestMethod",
+ "expected": "PUT",
+ "index": 0
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "due_on",
+ "index": 0
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "cards",
+ "merge-safe",
+ "due-on",
+ "clear"
+ ]
+ },
+ {
+ "name": "update-explicit-empty-content: an explicit empty content is sent, not dropped",
+ "description": "content is presence-bearing: an explicitly-empty value clears the card body and MUST reach the wire. A runner keying off non-emptiness rather than presence would drop it and pass this case as an omission, which is why the assertion checks the key is present AND empty. due_on is addressed so the case stays single-request.",
+ "operation": "UpdateCard",
+ "method": "PUT",
+ "path": "/card_tables/cards/{cardId}",
+ "pathParams": {
+ "cardId": 1069479350
+ },
+ "requestBody": {
+ "content": "",
+ "due_on": ""
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 1069479350,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:00:00.000-06:00",
+ "updated_at": "2024-01-20T15:30:00.000-06:00",
+ "title": "Implement user authentication",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/card1.json",
+ "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/subscription.json",
+ "position": 1,
+ "content": "Add OAuth2 login flow with Google and GitHub providers
",
+ "description": "Add OAuth2 login flow with Google and GitHub providers",
+ "due_on": "2024-02-01",
+ "completed": false,
+ "comments_count": 2,
+ "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/comments.json",
+ "completion_url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350/completion.json",
+ "parent": {
+ "id": 1069479347,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069479347.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069479347"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--xyz456",
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "personable_type": "User",
+ "title": "Chief Strategist",
+ "bio": "Leading product strategy",
+ "location": "Chicago, IL",
+ "created_at": "2024-01-01T08:00:00.000-06:00",
+ "updated_at": "2024-01-15T10:00:00.000-06:00",
+ "admin": true,
+ "owner": true,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/123.jpg",
+ "can_ping": true,
+ "can_manage_projects": true,
+ "can_manage_people": true
+ },
+ "assignees": [
+ {
+ "id": 1049715915,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abc789",
+ "name": "Annie Bryan",
+ "email_address": "annie@honchodesign.com",
+ "personable_type": "User",
+ "title": "Designer",
+ "admin": false,
+ "owner": false,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/456.jpg"
+ }
+ ],
+ "completion_subscribers": [],
+ "steps": [
+ {
+ "id": 1069479360,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:05:00.000-06:00",
+ "updated_at": "2024-01-18T11:00:00.000-06:00",
+ "title": "Set up OAuth providers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479360.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479360",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step1.json",
+ "position": 1,
+ "completed": true,
+ "completed_at": "2024-01-18T11:00:00.000-06:00",
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "completer": {
+ "id": 1049715915,
+ "name": "Annie Bryan"
+ },
+ "assignees": []
+ },
+ {
+ "id": 1069479361,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:06:00.000-06:00",
+ "updated_at": "2024-01-16T10:06:00.000-06:00",
+ "title": "Implement callback handlers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479361.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479361",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step2.json",
+ "position": 2,
+ "completed": false,
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "assignees": []
+ }
+ ],
+ "description_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "content",
+ "expected": "",
+ "index": 0
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "due_on",
+ "index": 0
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "cards",
+ "merge-safe",
+ "presence"
+ ]
+ },
+ {
+ "name": "update-explicit-empty-assignees: an explicit empty assignee list is sent as []",
+ "description": "An empty non-nil assignee list means \"unassign everyone\" and must be sent as assignee_ids: [] rather than omitted. Contrast the preservation case, where assignees are absent because the caller never mentioned them \u2014 the composite never echoes them back, since BC3 filters incoming ids through reachable_people.",
+ "operation": "UpdateCard",
+ "method": "PUT",
+ "path": "/card_tables/cards/{cardId}",
+ "pathParams": {
+ "cardId": 1069479350
+ },
+ "requestBody": {
+ "assignee_ids": [],
+ "due_on": ""
+ },
+ "mockResponses": [
+ {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json"
+ },
+ "body": {
+ "id": 1069479350,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:00:00.000-06:00",
+ "updated_at": "2024-01-20T15:30:00.000-06:00",
+ "title": "Implement user authentication",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/card1.json",
+ "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/subscription.json",
+ "position": 1,
+ "content": "Add OAuth2 login flow with Google and GitHub providers
",
+ "description": "Add OAuth2 login flow with Google and GitHub providers",
+ "due_on": "2024-02-01",
+ "completed": false,
+ "comments_count": 2,
+ "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958499/recordings/1069479350/comments.json",
+ "completion_url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350/completion.json",
+ "parent": {
+ "id": 1069479347,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/columns/1069479347.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/columns/1069479347"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--xyz456",
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "personable_type": "User",
+ "title": "Chief Strategist",
+ "bio": "Leading product strategy",
+ "location": "Chicago, IL",
+ "created_at": "2024-01-01T08:00:00.000-06:00",
+ "updated_at": "2024-01-15T10:00:00.000-06:00",
+ "admin": true,
+ "owner": true,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/123.jpg",
+ "can_ping": true,
+ "can_manage_projects": true,
+ "can_manage_people": true
+ },
+ "assignees": [
+ {
+ "id": 1049715915,
+ "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--abc789",
+ "name": "Annie Bryan",
+ "email_address": "annie@honchodesign.com",
+ "personable_type": "User",
+ "title": "Designer",
+ "admin": false,
+ "owner": false,
+ "client": false,
+ "employee": true,
+ "time_zone": "America/Chicago",
+ "avatar_url": "https://3.basecamp-static.com/avatar/456.jpg"
+ }
+ ],
+ "completion_subscribers": [],
+ "steps": [
+ {
+ "id": 1069479360,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:05:00.000-06:00",
+ "updated_at": "2024-01-18T11:00:00.000-06:00",
+ "title": "Set up OAuth providers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479360.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479360",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step1.json",
+ "position": 1,
+ "completed": true,
+ "completed_at": "2024-01-18T11:00:00.000-06:00",
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "completer": {
+ "id": 1049715915,
+ "name": "Annie Bryan"
+ },
+ "assignees": []
+ },
+ {
+ "id": 1069479361,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2024-01-16T10:06:00.000-06:00",
+ "updated_at": "2024-01-16T10:06:00.000-06:00",
+ "title": "Implement callback handlers",
+ "inherits_status": true,
+ "type": "Kanban::Step",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/steps/1069479361.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/steps/1069479361",
+ "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/step2.json",
+ "position": 2,
+ "completed": false,
+ "parent": {
+ "id": 1069479350,
+ "title": "Implement user authentication",
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/195539477/buckets/2085958499/card_tables/cards/1069479350.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/card_tables/cards/1069479350"
+ },
+ "bucket": {
+ "id": 2085958499,
+ "name": "The Leto Laptop",
+ "type": "Project"
+ },
+ "creator": {
+ "id": 1049715914,
+ "name": "Victor Cooper"
+ },
+ "assignees": []
+ }
+ ],
+ "description_attachments": []
+ }
+ }
+ ],
+ "assertions": [
+ {
+ "type": "requestCount",
+ "expected": 1
+ },
+ {
+ "type": "requestBody",
+ "path": "assignee_ids",
+ "expected": [],
+ "index": 0
+ },
+ {
+ "type": "requestBodyAbsent",
+ "path": "due_on",
+ "index": 0
+ },
+ {
+ "type": "noError"
+ }
+ ],
+ "tags": [
+ "cards",
+ "merge-safe",
+ "presence"
+ ]
+ }
+]
diff --git a/go/pkg/basecamp/cards.go b/go/pkg/basecamp/cards.go
index 01cbfd7fa..f419e3563 100644
--- a/go/pkg/basecamp/cards.go
+++ b/go/pkg/basecamp/cards.go
@@ -151,14 +151,28 @@ type CreateCardRequest struct {
}
// UpdateCardRequest specifies the parameters for updating a card.
+//
+// Every field is presence-bearing: a nil pointer means "leave this alone", so
+// Update can tell "the caller did not mention the due date" from "the caller
+// wants the due date cleared". That distinction is what makes Update
+// merge-safe — see its doc comment for why BC3 forces the issue.
type UpdateCardRequest struct {
- // Title is the card title (optional).
- Title string `json:"title,omitempty"`
- // Content is the card body in HTML (optional).
- Content string `json:"content,omitempty"`
- // DueOn is the due date in ISO 8601 format (optional).
- DueOn string `json:"due_on,omitempty"`
- // AssigneeIDs is a list of person IDs to assign this card to (optional).
+ // Title is the card title. Nil leaves it unchanged.
+ Title *string `json:"title,omitempty"`
+ // Content is the card body in HTML. Nil leaves it unchanged; a pointer to
+ // the empty string clears it.
+ Content *string `json:"content,omitempty"`
+ // DueOn is the due date in YYYY-MM-DD form. Nil leaves the existing due
+ // date in place; a pointer to the empty string clears it; a pointer to a
+ // date sets it.
+ DueOn *string `json:"due_on,omitempty"`
+ // AssigneeIDs is a list of person IDs to assign this card to. Nil leaves
+ // the assignees unchanged; an empty non-nil slice clears them.
+ //
+ // Note that BC3 filters incoming assignee IDs through reachable_people, so
+ // echoing back an id belonging to someone who has since lost board access
+ // silently unassigns them. Update therefore never resends assignees the
+ // caller did not ask for.
AssigneeIDs []int64 `json:"assignee_ids,omitempty"`
}
@@ -461,11 +475,54 @@ func (s *CardsService) Create(ctx context.Context, columnID int64, req *CreateCa
return &card, nil
}
-// Update updates an existing card.
-// Returns the updated card.
+// Update updates a card without disturbing fields the caller did not mention.
+//
+// BC3 builds the card's update params as `{ due_on: nil }.merge(card_params)`
+// (kanban/cards_controller.rb), so ANY update whose body omits due_on erases
+// the card's due date. A sparse PUT — the natural thing to write, and what
+// every generated SDK produces — therefore silently destroys data.
+//
+// Update works around that by fetching the card first and resending the
+// existing due date when the caller did not address it. It deliberately does
+// NOT resend everything: BC3 filters assignee IDs through reachable_people, so
+// echoing back assignees would unassign anyone who has since lost board access.
+// Only the caller's own title/content/assignee_ids go out, plus due_on.
+//
+// Costs one extra GET. There is a race: a concurrent due-date change landing
+// between the GET and the PUT is overwritten with the value this call read. Use
+// UpdateVerbatim if you want the single-request behaviour and will manage
+// due_on yourself.
func (s *CardsService) Update(ctx context.Context, cardID int64, req *UpdateCardRequest) (result *Card, err error) {
+ if req == nil {
+ return nil, ErrUsage("update request is required")
+ }
+ // Only pay for the GET when the caller left due_on unaddressed — that is
+ // the only case where BC3's default would destroy something.
+ if req.DueOn == nil {
+ current, getErr := s.Get(ctx, cardID)
+ if getErr != nil {
+ return nil, getErr
+ }
+ if current.DueOn != "" {
+ preserved := current.DueOn
+ merged := *req
+ merged.DueOn = &preserved
+ return s.UpdateVerbatim(ctx, cardID, &merged)
+ }
+ }
+ return s.UpdateVerbatim(ctx, cardID, req)
+}
+
+// UpdateVerbatim sends exactly the fields the caller set, in a single PUT, with
+// no preceding GET.
+//
+// This is the raw API behaviour, and it is sharp: because BC3 merges the body
+// over `{ due_on: nil }`, omitting DueOn CLEARS the card's due date rather than
+// leaving it alone. Reach for Update unless you specifically want one request
+// and are managing due_on yourself.
+func (s *CardsService) UpdateVerbatim(ctx context.Context, cardID int64, req *UpdateCardRequest) (result *Card, err error) {
op := OperationInfo{
- Service: "Cards", Operation: "Update",
+ Service: "Cards", Operation: "UpdateVerbatim",
ResourceType: "card", IsMutation: true,
ResourceID: cardID,
}
@@ -484,18 +541,27 @@ func (s *CardsService) Update(ctx context.Context, cardID int64, req *UpdateCard
}
body := map[string]any{}
- if req.Title != "" {
- body["title"] = req.Title
- }
- if req.Content != "" {
- body["content"] = req.Content
- }
- if req.DueOn != "" {
- if _, parseErr := types.ParseDate(req.DueOn); parseErr != nil {
- err = ErrUsage("card due_on must be in YYYY-MM-DD format")
- return nil, err
+ if req.Title != nil {
+ body["title"] = *req.Title
+ }
+ if req.Content != nil {
+ body["content"] = *req.Content
+ }
+ if req.DueOn != nil {
+ // A pointer to the empty string is an explicit clear. It is encoded by
+ // OMITTING due_on, because BC3 nils an omitted due date — the same
+ // behaviour Update exists to defend against is exactly what a caller
+ // asking to clear wants. Sending {"due_on": null} would violate the
+ // body-compaction rule in SPEC section 18, and five of the six SDKs
+ // strip nulls before the wire anyway, so omission is also the only
+ // encoding every SDK can express identically.
+ if *req.DueOn != "" {
+ if _, parseErr := types.ParseDate(*req.DueOn); parseErr != nil {
+ err = ErrUsage("card due_on must be in YYYY-MM-DD format")
+ return nil, err
+ }
+ body["due_on"] = *req.DueOn
}
- body["due_on"] = req.DueOn
}
if req.AssigneeIDs != nil {
body["assignee_ids"] = req.AssigneeIDs
diff --git a/go/pkg/basecamp/cards_test.go b/go/pkg/basecamp/cards_test.go
index ac5fb8a50..c7bd6847e 100644
--- a/go/pkg/basecamp/cards_test.go
+++ b/go/pkg/basecamp/cards_test.go
@@ -510,9 +510,9 @@ func TestCreateCardRequest_MarshalMinimal(t *testing.T) {
func TestUpdateCardRequest_Marshal(t *testing.T) {
req := UpdateCardRequest{
- Title: "Updated title",
- Content: "Updated content
",
- DueOn: "2024-04-01",
+ Title: cardStrPtr("Updated title"),
+ Content: cardStrPtr("Updated content
"),
+ DueOn: cardStrPtr("2024-04-01"),
AssigneeIDs: []int64{1049715914, 1049715915},
}
@@ -778,7 +778,11 @@ func testCardsServer(t *testing.T, handler http.HandlerFunc) *CardsService {
return account.Cards()
}
-func TestCardsService_UpdatePartial(t *testing.T) {
+// cardStrPtr is the presence-bearing test helper: UpdateCardRequest's scalars
+// are pointers so a caller can distinguish "leave alone" from "set empty".
+func cardStrPtr(v string) *string { return &v }
+
+func TestCardsService_UpdateVerbatimPartial(t *testing.T) {
fixture := loadCardsFixture(t, "get.json")
var receivedBody map[string]any
svc := testCardsServer(t, func(w http.ResponseWriter, r *http.Request) {
@@ -789,8 +793,8 @@ func TestCardsService_UpdatePartial(t *testing.T) {
w.Write(fixture)
})
- _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
- Title: "new title",
+ _, err := svc.UpdateVerbatim(context.Background(), 12345, &UpdateCardRequest{
+ Title: cardStrPtr("new title"),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -807,7 +811,7 @@ func TestCardsService_UpdatePartial(t *testing.T) {
}
}
-func TestCardsService_UpdateClearsAssignees(t *testing.T) {
+func TestCardsService_UpdateVerbatimClearsAssignees(t *testing.T) {
fixture := loadCardsFixture(t, "get.json")
var receivedBody map[string]any
svc := testCardsServer(t, func(w http.ResponseWriter, r *http.Request) {
@@ -820,7 +824,7 @@ func TestCardsService_UpdateClearsAssignees(t *testing.T) {
// An empty non-nil slice means "clear all assignees" — this must be sent
// to the API as assignee_ids:[], not omitted.
- _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
+ _, err := svc.UpdateVerbatim(context.Background(), 12345, &UpdateCardRequest{
AssigneeIDs: []int64{},
})
if err != nil {
@@ -1002,3 +1006,160 @@ func TestCardColumnsService_DisableOnHold(t *testing.T) {
t.Fatalf("expected column ID %d, got %+v", cardColumnsTestColumnID, column)
}
}
+
+// --- merge-safe Update (#467) ------------------------------------------------
+//
+// BC3 builds card_update_params as `{ due_on: nil }.merge(card_params)`, so a
+// sparse verbatim PUT erases the due date. Update fetches first and resends the
+// existing value when the caller did not address due_on.
+
+// recordingCardsServer records every request (method + decoded body) so a test
+// can assert on the whole exchange, not just the last request.
+type recordedRequest struct {
+ method string
+ body map[string]any
+}
+
+func recordingCardsServer(t *testing.T, recorded *[]recordedRequest, cardJSON []byte) *CardsService {
+ t.Helper()
+ return testCardsServer(t, func(w http.ResponseWriter, r *http.Request) {
+ rec := recordedRequest{method: r.Method}
+ if r.Method != http.MethodGet {
+ rec.body = decodeRequestBody(t, r)
+ }
+ *recorded = append(*recorded, rec)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+ w.Write(cardJSON)
+ })
+}
+
+func TestCardsService_UpdatePreservesDueOnWhenUnaddressed(t *testing.T) {
+ // cards/get.json carries due_on 2024-02-01.
+ fixture := loadCardsFixture(t, "get.json")
+ var recorded []recordedRequest
+ svc := recordingCardsServer(t, &recorded, fixture)
+
+ _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
+ Title: cardStrPtr("new title"),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(recorded) != 2 {
+ t.Fatalf("expected 2 requests (GET then PUT), got %d: %+v", len(recorded), recorded)
+ }
+ if recorded[0].method != http.MethodGet {
+ t.Errorf("expected request 0 to be a GET, got %s", recorded[0].method)
+ }
+ if recorded[1].method != http.MethodPut {
+ t.Errorf("expected request 1 to be a PUT, got %s", recorded[1].method)
+ }
+ if got := recorded[1].body["due_on"]; got != "2024-02-01" {
+ t.Errorf("due_on = %v, want the fetched 2024-02-01 resent — otherwise BC3 clears it", got)
+ }
+ if got := recorded[1].body["title"]; got != "new title" {
+ t.Errorf("title = %v, want 'new title'", got)
+ }
+ // The caller said nothing about assignees. Resending them would run BC3's
+ // reachable_people filter and could unassign someone who lost board access.
+ if _, ok := recorded[1].body["assignee_ids"]; ok {
+ t.Errorf("assignee_ids must be absent when unaddressed, got %v", recorded[1].body["assignee_ids"])
+ }
+ if _, ok := recorded[1].body["content"]; ok {
+ t.Errorf("content must be absent when unaddressed, got %v", recorded[1].body["content"])
+ }
+}
+
+func TestCardsService_UpdateExplicitClearOmitsDueOn(t *testing.T) {
+ fixture := loadCardsFixture(t, "get.json")
+ var recorded []recordedRequest
+ svc := recordingCardsServer(t, &recorded, fixture)
+
+ // A pointer to the empty string is an explicit clear. It needs no GET, and
+ // it is encoded by OMITTING due_on — BC3 nils an omitted due date.
+ _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
+ DueOn: cardStrPtr(""),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(recorded) != 1 {
+ t.Fatalf("expected exactly 1 request (no GET needed for an explicit clear), got %d", len(recorded))
+ }
+ if recorded[0].method != http.MethodPut {
+ t.Errorf("expected a PUT, got %s", recorded[0].method)
+ }
+ if v, ok := recorded[0].body["due_on"]; ok {
+ t.Errorf("due_on must be omitted to clear (never null — SPEC section 18), got %v", v)
+ }
+}
+
+func TestCardsService_UpdateExplicitDateSkipsTheFetch(t *testing.T) {
+ fixture := loadCardsFixture(t, "get.json")
+ var recorded []recordedRequest
+ svc := recordingCardsServer(t, &recorded, fixture)
+
+ _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
+ DueOn: cardStrPtr("2026-09-01"),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(recorded) != 1 {
+ t.Fatalf("expected exactly 1 request when due_on is explicit, got %d", len(recorded))
+ }
+ if got := recorded[0].body["due_on"]; got != "2026-09-01" {
+ t.Errorf("due_on = %v, want 2026-09-01", got)
+ }
+}
+
+func TestCardsService_UpdateSendsExplicitEmptyContent(t *testing.T) {
+ fixture := loadCardsFixture(t, "get.json")
+ var recorded []recordedRequest
+ svc := recordingCardsServer(t, &recorded, fixture)
+
+ // Pointer-to-empty is a real value for content: it clears the body. It must
+ // survive to the wire rather than being treated as "unset".
+ _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
+ Content: cardStrPtr(""),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ put := recorded[len(recorded)-1]
+ v, ok := put.body["content"]
+ if !ok {
+ t.Fatal("content must be present when explicitly set to empty")
+ }
+ if v != "" {
+ t.Errorf("content = %v, want the empty string", v)
+ }
+}
+
+func TestCardsService_UpdateSendsExplicitEmptyAssignees(t *testing.T) {
+ fixture := loadCardsFixture(t, "get.json")
+ var recorded []recordedRequest
+ svc := recordingCardsServer(t, &recorded, fixture)
+
+ _, err := svc.Update(context.Background(), 12345, &UpdateCardRequest{
+ AssigneeIDs: []int64{},
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ put := recorded[len(recorded)-1]
+ ids, ok := put.body["assignee_ids"]
+ if !ok {
+ t.Fatal("an empty non-nil AssigneeIDs must be sent as assignee_ids:[] to clear")
+ }
+ if arr, isArr := ids.([]any); !isArr || len(arr) != 0 {
+ t.Errorf("assignee_ids = %v, want []", ids)
+ }
+}
diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go
index cef7b41cc..cef1cd808 100644
--- a/go/pkg/generated/client.gen.go
+++ b/go/pkg/generated/client.gen.go
@@ -20874,11 +20874,11 @@ func (s *CardsService) Get(ctx context.Context, accountId string, cardId int64,
return s.client.GetCard(ctx, accountId, cardId, reqEditors...)
}
-func (s *CardsService) UpdateWithBody(ctx context.Context, accountId string, cardId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
+func (s *CardsService) UpdateVerbatimWithBody(ctx context.Context, accountId string, cardId int64, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
return s.client.UpdateCardWithBody(ctx, accountId, cardId, contentType, body, reqEditors...)
}
-func (s *CardsService) Update(ctx context.Context, accountId string, cardId int64, body UpdateCardJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
+func (s *CardsService) UpdateVerbatim(ctx context.Context, accountId string, cardId int64, body UpdateCardJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
return s.client.UpdateCard(ctx, accountId, cardId, body, reqEditors...)
}
diff --git a/go/templates/client.tmpl b/go/templates/client.tmpl
index 215490aa9..1ed27c12c 100644
--- a/go/templates/client.tmpl
+++ b/go/templates/client.tmpl
@@ -1551,11 +1551,16 @@ func (s *CardsService) Create{{.Suffix}}(ctx context.Context{{genParamArgs $path
}
{{end}}{{end}}
{{else if eq $opid "UpdateCard"}}
-func (s *CardsService) UpdateWithBody(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, contentType string, body io.Reader, reqEditors... RequestEditorFn) (*http.Response, error) {
+{{/* The plain Update name belongs to the merge-safe composite in
+ pkg/basecamp/cards.go. BC3's kanban/cards_controller.rb builds
+ card_update_params as `{ due_on: nil }.merge(card_params)`, so a sparse
+ verbatim PUT silently erases the due date (#467). The raw single-PUT path
+ stays reachable under a name that says what it does. */}}
+func (s *CardsService) UpdateVerbatimWithBody(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, contentType string, body io.Reader, reqEditors... RequestEditorFn) (*http.Response, error) {
return s.client.{{$opid}}WithBody(ctx{{genParamNames .PathParams}}{{if $hasParams}}, params{{end}}, contentType, body, reqEditors...)
}
{{range .Bodies}}{{if .IsSupportedByClient}}
-func (s *CardsService) Update{{.Suffix}}(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) {
+func (s *CardsService) UpdateVerbatim{{.Suffix}}(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) {
return s.client.{{$opid}}{{.Suffix}}(ctx{{genParamNames $pathParams}}{{if $hasParams}}, params{{end}}, body, reqEditors...)
}
{{end}}{{end}}
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 cf2b5f8f8..675b0f3d3 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
@@ -690,6 +690,33 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis
DispatchResult()
}
+ // Merge-safe composite: GET then PUT, resending the fetched due_on.
+ "UpdateCard" -> {
+ val cardId = tc.pathParams.longParam("cardId")
+ val rb = tc.requestBody
+ account.cards.update(
+ cardId,
+ title = rb?.get("title")?.jsonPrimitive?.contentOrNull,
+ content = rb?.get("content")?.jsonPrimitive?.contentOrNull,
+ dueOn = rb?.get("due_on")?.jsonPrimitive?.contentOrNull,
+ assigneeIds = rb?.get("assignee_ids")?.jsonArray?.map { it.jsonPrimitive.long },
+ )
+ DispatchResult()
+ }
+
+ // Raw single PUT, no read-before-write.
+ "UpdateCardVerbatim" -> {
+ val cardId = tc.pathParams.longParam("cardId")
+ val rb = tc.requestBody
+ account.cards.updateVerbatim(cardId, UpdateCardBody(
+ title = rb?.get("title")?.jsonPrimitive?.contentOrNull,
+ content = rb?.get("content")?.jsonPrimitive?.contentOrNull,
+ dueOn = rb?.get("due_on")?.jsonPrimitive?.contentOrNull,
+ assigneeIds = rb?.get("assignee_ids")?.jsonArray?.map { it.jsonPrimitive.long },
+ ))
+ DispatchResult()
+ }
+
// Synthetic scenario key (not a wire operation): exercises the
// read-modify-write edit closure by assigning each fixture key
// onto the corresponding TodoFields member.
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 20d4dcfc4..4dedb1a8f 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
@@ -117,7 +117,7 @@ val SERVICE_SPLITS: Map>> = mapOf(
* com.basecamp.sdk.services can add convenience methods (e.g. Todos
* gains merge-safe update/edit on top of the generated replace).
*/
-val EXTENSIBLE_SERVICES = setOf("Todos")
+val EXTENSIBLE_SERVICES = setOf("Todos", "Cards")
/**
* Services whose accessor constructs and declares a hand-written subclass
@@ -127,6 +127,7 @@ val EXTENSIBLE_SERVICES = setOf("Todos")
*/
val HAND_WRITTEN_SERVICES = mapOf(
"Todos" to "com.basecamp.sdk.services.TodosService",
+ "Cards" to "com.basecamp.sdk.services.CardsService",
)
/**
@@ -172,6 +173,9 @@ val METHOD_NAME_OVERRIDES = mapOf(
"RepositionCardStep" to "reposition",
"CreateCardStep" to "create",
"UpdateCardStep" to "update",
+ // The plain `update` name belongs to the merge-safe composite; the raw
+ // single-PUT path keeps a name that says what it does. See #467.
+ "UpdateCard" to "updateVerbatim",
"SetCardStepCompletion" to "setCompletion",
"GetQuestionnaire" to "getQuestionnaire",
"GetQuestion" to "getQuestion",
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt
index 53af221f1..1bfd4b1fb 100644
--- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt
@@ -44,8 +44,8 @@ val AccountClient.cardTables: CardTablesService
get() = service("CardTables") { CardTablesService(this) }
/** Cards operations. */
-val AccountClient.cards: CardsService
- get() = service("Cards") { CardsService(this) }
+val AccountClient.cards: com.basecamp.sdk.services.CardsService
+ get() = service("Cards") { com.basecamp.sdk.services.CardsService(this) }
/** Checkins operations. */
val AccountClient.checkins: CheckinsService
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/cards.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/cards.kt
index 6560204c7..6f9b02d4e 100644
--- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/cards.kt
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/cards.kt
@@ -10,7 +10,7 @@ import kotlinx.serialization.json.JsonElement
*
* @generated from OpenAPI spec — do not edit directly
*/
-class CardsService(client: AccountClient) : BaseService(client) {
+open class CardsService(client: AccountClient) : BaseService(client) {
/**
* Get a card by ID
@@ -37,7 +37,7 @@ class CardsService(client: AccountClient) : BaseService(client) {
* @param cardId The card ID
* @param body Request body
*/
- suspend fun update(cardId: Long, body: UpdateCardBody): Card {
+ suspend fun updateVerbatim(cardId: Long, body: UpdateCardBody): Card {
val info = OperationInfo(
service = "Cards",
operation = "UpdateCard",
diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/CardsService.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/CardsService.kt
new file mode 100644
index 000000000..bd3ba9353
--- /dev/null
+++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/CardsService.kt
@@ -0,0 +1,69 @@
+package com.basecamp.sdk.services
+
+import com.basecamp.sdk.AccountClient
+import com.basecamp.sdk.generated.models.Card
+import com.basecamp.sdk.generated.services.UpdateCardBody
+
+/**
+ * CardsService with a merge-safe [update] on top of the generated surface
+ * ([updateVerbatim], `get`, `move`, ...).
+ *
+ * BC3 builds the card's update params as `{ due_on: nil }.merge(card_params)`
+ * (`kanban/cards_controller.rb`), so **any** update whose body omits `due_on`
+ * erases the card's due date. A sparse PUT — the natural thing to write — is
+ * therefore destructive on the raw endpoint, which stays available as
+ * [updateVerbatim].
+ *
+ * [update] composes the public `get` and [updateVerbatim] methods, so hooks
+ * observe the two wire operations rather than a synthetic composite.
+ */
+class CardsService(client: AccountClient) :
+ com.basecamp.sdk.generated.services.CardsService(client) {
+
+ /**
+ * Updates a card without disturbing fields the caller did not mention.
+ *
+ * [dueOn] is tri-state, which is what makes this safe:
+ *
+ * - `null` (omitted) — the current due date is fetched and resent
+ * - `""` — the due date is cleared
+ * - a date — the due date is set
+ *
+ * The extra GET is only paid for in the `null` case, the one where the API
+ * would otherwise destroy something.
+ *
+ * Assignees are never resent on the caller's behalf: BC3 filters incoming
+ * IDs through `reachable_people`, so echoing back an id belonging to
+ * someone who has since lost board access would silently unassign them.
+ *
+ * Not atomic: a concurrent due-date change landing between the GET and the
+ * PUT is overwritten with the value this call read. The window is one
+ * round-trip.
+ */
+ suspend fun update(
+ cardId: Long,
+ title: String? = null,
+ content: String? = null,
+ dueOn: String? = null,
+ assigneeIds: List? = null,
+ ): Card {
+ // Clearing is encoded by OMITTING due_on — the generated body builder
+ // drops nulls via `?.let`, and BC3 nils an omitted due date. Sending an
+ // explicit null would violate body compaction (SPEC §18), and sending
+ // "" risks a date-format error.
+ val resolvedDueOn = when {
+ dueOn == null -> get(cardId).dueOn?.takeIf { it.isNotEmpty() }
+ dueOn.isEmpty() -> null
+ else -> dueOn
+ }
+ return updateVerbatim(
+ cardId,
+ UpdateCardBody(
+ title = title,
+ content = content,
+ dueOn = resolvedDueOn,
+ assigneeIds = assigneeIds,
+ ),
+ )
+ }
+}
diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CardsServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CardsServiceTest.kt
new file mode 100644
index 000000000..a1127a2c4
--- /dev/null
+++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/CardsServiceTest.kt
@@ -0,0 +1,126 @@
+package com.basecamp.sdk
+
+import com.basecamp.sdk.generated.cards
+import com.basecamp.sdk.generated.services.UpdateCardBody
+import io.ktor.client.engine.mock.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * BC3 builds a card's update params as `{ due_on: nil }.merge(card_params)`
+ * (`kanban/cards_controller.rb`), so any update whose body omits `due_on`
+ * erases the card's due date. `update` reads first and resends it;
+ * `updateVerbatim` is the raw single PUT.
+ */
+class CardsServiceTest {
+
+ private val cardJson = """{
+ "id": 42,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ "title": "Ship it",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/12345/card_tables/cards/42",
+ "app_url": "https://3.basecamp.com/12345/card_tables/cards/42",
+ "due_on": "2024-02-01",
+ "description_attachments": [],
+ "parent": {
+ "id": 2,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/12345/card_tables/columns/2.json",
+ "app_url": "https://3.basecamp.com/12345/card_tables/columns/2"
+ },
+ "bucket": { "id": 1, "name": "The Leto Laptop", "type": "Project" },
+ "creator": {
+ "id": 3,
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z"
+ }
+ }"""
+
+ private class Exchange {
+ val methods = mutableListOf()
+ var lastPutBody: String? = null
+ }
+
+ private fun clientFor(exchange: Exchange): AccountClient {
+ val engine = MockEngine { request ->
+ exchange.methods.add(request.method.value)
+ if (request.method == HttpMethod.Put) {
+ exchange.lastPutBody = (request.body as? io.ktor.http.content.TextContent)?.text
+ }
+ respond(
+ content = cardJson,
+ status = HttpStatusCode.OK,
+ headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
+ )
+ }
+ return testBasecampClient {
+ accessToken("test-token")
+ this.engine = engine
+ }.forAccount("12345")
+ }
+
+ @Test
+ fun updatePreservesDueOnWhenUnaddressed() = runTest {
+ val exchange = Exchange()
+ clientFor(exchange).cards.update(42, title = "Renamed")
+
+ assertEquals(listOf("GET", "PUT"), exchange.methods, "the composite must read before writing")
+ val body = exchange.lastPutBody!!
+ assertContains(body, "\"due_on\":\"2024-02-01\"")
+ assertContains(body, "\"title\":\"Renamed\"")
+ // Never echoed back: BC3 filters ids through reachable_people.
+ assertFalse(body.contains("assignee_ids"), "assignees must stay absent when unaddressed")
+ }
+
+ @Test
+ fun updateExplicitClearOmitsDueOnAndSkipsTheRead() = runTest {
+ val exchange = Exchange()
+ clientFor(exchange).cards.update(42, dueOn = "")
+
+ assertEquals(listOf("PUT"), exchange.methods, "an explicit clear needs no read")
+ // Clearing is omission, never a literal null (SPEC section 18).
+ assertFalse(exchange.lastPutBody!!.contains("due_on"))
+ }
+
+ @Test
+ fun updateExplicitDateSkipsTheRead() = runTest {
+ val exchange = Exchange()
+ clientFor(exchange).cards.update(42, dueOn = "2026-09-01")
+
+ assertEquals(listOf("PUT"), exchange.methods)
+ assertContains(exchange.lastPutBody!!, "\"due_on\":\"2026-09-01\"")
+ }
+
+ @Test
+ fun updateSendsExplicitEmptyContentAndAssignees() = runTest {
+ val exchange = Exchange()
+ clientFor(exchange).cards.update(42, content = "", assigneeIds = emptyList(), dueOn = "")
+
+ val body = exchange.lastPutBody!!
+ assertContains(body, "\"content\":\"\"")
+ assertContains(body, "\"assignee_ids\":[]")
+ }
+
+ @Test
+ fun updateVerbatimSendsOnePutWithNoRead() = runTest {
+ val exchange = Exchange()
+ clientFor(exchange).cards.updateVerbatim(42, UpdateCardBody(title = "Renamed"))
+
+ assertEquals(listOf("PUT"), exchange.methods, "verbatim must not read before writing")
+ assertFalse(exchange.lastPutBody!!.contains("due_on"))
+ }
+}
diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py
index 9798894df..e56b7f40a 100644
--- a/python/scripts/generate_services.py
+++ b/python/scripts/generate_services.py
@@ -150,6 +150,9 @@
"RepositionCardStep": "reposition",
"CreateCardStep": "create",
"UpdateCardStep": "update",
+ # The plain `update` name belongs to the merge-safe composite; the raw
+ # single-PUT path keeps a name that says what it does. See #467.
+ "UpdateCard": "update_verbatim",
"SetCardStepCompletion": "set_completion",
"GetQuestionnaire": "get_questionnaire",
"GetQuestion": "get_question",
diff --git a/python/src/basecamp/async_client.py b/python/src/basecamp/async_client.py
index be41eb6af..eb5630d55 100644
--- a/python/src/basecamp/async_client.py
+++ b/python/src/basecamp/async_client.py
@@ -291,7 +291,7 @@ def card_tables(self):
@property
def cards(self):
- from basecamp.generated.services.cards import AsyncCardsService
+ from basecamp.services.cards import AsyncCardsService
return self._service("cards", lambda: AsyncCardsService(self))
diff --git a/python/src/basecamp/client.py b/python/src/basecamp/client.py
index ad308a821..2293f47c9 100644
--- a/python/src/basecamp/client.py
+++ b/python/src/basecamp/client.py
@@ -292,7 +292,7 @@ def card_tables(self):
@property
def cards(self):
- from basecamp.generated.services.cards import CardsService
+ from basecamp.services.cards import CardsService
return self._service("cards", lambda: CardsService(self))
diff --git a/python/src/basecamp/generated/services/cards.py b/python/src/basecamp/generated/services/cards.py
index f53b882c0..5b710756c 100644
--- a/python/src/basecamp/generated/services/cards.py
+++ b/python/src/basecamp/generated/services/cards.py
@@ -19,7 +19,7 @@ def get(self, *, card_id: int) -> dict[str, Any]:
operation="GetCard",
)
- def update(
+ def update_verbatim(
self,
*,
card_id: int,
@@ -29,7 +29,7 @@ def update(
assignee_ids: list[int] | None = None,
) -> dict[str, Any]:
return self._request(
- OperationInfo(service="cards", operation="update", is_mutation=True, resource_id=card_id),
+ OperationInfo(service="cards", operation="update_verbatim", is_mutation=True, resource_id=card_id),
"PUT",
f"/card_tables/cards/{card_id}",
json_body=self._compact(title=title, content=content, due_on=due_on, assignee_ids=assignee_ids),
@@ -79,7 +79,7 @@ async def get(self, *, card_id: int) -> dict[str, Any]:
operation="GetCard",
)
- async def update(
+ async def update_verbatim(
self,
*,
card_id: int,
@@ -89,7 +89,7 @@ async def update(
assignee_ids: list[int] | None = None,
) -> dict[str, Any]:
return await self._request(
- OperationInfo(service="cards", operation="update", is_mutation=True, resource_id=card_id),
+ OperationInfo(service="cards", operation="update_verbatim", is_mutation=True, resource_id=card_id),
"PUT",
f"/card_tables/cards/{card_id}",
json_body=self._compact(title=title, content=content, due_on=due_on, assignee_ids=assignee_ids),
diff --git a/python/src/basecamp/services/cards.py b/python/src/basecamp/services/cards.py
new file mode 100644
index 000000000..18887e3e5
--- /dev/null
+++ b/python/src/basecamp/services/cards.py
@@ -0,0 +1,100 @@
+"""Cards service with a merge-safe ``update``.
+
+BC3 builds the card's update params as ``{ due_on: nil }.merge(card_params)``
+(``kanban/cards_controller.rb``), so **any** update whose body omits ``due_on``
+erases the card's due date. A sparse PUT — the natural thing to write — is
+therefore destructive on the raw endpoint, which remains available as
+``update_verbatim``.
+
+``update`` composes the public ``get`` and ``update_verbatim`` methods, so
+hooks observe the two wire operations, not a synthetic composite.
+
+Not atomic: a concurrent due-date change landing between the GET and the PUT is
+overwritten with the value this call read. The window is one round-trip.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from basecamp.generated.services.cards import AsyncCardsService as _GeneratedAsyncCardsService
+from basecamp.generated.services.cards import CardsService as _GeneratedCardsService
+
+_UPDATE_DOC = """Update a card without disturbing fields you did not mention.
+
+``due_on`` is tri-state, which is what makes this safe:
+
+* ``None`` (omitted) — the current due date is fetched and resent
+* ``""`` — the due date is cleared
+* a date — the due date is set
+
+The extra GET is only paid for in the ``None`` case, the one where the API
+would otherwise destroy something.
+
+Assignees are never resent on your behalf: BC3 filters incoming IDs through
+``reachable_people``, so echoing back an id belonging to someone who has since
+lost board access would silently unassign them.
+"""
+
+
+def _resolve_due_on(due_on: str | None, current: dict[str, Any] | None) -> str | None:
+ """Map the caller's tri-state ``due_on`` onto a wire value.
+
+ Clearing is encoded by OMITTING ``due_on`` — the generated service's
+ ``_compact`` strips ``None``, and BC3 nils an omitted due date. Sending an
+ explicit null would violate body compaction (SPEC §18), and sending ``""``
+ risks a date-format error.
+ """
+ if due_on is None:
+ return (current or {}).get("due_on") or None
+ if due_on == "":
+ return None
+ return due_on
+
+
+class CardsService(_GeneratedCardsService):
+ """Sync cards service with a merge-safe ``update``."""
+
+ def update(
+ self,
+ *,
+ card_id: int,
+ title: str | None = None,
+ content: str | None = None,
+ due_on: str | None = None,
+ assignee_ids: list[int] | None = None,
+ ) -> dict[str, Any]:
+ current = self.get(card_id=card_id) if due_on is None else None
+ return self.update_verbatim(
+ card_id=card_id,
+ title=title,
+ content=content,
+ due_on=_resolve_due_on(due_on, current),
+ assignee_ids=assignee_ids,
+ )
+
+ update.__doc__ = _UPDATE_DOC
+
+
+class AsyncCardsService(_GeneratedAsyncCardsService):
+ """Async cards service with a merge-safe ``update``."""
+
+ async def update(
+ self,
+ *,
+ card_id: int,
+ title: str | None = None,
+ content: str | None = None,
+ due_on: str | None = None,
+ assignee_ids: list[int] | None = None,
+ ) -> dict[str, Any]:
+ current = await self.get(card_id=card_id) if due_on is None else None
+ return await self.update_verbatim(
+ card_id=card_id,
+ title=title,
+ content=content,
+ due_on=_resolve_due_on(due_on, current),
+ assignee_ids=assignee_ids,
+ )
+
+ update.__doc__ = _UPDATE_DOC
diff --git a/python/tests/services/test_cards.py b/python/tests/services/test_cards.py
new file mode 100644
index 000000000..1bd79bedd
--- /dev/null
+++ b/python/tests/services/test_cards.py
@@ -0,0 +1,148 @@
+"""Tests for the cards merge-safe update surface (sync + async).
+
+BC3 builds a card's update params as ``{ due_on: nil }.merge(card_params)``
+(``kanban/cards_controller.rb``), so any update whose body omits ``due_on``
+erases the card's due date. ``update`` reads first and resends it;
+``update_verbatim`` is the raw single PUT.
+"""
+
+from __future__ import annotations
+
+import json
+
+import httpx
+import pytest
+import respx
+
+from basecamp import AsyncClient, Client
+
+BASE = "https://3.basecampapi.com/12345"
+
+
+def _card(card_id: int = 42, **overrides) -> dict:
+ card = {
+ "id": card_id,
+ "status": "active",
+ "visible_to_clients": False,
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ "title": "Ship it",
+ "inherits_status": True,
+ "type": "Kanban::Card",
+ "url": f"{BASE}/card_tables/cards/{card_id}",
+ "app_url": f"https://3.basecamp.com/12345/card_tables/cards/{card_id}",
+ "due_on": "2024-02-01",
+ }
+ card.update(overrides)
+ return card
+
+
+def _put_body(route) -> dict:
+ return json.loads(route.calls[-1].request.content)
+
+
+def _sync_cards():
+ return Client(access_token="test-token").for_account("12345").cards
+
+
+def _async_cards():
+ return AsyncClient(access_token="test-token").for_account("12345").cards
+
+
+class TestSyncUpdate:
+ @respx.mock
+ def test_preserves_due_on_when_unaddressed(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ _sync_cards().update(card_id=42, title="Renamed")
+
+ assert get_route.called, "the composite must read before writing"
+ body = _put_body(put_route)
+ assert body["due_on"] == "2024-02-01"
+ assert body["title"] == "Renamed"
+ # Never echoed back: BC3 filters ids through reachable_people.
+ assert "assignee_ids" not in body
+ assert "content" not in body
+
+ @respx.mock
+ def test_explicit_clear_omits_due_on_and_skips_the_read(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ _sync_cards().update(card_id=42, due_on="")
+
+ assert not get_route.called, "an explicit clear needs no read"
+ # Clearing is omission, never a literal null (SPEC section 18).
+ assert "due_on" not in _put_body(put_route)
+
+ @respx.mock
+ def test_explicit_date_skips_the_read(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ _sync_cards().update(card_id=42, due_on="2026-09-01")
+
+ assert not get_route.called
+ assert _put_body(put_route)["due_on"] == "2026-09-01"
+
+ @respx.mock
+ def test_explicit_empty_content_and_assignees_are_sent(self):
+ respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ _sync_cards().update(card_id=42, content="", assignee_ids=[], due_on="")
+
+ body = _put_body(put_route)
+ assert body["content"] == "", "an explicitly-empty content clears the body and must be sent"
+ assert body["assignee_ids"] == [], "an empty list unassigns everyone and must be sent"
+
+
+class TestSyncUpdateVerbatim:
+ @respx.mock
+ def test_sends_one_put_with_no_read(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ _sync_cards().update_verbatim(card_id=42, title="Renamed")
+
+ assert not get_route.called, "verbatim must not read before writing"
+ assert put_route.call_count == 1
+ assert "due_on" not in _put_body(put_route)
+
+
+class TestAsyncUpdate:
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_preserves_due_on_when_unaddressed(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ await _async_cards().update(card_id=42, title="Renamed")
+
+ assert get_route.called
+ body = _put_body(put_route)
+ assert body["due_on"] == "2024-02-01"
+ assert "assignee_ids" not in body
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_explicit_clear_omits_due_on_and_skips_the_read(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ await _async_cards().update(card_id=42, due_on="")
+
+ assert not get_route.called
+ assert "due_on" not in _put_body(put_route)
+
+ @pytest.mark.asyncio
+ @respx.mock
+ async def test_verbatim_sends_one_put_with_no_read(self):
+ get_route = respx.get(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+ put_route = respx.put(f"{BASE}/card_tables/cards/42").mock(return_value=httpx.Response(200, json=_card()))
+
+ await _async_cards().update_verbatim(card_id=42, title="Renamed")
+
+ assert not get_route.called
+ assert put_route.call_count == 1
diff --git a/ruby/lib/basecamp.rb b/ruby/lib/basecamp.rb
index 965eb7810..c5b18f17c 100644
--- a/ruby/lib/basecamp.rb
+++ b/ruby/lib/basecamp.rb
@@ -10,6 +10,11 @@
loader.on_load("Basecamp::Services::TodosService") do |klass, _abspath|
klass.prepend(Basecamp::Services::TodosExtensions)
end
+# Same shape for cards: the generated class owns the constant and the
+# merge-safe update is prepended over the generated update_verbatim.
+loader.on_load("Basecamp::Services::CardsService") do |klass, _abspath|
+ klass.prepend(Basecamp::Services::CardsExtensions)
+end
loader.setup
# Load generated types if available
diff --git a/ruby/lib/basecamp/generated/services/cards_service.rb b/ruby/lib/basecamp/generated/services/cards_service.rb
index 8750f67f8..359bf149c 100644
--- a/ruby/lib/basecamp/generated/services/cards_service.rb
+++ b/ruby/lib/basecamp/generated/services/cards_service.rb
@@ -23,8 +23,8 @@ def get(card_id:)
# @param due_on [String, nil] due on (YYYY-MM-DD)
# @param assignee_ids [Array, nil] assignee ids
# @return [Hash] response data
- def update(card_id:, title: nil, content: nil, due_on: nil, assignee_ids: nil)
- with_operation(service: "cards", operation: "update", is_mutation: true, resource_id: card_id) do
+ def update_verbatim(card_id:, title: nil, content: nil, due_on: nil, assignee_ids: nil)
+ with_operation(service: "cards", operation: "update_verbatim", is_mutation: true, resource_id: card_id) do
http_put("/card_tables/cards/#{card_id}", body: compact_params(title: title, content: content, due_on: due_on, assignee_ids: assignee_ids)).json
end
end
diff --git a/ruby/lib/basecamp/services/cards_extensions.rb b/ruby/lib/basecamp/services/cards_extensions.rb
new file mode 100644
index 000000000..b563a5a15
--- /dev/null
+++ b/ruby/lib/basecamp/services/cards_extensions.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Basecamp
+ module Services
+ # Merge-safe +update+ for cards, prepended onto the generated
+ # {CardsService} (see the +on_load+ hook in +basecamp.rb+).
+ #
+ # BC3 builds the card's update params as
+ # { due_on: nil }.merge(card_params)
+ # (+kanban/cards_controller.rb+), so *any* update whose body omits +due_on+
+ # erases the card's due date. A sparse PUT — the natural thing to write —
+ # is therefore destructive on the raw endpoint, which remains available as
+ # {#update_verbatim}.
+ #
+ # +update+ composes the public +get+ and +update_verbatim+ methods, so
+ # hooks observe the two wire operations, not a synthetic composite.
+ #
+ # Not atomic: a concurrent due-date change landing between the GET and the
+ # PUT is overwritten with the value this call read. The window is one
+ # round-trip.
+ module CardsExtensions
+ # Updates a card without disturbing fields the caller did not mention.
+ #
+ # +due_on+ is tri-state, which is what makes this safe:
+ #
+ # * +nil+ (omitted) — the current due date is fetched and resent
+ # * "" — the due date is cleared
+ # * a date — the due date is set
+ #
+ # The extra GET is only paid for in the +nil+ case, the one where the
+ # API would otherwise destroy something.
+ #
+ # Assignees are never resent on the caller's behalf: BC3 filters incoming
+ # IDs through +reachable_people+, so echoing back an id belonging to
+ # someone who has since lost board access would silently unassign them.
+ #
+ # @param card_id [Integer] card id
+ # @param title [String, nil] new title (nil = keep current)
+ # @param content [String, nil] new content (nil = keep current, "" clears)
+ # @param due_on [String, nil] new due date (nil = keep current, "" clears)
+ # @param assignee_ids [Array, nil] new assignees (nil = keep current, [] clears)
+ # @return [Hash] the updated card
+ def update(card_id:, title: nil, content: nil, due_on: nil, assignee_ids: nil)
+ resolved_due_on =
+ if due_on.nil?
+ get(card_id: card_id)["due_on"]
+ elsif due_on.to_s.empty?
+ # Clearing is encoded by OMITTING due_on — compact_params strips the
+ # nil below, and BC3 nils an omitted due date. Sending an explicit
+ # null would violate body compaction (SPEC §18), and sending ""
+ # risks a date-format error.
+ nil
+ else
+ due_on
+ end
+
+ update_verbatim(
+ card_id: card_id,
+ title: title,
+ content: content,
+ due_on: resolved_due_on,
+ assignee_ids: assignee_ids
+ )
+ end
+ end
+ end
+end
diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb
index 025dc4ba9..a645828b8 100644
--- a/ruby/scripts/generate-services.rb
+++ b/ruby/scripts/generate-services.rb
@@ -146,6 +146,9 @@ class ServiceGenerator
'RepositionCardStep' => 'reposition',
'CreateCardStep' => 'create',
'UpdateCardStep' => 'update',
+ # The plain `update` name belongs to the merge-safe composite; the raw
+ # single-PUT path keeps a name that says what it does. See #467.
+ 'UpdateCard' => 'update_verbatim',
'SetCardStepCompletion' => 'set_completion',
'GetQuestionnaire' => 'get_questionnaire',
'GetQuestion' => 'get_question',
diff --git a/ruby/test/basecamp/services/cards_service_test.rb b/ruby/test/basecamp/services/cards_service_test.rb
index 7d669371b..5a4ebe7f6 100644
--- a/ruby/test/basecamp/services/cards_service_test.rb
+++ b/ruby/test/basecamp/services/cards_service_test.rb
@@ -55,12 +55,13 @@ def test_create_card
assert_equal "New Feature", card["title"]
end
- def test_update_card
- # Generated service: /card_tables/cards/{id} without .json
+ def test_update_verbatim_card
+ # Generated service: /card_tables/cards/{id} without .json. The raw path
+ # sends exactly one PUT with no read-before-write.
updated_card = sample_card(id: 200, title: "Updated Title")
stub_put("/12345/card_tables/cards/200", response_body: updated_card)
- card = @account.cards.update(
+ card = @account.cards.update_verbatim(
card_id: 200,
title: "Updated Title",
content: "New content
"
@@ -69,6 +70,38 @@ def test_update_card
assert_equal "Updated Title", card["title"]
end
+ def test_update_card_preserves_due_on
+ # BC3 merges the body over `{ due_on: nil }`, so a sparse PUT erases the
+ # due date. The merge-safe update reads first and resends it.
+ current = sample_card(id: 200, title: "Old Title").merge("due_on" => "2024-02-01")
+ updated_card = sample_card(id: 200, title: "Updated Title")
+ stub_get("/12345/card_tables/cards/200", response_body: current)
+ stub_put("/12345/card_tables/cards/200", response_body: updated_card)
+
+ card = @account.cards.update(card_id: 200, title: "Updated Title")
+
+ assert_equal "Updated Title", card["title"]
+ assert_requested(:put, "#{BASE_URL}/12345/card_tables/cards/200") do |req|
+ body = JSON.parse(req.body)
+ body["due_on"] == "2024-02-01" &&
+ body["title"] == "Updated Title" &&
+ # Never echoed back: BC3 filters assignee ids through reachable_people.
+ !body.key?("assignee_ids")
+ end
+ end
+
+ def test_update_card_clears_due_on_by_omission
+ updated_card = sample_card(id: 200, title: "Updated Title")
+ stub_put("/12345/card_tables/cards/200", response_body: updated_card)
+
+ @account.cards.update(card_id: 200, due_on: "")
+
+ # Clearing is omission, never `{"due_on": null}` (SPEC section 18).
+ assert_requested(:put, "#{BASE_URL}/12345/card_tables/cards/200") do |req|
+ !JSON.parse(req.body).key?("due_on")
+ end
+ end
+
def test_move_card
stub_post("/12345/card_tables/cards/200/moves.json", response_body: {})
diff --git a/swift/Sources/Basecamp/CardsServiceExtensions.swift b/swift/Sources/Basecamp/CardsServiceExtensions.swift
new file mode 100644
index 000000000..2e785c9fa
--- /dev/null
+++ b/swift/Sources/Basecamp/CardsServiceExtensions.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+/// A merge-safe `update` on top of the generated `CardsService` surface
+/// (`get`, `updateVerbatim`, `move`, ...).
+///
+/// BC3 builds the card's update params as `{ due_on: nil }.merge(card_params)`
+/// (`kanban/cards_controller.rb`), so **any** update whose body omits `due_on`
+/// erases the card's due date. A sparse PUT — the natural thing to write — is
+/// therefore destructive on the raw endpoint, which stays available as
+/// `updateVerbatim`.
+///
+/// `update` composes the public `get` and `updateVerbatim` methods, so hooks
+/// observe the two wire operations rather than a synthetic composite.
+extension CardsService {
+ /// How a caller addresses a card's due date.
+ ///
+ /// The raw endpoint cannot express "leave it alone" — an absent `due_on`
+ /// already means "clear" there — so this is the distinction that makes
+ /// `update` safe.
+ public enum DueDate: Sendable, Equatable {
+ /// Fetch the card and resend whatever due date it currently has.
+ case preserve
+ /// Clear the due date.
+ case clear
+ /// Set the due date (YYYY-MM-DD).
+ case on(String)
+ }
+
+ /// Updates a card without disturbing fields the caller did not mention.
+ ///
+ /// The extra GET is only paid for when `dueOn` is `.preserve` — the one
+ /// case where the API would otherwise destroy something.
+ ///
+ /// Assignees are never resent on the caller's behalf: BC3 filters incoming
+ /// IDs through `reachable_people`, so echoing back an id belonging to
+ /// someone who has since lost board access would silently unassign them.
+ ///
+ /// Not atomic: a concurrent due-date change landing between the GET and the
+ /// PUT is overwritten with the value this call read. The window is one
+ /// round-trip.
+ public func update(
+ cardId: Int,
+ title: String? = nil,
+ content: String? = nil,
+ dueOn: DueDate = .preserve,
+ assigneeIds: [Int]? = nil
+ ) async throws -> Card {
+ // Clearing is encoded by OMITTING due_on — `encodeIfPresent` drops a
+ // nil, and BC3 nils an omitted due date. Sending an explicit null would
+ // violate body compaction (SPEC §18), and sending "" risks a
+ // date-format error.
+ let resolvedDueOn: String?
+ switch dueOn {
+ case .preserve:
+ let current = try await get(cardId: cardId)
+ resolvedDueOn = current.dueOn?.isEmpty == false ? current.dueOn : nil
+ case .clear:
+ resolvedDueOn = nil
+ case .on(let date):
+ resolvedDueOn = date
+ }
+
+ return try await updateVerbatim(
+ cardId: cardId,
+ req: UpdateCardRequest(
+ assigneeIds: assigneeIds,
+ content: content,
+ dueOn: resolvedDueOn,
+ title: title
+ )
+ )
+ }
+}
diff --git a/swift/Sources/Basecamp/Generated/Services/CardsService.swift b/swift/Sources/Basecamp/Generated/Services/CardsService.swift
index 79a62d410..55aaf20b1 100644
--- a/swift/Sources/Basecamp/Generated/Services/CardsService.swift
+++ b/swift/Sources/Basecamp/Generated/Services/CardsService.swift
@@ -49,7 +49,7 @@ public final class CardsService: BaseService, @unchecked Sendable {
)
}
- public func update(cardId: Int, req: UpdateCardRequest) async throws -> Card {
+ public func updateVerbatim(cardId: Int, req: UpdateCardRequest) async throws -> Card {
return try await request(
OperationInfo(service: "Cards", operation: "UpdateCard", resourceType: "card", isMutation: true, resourceId: cardId),
method: "PUT",
diff --git a/swift/Sources/BasecampGenerator/MethodNaming.swift b/swift/Sources/BasecampGenerator/MethodNaming.swift
index ec51496d4..ba0c44305 100644
--- a/swift/Sources/BasecampGenerator/MethodNaming.swift
+++ b/swift/Sources/BasecampGenerator/MethodNaming.swift
@@ -43,6 +43,9 @@ let methodNameOverrides: [String: String] = [
"RepositionCardStep": "reposition",
"CreateCardStep": "create",
"UpdateCardStep": "update",
+ // The plain `update` name belongs to the merge-safe composite; the raw
+ // single-PUT path keeps a name that says what it does. See #467.
+ "UpdateCard": "updateVerbatim",
"SetCardStepCompletion": "setCompletion",
"GetQuestionnaire": "getQuestionnaire",
"GetQuestion": "getQuestion",
diff --git a/swift/Tests/BasecampTests/CardsServiceExtensionsTests.swift b/swift/Tests/BasecampTests/CardsServiceExtensionsTests.swift
new file mode 100644
index 000000000..fa88a3764
--- /dev/null
+++ b/swift/Tests/BasecampTests/CardsServiceExtensionsTests.swift
@@ -0,0 +1,158 @@
+import XCTest
+@testable import Basecamp
+
+/// Swift mirror of `conformance/tests/cards_write.json`.
+///
+/// Swift is not part of `make conformance`, so these tests carry the same two
+/// assertions the five executable runners make: the composite `update` does
+/// GET-then-PUT and resends the fetched `due_on`, and `updateVerbatim` sends a
+/// single PUT with `due_on` absent.
+///
+/// Without the second case, later generator drift could silently turn both
+/// public methods into composite behaviour and nothing would notice.
+private final class CardRequestLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _methods: [String] = []
+ private var _putBody: [String: Any]?
+
+ var methods: [String] { lock.withLock { _methods } }
+ var putBody: [String: Any]? { lock.withLock { _putBody } }
+
+ func record(_ request: URLRequest) {
+ lock.withLock {
+ _methods.append(request.httpMethod ?? "?")
+ if request.httpMethod == "PUT", let data = request.httpBody ?? request.cardBodyStreamData() {
+ _putBody = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
+ }
+ }
+ }
+}
+
+extension URLRequest {
+ fileprivate func cardBodyStreamData() -> Data? {
+ guard let stream = httpBodyStream else { return nil }
+ stream.open()
+ defer { stream.close() }
+ var data = Data()
+ let bufferSize = 4096
+ let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize)
+ defer { buffer.deallocate() }
+ while stream.hasBytesAvailable {
+ let read = stream.read(buffer, maxLength: bufferSize)
+ if read <= 0 { break }
+ data.append(buffer, count: read)
+ }
+ return data
+ }
+}
+
+/// A card carrying a due date — the thing a sparse verbatim PUT would erase.
+private func cardJSON(id: Int = 1069479350) -> [String: Any] {
+ [
+ "id": id,
+ "status": "active",
+ "visible_to_clients": false,
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ "title": "Ship it",
+ "inherits_status": true,
+ "type": "Kanban::Card",
+ "url": "https://3.basecampapi.com/999/buckets/1/card_tables/cards/\(id).json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/card_tables/cards/\(id)",
+ "due_on": "2024-02-01",
+ "comments_count": 0,
+ "position": 1,
+ "description_attachments": [] as [Any],
+ "bucket": ["id": 1, "name": "The Leto Laptop", "type": "Project"],
+ "parent": [
+ "id": 2,
+ "title": "In Progress",
+ "type": "Kanban::Column",
+ "url": "https://3.basecampapi.com/999/buckets/1/card_tables/columns/2.json",
+ "app_url": "https://3.basecamp.com/999/buckets/1/card_tables/columns/2",
+ ],
+ "creator": [
+ "id": 3,
+ "name": "Victor Cooper",
+ "email_address": "victor@honchodesign.com",
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ ],
+ ]
+}
+
+final class CardsServiceExtensionsTests: XCTestCase {
+ private func makeCardsClient(log: CardRequestLog) throws -> AccountClient {
+ let data = try JSONSerialization.data(withJSONObject: cardJSON())
+ let transport = MockTransport { request in
+ log.record(request)
+ return (
+ data,
+ makeHTTPResponse(
+ url: request.url!.absoluteString,
+ statusCode: 200,
+ headers: ["Content-Type": "application/json"]
+ )
+ )
+ }
+ return makeTestAccountClient(transport: transport)
+ }
+
+ func testUpdatePreservesDueOnWhenUnaddressed() async throws {
+ let log = CardRequestLog()
+ let account = try makeCardsClient(log: log)
+
+ _ = try await account.cards.update(cardId: 1069479350, title: "Renamed card")
+
+ XCTAssertEqual(log.methods, ["GET", "PUT"], "the composite must read before writing")
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertEqual(body["title"] as? String, "Renamed card")
+ XCTAssertEqual(
+ body["due_on"] as? String, "2024-02-01",
+ "the fetched due date must be resent — BC3 clears an omitted due_on"
+ )
+ // Never echoed back: BC3 filters assignee ids through reachable_people.
+ XCTAssertNil(body["assignee_ids"])
+ XCTAssertNil(body["content"])
+ }
+
+ func testUpdateVerbatimSendsOnePutWithNoGet() async throws {
+ let log = CardRequestLog()
+ let account = try makeCardsClient(log: log)
+
+ _ = try await account.cards.updateVerbatim(
+ cardId: 1069479350,
+ req: UpdateCardRequest(title: "Renamed card")
+ )
+
+ XCTAssertEqual(log.methods, ["PUT"], "verbatim must not read before writing")
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertEqual(body["title"] as? String, "Renamed card")
+ XCTAssertNil(body["due_on"], "an unset due_on must stay off the wire on the raw path")
+ }
+
+ func testUpdateClearSkipsTheFetchAndOmitsDueOn() async throws {
+ let log = CardRequestLog()
+ let account = try makeCardsClient(log: log)
+
+ _ = try await account.cards.update(cardId: 1069479350, dueOn: .clear)
+
+ XCTAssertEqual(log.methods, ["PUT"], "an explicit clear needs no read")
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertNil(
+ body["due_on"],
+ "clearing is encoded by omitting due_on — never by sending null (SPEC section 18)"
+ )
+ }
+
+ func testUpdateExplicitDateSkipsTheFetch() async throws {
+ let log = CardRequestLog()
+ let account = try makeCardsClient(log: log)
+
+ _ = try await account.cards.update(cardId: 1069479350, dueOn: .on("2026-09-01"))
+
+ XCTAssertEqual(log.methods, ["PUT"])
+ let body = try XCTUnwrap(log.putBody)
+ XCTAssertEqual(body["due_on"] as? String, "2026-09-01")
+ }
+}
diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts
index 1c710da3e..7b3ff7105 100644
--- a/typescript/scripts/generate-services.ts
+++ b/typescript/scripts/generate-services.ts
@@ -318,6 +318,12 @@ const METHOD_NAME_OVERRIDES: Record = {
RepositionCardStep: "reposition",
CreateCardStep: "create",
UpdateCardStep: "update",
+ // The plain `update` name belongs to the merge-safe composite in
+ // services/cards-extensions.ts. BC3's kanban/cards_controller.rb builds
+ // card_update_params as `{ due_on: nil }.merge(card_params)`, so a sparse
+ // verbatim PUT silently erases the due date (#467). The raw single-PUT path
+ // stays reachable under a name that says what it does.
+ UpdateCard: "updateVerbatim",
SetCardStepCompletion: "setCompletion",
GetQuestionnaire: "getQuestionnaire",
GetQuestion: "getQuestion",
diff --git a/typescript/src/client.ts b/typescript/src/client.ts
index 241fd3613..d44965d05 100644
--- a/typescript/src/client.ts
+++ b/typescript/src/client.ts
@@ -30,7 +30,7 @@ import { MessagesService } from "./generated/services/messages.js";
import { CommentsService } from "./generated/services/comments.js";
import { CampfiresService } from "./generated/services/campfires.js";
import { CardTablesService } from "./generated/services/card-tables.js";
-import { CardsService } from "./generated/services/cards.js";
+import { CardsService } from "./services/cards-extensions.js";
import { CardColumnsService } from "./generated/services/card-columns.js";
import { CardStepsService } from "./generated/services/card-steps.js";
import { WormholesService } from "./generated/services/wormholes.js";
diff --git a/typescript/src/generated/services/cards.ts b/typescript/src/generated/services/cards.ts
index 1fb1562c2..497b89d3e 100644
--- a/typescript/src/generated/services/cards.ts
+++ b/typescript/src/generated/services/cards.ts
@@ -18,9 +18,9 @@ import { Errors } from "../../errors.js";
export type Card = components["schemas"]["Card"];
/**
- * Request parameters for update.
+ * Request parameters for updateVerbatim.
*/
-export interface UpdateCardRequest {
+export interface UpdateVerbatimCardRequest {
/** Title */
title?: string;
/** Text content */
@@ -110,10 +110,10 @@ export class CardsService extends BaseService {
*
* @example
* ```ts
- * const result = await client.cards.update(123, { });
+ * const result = await client.cards.updateVerbatim(123, { });
* ```
*/
- async update(cardId: number, req: UpdateCardRequest): Promise {
+ async updateVerbatim(cardId: number, req: UpdateVerbatimCardRequest): Promise {
if (req.dueOn && !/^\d{4}-\d{2}-\d{2}$/.test(req.dueOn)) {
throw Errors.validation("Due on must be in YYYY-MM-DD format");
}
diff --git a/typescript/src/index.ts b/typescript/src/index.ts
index 3e605535e..9b5db4df1 100644
--- a/typescript/src/index.ts
+++ b/typescript/src/index.ts
@@ -174,13 +174,20 @@ export {
} from "./generated/services/card-tables.js";
export {
- CardsService,
type Card,
type CreateCardRequest,
- type UpdateCardRequest,
+ type UpdateVerbatimCardRequest,
type MoveCardRequest,
} from "./generated/services/cards.js";
+// The plain names belong to the merge-safe composite. UpdateCardRequest keeps
+// working and is strictly wider than before (dueOn accepts null, to ask for an
+// explicit clear); UpdateVerbatimCardRequest above is the raw generated shape.
+export {
+ CardsService,
+ type UpdateCardRequest,
+} from "./services/cards-extensions.js";
+
export {
CardColumnsService,
type CardColumn,
diff --git a/typescript/src/services/cards-extensions.ts b/typescript/src/services/cards-extensions.ts
new file mode 100644
index 000000000..3f5f35fe1
--- /dev/null
+++ b/typescript/src/services/cards-extensions.ts
@@ -0,0 +1,103 @@
+import { CardsService as GeneratedCardsService } from "../generated/services/cards.js";
+import type { Card } from "../generated/services/cards.js";
+
+/**
+ * Request parameters for `update`.
+ *
+ * Every field is presence-bearing: omitting it leaves that part of the card
+ * alone. `dueOn` additionally accepts `null`, which is how you ask for the due
+ * date to be *cleared* — a distinction the raw API cannot express, because
+ * there an absent `due_on` already means "clear".
+ */
+export interface UpdateCardRequest {
+ /** Card title. Omit to leave unchanged. */
+ title?: string;
+ /** Card body (HTML). Omit to leave unchanged; `""` clears it. */
+ content?: string;
+ /**
+ * Due date (YYYY-MM-DD).
+ *
+ * - omitted → the current due date is preserved
+ * - `null` → the due date is cleared
+ * - a date → the due date is set
+ */
+ dueOn?: string | null;
+ /**
+ * Person IDs assigned to the card. Omit to leave the assignees alone; `[]`
+ * clears them.
+ *
+ * Assignees are never resent on your behalf. BC3 filters incoming IDs
+ * through `reachable_people`, so echoing back an ID belonging to someone who
+ * has since lost board access would silently unassign them.
+ */
+ assigneeIds?: number[];
+}
+
+/**
+ * CardsService with a merge-safe `update` on top of the generated surface
+ * (`get`, `updateVerbatim`, `move`, ...).
+ *
+ * BC3 builds the card's update params as `{ due_on: nil }.merge(card_params)`
+ * (`kanban/cards_controller.rb`), so **any** update whose body omits `due_on`
+ * erases the card's due date. A sparse PUT — the natural thing to write — is
+ * therefore destructive on the raw endpoint.
+ *
+ * `update` composes the public `get` and `updateVerbatim` methods, so hooks
+ * observe the two real wire operations rather than a synthetic composite.
+ */
+export class CardsService extends GeneratedCardsService {
+ /**
+ * Updates a card without disturbing fields you did not mention.
+ *
+ * Fetches the card first and resends its existing due date when you left
+ * `dueOn` unaddressed — so the extra GET is paid for only in the case where
+ * the API would otherwise destroy something. Naming `dueOn` explicitly (a
+ * date, or `null` to clear) skips the fetch entirely.
+ *
+ * Not atomic: a concurrent due-date change landing between the GET and the
+ * PUT is overwritten with the value this call read. The window is one
+ * round-trip. Use `updateVerbatim` to send a single request and manage
+ * `due_on` yourself.
+ *
+ * @param cardId - The card ID
+ * @param req - Fields to set; omitted fields are preserved
+ * @returns The updated Card
+ * @throws {BasecampError} If the request fails
+ *
+ * @example
+ * ```ts
+ * // Retitle without losing the due date.
+ * await client.cards.update(123, { title: "New title" });
+ *
+ * // Clear the due date.
+ * await client.cards.update(123, { dueOn: null });
+ * ```
+ */
+ async update(cardId: number, req: UpdateCardRequest): Promise {
+ const body: {
+ title?: string;
+ content?: string;
+ dueOn?: string;
+ assigneeIds?: number[];
+ } = {};
+
+ if (req.title !== undefined) body.title = req.title;
+ if (req.content !== undefined) body.content = req.content;
+ if (req.assigneeIds !== undefined) body.assigneeIds = req.assigneeIds;
+
+ if (req.dueOn === undefined) {
+ // Unaddressed: preserve whatever is there now.
+ const current = await this.get(cardId);
+ // The Card response carries wire-shaped keys; the request builder takes
+ // camelCase.
+ if (current.due_on) body.dueOn = current.due_on;
+ } else if (req.dueOn !== null) {
+ body.dueOn = req.dueOn;
+ }
+ // req.dueOn === null falls through with dueOn unset: an omitted due_on is
+ // how the API clears the date, so omission IS the clear encoding. Sending
+ // `{"due_on": null}` would violate body compaction (SPEC §18).
+
+ return this.updateVerbatim(cardId, body);
+ }
+}
diff --git a/typescript/tests/services/cards.test.ts b/typescript/tests/services/cards.test.ts
index 381c7788c..129d98932 100644
--- a/typescript/tests/services/cards.test.ts
+++ b/typescript/tests/services/cards.test.ts
@@ -100,22 +100,75 @@ describe("CardsService", () => {
});
});
- describe("update", () => {
- it("should update a card", async () => {
+ describe("updateVerbatim", () => {
+ it("sends a single PUT with no read-before-write", async () => {
const cardId = 42;
+ const methods: string[] = [];
server.use(
http.put(`${BASE_URL}/card_tables/cards/${cardId}`, async ({ request }) => {
+ methods.push("PUT");
const body = (await request.json()) as Record;
expect(body.title).toBe("Updated card");
+ // The raw path is sharp: an unset due_on stays off the wire, and BC3
+ // reads that as a clear.
+ expect(body.due_on).toBeUndefined();
return HttpResponse.json(sampleCard(cardId));
})
);
- const card = await client.cards.update(cardId, {
+ const card = await client.cards.updateVerbatim(cardId, {
title: "Updated card",
});
expect(card.id).toBe(cardId);
+ expect(methods).toEqual(["PUT"]);
+ });
+ });
+
+ describe("update (merge-safe)", () => {
+ it("refetches and resends due_on when the caller does not address it", async () => {
+ const cardId = 42;
+ const methods: string[] = [];
+
+ server.use(
+ http.get(`${BASE_URL}/card_tables/cards/${cardId}`, () => {
+ methods.push("GET");
+ return HttpResponse.json({ ...sampleCard(cardId), due_on: "2024-02-01" });
+ }),
+ http.put(`${BASE_URL}/card_tables/cards/${cardId}`, async ({ request }) => {
+ methods.push("PUT");
+ const body = (await request.json()) as Record;
+ expect(body.title).toBe("Updated card");
+ // BC3 merges the body over `{ due_on: nil }`, so omitting due_on
+ // would erase the date.
+ expect(body.due_on).toBe("2024-02-01");
+ expect(body.assignee_ids).toBeUndefined();
+ return HttpResponse.json(sampleCard(cardId));
+ })
+ );
+
+ const card = await client.cards.update(cardId, { title: "Updated card" });
+ expect(card.id).toBe(cardId);
+ expect(methods).toEqual(["GET", "PUT"]);
+ });
+
+ it("clears the due date by omitting due_on, with no GET", async () => {
+ const cardId = 42;
+ const methods: string[] = [];
+
+ server.use(
+ http.put(`${BASE_URL}/card_tables/cards/${cardId}`, async ({ request }) => {
+ methods.push("PUT");
+ const body = (await request.json()) as Record;
+ // Never `{"due_on": null}` — that would violate body compaction.
+ expect(body.due_on).toBeUndefined();
+ expect("due_on" in body).toBe(false);
+ return HttpResponse.json(sampleCard(cardId));
+ })
+ );
+
+ await client.cards.update(cardId, { dueOn: null });
+ expect(methods).toEqual(["PUT"]);
});
});