Stop the sparse document PUT from erasing the content (#543) - #601
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 828cfe14bc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
PUT /{accountId}/documents/{documentId} is a full replace. BC3's
DocumentsController#update runs
@recording.update! recording_attributes.merge(recordable: new_document)
where new_document is Document.new(params.require(:document).permit(:title,
:content)) — a brand-new recordable built from only the permitted params and
swapped in wholesale. A field absent from the body is nil on the replacement.
The shipped surface was a typed partial update named `update`, so the natural
call — retitle a document, mention nothing else — put exactly {"title": ...}
on the wire and BC3 erased the body.
Rename the wire operation UpdateDocument -> ReplaceDocument (no deprecated
alias; the ReplaceTodo precedent, #375) and give every SDK the same three-method
surface: merge-safe `update` (GET, overlay the set fields, full PUT),
read-modify-write `edit`, and the verbatim `replace`.
title and content are BOTH optional, measured against bc3 rather than assumed.
Omitting title is a 200 and the title becomes "Untitled" (Document#title is
super.presence || "Untitled", app/models/document.rb:7-9, with no presence
validation); omitting content is a 200 that clears it. Neither is a 422, so
neither earns @required the way ReplaceTodo's content did. What BC3 does
require is the wrapping document object (params.require(:document), which
Rails wrap_parameters synthesizes from a flat body), so a body naming neither
field is a 400.
The composites read every writable field out of the GET and write all of them
back, so they carry #576's guards: a non-string is refused before the PUT, not
coalesced or forwarded. Go, Kotlin and Swift decode into a typed model first;
TypeScript, Python and Ruby have no runtime decoder and do it by hand.
Conformance documents_write.json covers update-merge, edit-clear and
replace-omission-clears, dispatched in all six runners.
Read-side, the two fields are not symmetric, and it is the inverse of the write
side. Document.title is @required on the RESPONSE schema and BC3 can never
render it blank, so an absent or null title in a 2xx body is a malformed
response rather than an empty title — coalescing it to "" and sending that in
the full-replace PUT would blank the real title on a call that only touched
content. All six refuse it: Kotlin and Swift get it from the decoder (non-
optional String), and Go, Python, Ruby and TypeScript check explicitly, since
their reads would otherwise yield the string zero value. content is optional on
the response schema, so absent there is genuinely empty. Request optionality
and response requiredness are separate facts and are modelled separately.
828cfe1 to
c83bcae
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c83bcae267
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Go and Kotlin both let a malformed 2xx body escape as a raw decoder exception — *json.UnmarshalTypeError and kotlinx.serialization's SerializationException. The PUT is correctly avoided either way, but SPEC §6 defines a shape for a malformed successful response consumed by a composite: non-retryable, statusless api_error, with a hint naming the deliberate-overwrite escape hatch. A caller switching on *Error or catching BasecampException would miss the raw form. The Swift composite in this series already wraps DecodingError. Route Go's and Kotlin's reads through a private fetchDocument that does the same, so the three decoder-backed SDKs agree with each other and with the hand-written guards the other three carry. Narrow by construction: Go inspects for *json.UnmarshalTypeError/*json.SyntaxError and passes a transport or HTTP error through untouched, so a 404 still surfaces as a 404 rather than as "does not decode". Raised by Codex review on #601 (two P2s).
Zero-value guards made one legal request unreachable from Go's raw path. BC3 rejects a body naming neither field with a 400 (params.require(:document)), but a body naming both as "" is a legal full replacement that clears both. With `Title string` + omitempty, those two collapse into the same struct value and the length check turned the clear into a usage error — the other five SDKs can express it, Go could not. Both fields become *string: nil omits, a pointer to "" sends. The all-nil request stays a usage error, which is the 400 it stands for. Their server effect happens to coincide for title — an omitted title and an empty one both read back as "Untitled" — but the SDK must not collapse a distinction the wire makes, and content's does not coincide at all. The conformance Go runner builds the request presence-aware, so an absent fixture key stays absent on the wire. Raised by Codex review on #601 (P2).
|
Copilot errored out on all four attempts here ("Copilot encountered an error and was unable to review this pull request"), so its pass is missing rather than clean. Codex reviewed twice and CodeQL ran; all five findings are answered above — four fixed in `dfa5e7205` and `d4cceb6fa`, one declined with the four pre-existing |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4cceb6fa9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90a4a73f54
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two more review findings, and the first one retires the approach rather than patching it again. Deciding whether an error came from the response decoder by INSPECTING it does not work in either direction. Decoder errors are not enumerable: created_at and updated_at are time.Time, whose UnmarshalJSON returns *time.ParseError rather than an encoding/json sentinel, and content_attachments carries *types.FlexInt dimensions rejected with a plain fmt.Errorf that is no named type at all. The errors that PRECEDE a response are not enumerable either: a gating hook, a token provider, or a custom AuthStrategy may each return any sentinel they like, and the auth editor runs per request inside the generated client. So DocumentsService.Get splits GetDocument from ParseGetDocumentResponse — exactly the two calls GetDocumentWithResponse makes — and calls the normalizer on the decode step only. The origin is then known by construction. Everything preflight returns verbatim, so errors.Is keeps working for a caller's own sentinel. Second: a blank title is malformed, not just an empty one. Document#title is `super.presence || "Untitled"`, and Rails' presence treats a whitespace-only string as blank — so the API can never render " " either, and resending it would blank the real title. All six now trim before the check: strings.TrimSpace, String.isBlank, trimmingCharacters, .trim(), .strip.empty?, and not .strip(). Raised by Codex review on #601 (three P2s, fourth round).
DocumentsService.Get now calls gen.GetDocument then
generated.ParseGetDocumentResponse instead of the combined
GetDocumentWithResponse, so it can tell a preflight or transport failure apart
from a malformed body. The drift check only looked for `.gen.*WithResponse` and
so reported GetDocument as unwrapped.
Count `generated.Parse<Op>Response` as a wrapper too. It names the operation
exactly, appears nowhere else, and the results are still deduped, so this
recognizes an equivalent form rather than relaxing the invariant — removing a
wire call still fails the gate:
=== ERROR: Generated operations NOT wrapped by service layer (1) ===
ReplaceDocument
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e8c29fbf1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🟡 Human review recommended
It is a breaking wire-operation rename spanning the spec, six SDKs, generated artifacts, and conformance, and it deliberately carries byte-identical shared merge-safe helper files from the still-unmerged PR #597, so a human should verify the cross-PR coupling and breaking-change coordination before approval.
Review details
- Files reviewed: 45/63 changed files
- Comments generated: 0 new
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
…e guards The architecture table is what tells a future reader which hand-written service files are loaded at runtime; the note under it says anything absent is only a reference implementation. Six new composite files and three shared guard files are loaded, so they belong in the table.
There was a problem hiding this comment.
🟡 Human review recommended
It is a breaking six-SDK wire-operation rename touching generated artifacts and depends on byte-identical shared guard files copied from the still-unmerged #597, whose landing order is an open coordination decision requiring human judgment.
Review details
- Files reviewed: 46/64 changed files
- Comments generated: 0 new
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
…605) #601 added the documents merge-safe composite to SPEC.md, citing BC3 #12501 (2c0dafba13) as the commit that shipped Recording::DraftSubscribers and pinning the surface to a provenance at or after it. #590 added doc-constants-check, which rejects unmarked prose naming the current pin. Each merged green on its own branch; composed on main, the gate reads #601's two citations as unmarked pin restatements and every make check fails. Both citations are as-of facts bound to BC3 #12501 — they stay true when the pin advances — which is exactly what .unmarkedPinCitations grants. Record SPEC.md there with count 2.
Pre-existing breakage on main, absorbed here because every branch inherits it and nothing else is open against it: `main` at c441c23 fails Spec Gates on this alone (job 91663534361), with the assertion-types finding this branch introduced as the only difference. #601's Documents section and #590's doc-constants gate landed within minutes of each other, so the prose was never run past the gate. SPEC.md §Documents ended with "this surface is pinned to a bc3 provenance at or after `2c0dafba13`" — AGENTS.md's failure mode exactly, an as-of fact written in the grammar of a current-value claim, true the day it was written and silently false at the next repin. Fixed the way AGENTS.md prefers: name the pin by reference rather than by value ("at or after that commit"), so the sentence restates no constant and cannot drift. The remaining literal — "#12501 (`2c0dafba13`)" — is a genuine as-of fact bound to the PR that shipped it, so it is recorded in spec/doc-constants.json .unmarkedPinCitations with a count of 1, matching how spec/api-gaps/README.md and folders-api.md already cite the same revision.
#590 landed `make doc-constants-check` on main after this branch was cut, and it gates SPEC.md §19's table against the `conformance/schema.json` assertion enum: a new type cannot ship undocumented. This branch adds `errorRaised` to that enum, so the rebase inherited the obligation and Spec Gates went red with "defines 22 assertion types, the table documents 21". The row says what the type is for and what declaring it costs — it switches the stop-on-mismatch policy off for that case, which is why every fixture declaring it needs a control sibling. The other finding in that same red run — SPEC.md §Documents restating the current pin — was #601's, not this branch's, and #605 has since fixed it on main. An earlier revision of this branch carried its own fix for it; that is dropped, so the only line this PR adds to SPEC.md is the one above.
* Refuse a malformed GET field instead of writing it back (#576) The shipped Todos and Cards merge-safe composites in Python, Ruby and TypeScript read each writable field off a GET and PUT the FULL representation back. Every value read is therefore a value written -- on a call that never mentioned the field -- and none of the three validated what they read. Two failure modes, the same defect wearing different clothes: erasure a falsey non-string coalesced away, wiping the field corruption a non-string forwarded verbatim, writing a number, boolean, array or object where a string belongs Probed against the unfixed code, one call each, `update(content:)` and `update(title:)`: Python Todos description=False,0,[],{} -> PUT description="" description=42,True,["x"] -> PUT description=42 / True / ["x"] assignees[0].id="100" -> PUT assignee_ids=["100"] Python Cards due_on=False,0,[],{} -> PUT with due_on OMITTED, which is exactly how BC3 erases the date due_on=42,True,["x"] -> PUT due_on=42 / true / ["x"] Ruby Todos description=false -> PUT description="" description=0,[],{},42,... -> PUT description=0 / [] / {} / 42 Ruby Cards every shape -> PUT due_on=<shape verbatim> TS Todos all eight shapes -> PUT description=<shape verbatim> TS Cards due_on=false,0 -> PUT with due_on OMITTED (erased) All three now treat an absent key or an explicit null as genuinely empty, pass an actual string verbatim, and raise before the PUT naming the field. The ID-list fields get the analogous check: an array, of objects, each with an integer id. One level up, the response itself must be an object -- on main a scalar or null body produced a raw TypeError/AttributeError instead of the documented statusless api_error. The rule underneath: a composite is safe exactly when a decoder REJECTS a wrong-typed field at runtime, not when a type merely claims one. Go (json.Unmarshal) and Swift (Codable) genuinely refuse. TypeScript's schema.d.ts is erased at build time and the generated Python and Ruby services return an untyped dict/Hash, so those three do it by hand, in a shared per-language helper (_merge_safe.py, merge_safe.rb, merge-safe.ts) rather than six copies. Kill coverage lands in the SHARED conformance fixtures, not per language. This defect survived five consecutive review passes because each pass fixed one instance; a shared fixture catches every instance at once, in every runner, permanently. Four cases across todos_write.json and cards_write.json assert errorRaised + requestCount 1 -- the guard must fire BEFORE the PUT, because a guard that fires after has already lost the field. errorRaised is a new assertion type, the code-agnostic inverse of noError: the six SDKs refuse the same body by two different mechanisms (hand-written guard vs model decoder) that share no canonical error code. Declaring it also tells the Kotlin and Swift runners that a decoder rejection is the point of the case rather than an under-specified fixture body. Writing that fixture immediately earned its keep: it found a FOURTH affected language. Kotlin's client-wide `Json { isLenient = true }` coerces a JSON scalar into a String field, so `"description": 42` decodes to "42" and the composite writes it back -- proven on the wire with a temporary requestBody assertion. #576 lists Kotlin as structurally safe; it is not, for scalars. It cannot be fixed by this PR's pattern either, since the coercion happens at decode and the composite only ever sees a String, so the fixtures use array/object shapes (which kotlinx.serialization does reject) and the scalar hole is filed separately. Red proof, against unfixed composites: 85 Python, 81 Ruby, 78 TypeScript unit failures, and 4/4/2 conformance kill-case failures (py/rb/ts). Go, Kotlin and Swift pass the kill cases both before and after, which is the point. Deliberately out of scope: the caller-side mirror (a closure assigning 42 inside edit), the Kotlin lenient-decoder hole, and the generated validating layer that would make all of these guards deletable (#578). * Close the errorRaised coverage gaps: a TS-discriminating kill case and six runner unit tests Three verifier findings on #597, all about the new assertion proving less than it claimed. The Cards kill cases did not discriminate in TypeScript. The generated updateVerbatim guards due_on with /^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), and RegExp.test coerces its argument to a string first, so ["x"] ("x") and {} ("[object Object]") were already rejected before the PUT with or without the guard -- TypeScript conformance failed 2 kill cases against the unfixed composites, not 4, and TypeScript Cards had no regression protection at all. A fifth case uses ["2024-02-01"], which String() renders as exactly "2024-02-01": the format check waves it through and only the guard stops it. It still discriminates in Python and Ruby, and Go, Kotlin and Swift reject it structurally as they do any JSON array in a String field, so the shared fixture stays shared. The errorRaised handler had no unit test in any runner. Its failing branch is unreachable from conformance/tests/ -- every case declaring it is one the SDK does refuse -- so a handler that accepted everything would report green in all six runners at once, which is how #563 shipped a vacuous delayBetweenRequests check. The predicate is split out per runner and tested on both directions, with the message pinned verbatim in all six. Go also asserts the wiring, since a typo'd case label would fall through to the default and assert nothing. evaluateAssertions(dispatchFailed:) loses its default. The one call site passes it, but the default fails closed: a future call site that omitted it would report "the call succeeded" on a call that did not, reddening every errorRaised fixture far from the actual bug. Also fixes the Codex P2: the Swift HTTPS-enforcement probe recorded caughtError without setting dispatchFailed, so a trapped child process read as a successful call. Runner.swift now flags it, and both Swift and Kotlin derive the assertion from the union of the two signals rather than from call-site discipline. * Keep the errorRaised kill fixtures honest with a control-sibling gate Codex P2 on Runner.swift: declaring errorRaised switches OFF the #555 stop-on-mismatch policy in the decoder-backed runners. Swift's DecodingError branch and Kotlin's MissingFieldException/SerializationException branches normally fail loudly when a mock body no longer decodes into the generated model; when the fixture declares errorRaised they treat the refusal as the behaviour under test and pass. So if a model later gains a required field, or any unrelated field in one of these large bodies drifts, the decode fails for a reason unrelated to the field under test -- and errorRaised, requestCount: 1, requestMethod and requestPath all still hold. The case keeps passing and stops proving anything. The protection turns out to already exist, structurally: every kill body is a passing case's body with exactly one field perturbed, and that sibling does NOT declare errorRaised, so it keeps the full #555 policy and fails loudly on drift. Cards kill cases pair with update-preserves-due-on (differing only in due_on), Todos with update-merge (differing only in description). But nothing enforced the coupling -- edit one body without the other and it silently breaks. So enforce the claim rather than asserting it in a comment, which is the #576 lesson applied to #576's own fixtures: for every case declaring errorRaised, some case in the same file that does not declare it must have a mock body with the identical key set, differing in exactly one field. Runs in make conformance-fixtures-check, which CI already invokes. Proven non-vacuous: perturbing a kill body in a second field fails the gate (exit 1) and names the case, the missing control and the repair. * Pin the control gate to the response the kill case actually decodes Codex P2 on the gate added a commit ago: it matched a kill body against ANY queued response of ANY control case. Every kill case queues two object-shaped responses -- response 0 is the GET whose decoder rejection is under test, and response 1 is a decoy, queued so a runner cannot pass by exhausting the queue instead of refusing the field. The decoy is never consumed. So an unconsumed decoy could satisfy the gate while the body that actually gets decoded drifted away from its control, which is the same vacuity the gate exists to prevent, one level up. Reachable, not theoretical. Drift the consumed body by a second field and make the decoy differ from its control by exactly one, and the two gates split cleanly: the old one reports `ok` at exit 0, the new one fails at exit 1 naming the case. Now restricted on both sides: the FIRST mock response only, and a control exercising the SAME operation -- a body that decodes into a different model says nothing about whether this one still decodes. * Move $comment out of the assertion properties map, and metaschema-check first Codex P2: the errorRaised annotation sat INSIDE properties.assertions.items.properties, so it declared a property literally named "$comment" whose schema was a string. Draft 2020-12 requires every value under `properties` to be a schema object or boolean, so conformance/schema.json was not itself a valid schema -- and tests.schema.json references it, meaning a validator that meta-validates would reject the whole conformance schema before looking at a single fixture. Moved alongside `properties`, where JSON Schema puts annotations. The reason this shipped is that nothing checked it: the fixture pass validates fixtures AGAINST the schema and never validates the schema itself, so an invalid schema sails through. conformance-fixtures-check now runs --check-metaschema over schema.json and tests.schema.json FIRST, because validating fixtures against a schema that is not a valid schema proves nothing. Red proof, through the make target rather than the bare validator: putting the annotation back inside properties fails at REAL_EXIT=2 with conformance/schema.json::$.properties.assertions.items.properties['$comment']: '...' is not of type 'object', 'boolean' * Require a kill case to deliver its malformed value in a 2xx response Codex P2, the residual hole in the control gate: it compared operation and body but not the response OUTCOME. Change an errorRaised case's first mock response from 200 to 500, or to networkError while keeping its body, and the SDK fails on the HTTP or transport error instead. errorRaised is satisfied by that failure, requestCount / requestMethod / requestPath all stay green, and the malformed field is never decoded -- the case goes green having tested nothing, and body equality cannot see it. A kill case's premise is that the malformed value arrived in a SUCCESSFUL API response. That is what makes it the SDK's problem rather than the server's, and it is why #576 classifies the refusal as a statusless api_error rather than a transport or HTTP failure. So require it: the first mock response must carry a 2xx status and no networkError. Both failure modes proved red, each naming what it would have cost: status 500 -> "...so the call fails on the HTTP error before the body is decoded" (REAL_EXIT=1) networkError -> "...so the call fails in transport and the body is never decoded" (REAL_EXIT=1) * Require the control response to reach its decoder too Codex P2, the symmetric half of the previous commit: not_a_success was applied to the kill response but not to the control. A control earns its keep only by being DECODED -- that is what makes it fail loudly (#555) on model drift, which is the entire protection the kill case borrows from it. A sibling answering 500 or networkError with an object body never reaches its decoder, so it can sit green on its own HTTP/transport assertions while the drift it was supposed to catch goes unnoticed in both bodies. Same check now, on both sides. Red proof needed a second attempt, which is worth recording. Breaking a single control (update-preserves-due-on -> 500) did NOT fail the gate: cards_write.json has four non-errorRaised UpdateCard cases, and the gate correctly fell back to update-explicit-clear, whose body also matches on the same key set differing only in due_on. That first proof was vacuous -- it demonstrated the fallback working, not the check. With all four UpdateCard controls answering 500 the two versions split cleanly: the pre-fix gate reports `ok` at REAL_EXIT=0, the post-fix gate fails all three Cards kill cases at REAL_EXIT=1, naming the missing SUCCESSFUL (2xx) control. * Reject a kill case whose 2xx never reaches a decoder, and self-test the gate The control-sibling gate accepted any 2xx on both sides, which let a 204 through. A 204 is short-circuited before any parse — TypeScript returns `undefined`, Kotlin returns `Unit` without calling `parse`, Go rewrites the body to JSON `null` — so a kill case answering 204 never decodes its malformed field. The composite fails because the record came back absent, `errorRaised`, `requestCount: 1`, `requestMethod` and `requestPath` all still hold, and the control, still a 200, stays green. The gate printed `ok` and exited 0 for exactly that input: the same false green this gate exists to prevent, one layer down. Statuses are now an allowlist, {200, 201}, rather than a 204 exclusion. Two constraints meet there: Go's success arm is exactly {200, 201, 204}, so 202, 203, 205 and 206 are never decoded there at all; and 204/205 carry no body by definition. Closed-by-default, because a gate whose whole job is to prove a body is decoded cannot prove that for a status nobody has reasoned about. `not_a_success` is renamed `not_decoded` — a 204 does not fail the call, it bypasses the decode, and the old name said the wrong thing. Every rejection this gate makes was, until now, correct by inspection alone, which is the standard that let #576 through five review passes. So it gets a self-test: `conformance/test_check_kill_case_controls.py` crafts one input per claimed rejection and asserts the gate refuses it, driven through the real entry point via a new optional FIXTURE_DIR argument, with the real fixture set run as a positive control. Reverting only the two new status branches turns exactly four cases red — 204, 205, an undecoded 2xx, and the control-side 204 — and nothing else, so the suite is measured non-vacuous rather than assumed so. It runs inside `make conformance-fixtures-check`, which CI already invokes. * Document errorRaised in SPEC.md §19's gated assertion table #590 landed `make doc-constants-check` on main after this branch was cut, and it gates SPEC.md §19's table against the `conformance/schema.json` assertion enum: a new type cannot ship undocumented. This branch adds `errorRaised` to that enum, so the rebase inherited the obligation and Spec Gates went red with "defines 22 assertion types, the table documents 21". The row says what the type is for and what declaring it costs — it switches the stop-on-mismatch policy off for that case, which is why every fixture declaring it needs a control sibling. The other finding in that same red run — SPEC.md §Documents restating the current pin — was #601's, not this branch's, and #605 has since fixed it on main. An earlier revision of this branch carried its own fix for it; that is dropped, so the only line this PR adds to SPEC.md is the one above.
SPEC §19's Test Categories table and Appendix D each claim to account for every fixture under conformance/tests/, and both had drifted. The last three fixture-adding commits missed the convention in three different ways: dee221c (#601) added documents_write.json and updated neither table; b238e5e (#683) added uploads_write.json with four Appendix D rows and no §19 row; #726 added search.json's §19 row and missed Appendix D. Two half-applications in opposite directions is not carelessness — CONTRIBUTING.md tells contributors to add conformance tests and mentions neither table. Each cell is derived from the fixture's own description citations, which is the convention the existing rows follow. documents_write.json cites "SPEC 18 body compaction" and "SPEC 18 rule 6", and §5's Documents subsection already back-references it; uploads_write.json cites "SPEC §18", "SPEC.md §5" and "SPEC §6 step 11", the same four attributions its Appendix D rows already spell out. Also moves the search row into its sorted position, where #726 misfiled it between retry and schedule-entries-write. Claude-Session: https://claude.ai/code/session_01VyZUi8zkRfhatnBYoS4eyn
…them (#740) * Account for every conformance fixture in both SPEC rosters SPEC §19's Test Categories table and Appendix D each claim to account for every fixture under conformance/tests/, and both had drifted. The last three fixture-adding commits missed the convention in three different ways: dee221c (#601) added documents_write.json and updated neither table; b238e5e (#683) added uploads_write.json with four Appendix D rows and no §19 row; #726 added search.json's §19 row and missed Appendix D. Two half-applications in opposite directions is not carelessness — CONTRIBUTING.md tells contributors to add conformance tests and mentions neither table. Each cell is derived from the fixture's own description citations, which is the convention the existing rows follow. documents_write.json cites "SPEC 18 body compaction" and "SPEC 18 rule 6", and §5's Documents subsection already back-references it; uploads_write.json cites "SPEC §18", "SPEC.md §5" and "SPEC §6 step 11", the same four attributions its Appendix D rows already spell out. Also moves the search row into its sorted position, where #726 misfiled it between retry and schedule-entries-write. Claude-Session: https://claude.ai/code/session_01VyZUi8zkRfhatnBYoS4eyn * Gate both conformance-fixture rosters against git ls-files `sync-doc-constants.rb` already does table-completeness checking: @assertion-types wraps SPEC §19's assertion-type table in a block marker and set-compares it against conformance/schema.json. The two fixture rosters are the same shape one level out — a table that claims to account for every fixture under conformance/tests/, with nothing checking it — so they become two more block kinds rather than a new script and a new CI step. `make doc-constants-check` already runs the gate and its self-test, and is already in check-targets and spec-gates. The source is `git ls-files conformance/tests/*.json`, not Dir.glob, for tracked_markdown's reason: an untracked scratch fixture must not fail a developer's build. Direct children only — git's pathspec `*` matches across `/`, and a nested fixture is discovered by no runner, so demanding a roster row for it would be documenting a claim that is not true. That scope is also how SPEC §23's carve-out is honored: conformance/oauth/, oauth-token/ and event-feed*/ are documented at their own section and directory. The two invariants differ because the artifacts differ. §19's table is a bijection, so all of it is asserted: one row per fixture, both directions, and category slug == basename with `_` as `-` (verified across all 22 rows). Appendix D's rows are curated summaries that deliberately bundle several cases — uploads_write.json legitimately has four — so it gets coverage only, and a self-test case pins that difference by asserting several rows for one fixture still passes. Both tables also reject a row whose attribution cell is blank, and a `§N` reference that resolves to no `## §N.` heading — the latter catching a reference that resolved when written and stopped resolving when a section was renumbered, which a reviewer of the same PR cannot see. A row with no section reference at all is still accepted, because rejecting it needs a carve-out for live-my-surface.json's external-governance attribution and the carve-out list is the part that grows. Neither table is writable: --write only ever touched line spans, and a row here carries an owning-section attribution or a case summary only the fixture's author can make. Both checks reject SPEC.md as it stood before the preceding commit: SPEC.md:2110-2131: conformance/tests holds 22 tracked fixture(s), the table categorises 20; missing: `documents_write.json`, `uploads_write.json`. SPEC.md:3379-3458: no row maps these tracked fixtures to a primary section: `documents_write.json`, `search.json`. Claude-Session: https://claude.ai/code/session_01VyZUi8zkRfhatnBYoS4eyn * Keep block bodies under the pin scan, and reject colliding category slugs Two review findings on the roster gate, both real interactions rather than Markdown-spelling edge cases. Block span bodies were dropped from the prose pool along with line spans, but the two are not alike. The writer rewrites line spans only, and the block checkers read nothing but the `|` rows, so an ordinary sentence parked inside a roster or assertion-types block survives both untouched. Excluding the whole body let "verified against <current pin>" sit there with no marker and no grant — invisible to check_unmarked_pin and silently stale at the next repin, which is the exact claim class this gate exists to catch, hidden by the gate's own span bookkeeping. Only line spans leave the pool now. The §19 categories table tallied FILES, which catches one fixture on two rows but not two fixtures deriving one category. `_` and `-` collapse to the same slug, so `foo_bar.json` and `foo-bar.json` each satisfy the per-row slug rule while the table stops being the bijection its heading asserts. Now tallied on the DERIVED slug — a row whose category cell is simply wrong is already reported and still reaches that tally, so grouping by the declared cell would both miss real collisions and invent false ones. Appendix D is unaffected: it has no category column and deliberately allows many rows per fixture. Both self-test cases were shown to fail against the un-fixed gate first — reverting each fix in turn leaves exactly its own case reporting "expected FAILURE, gate exited 0". * Stop SPEC promising a gate this PR removed, and correct a comment inviting deletion Codex raised the first as a P1 and Copilot as a suppressed comment; both were right, and it is the defect class this PR family exists to prevent — prose claiming coverage CI cannot provide. SPEC §19 said `make check-fixture-execution` (#602) "is what detects it now". That gate was the source-text parser split out of this PR to conformance-skips-parser-archive; the prose describing it stayed behind. There is no such script and no such target — `make -n check-fixture-execution` exits "No rule to make target" — so the paragraph promised all-six detection that does not exist, while #602 is still open. Replaced with what is actually true: each runner's case census (#742) catches a case executed by no runner for a MECHANICAL reason, and explicitly does not catch the deliberate all-six exclusion this section describes, because each census counts its own skip and stays green. The roster below it is restated rather than derived, and nothing checks it (#736) — which is why #736 waits for #602's cross-runner manifest instead of being fixed on its own. Separately, roster_vacuity's comment claimed the guard "buys no coverage". That is true only when ONE side is empty. When BOTH are, `missing` and `extra` are both empty, the comparison is trivially satisfied, and this guard is the only thing refusing the vacuous pass — a committed self-test case covers exactly that. The comment as written invited deleting a live guard on the strength of reasoning that applies to a different case. * Require exactly three cells per roster row, and correct a self-falsifying claim Copilot raised the cell count three times across two rounds; taking it, because it asks for something different from the Markdown-spelling findings declined alongside it. Those asked the splitter to UNDERSTAND more Markdown — separator widths, backslash parity. This asks it to REFUSE what it does not understand, which is the direction this file already argues for: "a row the parser cannot see is a row it silently vouches for." And it closes the pipe class as a class rather than one spelling at a time — however a stray pipe was written, the cell count is wrong and the row fails loudly instead of being mis-parsed quietly. The consequence was real, not cosmetic. A raw pipe in an attribution shifts the real section into a fourth cell and leaves the fragment before it in cells[2], where non-`§` attributions are legitimately allowed — so the gate validated the wrong cell and a `§99` in the actual section position was never checked, on a gate whose whole claim is that it validates every section reference. Both tables had it; Appendix D's free-form summaries are the likeliest place for someone to write `supports A | B`. Self-test cases added for both tables and shown to fail against `< 3` first. CONTRIBUTING.md separately claimed the checklist item "is the only place this convention was written down" — false the moment this PR also stated it in SPEC §19 and the gate. Rephrased as the historical absence it describes.
Closes #543. First child of #374, and the template #547 (ScheduleEntries) copies — the shape established here is the one that wave repeats, so it is written to be copied rather than to be clever.
Was stacked on #593; that merged as
9459083ewhile this was in flight, so this is rebased ontomainand targetsmaindirectly. After that rebase this PR carries no repin of its own — it depends on the repin already onmainfrom #593, which is what makes it safe to ship (see below).PUT /{accountId}/documents/{documentId}is a full replace, and every SDK shipped a sparseupdateover it.UpdateDocumentbecomesReplaceDocument— breaking, no deprecated alias, theReplaceTodoprecedent (#375) — and the merge-safeupdate/edit/ rawreplacetriad lands in all six SDKs.Red proof — what the shipped
updateputs on the wireOne call,
update(title: "Q3 Plan"). It never mentionscontent. Captured against the base branch and against this one; literal output, pasted from the capture files.Python
Ruby
Go — worth its own run, because Go's
Updatewas a hand-written wrapper invaults.go, not the generated shape the other five shared:(Go's line is reproduced above with the HTML unescaped for readability; the raw capture prints Go's
<div>escaping of the same string. Kotlin, Swift and TypeScript shipped the same generated sparse body —body.title?.let { put(...) }and its analogues — so the two structurally distinct paths are the generated one and Go's, and both are shown.)Red proof — the tests
Every new test was run against deliberately un-fixed source, in a scratch copy of the tree rather than by mutating the tracked file:
AssertionError: assert 'content' in {'title': 'Project Overview'}— 3 faileddocument.get(key) or "")Failed: DID NOT RAISE <class 'basecamp.errors.ApiError'>/AttributeError: 'NoneType' object has no attribute 'get'— 29 failed, 4 passedputFieldswith|| undefinedAssertionError: expected { title: 'Project Overview' } to have property "content"— 2 failed??read)expected the call to reject, but it resolved/AssertionError: expected TypeError: Cannot read properties of null… to be an instance of BasecampError— 25 failed, 4 passedThat last line is the point of
requireRecord: un-guarded, a null envelope degrades to a rawTypeErrorinstead of the documented statuslessapi_error.The measured contract:
titleandcontentare BOTH optionalThis is the one place Documents diverges from Todos and Todolists, and the reason it is stated rather than inferred — modelling either field as
@requiredwould make the SDK reject a request the server accepts.DocumentsController#update→@recording.update! recording_attributes.merge(recordable: new_document), wherenew_documentisDocument.new(params.require(:document).permit(:title, :content))— a brand-new recordable from the permitted params, swapped in wholesale, so an absent field isnilon the replacementtitleis a200, not a422Document#titleissuper.presence || "Untitled"(app/models/document.rb:7-9); the model declares novalidates, and neitherRecordablenorRecordingpresence-validates the recordable's titlecontentis a200that clearscontenteither400params.require(:document)raisesActionController::ParameterMissing; Railswrap_parametersonly synthesizes the wrapper from a flat body carrying at least oneDocumentattribute name. Pinned upstream bytest "publishing a draft document requires the full payload and preserves it"(test/api/documents_controller_api_test.rb:56)The public API docs say it outright: "A status-only update fails with
400 Bad Request(thedocumentparameters are required), and omitting a field clears its value."Read-side, the asymmetry inverts
Document.titleis@requiredon the response schema and BC3 can never render it blank, so an absent or nulltitlein a 2xx body is a malformed response, not an empty title — coalescing it to""and sending that in the full-replace PUT would blank the real title on a call that only touchedcontent. All six refuse it: Kotlin and Swift get it from the decoder (non-optionalString), and Go, Python, Ruby and TypeScript check explicitly, because their reads would otherwise yield the string zero value.contentis optional on the response schema, so absent there is genuinely empty.Request optionality and response requiredness are separate facts, modelled separately. Getting that backwards in either direction is a bug:
@requiredon the input would reject a request the server accepts; a tolerant read would write a blank title nobody asked for.So
ReplaceDocumentInputmarks neither field@required. Go'sReplacerefuses a body with neither locally rather than spending a round-trip on the 400; the other five leave it to the server.Why this was blocked, and why it no longer is
A merge-safe composite PUTs a full representation and names neither
subscriptionsnornotify. BC3'snotify_paramdefaulted to"custom", sofind_subscribersranwhere(id: params[:subscriptions])→where(id: nil)→ empty, and every sparse update to a drafted recording reset its subscriber list to creator + updater. The list is also unreadable over the API — onlysubscription_urlis emitted — so the composite could not have preserved it by resending. Shipping before that fix would have preserved title and content while silently unsubscribing everyone.Fixed in bc3 #12494 (
344581a379) and #12501 (2c0dafba13) byRecording::DraftSubscribers, whose predicate isparams.key?(:subscriptions) || params.key?(:notify): a request addressing neither keeps the list it found. #593's repin to2c0dafba13is what brings that into the SDK's provenance, and it is already onmain— this PR reads that provenance rather than moving it, andspec/api-provenance.jsonis untouched here.One correction to #543's body: the concern shipped as
Recording::DraftSubscribers(app/controllers/concerns/recording/draft_subscribers.rb), notRecording::DraftSubscriberParams.The overlay does not reintroduce #576
The merge-safe read is the exact defect class #597 is fixing on Todos and Cards: a value read off the GET is a value written by the PUT, on a call that never mentioned the field. Documents reads two string fields, so it uses #597's shared guards rather than a fourth private copy —
require_mapping/writable_string,requireRecord/writableString,MergeSafe.require_hash/MergeSafe.writable_string. Go, Kotlin and Swift decode into typed models and need none (Kotlin's client-widecoerceInputValuesscalar hole is the known cross-service gap from #597, not something a composite can close).#597 has not merged yet, so
python/src/basecamp/services/_merge_safe.py,ruby/lib/basecamp/services/merge_safe.rbandtypescript/src/services/merge-safe.tsappear here as byte-identical copies — verified byshasum -a 256againstfix/composite-containment-576. When #597 lands they rebase to a zero diff. They are not forked and must not be edited here; #543 owns only the Documents call sites.Deliberately not carried over: a Documents kill fixture.
errorRaisedand the six runner branches that support it are #597's, and duplicating them — including the SwiftrequiresHTTPSCrashProbedefect still open on that PR — would be worse than waiting. The guard is covered by native tests in all three languages here, and the shared kill case lands as a follow-up onceerrorRaisedis onmain.Also out of scope, matching #597 exactly: the caller-side mirror.
edithands back a mutable view and a closure assigning42walks into the same PUT — but that is a value the caller chose, not one silently substituted for a value they asked to preserve. Ruby normalisesnil→""rather thanto_s, so nothing is coerced on the way out.Surface
go/pkg/basecamp/documents.go—Update/Edit/Replace+ privatereplaceDocumentowning the hook envelope and the singleReplaceDocumentWithBodyWithResponsecall site (SPEC §18 rule 1 carve-out:omitemptycannot express always-send-empty). Read/create/trash stay invaults.gosrc/services/documents-extensions.ts, import swapped inclient.ts, split export inindex.tssrc/basecamp/services/documents.py— sync + async,DocumentEdit/AsyncDocumentEditcontext managerslib/basecamp/services/documents_extensions.rb, prepended via a fourthon_loadinlib/basecamp.rbDocumentsadded toEXTENSIBLE_SERVICESandHAND_WRITTEN_SERVICES;services/DocumentsService.kt;generated-compat/UpdateDocumentBody.kt(the composite's body type, not a deprecated alias — null preserves where the generated body's null omits)Sources/Basecamp/DocumentsServiceExtensions.swift; the generatedUpdateDocumentRequest.swiftbecameReplaceDocumentRequest.swift, freeing the nameFull-state serialization: both fields always sent, empties included. On a full-replace endpoint
""is how a clear is expressed — never JSON null (SPEC §18), and never by omission, which would hand the clear back to the server's own rebuild and read as an accident rather than an intent. Three independent attempts to "simplify" that into dropping empties were caught and reverted during this change; each one would have turned a stated clear back into the accident.status: "active"(draft publish) stays unmodeled and is now said so in the operation doc: BC3 rejects a status-only update for the same reason it rejects an empty body, so publishing needs the full payload alongside it.Review rounds (Codex, 13 x P2 + 1 CodeQL)
Eleven fixed, two carried to follow-ups, one declined with evidence — five rounds. The Go read-error handling took four passes, and the honest summary is that the first three were the wrong shape, not merely incomplete.
Go and Kotlin leaked raw decoder exceptions (
*json.UnmarshalTypeError,SerializationException) where SPEC §6 wants a statusless non-retryableapi_error. The PUT was correctly avoided either way, but a caller switching on*Error/BasecampExceptionwould have missed it — and Swift in this same PR already wrappedDecodingError, so the three decoder-backed SDKs disagreed with each other. Both now read through a privatefetchDocument(dfa5e7205). Go's is deliberately narrow: a transport or HTTP error passes through untouched, so a 404 stays a 404. Red-proofed:A missing required
titleon read — covered by the read-side section above; the guard shipped in all six before the review landed.Go's
Replacecould not express{"title": "", "content": ""}— a legal full replacement that clears both. Zero-value guards collapsed it into the all-nil request, so the length check turned a legal clear into a usage error while the other five SDKs could express it. Both fields are now*string(d4cceb6fa); nil omits, a pointer to""sends, and the all-nil request stays the usage error standing in for BC3's400. This is the one Go request here that departs from the zero-value convention, and SPEC §5 says why.The decode-error wrapper's allowlist was incomplete — twice (second review round).
Document.created_at/updated_ataretime.Time, whoseUnmarshalJSONreturns*time.ParseError, so the encoding/json allowlist leaked. Adding it does not close the class either:content_attachmentscarries*types.FlexIntdimensions andFlexInt.UnmarshalJSONrejects a non-integral value with a barefmt.Errorfthat is no named type at all. Against the three-type allowlist:28d6871datherefore classifies by exclusion: pass through an error already carrying the SDK taxonomy (checkResponsemaps HTTP status), a transport failure, or a cancelled context; normalize everything else. An allowlist of decoder error types can only be extended, never completed. The test table spans all three shapes on purpose.A blank
titleon read was accepted in five of six.Document#titleissuper.presence || "Untitled", so BC3 can never render one —""on a 2xx read is a malformed response, not an empty title, and resending it blanks the real title on a call that only touchedcontent. Go already refused it; the other five only covered absent and null. Kotlin and Swift needed it written by hand for the same reason: their non-optionalStringrefuses absent/null at decode, but""decodes fine (f7707c6b7).A gating-hook error was misclassified as a decode failure. The classifier wrapped the whole of
Get, which sits above the gate, so a circuit breaker's own sentinel came back as "does not decode" — breakingerrors.Isand misreporting why nothing was sent:Rather than teach the classifier to recognize an arbitrary sentinel, it moved below the gate, to the one call site whose only origins are the transport and the decoder. That removes the gate from the candidate set entirely, and fixes the same misclassification for direct
Getcallers too (f7707c6b7).Classifying the Go read error by inspection could not be made to work, in either direction. The allowlist of decoder error types leaked
*time.ParseError(fromcreated_at) and thentypes.FlexInt's barefmt.Errorf(from an attachment dimension). Inverting it to an exclusion list then leaked a gating hook's sentinel, and then an arbitraryAuthStrategy's — the auth editor runs per request inside the generated client, so it surfaces at the same call site with no HTTP response behind it. Neither set is enumerable.406d16943stops guessing:DocumentsService.Getsplitsgen.GetDocumentfromgenerated.ParseGetDocumentResponse— exactly the two callsGetDocumentWithResponsemakes — and normalizes the decode step only, so the origin is known by construction. Everything preflight returns verbatim anderrors.Iskeeps working. Both are public generated methods and the hook envelope is unchanged, so SPEC §18 rule 1 still holds. The service-drift gate only recognized.gen.*WithResponse, so2e8c29fbfteaches it the two-stage form — verified still failing when a wire call really is missing:A whitespace-only
titlewas accepted. Rails'presencedelegates toblank?, so BC3 can no more render" "than"". All six now trim before the check.The same auth-phase defect in Kotlin and Swift — split out as Isolate the response decoder from the auth/transport phase in Kotlin and Swift BaseService #604, not fixed here. Their
AuthStrategyruns inside the request path, so a custom strategy throwingSerializationException/DecodingErrorwhile loading credentials is caught by the composite's decodecatch. Go could be fixed locally becauseGetDocumentWithResponseis literally its two halves; Kotlin and Swift have no seam in the composite — the decode happens insideBaseService.request(info, block, parse), one level down and shared by all 243 operations. Wrappingparsethere is the right fix, is a strict improvement for every service, and would let Todos, Todolists, Cards and Documents all drop their per-composite catches. That is a cross-cutting change to both SDKs' decode contract and belongs on its own. Residual: a mislabelled message in a scenario needing a custom auth strategy that throws a serialization error; no PUT is issued either way.CodeQL
java/class-name-matches-super-class— declined. It is the deliberate architecture of every extensible Kotlin service: the accessor must resolve to a subclass of the same name so callers need no import (SPEC §18 rule 5). Four identical alerts are already open onmainforTodolistsService,UploadsService,TodosServiceandCardsService; this is the fifth instance of an accepted pattern, and if we want it gone it should be one repo-level decision covering all five.SPEC
Replace*when the server clears what the body omits — including replace-with-declared-carve-outs, because a carve-out is one named field excluded from the swap while a merge is the server preserving everything omitted;Update*when it merges (Messages) or is hybrid (Cards, Cards.update silently erases due_on on every sparse update (BC3 forced-replace) #467). It also names the two operations still carrying the debt:UpdateTodolistOrGroup(method renamed viaMETHOD_NAME_OVERRIDES, operationId not) andUpdateScheduleEntry(neither renamed nor composited yet, ScheduleEntry write contract: preservedOnOmission trait extension + bc3 url/highlighted guard-and-emit #546/ScheduleEntries triad: ReplaceScheduleEntry + carve-out-aware composites #547).METHOD_NAME_OVERRIDESentry — the wire rename makesreplacefall out of the ordinary naming algorithm.Conformance
conformance/tests/documents_write.json—update-merge,edit-clear,replace-omission-clears, dispatched in all six runners (Go / Python / Ruby / TypeScript switches, KotlinMain.kt, SwiftDispatch.swift). A fixture whose operation has no dispatch case silently skips, so each runner'sPASS:lines were read back individually rather than trusting the totals.Follow-ups filed
BaseService, the one place the seam exists. Note the samecatch-around-the-whole-getalready ships onmainin Swift'sTodolistsServiceExtensions.swift(fetchTodolist, Stop the sparse todolist PUT from erasing the description #574), so Documents matched the shipped precedent rather than diverging from it — Isolate the response decoder from the auth/transport phase in Kotlin and Swift BaseService #604 covers all three call sites in one pass, before ScheduleEntries triad: ReplaceScheduleEntry + carve-out-aware composites #547 copies the shape a fourth time. (Kotlin and Swift composites mislabel an auth-phase decoder failure as a malformed response body #603 was a same-minute duplicate from a parallel agent and is closed in its favour.)errorRaisedassertion is onmain(see above).