Refuse a malformed GET field instead of writing it back (#576) - #597
Conversation
|
Kotlin follow-up from the shared kill fixtures filed as #598 — |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d60369fe5c
ℹ️ 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.
Pull request overview
This PR closes #576 by hardening the merge-safe Todos.update/edit and Cards.update composites in the three SDKs that lack a runtime decoder (Python, Ruby, TypeScript). These composites GET a record, read each writable field, and PUT the full representation back — so any value read is a value written, on a call that may never have mentioned the field. Previously a malformed field read off the wire was either coalesced away (erasure) or forwarded verbatim (corruption). The fix validates each fetched field and raises a statusless, non-retryable api_error before the PUT, naming the offending field. It also adds a code-agnostic errorRaised conformance assertion and shared "kill" fixtures so the defect class is caught once, across all six runners, rather than per-language.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
- New per-language shared guard helpers (
_merge_safe.py,merge_safe.rb,merge-safe.ts) providingrequire*/writable_string/writable_id_listthat reject non-object bodies, non-string writable fields, and malformed ID lists. - Todos/Cards composites in Python, Ruby, and TypeScript rewired to read fields through the guards instead of
or ""/|| ""/?? ""/if (current.due_on). - New code-agnostic
errorRaisedassertion added toconformance/schema.jsonand all six conformance runners, plus four shared kill fixtures acrosstodos_write.json/cards_write.json.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| python/src/basecamp/services/_merge_safe.py | New shared guard helpers raising ApiError before the PUT. |
| python/src/basecamp/services/todos.py | Todos composite reads fields via guards. |
| python/src/basecamp/services/cards.py | _resolve_due_on validates the fetched due_on. |
| ruby/lib/basecamp/services/merge_safe.rb | New shared guard module (Zeitwerk-autoloaded). |
| ruby/lib/basecamp/services/todos_extensions.rb | Todos composite uses MergeSafe. |
| ruby/lib/basecamp/services/cards_extensions.rb | Cards current_due_on validates the fetched date. |
| typescript/src/services/merge-safe.ts | New shared guard helpers returning BasecampError (api_error). |
| typescript/src/services/todos-extensions.ts | Todos composite uses guards. |
| typescript/src/services/cards-extensions.ts | Cards composite validates fetched due_on. |
| conformance/schema.json | Adds errorRaised to the assertion enum. |
| conformance/runner/{go,python,ruby,swift,typescript}, kotlin/conformance/.../Main.kt | Implement errorRaised; Kotlin/Swift treat decoder rejection as the point of the case. |
| conformance/tests/{todos_write,cards_write}.json | Four kill fixtures asserting refusal before the PUT (requestCount: 1). |
| {python,ruby,typescript} test files | Comprehensive erasure + corruption coverage for all writable fields, ID lists, and non-object bodies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…d 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.
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
|
Review round pushed as 1. The shared kill fixture had a hole in TypeScript. The two Cards cases ( 2. The 3. Disclosed a behaviour change beyond pure containment. Ruby 4. Corrected the write-up. The Ruby red proof's exit code is 2, not 1 — Full suite re-run at this head, every command with |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a9c1814cae
ℹ️ 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: ec7cec5c0e
ℹ️ 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: cb8cd466d9
ℹ️ 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".
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).
…d 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.
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.
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.
…ck 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'
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)
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.
…he 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.
#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.
89b0f14 to
f3377f8
Compare
|
Ready for merge at Review state. All 8 threads resolved, each with a posted, argued reply rather than a silent resolve. Every review body was audited for a
Copilot could not review this revision. It has returned "Copilot encountered an error and was unable to review this pull request" four times, including once after an explicit re-request. Its last successful pass was at CI is green at this head: 42 success, 2 neutral (CodeQL, cubic), 2 skipped, 0 failures. Independently re-verified locally at this base, each with One item is a behaviour change beyond pure containment and is called out in the description rather than folded into the containment story: Ruby |
|
Rebased onto
The entire remaining SPEC.md footprint is one line: the Gates, run with the SHA pinned before and after so the result provably applies to that exact tree — a precaution worth taking here because several agents are working this repo concurrently and a symbolic Review state: 8 threads, 0 unresolved. The last one — the One red check, and it is not a finding. Checks otherwise: 42 success, 0 pending, 0 other failures. |
* Kotlin: stop coercing a wrong-typed scalar into a String (#598) The client-wide Json carried `isLenient = true`, which relaxes RFC-4627 far enough to read a JSON number or boolean into a declared String. `"description": 42` decoded to "42", and the merge-safe composites then PUT that fabricated value back to a full-replace endpoint on a call that never mentioned the field. The coercion happened inside the decoder, so the per-composite guard that fixed Python, Ruby and TypeScript in #597 was structurally incapable of catching it: by the time the composite ran, the value was an ordinary String. `isLenient` is the culprit and `coerceInputValues` is not. The two are not interchangeable and the release plan blamed the wrong one, so removing it would have shipped a no-op as a fix. `coerceInputValues` rewrites an explicit null to the declared default for a non-nullable property and has nothing to say about a scalar type; it stays. DecoderStrictnessTest pins both behaviours in nine tests so the attribution lives in the tree rather than in anyone's recollection. The evidence that the fix landed is a tripwire flipping. TodolistsServiceTest carried `updateCoercesABareScalarDescription`, whose own doc comment said it recorded the coerced PUT "so closing #576 flips it visibly". It is now `updateRefusesABareScalarDescriptionBeforeWriting` and asserts the refusal — no PUT, a statusless non-retryable BasecampException.Api with a hint — for both a number and a boolean. Todolists is a third affected service beyond the Todos and Cards that #576 and #598 named; Documents and Schedules share the shape. conformance/tests/todos_write.json gains a bare-scalar kill case. It needs no per-language skip: with the decoder fixed, all six SDKs refuse a non-string scalar — Go, Kotlin and Swift in their model decoders, TypeScript, Python and Ruby in the hand-written writableString guards — so the case that had no home before is now a six-runner regression guard. Restoring `isLenient` locally turns it red (`Expected the call to fail, but it succeeded`, make conformance-kotlin exit 2), which is what makes it a kill case rather than decoration. The rest is prose the fix falsified: comments in DocumentsService, SchedulesService and TodolistsService and their tests described the scalar hole as an open cross-service gap. * Pin the decoder the SDK actually configures, and cover Cards DecoderStrictnessTest built its own three Json instances and never touched BasecampClient, so it pinned kotlinx.serialization's flag semantics and nothing about this SDK's configuration. Restoring main's BasecampClient.kt — isLenient and all — and running the whole file was green. The comment claiming it "cannot drift from the semantics" was writing a cheque the test did not cover. `json` is internal and commonTest is the associated compilation, so add a case that decodes through the instance BasecampClient builds. Against main's client it fails and it is the only failure in the file; against this branch's the file is green. Narrow the negative assertions from Exception to SerializationException while here — every one of them throws it. Cards had no wrong-typed-due_on coverage. #598 filed it as a read-modify- write hazard, but #647 had already deleted the preservation GET and the three Cards kill cases along with it, so there is no read-back left to refuse before a PUT. What survives is the response decode: Card.dueOn is String?, and under isLenient a bare-scalar due_on off the wire decoded to "42"/"false" and reached the caller as an ordinary String. Cover that, which is the shape the shared decoder still governs.
Closes #576.
The shipped Todos and Cards merge-safe composites in Python, Ruby and TypeScript GET a record, read each writable field, 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. Testing only for the first is what produced #576's original wrong verdict on TypeScript.
Red proof — what
mainactually puts on the wireProbed against the unfixed composites, one call each:
update(content: "New title")andupdate(title: "Renamed"). Nothing in these calls mentions the corrupted field.Python Todos (
update(content=…)with a malformed GETdescription)Python Todos — the ID lists, resent in full as the complete assignee set:
Python Cards (
update(title=…)with a malformed GETdue_on)Ruby Todos / Ruby Cards
TypeScript Todos / TypeScript Cards
One correction to the issue's TypeScript Cards table: the truthy shapes do not reach the wire on
main— the generatedupdateVerbatim's YYYY-MM-DD check intercepts them first (misclassified as a callervalidationerror, but no PUT). The falsey shapes are the ones that do real damage, and they are the worst case in the issue:if (current.due_on)drops them, and an omitteddue_onis precisely how BC3 erases a card's due date.Red proof — the tests
Every new test was run against the unfixed composites (
git show HEAD:<path>swap, no stash) before the fix landed:python/tests/services/{test_todos,test_cards}.pyrubyfull suite (make rb-test)typescript/tests/services/{todos,cards}.test.tsExit codes are the ones those exact commands produce: a failing
make <target>exits 2 (make's own code for a failed recipe) while the bare runner underneath exits 1 —make: *** [rb-test] Error 1on the line aboveREAL_EXIT=2. An earlier revision of this write-up reported 1 for the Ruby row; the counts were right, the number was not.Representative messages, all from the unfixed code:
The fix
Absent or explicit null is genuinely empty; an actual string passes verbatim; anything else raises before the PUT, naming the field. The ID-list fields get the analogous check — an array, of objects, each carrying an integer
id. One level up, the response body itself must be an object: onmaina scalar or null body produced a rawTypeError/AttributeErrorrather than the documented statuslessapi_error.Classification is
api_error, notusage: the value arrived in a successful API response, so nothing the caller passed is at fault. Non-retryable — re-requesting cannot repair a malformed body.The rule underneath, from #576: 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'sschema.d.tsis erased at build time and the generated Python and Ruby services return an untypeddict/Hash, so those three do it by hand — once per language in a shared helper (_merge_safe.py,merge_safe.rb,merge-safe.ts) rather than six copies.Those three helper files are no longer part of this diff. #601 (the Documents triad) landed byte-identical copies of all three while this PR was in review, so rebasing onto
maincollapsed them to a zero diff — confirmed by matching SHA-256 on each before the rebase, and by their absence fromgit diff --name-only origin/main...HEADafter it. They aremain's files now, unchanged. What remains here is the Todos and Cards consumers plus the conformance work.Todolists keeps its own copy from #574. It is deliberately untouched here: #544 owns those files, and #578's generated validating layer is the intended end state for all of them.
Shared kill fixtures, and the fourth language they found
Per #576, kill coverage belongs in the shared conformance fixtures, not per language — the defect survived five consecutive review passes precisely because each pass fixed one instance. Five cases across
todos_write.jsonandcards_write.jsonasserterrorRaised+requestCount: 1: the guard must fire before the PUT, because a guard that fires after has already lost the field. A second mock response is queued in each so a runner cannot pass by exhausting the queue instead of refusing the field.errorRaisedis a new assertion type, the code-agnostic inverse ofnoError. The six SDKs refuse the same body by two different mechanisms — a hand-written guard versus a model decoder — and those share no canonical error code, soerrorTypewould make the fixture unwritable. 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 (their #555 policy otherwise fails it).Writing the fixture immediately earned its keep: it found a fourth affected language. #576 lists Kotlin Todos and Cards as structurally safe on the grounds that "kotlinx.serialization rejects a wrong-typed field at decode". That is only true for structural mismatches. Kotlin's client-wide
Json { isLenient = true; coerceInputValues = true }(BasecampClient.kt:156) coerces a JSON scalar into aStringfield, so the composite decodes it and writes it back. Proven on the wire by temporarily flipping the kill fixtures torequestCount: 2with arequestBodypin, all four PASSING:i.e.
GET "description": 42→PUT "description": "42", andGET "due_on": false→PUT "due_on": "false".This cannot be fixed by this PR's pattern: the coercion happens at decode, so by the time the composite runs it only ever sees a
String. The shipped fixtures therefore use array/object shapes, which kotlinx.serialization does reject, and the scalar hole is filed separately as a follow-up. The fixture descriptions say so, in the fixtures, so the limit of what they prove travels with them.The fifth case, and the TypeScript Cards hole it closes
The first two Cards kill cases (
["x"],{}) do not discriminate in TypeScript. The generatedupdateVerbatimguards the date with/^\d{4}-\d{2}-\d{2}$/.test(req.dueOn), andRegExp.testcoerces its argument to a string first:String(["x"])is"x"andString({})is"[object Object]", so both are rejected before the PUT with or without this PR's guard. Measured, not reasoned — against the unfixed composites TypeScript conformance failed 2 cases, not 4, and both were Todos. TypeScript Cards had no regression protection from the shared fixture at all.A fifth case fixes that.
["2024-02-01"]is the shape the format check is blind to:String(["2024-02-01"])is exactly"2024-02-01", so the regex matches and the array rides through untouched. With the case temporarily flipped torequestCount: 2+requestMethod PUT+ arequestBodypin — the same probe used for Kotlin above — against the unfixed TypeScript composite:That is
mainissuing a real second request whose body carries"due_on": ["2024-02-01"]— a JSON array written onto a field the API stores as a date, on a call that only renamed the card.Restored to its shipped
errorRaised+requestCount: 1form, it is red against the unfixed composites in all three hand-written-guard languages. TypeScript (make conformance-typescript,REAL_EXIT=2):Python and Ruby (
make conformance-python/make conformance-ruby, bothREAL_EXIT=2) print the same two lines, and name the PUT that the TypeScript runner stops short of reporting becauseexpectthrows on the first failing assertion:So the shared fixture is now non-vacuous in every language it can be: TypeScript fails 3 cases against the unfixed composites rather than 2, Python and Ruby 5 rather than 4.
It also keeps the property that makes a shared fixture worth writing. Go, Kotlin and Swift decode
due_oninto aString, andjson.Unmarshal, kotlinx.serialization andCodablereject a JSON array there structurally, exactly as they reject["x"]. The date-shaped element is invisible to a decoder — it is a string inside an array, and the array is the mismatch. A scalar still cannot be used, for the Kotlin coercion reason above.The
errorRaisedhandler is unit-tested in all six runnerserrorRaised's failing branch is unreachable fromconformance/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. That is precisely the #563 shape — adelayBetweenRequestscheck that passed vacuously because no fixture supplied a gap it could fail on.So the predicate is split out per runner —
error_raised.go,error_raised_failureinrunner.py,ErrorRaised.checkinrunner.rb,error-raised.ts,ErrorRaised.kt,ErrorRaised.swift— and unit-tested on both directions, next to the existingdelayGapstests. Go additionally asserts the wiring, thatcheckAssertionroutes the type to that branch: a typo'd case label would fall through to the default and assert nothing.Non-vacuity proved by mutation rather than by inspection. Each handler was temporarily changed to return the pass value unconditionally, and every runner's unit test went red:
go test ./...--- FAIL: TestErrorRaisedFailure,--- FAIL: TestCheckAssertionRoutesErrorRaised— exit 1uv run python -m pytest -q test_error_raised.py1 failed, 1 passed— exit 1bundle exec ruby error_raised_test.rb2 runs, 2 assertions, 1 failures— exit 1./gradlew --quiet :conformance:test12 tests completed, 1 failed— exit 1swift testErrorRaisedTestsfailed — exit 1npx vitest run error-raised.test.tsTests 1 failed | 1 passed (2)— exit 1Verbatim, for the three that had previously only been checked by inspection:
The message is pinned verbatim in all six, so a fixture debugged in one language does not read differently in another. Go, Python, Ruby, Kotlin and Swift run under
make conformance-runner-tests(and in CI, which this PR extends to cover the two new Ruby and Python files); the TypeScript case runs undermake conformance-typescript, wheredelay-gaps.test.tsalready lives.One behaviour change beyond pure containment
Ruby
Cards.update, in the case where the server returnsdue_on: "".get(card_id:)["due_on"]handed""straight toupdate_verbatim, andcompact_paramsiskwargs.compact, which strips onlynil— so the PUT went out carryingdue_on: "".writable_stringpasses the""through (it is a String, so the guard does not fire),current_due_onthen normalises""tonil, andcompact_paramsstrips it — so the PUT omitsdue_on.An omitted
due_onis exactly how BC3 clears the date ({ due_on: nil }.merge(card_params)), so this changes what goes on the wire, not merely what gets validated. It is called out here rather than left inside the containment story. Three reasons it is nonetheless the right change:... or Nonecollapses""toNoneand the generated_compactstrips it. Ruby was the outlier, before and after.due_on: ""is not a value BC3 accepts. The composite's own comment on the sibling branch already says sending"""risks a date-format error", which is why an explicit caller-requested clear is encoded as an omission. Thenilbranch now agrees with the""branch instead of contradicting it.due_onleaves it with no due date. The bytes change; the card does not.The trigger is narrow — BC3 returns
null, not"", for a card with no due date — so this is an edge-shaped-response path, which is the path this PR is about.Verification
Re-run in full after the rebase onto
mainatdc5f17ee6— #592, #601, #590, #605 and three dependency bumps all landed under this branch, so every number below is measured at the current head, not carried forward. Every conformance total is unchanged by the dependency bumps. Every command was run withREAL_EXIT=$?written into a log and grepped back; a failingmake <target>exits 2, the bare runner underneath exits 1.make py-checkno issues found in 38 source files; ruff clean — exit 0make rb-checkmake ts-checkmake kt-checkBUILD SUCCESSFUL— exit 0. Locally this was a GradleallTests UP-TO-DATEhit, so the load-bearing evidence is CI's Kotlin Tests job at this head, which builds cold.make conformance-kotlindid execute (155/0/1).make swift-checkExecuted 333 tests, with 0 failures(genuinely ran, not the macOS SKIP line) — exit 0make conformance-gomake conformance-pythonmake conformance-rubymake conformance-typescriptmake conformance-kotlinmake conformance-swiftmake conformance-runner-testsErrorRaisedTests— exit 0make conformance-fixtures-checkerrorRaisedfixtures have a body-pinning control sibling; gate self-test 25/25 — exit 0The conformance totals are higher than the previous revision because the rebase brought in #601's Documents fixtures, not because of anything here.
All five kill cases were confirmed to actually run in every runner — a fixture whose operation has no dispatch case silently skips, which would look identical to a pass in the totals. Verbatim
PASS:lines in Go, Python, Ruby, Kotlin and Swift, and a--reporter=verbosere-run for TypeScript, which does not name passing cases by default:The Go runner-unit result above is from a
-count=1re-run, becausego testhad reported a cachedokafter the mutation probe restored the file.Review follow-ups
Codex P2,
Assertions.swift— "mark an enforced HTTPS crash as a dispatch failure". Real, and fixed. The.enforcedbranch recordedcaughtErrorbut never setdispatchFailed, so anerrorRaisedfixture with anhttp://configOverrides.baseUrlwould have read a trapped child process as a call that succeeded. Fixed at both ends rather than one:Runner.swiftnow setsdispatchFailed = truein that branch (the child died as required, so the dispatch did fail — by a trap rather than a throw), and the assertion readsdispatchFailed || caughtError != nil, so the union holds by construction instead of by call-site discipline. Kotlin'sMain.kttakes the same union for the same reason.Codex P2,
Runner.swift— "require the intended decoding error". Also real, also fixed, by a different route than proposed. DeclaringerrorRaisedswitches the #555 stop-on-mismatch policy off wholesale in the decoder-backed runners: Swift'sDecodingErrorbranch and Kotlin'sMissingFieldException/SerializationExceptionbranches accept any decode failure. So if a model gains a required field, or an unrelated field in one of these 27-key bodies drifts, the decode fails for an unrelated reason whileerrorRaised+requestCount: 1+requestMethod+requestPathall still hold — the case passes and has stopped testing the field it names. Nothing else covered it:conformance-fixtures-checkvalidates fixture format, not that mock bodies still decode into the generated models.The proposed fix (pin the expected field per decode path) is structured and cheap in Swift, but in Kotlin it means matching
SerializationException's message prose forat path: $.due_on— the type-mismatch case has no structured field accessor — and a cross-language invariant encoded as a string match against one serializer's error text is a worse thing to own than the hole.What the investigation turned up instead: the protection already exists structurally and only needed enforcing. 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 precisely that drift — Cards kill cases pair withupdate-preserves-due-on(differing only indue_on), Todos withupdate-merge(differing only indescription). But nothing enforced the coupling: edit one body and not the other and it silently breaks, which is the real failure mode. Soconformance/check_kill_case_controls.pynow asserts the invariant — for every case declaringerrorRaised, 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 — and that one differing field is the field under test. It runs insidemake conformance-fixtures-check, which CI already invokes, so no new job. Enforcing the claim rather than asserting it in a comment is #576's own lesson applied to #576's fixtures.Non-vacuous, not assumed. Perturbing a kill body in a second field fails it:
A follow-up Codex P2 then found the gate had the same vacuity one level up: it matched a kill body against any queued response of any control. Each kill case queues two object-shaped responses — response 0 is the GET whose decoder rejection is under test, response 1 is a decoy queued so a runner cannot pass by exhausting the queue — and the decoy is never consumed. Reachable, not theoretical; the case that separates the two versions drifts the consumed body by a second field while making the decoy differ from its control by exactly one:
The gate is now restricted on both sides — the first response only, and a control exercising the same operation — and on the shipped fixtures it names each pairing, at
REAL_EXIT=0:Codex P2,
conformance/schema.json— "move$commentout of the properties map". Correct, and the interesting half is why it shipped. TheerrorRaisedannotation sat insideproperties.assertions.items.properties, declaring a property literally named$commentwhose schema was a string; Draft 2020-12 requires every value underpropertiesto be a schema object or boolean, soschema.jsonwas not itself a valid schema — andtests.schema.json$refs it, so a validator that meta-validates would reject the conformance schema before reading a single fixture.The gap underneath is that
conformance-fixtures-checkvalidated fixtures against the schema and never validated the schema itself, so an invalid schema sailed through a green gate. Same shape as everything else here, so it got the same treatment: the target now runs--check-metaschemaoverschema.jsonandtests.schema.jsonfirst. Red proof through the make target rather than the bare validator — putting the annotation back insidepropertiesgivesREAL_EXIT=2and'...' is not of type 'object', 'boolean'.evaluateAssertions(dispatchFailed:)lost its default. It wasdispatchFailed: Bool = false. There is exactly one call site and it does pass the argument, so nothing was broken — but a defaulted parameter on a new assertion path is an invitation, and this default fails closed: a future call site that omitted it would report "the call succeeded" on a call that did not, turning everyerrorRaisedfixture red for a reason nowhere near the actual bug. The parameter is now required, so omitting it is a compile error.Codex P2,
check_kill_case_controls.py— "reject 204 responses before accepting kill cases". Correct, and it is this PR's own false-green shape one layer further down. Both sides of the gate accepted any 2xx, so a kill case answering 204 passed: TypeScript returnsundefinedfor a 204 (base.ts), Kotlin returnsUnitwithout callingparse(BaseService.kt), Go rewrites the body to JSONnull(client.go). The malformed field is never decoded; the composite fails because the record came back absent; anderrorRaised+requestCount: 1+requestMethod+requestPathare all satisfied by that unrelated failure while the control, still a 200, stays green. Strictly worse than the 500 case above it — a 500 at least fails the call, soerrorRaisedis satisfied by something about the response.Reachable, not merely structural. The shipped Todos kill case and its shipped control, with only the kill's first response changed 200 -> 204, against the gate at
c348acf7b:Fixed as an allowlist,
{200, 201}, rather than a 204 exclusion — the review offered both, and excluding 204 by name only closes the status somebody happened to think of. Two constraints meet there: Go's success arm is exactly{200, 201, 204}(case http.StatusOK, http.StatusCreated, http.StatusNoContent), so 202, 203, 205 and 206 are never decoded there at all; and 204/205 forbid a body outright. A 202 is now rejected too, with a message that names why.not_a_successbecomesnot_decoded, because a 204 does not fail the call — it bypasses the decode, and the old name asserted the wrong property.The gate itself is now self-tested
This was the fifth defect found in a gate every one of whose rejections was correct by inspection alone — the standard that let #576 through five review passes. So it gets #576's own treatment.
conformance/test_check_kill_case_controls.pycrafts 25 inputs — 21 claimed rejections (both sides' 204 / 205 / undecoded-2xx / HTTP-error /networkError, the same-operation, key-set and exactly-one-field rules, the first-response-only restriction on both sides, and the entry point's own failure modes) plus 4 positive controls, the real fixture set among them — and drives each through the real entry point, via a new optionalFIXTURE_DIRargument added for exactly that purpose.Non-vacuous by measurement rather than by claim. Reverting only the two new status branches, leaving the renames, the argument and every other rejection intact, turns exactly four cases red and nothing else:
It runs inside
make conformance-fixtures-check, which CI already invokes, so no new job.The doc-constants gate the rebase brought into range
#590 landed
make doc-constants-checkonmainafter this branch was cut, and it holds SPEC.md §19's marked table against theconformance/schema.jsonassertion enum: "defines 22 assertion types, the table documents 21; missing:errorRaised". This branch is what added the 22nd, so §19 now carries a row for it — what the type is for, and what declaring it costs (declaring it switches the stop-on-mismatch policy off for that case, which is why every fixture declaring it needs a control sibling).That one row is the entire SPEC.md diff of this PR, and
spec/doc-constants.jsonis untouched (byte-identical tomain).The same red run also flagged SPEC.md §Documents for restating the current pin, which was #601's line, not this branch's. An earlier revision of this branch carried a fix for it — at the time
mainwas red on it and nothing was open against it. #605 has since fixed it onmain, with a count-2 grant that keeps the prose as written, so that commit is dropped rather than rebased: it would have contradicted a merged decision inside an unrelated PR. If the by-reference rewrite AGENTS.md prefers is still worth making, it belongs in its own PR.Deliberately out of scope
edithands the caller a mutable view and a closure assigning42walks into the same PUT. That is caller misuse of a value the caller chose, not a value silently substituted for one they asked to preserve, and Python, Ruby and TypeScript merge-safe composites forward a non-string GET field into the full-replace PUT #576 is scoped to the GET-field path.Summary by cubic
Refuse malformed GET fields in merge‑safe
TodosandCards(Python, Ruby, TypeScript) and fail before any PUT; strengthen cross‑SDK checks witherrorRaisedfixtures, gates, and runner unit tests.Bug Fixes
api_errorif not.content,description,due_on, etc.): allow absent/null or a string; otherwise raise before PUT.id; otherwise raise before PUT.Cards.update: validatedue_on; reject non‑strings and, in Ruby, omit""instead of sending it.Refactors
errorRaisedin Go, Python, Ruby, TypeScript, Kotlin, and Swift with unit tests; add CI hooks.conformance/check_kill_case_controls.pygate: same operation, first response only, identical key set with exactly one differing field; require decoded 2xx on both kill/control; forbid 204/205 andnetworkError; add a self‑test suite.$commentout of assertionproperties; validate fixtures after.["2024‑02‑01"]).dispatchFailed(no default).errorRaisedin SPEC §19 and update Makefile/workflows to run the new checks.Written for commit f3377f8. Summary will update on new commits.