Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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.

Expand Down
35 changes: 33 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Incomplete description of TypeScript null stripping: JSON.stringify drops undefined keys but preserves null values — it does not by itself strip null from output. Since the TypeScript composite type dueOn?: string | null accepts null as a clear signal, the implementation must pre-process null to undefined (or use a replacer) before calling JSON.stringify. The spec lists the serialization step alone, which is insufficient to produce the required wire omission for null inputs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At SPEC.md, line 1300:

<comment>Incomplete description of TypeScript null stripping: `JSON.stringify` drops `undefined` keys but preserves `null` values — it does not by itself strip null from output. Since the TypeScript composite type `dueOn?: string | null` accepts `null` as a clear signal, the implementation must pre-process null to undefined (or use a replacer) before calling `JSON.stringify`. The spec lists the serialization step alone, which is insufficient to produce the required wire omission for null inputs.</comment>

<file context>
@@ -1264,9 +1290,14 @@ All wire operations are generated (rubric 1A.6). One narrow exception is sanctio
+- **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`).
 
 ---
</file context>


---

Expand Down
34 changes: 34 additions & 0 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions conformance/runner/ruby/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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]] }
Expand Down
26 changes: 26 additions & 0 deletions conformance/runner/typescript/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,32 @@ async function executeOperation(
await client.todos.update(Number(params.todoId), mapTodoWireFields(body));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Field-mapping logic for title, content, and assignee_ids is duplicated between UpdateCard and UpdateCardVerbatim. Consider extracting a shared helper (similar to mapTodoWireFields) to keep mappings consistent and reduce drift risk between the two paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At conformance/runner/typescript/runner.test.ts, line 232:

<comment>Field-mapping logic for `title`, `content`, and `assignee_ids` is duplicated between `UpdateCard` and `UpdateCardVerbatim`. Consider extracting a shared helper (similar to `mapTodoWireFields`) to keep mappings consistent and reduce drift risk between the two paths.</comment>

<file context>
@@ -227,6 +227,32 @@ async function executeOperation(
 
+      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) } : {}),
</file context>

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[] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unsafe as number[] type assertion on body.assignee_ids. Since body is Record<string, unknown>, this cast silently trusts the runtime type — if fixture data ever carries string IDs (e.g., ["1", "2"]) the API receives strings where numbers are expected. Prefer reading it as a conversion: (body.assignee_ids as number[]).map(Number), or validate the type before passing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At conformance/runner/typescript/runner.test.ts, line 239:

<comment>Unsafe `as number[]` type assertion on `body.assignee_ids`. Since `body` is `Record<string, unknown>`, this cast silently trusts the runtime type — if fixture data ever carries string IDs (e.g., `["1", "2"]`) the API receives strings where numbers are expected. Prefer reading it as a conversion: `(body.assignee_ids as number[]).map(Number)`, or validate the type before passing.</comment>

<file context>
@@ -227,6 +227,32 @@ async function executeOperation(
+            ? { dueOn: body.due_on === "" ? null : String(body.due_on) }
+            : {}),
+          ...(body.assignee_ids !== undefined
+            ? { assigneeIds: body.assignee_ids as number[] }
+            : {}),
+        });
</file context>

: {}),
});
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.
Expand Down
Loading
Loading