Loom — Runtime-Semantics Conformance
Status: v1 (2026-07-04). Seeded from the #1620–#1660 wire-parity sweep. The rule registry is machine-readable at
test/conformance/semantics-rules.tsand pinned bytest/conformance/semantics-rules.test.ts. This doc is the human-readable companion — when the two disagree, the registry (and the code it points at) wins.
Why this exists — the gap the structural gate can't see
Loom already has a structural cross-backend contract:
docs/conformance.md diffs every backend's emitted OpenAPI
spec (operations, operationIds, schema names, field names, required sets,
enum value-sets, path-param types, RFC 7807 error shape) and asserts
drop-in-client equality. wire-spec.json (src/system/wire-spec.ts) is its
per-aggregate companion.
That contract is structural, and deliberately casing-tolerant — it compares the shape of the spec, not the values a running backend actually returns. So an entire class of drift sails straight through it:
#1620, in the fixer's own words: *"Two runtime bugs the showcase 5-backend behavioral run surfaced on the vanilla Phoenix backend — the OpenAPI spec-diff was blind to both."*
The evidence that this is the repo's dominant live bug-source: ~40% of the
last 50 commits are fix:, and the majority are Elixir wire-parity chases
(#1620, #1622, #1626, #1628, #1632, #1633, #1636, #1639, #1660 …) — code that
compiled green, passed the structural parity gate, and failed on a runtime
round-trip. Each was found late (nightly docker conformance-full, or by
hand), not per-PR, because the only per-PR behavioral round-trip runs for
Hono/node (test/behavioral/, single-backend by construction).
This document names those runtime guarantees as RS-rules so that:
- Each past fix becomes a named, provenance-linked contract clause, not tribal knowledge buried in a commit.
- The next regression is "RS-N violated," reviewable against a spec, instead of a fresh archaeology dig.
- There is a single target for the enforcement work
(
A6.2— a second backend in the per-PR behavioral tier), so the rules move from documented to gated.
Scope boundary. conformance.md owns the spec (what a client binds
against). This doc owns the runtime values (what a booted backend actually
sends and accepts over that spec). A rule belongs here only if a structural
spec-diff cannot catch its violation.
How a rule is enforced (the tiers)
| Tier | Mechanism | Runs | Catches |
|---|---|---|---|
| T0 static | assert a property of emitted source across all 5 backends (test/conformance/*) |
per-PR, no docker | rules whose violation is visible in generated code |
| T1 behavioral (node) | boot the Hono deployable on PGlite, round-trip (test/behavioral/) |
per-PR, no docker | any runtime rule — but only proves node |
| T2 behavioral (Nth backend) | A6.2 — landed for Python (test/behavioral/run-python.mjs + behavioral-e2e-python.yml): boots the generated FastAPI backend on a services: postgres sidecar and HTTP-dispatches the same emitted api e2e |
per-PR (path-filtered) | the actual cross-backend drift the RS-rules describe (RS-1/4/6/7/8 on Python) |
| T3 full | 5-backend docker round-trip (conformance-full.yml) |
nightly / label | everything — but too slow to be the per-PR net |
The RS-rules below tag each with the lowest tier that can gate it today.
The strategic goal of A6.2 is to pull the T2 column into per-PR range so the
casing/casting/association rules stop landing on main before they're caught.
The rules (v1)
Each rule: the guarantee, the .ddd trigger, the observable wire behavior,
the conforming backends, and the fix that established it.
RS-1 · Wire keys are camelCase, both directions
- Guarantee. Response bodies serialize field keys in camelCase
(
commitSha,startedAt,externalId). Inbound request bodies are accepted in camelCase and normalized to the backend's storage casing before persistence — a multi-word field must never be silently dropped. - Trigger. Any aggregate with a multi-word field (
commitSha: string) on create/update. - Observable.
POST {"commitSha":"…"}persistscommit_shaand reads back{"commitSha":"…"}. A backend that casts snake atoms against verbatim camelCase keys drops the field → spurious422 validate_required. - Conforms. node, dotnet, java, python, elixir.
- Provenance. #1620 (elixir
__normalize_keys/1inbase_changeset), #1632 (nested relational changesets), #1636 (document schemaless changeset). Elixir was the last to conform; the JS/EF/JPA/Pydantic layers were camelCase natively. Tier: T1 (single-word fields hid this on node — needs a multi-word round-trip).
RS-2 · Enum values use declared casing on the wire
- Guarantee. An enum value declared
Passedserializes and casts as"Passed"— never a backend-idiomatic re-casing (passed,PASSED). - Trigger.
enum BuildState = Passed | Failed; a field of that type on a create body. - Observable.
POST {"buildState":"Passed"}→201, reads back"Passed". A backend that snake-cases enum storage values returns422 "is invalid". - Conforms. node, dotnet, java, python, elixir.
- Provenance. #1622 (Ecto.Enum was snake-casing
:passedvs the declared"Passed"). Tier: T0 — gated per-PR bytest/conformance/enum-casing-parity.test.ts(the emitted enum definition fixes the wire value, so no boot is needed).
RS-3 · No persistence-internal columns leak to the wire
- Guarantee. Framework/storage bookkeeping —
inserted_at/updated_at, soft-delete flags, internal jsonb envelopes — never appears in a response unless it is a declared field. The response key-set equalswireShape. - Trigger. Any read of a
softDeletable/auditableaggregate, or any backend whose ORM auto-stamps timestamps. - Observable.
GETreturns exactly thewireShapekeys — noinserted_at, noupdated_at. - Conforms. node, dotnet, java, python, elixir.
- Provenance. §14 sweep, #1628 (
wireShape-driven serializer replacing aMap.from_structleak). Tier: T0 — gated per-PR bytest/conformance/wire-no-leak-parity.test.ts(asserts the framework-timestamp leak signature is absent at each backend's wire-serialization site).
RS-4 · Declared temporal fields round-trip
- Guarantee. A declared
createdAt/temporal field submitted on create is cast and persisted, not dropped or clobbered by an ORM auto-value, and reads back equal. - Trigger. An aggregate declaring
createdAt: instantwith an explicit create. - Observable.
POST {"createdAt":"2026-01-01T00:00:00Z"}reads back the same instant, in the canonical…00Zwire form (trailing zero fractional seconds trimmed) across every backend. - Conforms. node, dotnet, java, python, elixir.
- Provenance. #1626 (cast declared
createdAt+ preload insert associations); RST-9 (canonicalize the .NET instant wire form — trailing zero fractional seconds trimmed to…00Z, matching node/python/java). Tier: T1.
RS-5 · Union-variant absence match is a presence check everywhere
- Guarantee. A union-find variant
matchagainst an absent/nullable subject lowers to a nullable-subject presence ternary (subjectShape:"absence") on all five backends — not a type-tag comparison that only one backend's representation supports. - Trigger.
find one X or absentfeeding amatch { X => … | absent => … }. - Observable. The absent arm is taken when the row is missing, identically across backends.
- Conforms. node, dotnet, java, python, elixir.
- Provenance. #1631 (S2: absence match → nullable-subject ternary +
per-backend
absenceCheckleaf). Tier: T0 — already gated per-PR by the pre-existingtest/conformance/union-find-absence-parity.test.ts, which anchors each backend's absence-check leaf (result == null,is None, …) and the 404 ProblemDetails mapping across all five.
RS-6 · Boolean create defaults materialize at the wire boundary
- Guarantee. A field declared
active: bool = true, omitted on create, arrivestrue— the default is applied at the wire projection, not left to a zero-valuefalse/null. - Trigger.
active: bool = true; a create body omittingactive. - Observable.
POST {}(noactive) reads back{"active":true}. - Conforms. node, dotnet, java, python, elixir. .NET binds the declared
value as a create-record positional default (
bool Active = true), Phoenix as an Ecto schema default plus a changeset__default(:active, true); both are pinned statically bytest/conformance/create-input-default-parity.test.tsand behaviourally by thefield-defaultscorpus case, whose e2e assertsread.activeistrueafter a create that omits it and whose wire golden (test/behavioral/wire-golden/field-defaults.json) is recorded on all seven wire-gated legs with no skip or waiver. - Provenance. July full-code-review finding B14; Java closed by RST-10. Tier:
T1 — gated per-PR on node and python (A6.2) and on Java via the behavioral
tier. The python behavioral gate found and closed a real parity bug here:
the FastAPI create model hardcoded
active: bool = False(the zero value) instead of the declared default; fixed by rendering the field's lowereddefaultexpr in the create request field (routes-builder.ts). Java (RST-10) hit the same class — the Spring create record made the defaulted field a required primitive and 400ed on an omitted key; fixed by boxing the create-DTO component and materializing the declared default in the request→domain mapping (emit/dto.ts+emit/service.ts).
RS-7 · Value-object subfields survive a jsonb round-trip
- Guarantee. A value object stored inline as jsonb rehydrates so that subfield reads and wire serialization neither crash nor drop — the VO reads back with all subfields.
- Trigger.
valueobject Money { amount: decimal, currency: string }as an aggregate field on a jsonb-storing backend. - Observable.
GETreturns the nested{"amount":…,"currency":…}; a subsequent op readingself.price.amountdoes not raise. - Conforms. node, dotnet, java, python, elixir.
- Provenance. #1660 (VO subfield reads + wire serialization crash on jsonb-loaded VOs). Tier: T1.
RS-8 · Associations persist and preload on round-trip
- Guarantee. Nested relational parts and
X id[]ref-collections mutated via an op (lines += …,members += t) persist through the association (preload /put_assoc/ cascade), and read back — no in-memory projection that silently omits the join write. - Trigger. An op mutating a containment or ref-collection.
- Observable. After the op,
GETnests the added child / lists the added id. - Conforms. node, dotnet, java, python, elixir.
- Provenance. §11c, #1626 (preload insert associations), DEBT-03 tail
(vanilla returning-op
put_assoc). Tier: T1.
RS-9 · Error bodies converge on RFC 7807 with the 400/422 split
- Guarantee. Validation failures return
application/problem+json(RFC 7807); a malformed/unparseable body is400, a well-formed body failing a domain invariant is422. The wire shape of the problem body is identical across backends (per-field errors in the same envelope). The guarantee covers the faults the framework raises too — an unknown path, a verb a path does not serve, an unreadable body. Those never reach an emitted arm, which is exactly why they drifted: measured on booted backends,PUTagainst a POST-only route answered404 text/plain(node),405 application/json(python),405with no body (dotnet),500 problem+json(java) and404 application/json(elixir) — five shapes, three statuses, and one backend calling a client's typo a server fault. Each backend now routes framework faults through the same responder its domain arms use. - Trigger. A create violating an invariant, a create with a malformed body, and a request the framework refuses before any handler runs.
- Observable. Same status + same problem-body shape (
type: about:blank,application/problem+json) on every backend, whichever layer refused. - Conforms. node, dotnet, java, python, elixir.
- A value object refused INSIDE a body is the domain floor, with one
errors[]entry (M-T5.1). A value object a body builds (qty := Qty { value: n }in an operation or a workflow step) refuses at its constructor rather than at the request schema, so there is no request member to point at. It answers the domain-floor status and title, the rule's message asdetail, and ONEerrors[]entry{pointer: "", message, code?}—codeonly for a messaged rule. Before this node/.NET/java/python answered the bare domain floor (noerrors[], nocode) and elixir did not check the value at all (the op persists throughforce_change, which runs no validator —resize(0)answered 204 and stored{"value": 0}). Pinned by thevo-invariant-in-bodywire golden; runtime-proven on node and python (booted, golden-matched; each mutation-proved by disabling that backend's answer, which the golden then reports as$.errorsabsent), compile-checked on .NET / java / elixir. A message-LESS rule is compiled but not in the golden: its text is each backend's derived default, and elixir's value-object carrier is Ecto's native chain — the same split the wire layer has for a message-less rule. - A wrong verb is
405everywhere, withAllow. This was briefly recorded here as a known divergence — node and elixir answering404because "hono and phoenix route on (method, path) as one key, with no method-not-allowed concept to hook". That premise was wrong. Both routers expose the lookup:app.router.match(method, path)on hono,Phoenix.Router.route_info/4on phoenix. Neither was unable to tell a method mismatch from a missing path; neither was asked. Both now probe the other verbs on a miss and answer405with anAllowheader, which is the machine-readable half of RFC 9110 §15.5.6 — a caller learns to fix the verb rather than the URL. - Provenance. #1620 (hardened changeset-error renderer), the two-tier
400/422 model dispositioned in
generated-code-review-2026-06-30.md. Tier: T1 (structural envelope is T0 via the spec; the 400-vs-422 routing is runtime). The framework half is gated per-PR twice over:test/conformance/framework-error-contract-parity.test.tspins the SEAM each backend installs, and the M-T9.11 wire golden now carries three framework-fault probes per case — a wrong verb, an unknown path, an unreadable body — issued through each runner's dispatch chokepoint, so the RESPONSE is compared byte-for-byte on five booted backends. The static gate existed because the emitted suites only request what the API serves; the probes remove that limitation rather than work around it.
RS-10 · Rehydration trusts the store — invariants guard transitions only
- Guarantee. Reading a persisted row never re-runs the aggregate's
invariants: reconstituted state was valid when stored, and invariants gate
transitions (
create+ every mutating operation), not loads. Tightening an invariant must not make pre-existing rows unreadable — the fix-it path (load → repair via an operation) stays open. - Trigger. An aggregate with an invariant; a stored row that predates a
tightened invariant is read back (
GET/findAll), then repaired. - Observable. The read returns the row (no 500);
createwith the same state and a mutator leaving the invariant violated both still fail. - Conforms. node, dotnet, java, python, elixir. node and python construct
domain objects on load, so their repositories hydrate through the
non-asserting
_rehydrate(the asserting_createstays the domain-side construction path — in-op part builds use it); .NET/Java materialize via EF/JPA and elixir loads Ecto structs, which never ran invariants on load. - Provenance.
generated-code-ddd-review-2026-07.md§S6. Tier: T0 — gated statically per-PR bytest/conformance/rehydration-trust-parity.test.ts.
RS-11 · A created versioned aggregate reads back at version 1
- Guarantee. A freshly-created
versionedaggregate reads back withversion= 1 — the canonical value, fixed by the capability declarationversion: int token = 1(src/macros/prelude.ts), mirrored toversion INTEGER NOT NULL DEFAULT 1. - Trigger. A
versionedaggregate created viaPOST, then read. - Observable. Every backend now reads back
1. node stampsversion = 1in its versioned save and (since M-T6.63) seeds it in the factory too, so the in-memory aggregate no longer reports0while the row it wrote says1; elixir carries the Ectofield :version, :integer, default: 1; dotnet/java/python originally seeded0— atokenfield is dropped from the create body, so the ORM inserted the persistence-layer zero and the DBDEFAULT 1never fired. The fix seeds the field's= 1IR default in each domaincreatefactory (constructionSeededFields,src/generator/construction-default.ts), which is persistence-agnostic — every create path flows through the factory (EF/Dapper/document, JPA, SQLAlchemyaggregate.version). Java'sversionwas mapped@Versionat the time, which keeps the factory's non-unsaved value; RS-20 later made it a plain column driven by an explicit guarded bump, and a create — matching no row — still carries the factory's1. (A cautionary case: the differential found the divergence, but the oracle came from the spec — the= 1declaration — not the three-backend majority.) - Conforms. node, dotnet, java, python, elixir. (dotnet/java/python fixed +
runtime round-trip verified — created
versionedaggregate reads backversion: 1on a real postgres boot.) - Provenance. M-T9.11 differential (run 30277275068, PR #2220);
src/macros/prelude.tsversioned = 1. Fixed by M-T6.11. Tier: behavioral.
RS-12 · Money wire scale is consistent across backends
- Guarantee. A money-typed field serializes at a FIXED scale of 4
decimal places on every backend's wire — the canonical
NUMERIC(19,4)money storage scale (MONEY_WIRE_SCALE,src/generator/money-scale.ts).12.5,12.50, and12all read back as"12.5000","12.5000","12.0000". - Trigger. A money field on the wire — stored (
subtotal), derived (derived floor: money = money("0.00")→"0.0000"), OR aggregated (a query-time projection'ssum/max/minover a money column, and the zero it reads over an empty table →"0.0000", not"0"). - Observable / decision. Owner decision (2026-07-27, refined 2026-07-28
from a live 5-backend probe): fixed scale 4, not "preserve the submitted
scale." The backends could not agree on a value scale — node's decimal.js
.toString()normalizes trailing zeros ("12.5"), dotnet/java echoed the as-parsed scale ("12.50"), python'sNUMERIC(19,4)quantized stored money to"12.5000"but a DERIVED money still stringified at its literal scale ("0.00") — and node cannot echo a submitted scale at all (decimal.js normalizes at parse time). A fixed scale is the only representable consistent choice, and 4 (the storage scale) is the lossless one. Each backend now formats money to 4 dp at the wire boundary: node.toFixed(4), .NETToString("F4"), JavasetScale(4, HALF_UP).toPlainString(), Pythonquantize(Decimal("1e-4"), ROUND_HALF_UP), ElixirDecimal.round(d, 4)(the__money_round/1wire helper). This subsumes theseqTagderived-money candidate: a money value carries scale 4 in every wire context. - Conforms. node, dotnet, java, python, elixir. (node/python/java/dotnet runtime round-trip verified byte-identical on a real postgres boot — stored and derived money both read back at 4 dp; elixir verified by emission.)
- Provenance. M-T9.11 differential (run 30277275068, PR #2220); owner decision. Fixed by M-T6.11. Tier: behavioral.
- Later gap (#2549). "Every wire context" did not hold on the PROJECTION
path: a projection's SQL aggregate was serialized as it came back, so money
echoed the scale its rows were STORED at (
"40.00") — while the same declared field read through its aggregate's own route sent"40.0000". It reached elixir, java AND .NET; node and python passed only incidentally, because each writes money at 4dp and so read its own scale back. The empty-table zero was a bare"0"on all five, oracle included. Each backend now formats a money aggregate through the same renderer listed above; pinned at runtime by theprojection-aggregation/projection-groupbywire goldens and structurally bytest/generator/projection-aggregate-money-scale.test.ts. - Lineage gap (#3527). A provenanced
moneyfield's lineage (inputs[].value,computedValue) is a wire context too: the owner ruled (2026-10-21) that it carries the same fixed-scale string. TheCodecfact (src/ir/enrich/codec.ts, D-CODEC) records this aslineage: "wire". node conforms; it wrote decimal.js's unscaled"3000.3"until the fact. java and .NET send a JSON number, elixir an unscaled Decimal string, and python 500s on the write. Those four bootcorpus/provenance-moneyas tracked expected failures until each is moved onto the fact. Pinned by theprovenance-moneywire golden.
RS-13 · A create POST returns the id envelope, not the whole aggregate
- Guarantee. A create
POST /api/<plural>answers201with the id envelope{"id": …}— nothing else. That envelope is what every backend's emitted OpenAPI declares for the create response. - Trigger. Any aggregate created via
POST /api/<plural>. - Observable. Every backend returns
{"id":…}. Elixir used to return the FULL aggregate ({"id":…,"owner":"alice","balance":0}), on every create, in every shared system — so a client written against the declared create response could read fields on Elixir it cannot read on the other four. The OpenAPI spec-diff was blind to it by construction: the specs agreed — only the bytes differed. The majority and the oracle happened to coincide, but the oracle was not the vote: Elixir over-returned against its own published contract, so the emitted spec settled it without appeal to the other four. Confirmed at the source — the emittedCreate<Agg>Responseschema declaresproperties: %{ id: … }(openapi-emit.ts) while the controller'screate_result/2answered201withjson(serialize(record)). - Fix. The three Elixir create actions (
api-emit.ts×2 — audited and plain — pluseventsourced-emit.ts) now answer%{"id" => record.id}. The string-keyed map matches the serializer's own"id" => …entry, so the id's wire form is identical on the create and read paths. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found by the M-T9.11 slice-(c) per-PR wire-golden gate on its
first five-backend run (
test/behavioral/wire-golden/{ledger,payments,sales, shapes}.json); fixed in the same PR, so the waiver is gone and the gate enforces the envelope unconditionally. Tier: behavioral.
RS-14 · version increments on every persisted mutation, document shapes included
- Guarantee. A
versionedaggregate reads backversion: 2after one post-create mutation —1at create (RS-11),+1per persisted mutation — regardless ofshape:. - Trigger. A
versionedaggregate withshape: document(jsonb-stored): create, invoke an operation, read back. - Observable. Every backend now reads back
2. Historically each of three dropped the bump on a different shape — documentCart/ embeddedWishlist/ plainOrder(canonically 2 / 2 / 3) read back 1 / 2 / 3 on dotnet+java and 2 / 1 / 1 on elixir — which is exactly why this survived every existing gate: the behavioral tiers assert locally (each backend passes its own emitted asserts), and no test author thought to assertversionafter an operation. Thedapperadapter incremented correctly throughout — same .NET emitters, raw Npgsql with hand-rolled document SQL — which localized the dotnet/java half to the EF/JPA mapping rather than the wire emitters. - Fix. dotnet — the document
SaveAsyncresolves the next version FIRST and stamps it into the serialized snapshot (ToSnapshot() with { Version = … }), sodata.versionand the row column can no longer disagree. java — the document upsert writes the incremented counter back into the blob:jsonb_set(excluded.data, '{version}', to_jsonb(<t>.version + 1)). elixir — the relational/embedded operation persist path bumpsversionthe way the document path already did. (Each is guarded on theversionedcapability; the relational .NET/Java paths needed nothing, since there the bumped column IS the wire value.) - A fourth shape, found later: INHERITED aggregates on java. The rule is
also
inheritanceUsing:-dependent, and java failed it for every subtype of an abstract aggregate —versionfroze at the create factory's1and never moved (paymentscredit card / bank account,tphcar / truck: canonically2, java1). One cause, and it is not Hibernate semantics this time (unlike RS-20): theversiontoken field is declared once, on the base, and the concrete-entity emitter skips inherited fields — so the counter's mapping had to be emitted byrenderAbstractBase, which had no such arm. At the time that mapping was@Version;renderEntity's arm even carried the comment "a TPH/TPC base carries it once", an intent the base builder never implemented, so it was emitted nowhere and the column was a plain@Column. Beyond the wire value this left the whole hierarchy's optimistic-concurrency guard inert — noWHERE version = ?CAS, so a lost update between two writers was silent. Fixed (javaemit/entity.ts); gated structurally for TPH and TPC, with the negative that the concrete must not redeclare it (generator-java-concurrency-conflict.test.ts), and re-verified by bootingpayments/tph/inheritanceagainst Postgres ingradle:9-jdk25. (RS-20 later removed@Versionfrom java altogether — the base now carries the plain column plus the_applyVersionmutator the repository's guarded bump writes through, and the same structural gate covers it.) - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found by the M-T9.11 slice-(c) per-PR wire-golden gate
(
test/behavioral/wire-golden/shapes.jsonseq #3,GET /api/carts/{id}); fixed in the same PR, so the waiver is gone and the gate enforces it unconditionally on all five backends. Note RS-11 covered version at create only — this is the increment path, and it is shape-dependent. The inherited-shape half above was found by the caller census'supdatedrain (#2438) — the first callers ever put onPOST /api/<aggs>/{id}/update, which is the only route that mutates acrudishaggregate without a domain operation, and therefore the only one that reaches an inherited aggregate whose subtypes declare no operations at all. Tier: behavioral.
RS-15 · A domain-floor rejection is 422, not 400
- Guarantee. A request that parses and typechecks but is rejected by the
domain — a false
precondition, a tripped aggregate-levelinvariant, anyDomainError-class throw the wire validator cannot express — answers 422 Unprocessable Entity with an RFC 7807 body titled"Unprocessable Entity", on every backend. 400 is reserved for genuinely malformed input (an unparseable body, a missing multipart field). - Trigger. An
operationwith apreconditioninvoked in a state that fails it; or aninvariantthat no Zod/DataAnnotations/Pydantic refine can mirror, tripped on write. - Why 422 and not 400. RFC 9110 §15.5.21 defines 422 as "the request was
well-formed but was unable to be followed due to semantic errors" — which is
exactly the domain floor. §15.5.1's 400 is "the server cannot or will not
process the request due to something that is perceived to be a client error
(e.g., malformed request syntax)". A precondition failure is not a syntax
problem: the payload parsed, every field typechecked, and the server
understood it completely. Elixir already answered 422 here via a coherent
denial ladder (
when→ 409,requires→ 403,precondition→ 422), and that ladder — not the four-backend majority — turned out to be the right side. RS-11 is the standing reminder that a majority can be the wrong answer; this rule is the second time it paid off. - What this does NOT collapse. 422 is now shared by the wire-validation
tier and the domain floor, but the two stay distinguishable: a wire-tier
rejection carries the §3.2
errors[]per-field extension, the domain floor does not. The status was never the discriminator; the extension is. - Known residual divergence — the
detailwording. node/dotnet/java/python send"Precondition failed: <the predicate source>"; elixir's typed-denial path ({:error, :precondition_failed}) sends a generic"A precondition failed", because the atom carries no message. (Elixir's raise/rescue path already forwards the specific message.) The status, title,typeandinstanceagree on all five; onlydetaildiffers. Tracked separately — the fix threads the predicate source through the denial tuple. Until it lands, the golden's error-envelope coverage stays limited to the fields that do agree. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Opened by the M-T9.11 wire-golden gate while extending its coverage (the four-vs-one status split), decided by the owner in favour of the RFC-idiomatic side, and closed in the same change that flipped the four runtime mappings + their OpenAPI declarations. Tier: behavioral.
RS-16 · The RFC 7807 type member is always present
- Guarantee. Every error response carries all five RFC 7807 members —
type,title,status,detail,instance— withtypenever omitted. Its value depends on the kind of error: a framework problem (the domain floor, wire validation, aggregate-not-found) carries"about:blank"; a declarederrorpayload carries its derived/errors/<kebab-name>URI (errorTypeUriinsrc/util/error-defaults.ts—NotFound→"/errors/not-found"). Both forms must be present and identical on all five. - Trigger. Any error response on any backend serving an api: a tripped
precondition, a wire-validation failure, a framework 404, a declarederrorvariant. - Why absence is still a divergence. RFC 9457 §3.1 says a missing
typeis equivalent toabout:blank, so omitting it is legal. It is still a wire break: a client readingbody.typegets a string on four backends andundefinedon the fifth, and any equality check across backends fails. - The framework trap. Java was the outlier for a framework reason, not an
emitter oversight.
ProblemDetail.forStatus(...)does installabout:blank, but Spring'sProblemDetailJacksonMixinannotatesgetType()@JsonInclude(NON_DEFAULT)— so the default value is dropped during serialization and never reaches the wire. No emitted-string assertion could have caught this: the generated Java looks correct, and only a booted response shows the missing key. The fix writestypethroughsetProperty, which serializes via the mixin's@JsonAnyGetter; the still-suppressedgetType()is exactly why that cannot produce a duplicate key. instanceneeded no help. Spring's message converter fills a nullinstancewith the request URI on the way out, so it already matched.- Conforms. node, dotnet, java, python, elixir.
- Provenance. Found by the M-T9.11 wire-golden gate at
test/behavioral/wire-golden/wire-contract.jsonseq #7, the first moment any golden contained an error body — which RS-15 had just made possible. A rule that existed for months and was invisible until the gate's coverage reached it. Verified on a real booted Spring app, not an emitted-string assertion. Tier: behavioral.
RS-17 · A when state-gate rejection names the operation it refused
- Guarantee. An
operation … when <pred>invoked in a state the predicate refuses answers 409 with title"Disallowed"and the occurrence-specific detailoperation '<op>' is not allowed in the current state of <Agg> (when <pred>).on every backend —<pred>is the predicate's.dddsource, the way the 422 detail names aprecondition. One function builds the sentence for all five (disallowedDetail,src/ir/util/denial-detail.ts). Unlike the 403 detail it is echoed under every verifier: awhengate is a lifecycle rule, not an authorization predicate. - Trigger. The 409 rung of the denial ladder.
- Why the title is the error NAME, not
"Conflict".errorTitle(src/util/error-defaults.ts) derives a 7807 title by humanising the error name, falling back to the status reason phrase only when there is no named error — andDisallowedis a blessed stdlib name inSTDLIB_ERROR_STATUS. The sibling 409 rungs,UniquenessConflictandConcurrencyConflict, are the ones correctly titled"Conflict"; conflating them is exactly the mistake to avoid here (and the one an over-broadnot.toContain('409, "Conflict"')assertion made on the first attempt — it failed four correct backends). - Two independent divergences, and the split was not the one first recorded.
detail— elixir alone sent the fixed sentenceOperation not allowed in the current state, because:disallowedwas a bare atom carrying no message. Same shape and same fix as RS-15: the reason is now a{:disallowed, msg}tuple built at the producer. That is also why the event-sourcedcommand_error/2clause being shared across an aggregate's commands never mattered — it only binds the detail. The first draft of this rule called that the hard part; it wasn't.title— elixir and python sent"Conflict". The first draft recorded a 4-vs-1 split with python on the conforming side; that was inferred from python's (correct) detail and never checked against its title. It is 3-vs-2. Worth recording: on one small rule, the cheap inference was wrong twice.
- Not fixed here. Elixir hardcodes the
409literal where the other four resolve it throughresolveErrorStatus("Disallowed", …), so anhttpStatus Disallowed -> Noverride moves four backends and not the fifth. That is the ladder-routing gap — mission M-T5.20. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-07-30 while extending the M-T9.11 golden set to the
corpus feature cases — by reading a freshly-minted golden (
state-gate), before booting a second backend. No run would have failed: the gate's coverage had not reached this case. The finding came from treating a golden as an answer key to be reviewed rather than a file to be committed. Tier: behavioral.
RS-18 · A provenanced field's lineage rides the wire as <field>_provenance
Guarantee. A GET on an aggregate with a
provenancedfield carries the lineage under the co-located snake_case key<field>_provenance(e.g.total_provenance), never a camelCase<field>Provenance.Trigger. Any read of an aggregate declaring
provenanced(seeprovenance.md).The one deliberate non-camelCase key. Every other wire key is camelCase (
unitPrice,amountDue,createdAt). This one mirrors its backing jsonb column, is the keyprovenance.mddocuments, and — decisively — is what the scaffolded frontend reads: the generated React detail page emitsdata.<field>_provenanceverbatim (scaffold/_body-builders.ts).Why it mattered. Java emitted
totalProvenance— its DTO record component name went straight onto the wire. A 4-vs-1 split in which, unusually, the majority was right. And the consequence was not cosmetic: the camelCase key silently blanked the provenance "?" disclosure on every generated UI pointed at a Java backend. No error anywhere — the frontend simply reads a key the backend never sends. This is the failure mode the differential exists for: both halves compile, both look correct in isolation, and only comparing the actual bytes reveals it.The fix.
@JsonProperty("<field>_provenance")on the record component, so Java keeps an idiomatic component name and the wire key matches. Verified on a real booted Spring app, not an emitted-string assertion — the entire failure mode is a name that looks right in the source.Warning for future sweeps. A "normalise the wire to camelCase" change must treat this key as a deliberate exception, or it will re-break the frontend.
Conforms. node, dotnet, java, python, elixir.
Provenance. Found 2026-07-30 by reading the freshly-minted
provenancegolden during the M-T9.11 coverage expansion — one key out of camelCase in an otherwise camelCase body. Fixed on java, then the rule was briefly recorded as all-five conforming — and it was 3-of-5. The check that cleared dotnet grepped generated text fortotal_provenanceand matched the EF column mapping, not the wire DTO; only booting dotnet (2026-07-31) showed it sendingtotalProvenancetoo. A generated-source grep is not a wire observation — that is the standing lesson, and it is why the loop's boot step is not optional.The same grep misled twice. Elixir stayed in
conformson that same evidence until its first real boot (2026-08-01), which showed vanilla omitting the key entirely — its REST serializer projectswireShape, and the provenance sidecar is not awireShapemember on any backend (node appends it separately, after the shape). So the rule was 3-of-5 twice over, cleared twice by the same non-observation. Fixed by appending the sidecar inwire-serialize.tsexactly as node does. Tier: behavioral.
RS-19 · A declared error variant's fields ride the problem body
- Guarantee. An operation returning
T or <Error>that selects the error variant answers with the RFC 7807 envelope plus the payload's declared fields as extension members:error NotFound { resource: string }puts"resource": "OR1"alongsidetype/title/status/detail. - Trigger.
operation reject(): string or NotFound { return NotFound { resource: code } }— seepayloads.md. - Why it hid. Java emitted the arm's status, title, type and detail and then
dropped the payload entirely, so a client got a 404 with the right shape and
no data. A
toThrow(404)assertion passes on all five backends — the status is right; only the body differs. The emitted OpenAPI for the union already declared the fields, so this was a spec violation too. - A near miss worth noting. Java's sibling arm — the find-absence 404 —
already set
resource. The two arms were written independently, which is whyunion-find-absencepassed whileoperation-returnsfailed on the same release. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-07-30 by the M-T9.11 gate on the newly-minted
operation-returnsgolden (java leg). Fixed in java by projecting the arm's declared fields throughsetProperty. dotnet had the same bug — its arm discarded the variant entirely (case <Union>_<Tag> _:), so it could project nothing — and that only surfaced when the dotnet leg was actually run (2026-07-31), after the rule had already been recorded as all-five conforming. Both verified on booted apps. Tier: behavioral.
RS-20 · version counts persisted mutations, not entity-graph dirtiness
Guarantee.
versionis1at create and+1per persisted mutation, independent of which part of the aggregate graph changed (RS-11 + RS-14) and of whether any column value actually differs. A command that ran and was persisted bumps the counter; that is what "optimistic concurrency token" means here.Trigger. A
versionedaggregate whose mutation touches only a child (a singlecontains), whose create also writes a value-object collection, or whose command re-assigns a value the row already holds.Observable — java diverged in every direction, from one cause.
case canonical java (before) single-containment—shipmutates the contained child21(bump missed)value-collections— create writes a VO collection12(extra bump)saga— an idempotentmarkTracked()re-assign32(bump missed)Java mapped
versionto JPA@Version, and Hibernate bumps that from the dirtiness of the root entity's own state. A change confined to a child or collection doesn't mark the root dirty; neither does re-assigning an unchanged value; a second flush that writes the collection during create does. The other four backends set the counter explicitly at the persist site, so they count commands the way the capability declares.Relationship to RS-14. This is RS-14's family — "the increment is shape-dependent and inverted between backends" — in shapes RS-14's fixture set never reached. RS-14 lists java as conforming; that holds for the shapes it measured (document/embedded) and not for these. This rule names the gap rather than rewriting RS-14's history.
Fix (java).
@Versionis gone from both entity arms (flat and the TPH/TPC abstract base) —versionis a plain@Column— and the counter is driven from the repositorysave, the same place node and elixir drive theirs:@Modifying(flushAutomatically = true, clearAutomatically = false) @Query("update Order e set e.version = e.version + 1 where e.id = :id and e.version = :expected") int bumpVersion(@Param("id") OrderId id, @Param("expected") int expected);var __expectedVersion = aggregate.version(); if (jpa.bumpVersion(aggregate.id(), __expectedVersion) == 1) { aggregate._applyVersion(__expectedVersion + 1); // reflect it onto the live instance } else if (jpa.existsById(aggregate.id())) { throw new ObjectOptimisticLockingFailureException(Order.class, aggregate.id().value()); } var saved = jpa.save(aggregate);One row affected == this command bumped, whatever it touched. Zero rows on a row that exists == another writer moved it inside the load→save window — the same
ObjectOptimisticLockingFailureExceptiontheIf-Matchguard raises, so the 409 semantics are unchanged (the write-time CAS is now this guard rather than Hibernate's). Zero rows with no such row == a create: the factory already seeded1and the insert carries it. The five RS-20 waivers were deleted in the same change.Conforms. node, dotnet, java, python, elixir.
Provenance. Found 2026-07-30 by the M-T9.11 gate on the newly-minted
single-containmentandvalue-collectionsgoldens; the idempotent-command face surfaced later onsaga. Fixed in java by the command-driven guarded bump above. Tier: behavioral.
RS-21 · A union response carries its type discriminator
- Guarantee. An operation returning
T or <Error>that selects a success variant answers 200 with the tagged form —{"type":"string","value":"OR1"}— using the discriminator named by_payload/union-wire.ts, the single source of truth for the tagged-wire shape. A typed client narrows ontype; without it the union is unreadable. - Trigger.
operation accept(): string or NotFound. - Why it hid. dotnet's DTO carried the right attribute the whole time —
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]. But System.Text.Json only writes the discriminator when it serializes through the base type, andOk(object)leavesObjectResult.DeclaredTypenull, so STJ used the runtime type and the tag vanished. The(Union)cast in the emitted source does not survive the boxing. The code reads correct and the wire is not — no static gate can see this; only a booted round-trip can. - Fix. An explicit
ObjectResult { DeclaredType = typeof(<Union>) }. - Then elixir broke the same rule, for the opposite reason. One leg later
(2026-08-01) the vanilla Phoenix backend shipped a bare
"OR1". The pairing is the interesting part: the same guarantee failed once because a framework silently declined to write the tag and once because the emitter never produced it. Vanilla carries a returning op's outcome as a tuple —{:ok, value} | {:error, tag, data}— and only the error arm ever put its tag in the tuple; the controllerjson/2s the success value straight through, so there was no later seam that could have added it. Fixed at the producer, from thevariantTag/variantShapethe IR already carries on thereturnstatement (the same two fields the TS backend reads). - Two shapes remain unimplemented everywhere. The aggregate success
variant —
operation adjust(): Item or NotFoundfalling through, or ending inreturn this— has no conforming oracle. Node's emitted domain method for the fall-through has noreturnat all (the routec.jsonsundefined), and itsreturn thisrenders{ type, ...this }, which spreads the domain class's private_-prefixed fields. Vanilla is deliberately left untagged there rather than guessing at a contract no shipped backend implements. TheConformsline below is therefore scoped to the shapes that have an oracle: scalar, record literal, andnone. - Conforms. node, dotnet, java, python, elixir — for the scalar / record /
nonevariants. The aggregate variant is an open gap on all five. - Provenance. Found 2026-07-31 by the M-T9.11 gate on
operation-returns(dotnet leg); violated again 2026-08-01 on the elixir leg's first real boot. Tier: behavioral.
RS-22 · The RFC 7807 envelope is exactly five members plus declared extensions
- Guarantee. An error body carries
type,title,status,detailandinstance(the request path) — and nothing a framework adds on its own.instanceis never null; notraceId/correlation member rides the body, because trace correlation is deliberately anx-request-idheader so the envelope stays byte-identical across backends. Only a declared error payload's own fields (RS-19) may extend it. - Trigger. Any error response — framework problem or declared
error. - Why it hid. dotnet diverged both ways at once —
instancenull and an extratraceId— because those arms calledControllerBase.Problem(...), which routes throughProblemDetailsFactory: the factory fills neitherinstancenor the content type, and injectstraceIdoff the ambient Activity. The app's own exception filter already hand-built the envelope for exactly this reason; the union arms and the find-absence arm were simply the sites nobody had converted. - Why it is a rule and not just a fix note. "The framework helper adds a member nobody else sends" is invisible to every static gate — the emitted source names none of it. This is the same class as RS-16 (java) and RS-21 (dotnet): a wire shape decided by a framework rather than by the emitter.
- Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-07-31 by the M-T9.11 gate on
operation-returnsandunion-find-absence(dotnet leg). Tier: behavioral.
RS-23 · An absent collection is [] on every persistence adapter
Guarantee. An optional collection (
surcharges: Money[]?) that was never written reads back as[]— on everypersistence:adapter, not just the default one.Trigger. The same
.ddd, the same backend, a differentpersistence:clause:dapper(.NET) ormikroorm(node).Why RS-8 wasn't enough. RS-8 already says a collection is
[], never null — but it was only ever proven on the default adapters, and they get it right by accident of storage topology: EF Core maps the collection to anOwnsManychild table and Drizzle to a join, and an empty child set materializes as an empty list. Both alternative adapters store it as one nullable jsonb column and faithfully round-trip SQL NULL. Two adapters, one class:- dapper — the row→domain hydrate emitted
is null ? (List<T>?)null. - mikroorm — the shared
deserializeFieldoptional arm short-circuited on null before the array arm's?? []could apply.
Fixed on the read in both, not the write, so rows already stored as NULL are repaired rather than only newly-written ones.
- dapper — the row→domain hydrate emitted
The generalisable lesson. A persistence adapter is a wire-visible choice, not an internal one. Any rule proven only on the default adapter is proven on exactly one storage topology — which is why the
dapperandmikroormlegs carry the goldens too, and why "the backend is correct" is not the same claim as "every adapter of that backend is correct".Conforms. node, dotnet, java, python, elixir (all adapters).
Provenance. Found 2026-07-31 by the M-T9.11 gate once the expanded golden set reached the dapper + mikroorm legs. Tier: behavioral.
RS-24 · A plain decimal is a JSON number; only money is a string
- Guarantee. A
decimalfield serializes as a JSON number (9.99,5) — and as the same number every other backend sends: the wire width is an IEEE-754 double (≤17 significant digits), whatever the backend computes in. This is the deliberate counterpart to RS-12, wheremoneyis a fixed-scale string ("19.5000") so no float rounding can touch a monetary amount. The two types differ on the wire, and a backend must not collapse them into one. - Trigger. Any GET returning an aggregate, a nested value object or a
query-time projection row with
a
decimalfield. - Why it hid. Nothing in the vanilla emitter ever chose a string. Jason's
Decimalencoder emits a JSON string, so every%Decimal{}that reached the serializer un-transformed shipped quoted. That is exactly right for money (RS-12 wants the string, and__money_round/1leaves the value aDecimal) and exactly wrong for a plain decimal. The same accident produced the correct answer for one type and the wrong one for the other — which is why reading the emitter, where both types look equally untouched, would never have found it. It was root-caused by runningJason.encode!/1against the real library. - The fix. A
__decimal_num/1helper (Decimal.to_float/1) applied to plain-decimal wire entries — property, derived, anddecimal[]element alike.to_floatreproduces the oracle exactly rather than merely narrowing the gap: node's value is a float64 to begin with. - The narrowing half. "A JSON number" was never the whole rule — it is the
same number. A backend whose domain type is wider than a double has to
narrow at the wire boundary, response direction only:
- .NET (#2563 / #2575): a response
decimalis adouble.System.Decimalcarries ~15 significant digits, so a non-terminatingavgshipped2.33333333333333where the oracle shipped2.3333333333333335. - Java (M-T6.46, amending this rule): the domain type is
BigDecimaland aderiveddivision renders throughMathContext.DECIMAL128, so an un-narrowed response shipped up to 34 significant digits. Java's conformance here was partial until then — the wire was a JSON number, but only the projectionavgarm was double-parity, and only by the provider's accident of typing an average as aDouble; sums, per-row and derived reads all shipped exact digits. - The request direction deliberately stays on the wide type in both:
a
doublerequest field turns an out-of-range 400 into a conversion 500. A client may send more precision than it reads back.
- .NET (#2563 / #2575): a response
- Why the differential could not see the java half. The wire-golden
comparator JSON-parses both bodies before diffing (
test/_helpers/wire-record.ts), which collapses every JSON number to a JS double. Deficient precision changes the parsed double and fails; excess precision parses to the identical double and can never fail.WIRE_WAIVERSis empty, and no golden moved when java was fixed. See the audit's F16 → M-T9.37. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-08-01 by the M-T9.11 gate on the elixir leg
(
value-collections$.lineItems[*].amount); extended to .NET by #2563/#2575; amended 2026-08-24 for java by the numeric-types audit (F9, register #2644) → M-T6.46. Extended 2026-10-11 to the elixir query-time projection select (a joineddecimalshipped"2.5"; wave C3 3d's D19), which now rides the same shared__decimal_num/1—corpus/datetime-wire'svenueFee. Tier: behavioral (the java half is pinned statically bytest/generator/java/java-decimal-wire.test.ts, since the behavioral gate is blind to it).
RS-25 · internal / secret fields never reach the read wire
- Guarantee. A field declared
internal(domain-only state) orsecret(never disclosed) is absent from the response body — not null, absent.forApiReadis the read-boundary projection overwireShape, and every read surface applies it. - Trigger. A GET on an aggregate carrying an
internalfield — for example thetenantId/dataKeythat thetenantOwnedcapability injects (tenancy.md). - Why it mattered. The vanilla REST serializer projected the raw
wireShapeand never appliedforApiRead, so a multi-tenant aggregate shipped its tenant key to every client on every GET, and asecretfield would have leaked the same way. - Why it is more than a stray field. The same backend's OpenAPI emitter
did apply
forApiRead— so the served spec promised a body the running server did not send. Spec and runtime disagreed inside one deployable, and a spec-diff gate compares specs: it can only ever see the half that was already right. That gap is the premise of this whole tier. - The fix.
forApiReadat both vanilla read boundaries — the REST serializer and the returning-op success body. Deliberately not applied toeventsourced-emit.ts'sstructFields, which names the in-memory struct's fields: the domain needs its internal state. - Relation to RS-3.
RS-3 is about framework bookkeeping leaking (
inserted_at, soft-delete flags). This one is about declared fields whose access modifier says they stay behind the boundary. Same symptom, different mechanism — and RS-3's "the response key-set equalswireShape" should be read asforApiRead(wireShape). - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-08-01 by the M-T9.11 gate on the elixir leg
(
tenancy-owned$.tenantId). Tier: behavioral.
RS-26 · An omitted UPDATE field is a client error, never a silent default
- Guarantee. A field omitted from a full-replacement
PUTis rejected as missing required input. It is not silently set to a zero value, and the create default is not re-applied. - Trigger.
active: bool = true; a PUT body omittingactive. - Observable.
PUTwithout"active"is rejected — it does not storeactive=false. - Relation to RS-6. The exact inverse, and the half that eats data. RS-6 says an omitted create bool materializes its declared default. A default is a construction rule, so on update — where nothing is constructed — "absent" cannot mean "the default"; it can only mean a required field is missing.
- Why it mattered. The emitted
UpdateItemRequestcarriedactive: z.coerce.boolean().default(false)against a model declaringactive: bool = true. A PUT omittingactiveflipped a storedtruetofalse— not even the declared default, because the value came from a hardcoded implicit-bool rule rather than the model. The rule was a create-input rule (hasImplicitDefault) that leaked into every request body through one shared helper, so it hit every operation's bool parameter, not justupdate. - Prior art. This is the proto3 lesson: a wire-level default makes "absent" indistinguishable from "the default value", which breaks partial and full-replacement updates alike. proto3 dropped custom field defaults for exactly this reason and had to re-add explicit field presence in 3.15.
- The split. 1-vs-4 with the minority correct — the inverse of RS-15's
shape. Node's
.default(false)was added deliberately to match .NET model-binding and Phoenix, so four backends agreed and the agreement was wrong. - Conforms. node, dotnet, java, python, elixir — and no two backends
needed the same fix, which is the case for numbering the rule rather than
patching one emitter: node scoped
zodForto acreate-bodycontext; python gaverequestFieldDecla slot; elixir's runtime already conformed and only its spec disagreed (@update_requiredlisted every bool while the OpenApiSpex schema did not — RS-25's divergence inverted); java had both halves wrong (primitive record components let Jackson supply0/falsefor an omitted key whileRequiredSetclaimed the field required) and needed boxing +@NotNull; and .NET could not express it with[Required]at all — that attribute tests for null, and an omitted value type binds to0/false, so presence had to become a deserialization question via[property: JsonRequired]. - The coercion is a default. The node fix took two rounds, and the second is
the reusable lesson:
.default(false)was only the visible half.z.coerce.boolean()isBoolean(input)andBoolean(undefined) === false, so the coercion is a wire default — removing the.default(changed nothing. The first version of the static gate keyed on the absence of.default(, so it passed a backend that still had the bug; the divergence surfaced only in the 5-way OpenAPI parity run, becausezod-to-openapiderivesrequired[]fromschema.isOptional()— literally "does it acceptundefined". Body bools are now uncoercedz.boolean()(JSON carries real booleans; only query params, which are strings, keep the coercion), and the gate asks the behavioural question instead of the spelling one. - A gate can read the wrong artifact. The Elixir regression later the same
day is the twin lesson.
isRequiredUpdateInputtested the implicit-boolrule and never reached the explicit default, soactive: bool = truecame back omittable and@update_requiredstopped listing it — while the OpenApiSpex schema still advertised the field as required. Elixir promised what it did not enforce. The gate missed it because its Elixir arm readupdate_item_request.ex(the schema, which documents) and notitem_changeset.ex(validate_required/2, which enforces). Where a backend splits "what the server says" from "what the server checks" across two files, a conformance gate owes an assertion to each; otherwise it certifies the promise, not the behaviour. The predicate is now!isNullable(f)— a default of either kind is a construction rule, so neither relaxes a full-replacement update — and the fixture gained a bareflag: bool, the case that separates the create seam from the update seam. - Provenance. Found 2026-08-01 reconciling where
= defaultbelongs (domain vs wire boundary); node fixed by scoping the implicit-bool rule to acreate-bodycontext inroutes-builder.ts, then by uncoercing body bools. Tier: static —create-input-default-parity.test.tsasserts the rule on all five, and keeps an emptyUPDATE_BOOL_WAIVEDmap as the ratchet so a regression is recorded rather than the assertion relaxed.
RS-28 · An unrecognised error term is a sanitized 500, never a 400 that echoes it
- Guarantee. A fault that matches no declared
errorvariant, no wire-validation failure and no denial rung answers 500 "Internal Server Error" withdetail= the fixed string"internal". Two claims, both wire-visible:- Status. An error the server did not model is a server fault. 4xx tells the caller to fix a request that was never the problem.
- Detail. The term is never rendered into the body. A serialized internal value leaks struct names, module paths, and sometimes the failing value itself, to an unauthenticated caller.
- Modelled faults are unaffected. A declared
errorpayload, a wire-validation failure, and each denial rung (403 / 409 / 422) keep their own status and their occurrence-specificdetail. This rule governs only the arm none of them matched. - Trigger. A hand-written
externhandler returning an unmodelled error, an unexpected fault escaping a workflow'srun, or — the case both of those presuppose away — a fault raised anywhere else in the app, on a system that declares neither. That last one is the reason each backend needs an APP-GLOBAL handler and not only per-route arms:app.onError(hono),DomainExceptionFilter(.NET),ApiExceptionAdvice(java),install_error_handlers(python),<App>Web.FaultHandler(elixir). A rule checked only on the paths a fixture reaches is checked on the paths that were already fine — see M-T6.30, where elixir's arm existed solely inside the workflow/externrespond/2dispatchers, so the most common system shape (CRUD, no workflow) emitted none at all and answered an HTML debug page in dev and the exception's own message asdetailin prod. - Why it hid. Elixir answered
400andinspect/1'd the term straight intodetail. It survived RS-15's 400 → 422 sweep precisely because it is not the domain floor: RS-15 moved the rejections the domain makes, and this is the rejection nobody made. And no system in the shared behavioural corpus reaches this arm — every error those fixtures produce is modelled — so all five M-T9.11 legs were green with the divergence in place. An arm no fixture reaches is exactly the arm that needs a name. - A second, smaller divergence on the same arm — and how it was found. The
fix's own report proposed this rule as all-five-conforming. That list was
inferred. Checking it showed node/.NET/java emit the literal
"internal"while python emits"An unexpected error occurred.". Python has no leak — its string is fixed and reflects nothing — so it isn't the defect the rule was minted for; it simply isn't byte-identical, and byte-identity is the entire premise of the M-T9.11 golden. Listed as a target until python moves. - The habit this rule family keeps failing at. This is the third time an
all-five
conformswas asserted from reading rather than checking (RS-18 twice, RS-19 once). Enumerate the other backends' emitted literal before writing the list, every time. - Conforms. node, dotnet, java, elixir. Targets: python.
- Provenance. Found 2026-07-29 by grepping the vanilla Phoenix denial
protocol's edges after #2300 centralised it (M-T6.24). Python divergence found
2026-08-01 by verifying the proposed
conformslist instead of accepting it. The third trigger above (and elixir's floor for it) landed 2026-08-23 with M-T6.30, gated per-file on a plain-CRUD fixture intest/conformance/internal-fault-parity.test.tsand witnessed on a booted Phoenix app. Tier: static — promote to behavioral once a fixture reaches the arm.
RS-27 · A 404-by-id carries the sentence "<Aggregate> <id> not found"
- Guarantee. When a read addressed by id finds nothing, the RFC 9457
body's
detailis the sentence"<Aggregate> <id> not found"— the aggregate's PascalCase name, the requested id, the words "not found" — on every backend. Not a machine token, not a framework default. - Trigger.
GET /api/<aggs>/{id}, orGET /api/<aggs>/{id}/history, for an id that does not exist. - Scope. By id. The 404 an optional find answers
(
find byCode(...): Order optionwith no match) is a different class and keeps the"not_found"token — node and python already agree there, and the rule deliberately does not touch itsdetail.Correction (2026-08-05). "node and python already agree there" was read, at the time, as everyone agreeing there — and no test drove that path, so nothing checked. The caller census drain wrote the first callers for an
optionfind (corpus/union-find-absence'smaybeFirst) and an optional find (corpus/inheritance'sbyEmail), and java answered an EMPTY body on both:ResponseEntity.notFound().build(), Spring's own bare 404, which never reaches the@RestControllerAdvice. That is not an RS-27detailquestion — it is a straight RS-22 violation (no envelope at all), and it is this rule's own lesson — "don't hand-roll a 404" — at two arms nobody had converted, in the very controller whoseerror-variant branch built a real ProblemDetail three lines away. Fixed by throwingAggregateNotFoundException("not_found")so both arms land in the shared producer (emit/common.ts→JAVA_FIND_ABSENCE_THROW);generator-java-api.test.tspins it, mutation-proven, with a list-find scope guard. Thedetailtoken itself is unchanged and still out of scope.And the same again on .NET, hours later (2026-08-05). The dapper behavioural leg then reported 28 wire divergences, all one bug: both .NET find-absence arms
return NotFound();— ASP.NET's own bare 404, which never reachesDomainExceptionFilterand is rendered byProblemDetailsFactoryinstead. Four wrong members at once, on every declared-find miss across five cases (by_sku,by_reference×2,by_code,by_email,maybe_first):type= the rfc9110 §15.5.5 URI rather thanabout:blank,detail= null rather than the token,instance= null rather than the request path, plus an injectedtraceId. RS-22 names that factory behaviour exactly and still listed dotnet as conforming — because the arms nobody had converted were the arms nobody had CALLED. The controller emitter is shared between the EF and Dapper adapters (its onlyusingDapperbranch is the destroy FK catch — the two controllers are otherwise byte-identical), so the EF leg carried it identically; the dapper leg simply reported first. Fixed the same way (emit/common.ts→dotnetFindAbsenceThrow), pinned inunion-emit.test.tswith the same list-find scope guard, and both legs re-booted to 0 divergences on all five cases.And elixir, the same day, with TWO spellings at once. Its
T?arm sent"<Aggregate> not found"— a sentence that reads like this rule's by-id form but carries no id — and itsT optionarm sent"Not Found"(itsproblem_variant/5helper setsdetail: title). Nine of the elixir leg's eleven wire divergences were those two arms, acrossunion-find-absence,inheritance,provenance,auditedandwire-contract. Both now callProblemDetails.problem_response(conn, 404, "Not Found", "not_found")— the same shared producer the by-id read already used.Four backends, one rule, four separate discoveries — and in each the defect sat beside a correct sibling arm in the same file: java's
error-variant branch built a real ProblemDetail three lines from the empty one; .NET's did the same; elixir's two arms disagreed with each other. That is the shape to look for when adding a route class: not "is the producer right", but "does every arm reach it". Only node and python — the two that never hand-rolled a find 404 — were right from the start, which is the rule restated: the arms that were wrong are exactly the arms that answered locally instead of reaching the producer.And a fifth discovery, at the two read sites nobody had counted (2026-08-11, M-T6.31 / #2520). The four corrections above all concern the aggregate's own routes. Two more by-KEY reads exist — the projection show (
GET /api/projections/<p>/{key}) and the workflow-instance show (…/instances/{id}) — and on those, .NET was stillreturn NotFound();at six arms (projection EF + Dapper, instance ES/state × EF/Dapper) and java stillResponseEntity.notFound().build()at three. Same bug, same "correct sibling arm in the same file" shape, one route class further out; RS-22 listed both backends as conforming the whole time, because no golden reached those routes either. Elixir added a third variant of its own: its instance 404 said"<Wf> instance <id> not found"where node and python said"<Wf> <id> not found". All nine arms now raise the shared carrier and the sentence is byte-identical on all five. Gated statically per SITE (test/conformance/absent-read-envelope-parity.test.ts) and at runtime by an absent-read probe appended to the wire-golden dispatch — the structural cause of the whole five-part story is that the emittedtest e2eDSL has no verb for "read a key that isn't there", so the probe manufactures one from the URLs each tier already requested.And a sixth, at the last by-key read of all (2026-08-23, M-T6.39 / #2645).
GET /files/{key}— the root file-download route over a boundobjectStore— was the one absent-read site outside all five discoveries above, and it was wrong on all five backends at once: node/python/elixir answered{"error":"not found"}as plainapplication/json, dotnet/java answered bodiless. The bodiless pair is the subtler half — neither stays empty on the wire, becauseUseStatusCodePagesand the servlet container fill a bodiless 4xx with the FRAMEWORK-miss problem, whosedetailreadsno route for GET /files/<key>. That sentence is false: the route exists, the OBJECT does not, so a client cannot tell a mistyped URL from a deleted upload. All five now reach their one producer withFile <key> not found— .NET through a new static responder onDomainExceptionFilter, because the route is a MINIMAL API and anIExceptionFilternever sees a throw from one. Same gating shape as the fifth discovery: a per-SITE pin (test/conformance/files-absent-object-envelope-parity.test.ts) plus an absent-FILE probe on the wire-golden dispatch. The reason it survived the 2026-08-11 sweep is the same reason RS-22 listed .NET and java as conforming through all of the above — no golden reached the route, and none could: the routes are emitted only for a system with BOTH aFilefield and anobjectStore, and no corpus fixture had one untilfile-download.ddd.And a seventh — this time on the OTHER side of the scope line (2026-09-21). Everything above concerns a read addressed BY KEY, which is what this rule governs. The sibling class it scopes out — the DECLARED-FIND miss, whose
detailis the bare"not_found"token because a predicate has no id to name — turned out to be split too, and the scope note above understated it by naming only theoptioncarrier. A single-rowfindhas FOUR carriers (: T,: T?,: T option,: T envelope), and node spelled the token"not found"— with a space — on two of them:: Tand: T enveloperefuse the miss in the REPOSITORY (typescript/repository-find-builder.ts), where the space-spelling lived, while: T?and: T optionrefuse it in the ROUTE (routes-builder.ts), which already answered the token. So it was a 4-vs-1 cross-backend split AND an intra-backend one — the same "one service, two answers" shape as node's by-id bypass above, one carrier axis further out. Fixed on node (the other four already answered the token); gated per SITE across all five backends x all four carriers bytest/conformance/find-miss-detail-parity.test.ts, whose last assertion pins the by-id SENTENCE alongside, so the two classes cannot be collapsed into one answer by a later "tidy-up". It survived because no fixture declares a non-optional single-row find AND drives it to a miss:envelope.dddwas authored with atest e2eblock and the block was withdrawn precisely because the golden would have frozen node's spelling as the answer key and reddened the other four legs. - The real rule: don't hand-roll a 404. This was not five backends inventing
five strings. Two agreed out of the box, because on each the message comes
from one shared producer — the repository's
getById(python/repository-builder.ts) or Phoenix'sProblemDetails.not_found_response/3. The three outliers were precisely the three routes that bypassed their own producer, and in every case it was the by-id READ — the one placefindByIdreturnsnulland tempts a local answer, while the writes were already correct:- node probed with
repo.findByIdand raised its ownAggregateNotFoundError("not_found")— no aggregate, no id. The same service'sDELETEroute already answered the sentence, because it loads throughrepo.getById. One service, two answers. - .NET returned
NotFound(), ASP.NET's own bare 404, so it never reachedDomainExceptionFilter'sAggregateNotFoundExceptionarm that every other .NET 404 goes through. That also put this one route outside RS-22 — the factory omitsinstanceand injectstraceId— an unmeasured hole in an existing rule, closed by the same fix. - java returned
ResponseEntity.notFound().build()— Spring's own bare 404, an empty body — because the service read ended.orElse(null)while every java write path loads throughrepository.getById, which throws.
- node probed with
- Why it survived so long. Two independent blind spots, both of which
generalize:
- No caller. Nothing in the repo had ever driven a
GET /<aggs>/{id}- It surfaced from the API-operation caller census
(
test/ir/api-caller-census.test.ts): draining thedestroy/allpins needed a "the row is really gone" assertion, and that assertion was the first request of its kind any wire golden ever recorded.
- It surfaced from the API-operation caller census
(
- Unbaselinable. Even with a test, no golden could have held the field:
the sentence embeds a per-run uuid, and
WIRE_NORMALIZEtemplated only path-shaped strings — sodetaildiffered on every run of every backend. A field that cannot be recorded cannot be gated. Generalizing the rewrite to a uuid embedded anywhere in a string (test/_helpers/wire-record.ts) is what makes this rule enforceable at all.
- No caller. Nothing in the repo had ever driven a
- And a third, about the verification. java's bypass was missed on the
first pass: the emitter survey read the repository (which emits the
sentence) and stopped there. The first parity pin then encoded the same
mistake — a
.java-widetoContainof the message, which the repository satisfies. So "java emits the sentence" was literally true while the route answered"", and the pin stayed green until a booted leg failed. A 404 is a property of the route, so only a route-scoped assertion can pin it; the per-file scoping innot-found-by-id-detail-parity.test.tsexists for that reason, and is mutation-proven per backend. - Conforms. node, dotnet, java, python, elixir. python and elixir needed
no change — and that is now checked at the route, not read: elixir's
show/2and history action callProblemDetails.not_found_response/3directly, python's route callsrepo.get_by_id, which raises. - Provenance. Found 2026-08-04 by the M-T9.11 golden gate on the python leg
of
corpus/core-domain, immediately after the caller-census drain added the first-ever getById-404 caller:$.detail— golden"not_found"vs python"Order <uuid> not found". Fixed on node (src/platform/hono/v4/routes-builder.ts, getById + history) and .NET (src/generator/dotnet/emit/api.ts, throw instead ofNotFound()); java followed on the behavioural-java leg of PR #2429 (emit/service.ts.orElseThrow(...)+emit/api.tsResponseEntity.ok(...), with the five message sites unified behindjavaNotFoundThrowinemit/common.ts). Runtime-verified on node, python and java — all three match the golden byte-for-byte oncore-domain. Tier: behavioral — the wire golden now holds"Order {id} not found", so every behavioral leg gates it per-PR.
RS-29 · The wire-validation rung is Validation failed, distinct from the domain floor
- Guarantee. A 422 raised by wire validation — a malformed body, a
missing required field, a boundary-expressible
invariant— carries title"Validation failed", detail"One or more fields are invalid.", and theerrors[]pointer array. - Why the title is deliberately not the reason phrase. The domain floor
also answers 422 (RS-15), and its
title is
"Unprocessable Entity". Both rungs are 422, so status alone cannot separate them:titlepluserrors[]is the only thing telling a client "your JSON is malformed" from "your request was understood and refused". A backend that titles the validation rung with the status reason phrase collapses the two rungs into one. - Trigger. Any
POSTwith a body the wire schema rejects. - The split. 4-vs-1, python the outlier —
"Unprocessable Entity"/"Request validation failed."against the other four's"Validation failed"/"One or more fields are invalid.". Both halves of the body differed. - Where it sat is the point. Wire validation is the highest-traffic error
path in any API — every malformed request hits it — and it was invisible to
every gate. The M-T9.11 golden cannot see it because only 4 of the 31
goldens record an error body at all — and the single 422 among them
(
wire-contract) is the domain floor, i.e. the other rung.conformance-parityis no help either: it compares declared response shapes, not the values inside them. - How it was found. The M-T9.25 census probe, on its first run:
enumerate every 7807 arm each backend emits, then diff them. That probe exists
because the two bugs before it were both intra-backend — a router that
ignored an override, and
mergeContextsdropping the override maps — a backend disagreeing with itself, which no compare-to-another-backend gate can see. This one turned out to be cross-backend, surfaced by the same sweep. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-08-01; confirmed by generating all five and
reading the emitted arm, not by grepping the emitter. Pinned by
test/conformance/problem-arm-census.test.ts, verified to fail on all three assertions with the fix reverted. Tier: static — promote to behavioral the moment a golden records a 4xx.
Adding a rule
When a cross-backend runtime bug is fixed:
- Add an
RS-Nentry totest/conformance/semantics-rules.ts(id, title,.dddtrigger sketch, conforming backends, provenance PR, lowest gating tier). - Add the human clause here, mirroring the registry.
- If the rule is T0-gateable, add the static assertion in
test/conformance/. If T1/T2, add the round-trip totest/behavioral/corpus.json(and, onceA6.2lands, it gates the Nth backend too). semantics-rules.test.tsfails until the registry entry is well-formed and itsbackendsare a subset of the five — so a rule can't be added as prose only.- Regenerate the committed spec mirror:
UPDATE_SEMANTICS_SPEC=1 npx vitest run test/conformance/semantics-spec-sync.test.tsand committest/conformance/semantics-spec.json—semantics-spec-sync.test.tsgates the drift.
Claim the NUMBER before you build
The registry is append-only with a monotonic counter and no reservation
mechanism, so two agents minting rules in parallel collide every time — and
RS-26 was minted twice on 2026-08-03 by two branches that were both correct.
The id contract ("never renumbered") settles who moves — whichever landed
on main first keeps the number — but the loser then renames across every
citing file, which on that occasion was 23 of them.
So: state the id you are taking in your draft PR title/body before you write the rule, the same way CLAUDE.md has you claim the work itself. Read the open drafts first; if one already claims your number, take the next.
If you do have to renumber, note that applying 26→27, 27→28, 28→29 as a
left-to-right sweep double-bumps anything already advanced during conflict
resolution. Verify with a uniqueness check over the emitted ids rather than by
reading the diff:
grep -o 'id: "RS-[0-9]*"' test/conformance/semantics-rules.ts | sort | uniq -d
Make the fixture able to falsify the rule
A rule is only as good as the data its fixture carries. RS-30 (declared-error
extension members are camelCase) sat undetected because the one golden recording
a declared-error body used error NotFound { resource: string } — and
resource is the same string in snake_case and camelCase. Before trusting a new
gate, ask what its fixture would have to look like for the rule to fail:
| rule about | fixture must carry |
|---|---|
| casing | a multi-word field name |
| defaults | a value that differs from the type's zero value |
| an override being honoured | a non-default override — default emission cannot distinguish "resolved to the default" from "hardcoded" |
| an error arm | the path the rule's own trigger sentence names, not just the fall-through arm |
And verify the gate by reverting the emitter line, not by stashing the
working tree: if the fix is already committed, git stash push -- src/ reverts
nothing and the test passes vacuously.
Roadmap
Claimable follow-up tickets (parallel-agent-ready — RS-9 gating, more backends, the diffable spec artifact, corpus breadth) are enumerated in
plans/runtime-semantics-tier-followups.md.
- v1 (this doc): the registry + well-formedness gate + the T0-tier rules
gated statically across all five backends — RS-2 (
enum-casing-parity), RS-3 (wire-no-leak-parity), and RS-5 (the pre-existingunion-find-absence-parity). (here) Every rule assertable from emitted source is now gated per-PR; the remainder (RS-1/4/6/7/8/9) are behavioral and wait on A6.2. - A6.2 (Python api tier LANDED):
run-python.mjs+behavioral-e2e-python.ymlboot the generated FastAPI backend on aservices: postgressidecar and HTTP-dispatch the emitted api e2e — the T2 column is now per-PR for RS-1/4/6/7/8 on Python. On day one it surfaced a real codegen bug (a cross-aggregate operation-param id type —ProductId— omitted from the emitted FastAPI route imports); that fix has landed (the route import collector now draws candidates from every context aggregate), and the association round-trip (RS-8) is back in the fixture. Next: a second backend (.NET/Java) on the same seam, then the unit tier. - v2 (diffable spec artifact — LANDED): the registry is mirrored to a
committed, diffable JSON spec at
test/conformance/semantics-spec.json(thewire-spec.json/langium-generated"derived file + CI drift gate" precedent) so a contract change surfaces as a reviewable JSON diff. The registry is a global toolchain contract, not a per-generated-system fact, so the mirror lives here — it is not emitted into each system's.loom/bundle. The JSON is derived byserializeSemanticsSpec()insemantics-rules.tsand pinned bysemantics-spec-sync.test.ts; regenerate after editing the registry withUPDATE_SEMANTICS_SPEC=1 npx vitest run test/conformance/semantics-spec-sync.test.tsand commit the result. Still open: wire each RS-rule to a live round-trip assertion in the harness.
RS-30 · A declared error's fields are camelCase extension members on the problem body
- Guarantee. When an operation or find declared
T or SomeErrorreturns the error variant, each field ofSomeErrorreaches the RFC 7807 body as a §3.2 extension member spelled in camelCase — the same casing every other wire key uses. snake_case there is a wire break: a client readingminAmountoff four backends getsundefinedfrom the fifth. - Trigger.
error PriceTooLow { minAmount: int, … }returned from an exception-less operation. Multi-word field names are the trigger — with one-word names the rule is untestable. - The split. 4-vs-1, elixir the outlier:
%{min_amount:, offered_amount:, currency_code:}against the other four'sminAmount/offeredAmount/currencyCode. Elixir built the extension map by rendering the error record through the sharedobjectexpression leaf, which snakes names — correctly, because every other object literal in elixir is a domain-side Ecto map. The fix keys the map off the declared field names and renders only the values through the leaf. - The one casing divergence in the whole sweep. At the six mainstream wire
sites — read DTO, create input, paged carrier, projection read,
workflow-instance read, nested parts and value objects — all five backends
agree, camelCase, in identical
wireShapeorder. That iswireShapedoing exactly what it exists for. This site is the one that doesn't consult it. - Why it was invisible, and the transferable half. The only golden recording
a declared-error body (
operation-returns.json) useserror NotFound { resource: string }— a single-word field, where snake and camel are the same string. A fixture with one-word names cannot test a casing rule, however many backends it runs on. (Secondarily:conformance-paritycompares declared response shapes, and extension members aren't in the declaredProblemDetailscomponent.) - It was also intra-backend, which is the sharper half — elixir's own
emitted OpenApiSpex schema declares
minAmount/offeredAmount/currencyCode, so the spec the app published and the body it sent disagreed with each other inside one generated project. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Found 2026-08-02 by the M-T9.25 casing/absence census sweep;
confirmed by generating all five from a deliberately multi-word error record
and reading the emitted body. Fixed in
src/generator/elixir/vanilla/operation-returns-emit.ts(atom keys, matching the base mapproblem_variant/5merges into, so a field namedtypecannot duplicate in the JSON). Pinned bytest/conformance/error-extension-casing.test.ts, verified to fail two of its three assertions with the fix reverted. Tier: static — wideningunion-find-absence.ddd's error payload to a multi-word field would promote it at no new CI boot cost, and is the highest-yield single golden change available.
RS-31 · A string .length bound counts Unicode code points, not the host's native string length
- Guarantee.
s.lengthon a string — in an invariant, a precondition, or a plain domain read — is a count of Unicode code points."😀X"is 2, not 3: the emoji is one code point and two UTF-16 code units. This is the unit the emitted JSON Schema already publishes asminLength/maxLength, so the bound a backend enforces and the bound it advertises are the same number. - Trigger. Any
len-*bound (code.length >= 3,label.length <= 16,currency.length == 3) fed a value containing an astral character. - The split. 3-vs-1-vs-1 before the fix: JS
s.length, C#s.Lengthand Javas.length()count UTF-16 code units; python'slencounts code points; elixir'sString.length/1counts graphemes. The three code-unit backends accepted a value their own publishedmaxLength/minLengthforbade — the write side persisted data the read side could not legally serve. - Both carriers, or neither. A message-less single-field bound rides each
backend's native validator chain (zod
.min/.max, FluentValidation.MinimumLength, …); a messaged rule and the domain floor ride the expression renderer. They are separate code paths, so fixing one leaves the other wrong — which is why the pinned case exercises both directions. - The declaration survives. zod cannot describe a
.refineto the OpenAPI emitter, so the Hono routes re-attach.openapi({ minLength, maxLength }). The published bound is byte-identical to before; only what the server enforces changed. - Elixir is a signed residual, not a conformer. Graphemes agree with code
points on every astral character (so it passes the pinned case) and diverge
only on combining sequences (
"e\u0301"— one grapheme, two code points), which nothing in the corpus reaches. Ecto'svalidate_length/3has no:codepointscount, so closing it means hand-rolling Ecto's error tuples — a unit of its own. - Conforms. node, dotnet, java, python. Residual: elixir.
- Provenance. Found 2026-08-06 by the M-T9.21 schemathesis leg (finding F5,
waiver W6). Fixed via one shared definition,
src/generator/_expr/code-point.ts, consumed by both the domain rule renderer and the wire-boundary validator emitter of each backend. Pinned intest/fixtures/corpus/validation-messages.dddand recorded inwire-golden/validation-messages.json— a 2-code-point label DENIED by>= 3, a 9-code-point / 18-code-unit label ADMITTED by<= 16and round-tripped — verified to fail with each half of the fix reverted independently. Statically pinned per backend bytest/generator/string-length-code-points.test.ts. Tier: behavioral.
RS-32 · A malformed path {id} answers the declared 422, not a framework default
- Guarantee.
GET /api/orders/not-a-uuid— a path{id}that will not parse — answers 422 with the same §3.2errors[]envelope the body tier emits (pointer: "/id"), on every backend. It is a client fault, and reporting it as a 500 tells the caller to retry a request that can never succeed. - Why it is not a judgement call. Every backend already publishes the 422:
the per-operation error matrix (
src/ir/util/openapi-errors.ts) says "a path{id}is parsed as a uuid and a query parameter is parsed against its declared type, and a failure at either answers the same 422 the body tier does", and each emitted spec declares the parameterformat: uuid. What differed was what they answered. - Trigger. Any route binding
{id}—getById,destroy, the canonicalupdate, each named operation, eachcan_<op>probe, the entity-history read, the workflow-instance read. - The split. 2-vs-3 when first measured (#2652): node's
z.string().uuid()param →defaultHook, and .NET's[FromRoute] Guid→InvalidModelStateResponseFactory, both answered 422. Java raisedMethodArgumentTypeMismatchException, which does not implementErrorResponse, so the catch-all reported 500; python bound the param as a barestrcarrying a documentation-onlyformat: uuid, so nothing rejected it and the malformed value reached the repository. Elixir was never measured in that pass: it handed the raw string toRepo.get/2, where a malformed:binary_idraisesEcto.Query.CastErrorout of Ecto, leaving only the app-global fault floor — which answers whateverPlug.Exception.status/1says. Measured on a booted app:400 "Bad Request", noerrors[], no pointer. (The ledger row that raised this said 500; the boot corrected it. It is the wrong rung either way — the app refused a request its own spec says answers 422, on a rung with nothing a client can bind to.) - Where the guard belongs is part of the rule. On Phoenix it is a controller
plug, not a per-actioncase: a controller gains actions over time and a per-action guard is the one the next action's emitter forgets. It is opt-in per controller rather than living in the shared<App>Webcontrollerquote, because an api's explicitroutelist may declare a{id}of its own that is not an aggregate id. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Four backends aligned by #2652 and pinned in
test/generator/malformed-path-id-status.test.ts; elixir added there in the W1b elixir packet (renderPathIdCastPlug+ProblemDetails.invalid_path_id_response/2). Runtime-proven, not inferred: a generated Phoenix app booted against Postgres answers422 {"errors":[{"pointer":"/id","message":"Expected UUID."}]}, and with the plug reverted (file-copy, regenerate, recompile, re-boot) the same request answers400 {"title":"Bad Request"}with noerrors[]. Tier: generator, with the runtime half measured per backend.
RS-33 · An errors[] pointer names the whole path to the offending field
- Guarantee. A 422
errors[]entry carries an RFC 6901 JSON pointer to the field that failed, however deeply nested —/lines/0/qty,/sku/code— never just the top-level container it sits under, and never an empty array. It is what lets a frontend ACL (applyServerErrors) bind the denial to the form control that caused it. - Trigger. A violation inside a containment part, a value-object collection row, or a value-object field.
- The split. 3-vs-1-vs-1. .NET's
PointerOfconvertsItems[0].Qtyto/items/0/qty, node joins the whole zodissue.path, python keeps every pydanticlocsegment. Java emits/lineTotals[0].unitPrice— a field path, not a pointer. Elixir was structurally depth-1: the body was built from a flatchangeset.errorswalk intopointer_of([field]), andEcto.Changeset.errorsholds only the top level, so acast_embed/cast_assocchild violation answerederrors: []— a 422 naming no field at all. - A value object is a third carrier, not a nested changeset. On Phoenix a VO
persists as one jsonb
:mapcolumn and is checked byvalidate_change/3, so there is no child changeset for the walk to find. It has to forward its own errors explicitly — with the inner field path and the authored message and theloom_codethe i18n catalog is keyed by. Collapsing it to[{field, "is invalid"}]discarded all three. - Conforms. node, dotnet, java, python, elixir. Java's bracket spelling
(
/lineTotals[0].unitPrice) is closed: everyerrors[]call site insrc/generator/java/emit/api.tsnow routes through apointerOf(String)helper that splits on., turns each[i]indexer into its own numeric segment and applies the RFC 6901 escapes (M-T9.25 / F1, pinned bytest/generator/java/errors-pointer-rfc6901.test.ts). - Provenance. Recorded as ledger rows
F2-W-03andnested-errors-pointer-shape; the elixir arm fixed in the W1b elixir packet (collect_changeset_errors/2+ theloom_pathopt onvalidate_vo/3), mutation-proven by deleting the recursion's call site — which the first version of the gate did not catch, because it asserted the helper clauses existed rather than that they were called. Runtime-proven: a booted Phoenix app answers a{"sku":{"code":"ab"}}create with422 {"errors":[{"pointer":"/sku/code","message":"SKU code needs at least 3 characters"}]}, and withvalidate_vo/3reverted the same request answers{"pointer":"/sku","message":"is invalid"}— the inner field, the authored text and the wire code all gone. Tier: generator; a wire golden carrying a VO-collection violation is still wanted (only 4 of 31 record any error body).
RS-34 · A query-time projection join is LEFT, not INNER — the joined field is wire null when the target is absent
- Guarantee.
join <Agg> as c on <idRef>in a query-timeprojectionbulk-loads the followed aggregate through its own repository — so the joined aggregate's OWN capability filters (asoftDeletabletarget that has been soft-deleted, an out-of-tenant target undertenantOwned, or an ordinary dangling<idRef>with no row on the other end) apply to that load, independently of whether the SOURCE row itself is visible. When the join target is absent from that bulk-load, the source row still ships and everyselected field read through the join alias carries the WIRE VALUEnull, on every backend — never a thrown exception (NullPointerException/KeyNotFoundException), never a silently dropped source row. - Why "the wire carries
null," not the backend's own scalar default. Two shapes were on the table, and they disagree exactly at a non-nullable scalar joined field (a joinedint,bool,decimal, ordatetime, never exercised by the fixture that first pinned this rule — see Scope below): one language's own zero/default value (.NET'sdefault!—0forint,falseforbool,DateTime.MinValuefordatetime) is NOT the same value as every other backend'snull/None/nil, and the projection's Response schema declares the field's ordinary (non-nullable) type either way — so BOTH shapes read as a violation of their own declared wire type on a non-string field. The rule adopts wirenull, uniformly, because it is the only value that means the same thing on every backend's wire serialization and needs no per-type default table. Scope, stated plainly: every backend's LANDED implementation was verified against a joinedstringfield (the RS-15-adjacent fixture:customerName = c.name), wherenull/default!(nullfor a reference type) happen to coincide — so the rule as implemented is proven on reference-typed joined fields, and the value-typed case (a joinedint/decimal/bool/datetime) is a DOCUMENTED GAP on .NET specifically (default!reads0/false/DateTime.MinValue, notnull, for those kinds), not yet re-verified nor fixed — see Open below. - Trigger. Any query-time
projection … join <Agg> as c on <idRef> … select f = c.<member>where the target aggregate can genuinely be absent from its own bulk-load:softDeletable,tenantOwned, or simply a reference with no live row. - The split when raised (wave 1,
G2667-D3). All five backends indexed the bulk-load map/dictionary UNGUARDED — .NETcustomerById[d.CustomerId].Name(KeyNotFoundException), nodecustomerById.get(...)!.name(undefined deref), pythoncustomer_by_id[str(...)].name(KeyError), javacustomerById.get(a.customerId().value()).name()(NullPointerException), elixirMap.get(customer_by_id, record.customer_id).name(nil.name—FunctionClauseError/KeyErrordepending on the struct access form) — every one a 500 on data the model permits, on a route that is not even about the missing row. - Rejected: dropping the source row instead. A FOREIGN aggregate's filters would then change THIS projection's row count while the source aggregate's own list still shows the row unfiltered — one silent failure traded for another, and a worse one (the row disappears from a read that never mentioned the join target at all).
- Per-backend shape. .NET:
customerById.TryGetValue(d.CustomerId, out var __j0) ? __j0.Name : default!— oneout varPER joined select, all in one lambda scope (distinct temp names,CS0128-safe), the wire coercion sitting INSIDE the guarded arm. Node:const __j0 = customerById.get(r.customerId as string); … __j0 === undefined ? null : __j0.name— one binding per row per join alias, reused across every select reading that alias. Python: a walrus-bound guard inline per select,(__j0.name if (__j0 := customer_by_id.get(str(r.customer_id))) is not None else None). Elixir: a total__joined/2reader,__joined(Map.get(customer_by_id, record.customer_id), :name), nil-safe by construction. Java (wave-2 residue): a null-guarded ternary re-evaluating the map.get(...)(Java has noout var/walrus binding inside a record-constructor argument list),customerById.get(a.customerId().value()) == null ? null : customerById.get(a.customerId().value()).name(), the wire coercion (domainToWire) applied to the member-read expression INSIDE the guard's true-branch so a joinedmoney/decimal/datetimefield's non-null narrowing (.setScale(…),.doubleValue(),.toString()) never runs on the absent row. - Conforms. node, dotnet, python, elixir, java (all five arms landed: dotnet/python/elixir/node in wave 1, java in the wave-2 residue).
- The value-typed arm — closed (wave C5 moment 5b, D-ABSENT-JOIN-DATETIME-WIRE).
The
.NETgap named above (a joinedint/decimal/boolreaddefault(T)—0/false— not wirenull; a joineddatetimeis a string on the .NET wire, so itsdefault!already wasnull) is fixed: every member aselectreads through a join alias is NULLABLE in the row record, and the absent branch isnull(joinReadFieldNames,src/generator/_projection/join-read.ts, shared by .NET / java / python). The runtime witness that closed it —corpus/datetime-wire.ddd, which soft-deletes a join target and asserts its joineddatetimeandintreadnull— found the same class on three backends listed as conforming: python's row model declared the joined member non-nullable, so FastAPI's response validation answered 500; java's primitiveintcomponent unboxed the guard'snull(500,NullPointerException); and elixir's join load did not apply the JOINED aggregate's capability filter, so a soft-deleted target still joined (its fields leaked onto the row). All four are fixed, and the Response schema widens each joined member to nullable. - Provenance. Raised as ledger row
G2667-D3-projection-join-unguarded-index. Landed dotnet (b75ce2c) and node (40202d9) in wave 1 packets 1b/1c; python in wave 1 packet 1e; elixir in wave 1 packet 1d. Java landed in the wave-2 residue (src/generator/java/emit/query-projection-reads.ts'srenderSelectWire), pinned bytest/generator/java/query-projection-join-missing.test.tsand the java arm oftest/ir/projection-comprehension.test.ts's Hono-emission describe block. Mutation-proven: revertingrenderSelectWireto the pre-fix unguarded<mapVar>.get(<key>).<member>()(file-copy revert, nevergit checkout --) fails 4 named assertions across those two files. Tier: behavioral since wave C5 moment 5b —test/behavioral/wire-golden/datetime-wire.jsoncarries a join-target-absent row diffed by all seven wire-gated legs. Before it the rule was string-pinned per backend only, which is exactly how three of the five value-typed arms stayed broken while the registry listed them as conforming.
RS-35 · An absent optional is null on the wire, never an omitted key
Guarantee. An optional scalar (
estimate: int?) that a create body omitted reads back as the key CARRYING an explicit null —{"estimate": null}— on every backend and every persistence adapter. The key is never dropped from the payload, so a client can tell "declared but unset" from "not a field of this resource" without consulting the schema.Trigger.
absent-optional.ddd: an aggregate with an optional scalar and a null-guard invariant over it (estimate == null || estimate >= 0), created by a body that omits the field entirely, then read back by id and by list.Why the structural gate can't see it. Both spellings satisfy the same emitted schema — an
estimate?: numbermember is happy with a null and equally happy with nothing. Loom has one absence value; JSON has two spellings of it; nothing in the OpenAPI diff chooses between them.Why this is documented rather than established. Unlike most rules here, RS-35 records a contract the tier already enforced — the M-T5.36 packet that added the
toBeNull()/toBeAbsent()matchers verified the gate rather than building one. Three things have to hold, and all three were checked at the code face:- Normalization keeps the evidence.
normalizeBody(test/_helpers/response-diff.ts) collapses volatile values to tokens but never drops keys, and returnsnullunchanged. Absent and null stay distinguishable through it — as its own comment puts it, "absence is contract, so it must remain visible todiffBodies". - The differ raises on it.
diffBodiesunions both sides' key sets and raises akey-setdivergence when a key is on one side only;null-vs-emptyis a separate kind, covering[]/{}against null. - The subject is actually compared.
wire-golden/absent-optional.jsonrecords"estimate": nullon both read bodies — the golden CARRIES the optional key, rather than only the fields a non-conforming backend would also have sent. (This is the "make the fixture able to falsify the rule" test above: a golden that omittedestimatecould not fail on it.)
- Normalization keeps the evidence.
Reach, confirmed by derivation rather than assumed. The recurring failure shape in this repo is a check that never reaches the thing it names, so the case-to-leg mapping was derived, not read:
requiredGoldenCases()lists all seven wire-gated legs —run.mjs(node),run-mikroorm.mjs,run-dotnet.mjs,run-dapper.mjs,run-java.mjs,run-python.mjs,run-elixir.mjs— as recorders ofabsent-optional, and none of the three escape hatches covers it:WIRE_WAIVERSis empty,GOLDEN_OPT_OUTis empty, andBEHAVIOURAL_SKIPis drained for every platform clause. Four sibling optional-carrying cases (embedded-optional,optional-reference,optional-valueobject,union-find-absence) derive the same seven legs.The shape of the guarantee, stated plainly. Each leg is diffed against the committed node-oracle golden, so what is enforced is "all five agree with the reviewed recording", not "all five were compared to each other". Moving the contract means rebaselining a checked-in file (
LOOM_WIRE_UPDATE=1), which lands as a visible diff a human approves — deliberate, not silent.The DSL surface. The absence pair (
docs/language.md):expect(<read>.<field>).toBeNull()asserts the spelling above;expect(<read>.<field>).toBeAbsent()asserts the other one and therefore has no passing subject on any backend today. That is intentional — it is not special-cased into passing, so a backend that starts omitting a key turns a test red instead of drifting silently.toBeAbsent()is e2e-only (loom.unit-absent-invalid): in-process a declared field always exists.Conforms. node, dotnet, java, python, elixir (all adapters).
Provenance. Contract enforced since #2577 / M-T9.11 (the per-PR wire differential); named and written down by M-T5.36/P11b. Tier: behavioral.
Registry. RS-35 has its entry in
test/conformance/semantics-rules.ts. When this clause was written the registry stopped at RS-31 — RS-32..RS-34 had merged as prose only, which made any new rule unlandable under the registry's gap-free assertion. That has since been closed: the registry was backfilled (it now holds RS-1 … RS-45, contiguous), andsemantics-rules.test.tsgained the prose-vs-registry parity assertion this finding asked for ("the prose and the registry describe the SAME set of rules", plus a per-rule title check), so a prose-only rule now fails CI instead of slipping past it.
RS-36 · .first on an EMPTY collection fails on every target; .firstOrNull is the total form
- Guarantee.
firstis declaredT— non-optional — insrc/util/collection-ops.ts, so reading it from an empty receiver FAILS on every target rather than yielding a value that lies about its own type.firstOrNullis declaredT?and is the TOTAL form: null/nil on empty, never raising. The failure surfaces as the sanitized 500 RS-28 already governs, not a domain-floor 422 — the request was valid and the MODEL's assumption ("this collection has a first element") was not. - Trigger. Any
.firstwhose receiver can be empty:lines.first.skuafter awherethat matched nothing, afindresult bound and read positionally, aderivedover an empty containment. - The split when raised (
F2-EXPR-7). Three targets already failed at the point of the mistake and two degraded silently: dotnet.First()(InvalidOperationException), java.get(0)(IndexOutOfBoundsException) and python[0](IndexError) — against node${recv}[0](undefined) and elixirList.first(${recv})(nil). Elixir'sfirstandfirstOrNullwere literally the same snippet, so the non-optional form had no distinct meaning at all, and on node astring-typed getter returnedundefined, which then shipped on the wire or died later somewhere that never mentions the collection. - Rejected: making
firsttotal (T?). That contradicts the declared signature and would break everylines.first.skuin the language for a case authors can already express withfirstOrNull. A failure AT the read is diagnosable; a null that ships is not. (This is RS-34's argument reaching the opposite conclusion, and for the stated reason: there the absent value has a MEANING — no joined row — and here it does not.) - Per-backend shape. node emits an arrow IIFE guard rather than a bare
[0], so the receiver is evaluated once and the message names the total form:((__c) => { if (__c.length === 0) throw new Error("'.first' on an empty collection — use '.firstOrNull' for the total form"); return __c[0]; })(<recv>)(src/generator/_expr/js-collection-ops.ts, shared with the JS frontend walkers — where a stdlib collection op in a page body is refused outright byloom.frontend-collection-op-unsupported, so the guard is backend-reachable only). elixir movesfirsttohd/1(ArgumentErroron[]) and keepsList.first/1forfirstOrNull(src/generator/elixir/render-expr.ts). dotnet / java / python are unchanged: their natural renderings already raise. - Provenance. Ruled as D-FIRST-ON-EMPTY (
docs/decisions.md); raised as ledger rowF2-EXPR-7; built in wave C2 packet 2n. Tier: generator (per-backend arm test,test/generator/collection-op-first-partial.test.ts) — the edge itself is pinned as prose insrc/util/collection-ops.tsthe waysrc/util/intrinsics.tspins scalar edge behaviour. The FRONTEND half is vacuous by construction today (the page-body gate above); should that gate ever widen, the frontends follow this same rule rather than degrading toundefined.
RS-37 · decimal arithmetic is EXACT — 0.1 + 0.2 is 0.3 on the wire and in storage
Guarantee. Every backend computes
decimalarithmetic in DECIMAL, not in binary floating point:+,-,*,/,%whose result types asdecimal(including theint / intdivision the type system widens todecimal), adecimalsumfold, anddecimal.round(n)(half away from zero on the exact value).0.1 + 0.2answers0.3, and so does the value an operation writes to a stored column and a later read returns. RS-24 is unchanged — the result still serializes as a float64 JSON number; only the computation moved.Trigger.
test/fixtures/corpus/decimal-exact.ddd:expression double exact a + b(0.1 + 0.2)0.300000000000000040.3c * c * c(1.1³ — a chain)1.33100000000000041.331d / a(0.3 / 0.1)2.99999999999999963(a + b) * 3 - d0.60000000000000010.6tie.round(2)(1.005)11.01parts.sum(p => p.weight)over 0.1, 0.20.300000000000000040.3total := total + x(0.1 + 0.2, stored, read back)0.300000000000000040.3Every literal is binary-INEXACT on purpose, and each pair differs AFTER the RS-24 narrowing — a value that differed only in the 20th digit would pass with the fix reverted. One more row witnesses the chain, not the double:
a / 3 * 3(0.1) is0.0999…9exactly and narrows to0.1on the wire, while a backend that computed each step exactly but narrowed to a double between steps answers0.09999999999999999— so a chain stays decimal until its root. It is asserted on the wire only: in-process, the decimal-typed backends hold the un-narrowed0.0999…9.Why it hid. RS-24 pinned how a
decimalserializes and nothing pinned how it computes. .NET (System.Decimal), Java (BigDecimal) and Elixir (Decimal) computed exactly; node (a JSnumber) and python (afloat) computed in doubles — and persisted the result into the shared unboundedDECIMALcolumn. Single divisions of binary-exact operands agree by coincidence (double division is correctly rounded), which is why7 / 3never exposed it, and the corpus carried no float-error-visible decimal arithmetic at all: the witness could not be added first, because against the node oracle it alone would have turned the three CORRECT backends red.The ruling. D-DECIMAL-EXACT-MOMENT, given by the owner 2026-09-07:
decimalexists precisely to avoid binary-float error, so a decimal that answers0.30000000000000004is broken by its own definition. It superseded the numeric-types audit's proposed float64/node-oracle default.moneywas never at stake (a fixed-scale-4 string, already exact on all five — RS-12).Per-backend shape. node lifts the operation into decimal.js and narrows with
.toNumber()once, at the root of the chain — a nested decimal operand hands over itsDecimalun-narrowed, soc * c * cis one exact product, not two re-rounded doubles (src/generator/typescript/render-expr.ts:new Decimal(this._c).times(this._c).times(this._c).toNumber()). python lifts each operand throughDecimal(str(x))— the shortest-repr route, a no-op by value for an operand that already is aDecimal— and narrows withfloat(...)once (src/generator/python/render-expr.ts:float(Decimal(str(self._a)) + Decimal(str(self._b)))); adecimal%stops detouring throughtrunc_mod, becauseDecimal's remainder already truncates. Both keep the domain and wire type a double, so no repository, DTO or codec changed. .NET / Java / Elixir are unchanged.Historical rows. Values node and python persisted before this rule are NOT rewritten — see
migrations.md.Not part of the guarantee (named, handed off). node computes at decimal.js's default 20 significant digits (python/.NET/Elixir 28; Java exact for
+ - *, 34 digits for/) — every value above agrees after the narrowing, and a result that differs only in its 18th–28th digit is the precision-alignment follow-up, not a conformance claim here. The wire-boundary zod.refinenode emits for a cross-field invariant (src/generator/zod-refine.ts, shared with the JS frontends) still evaluates in doubles; the domain's own invariant check behind it is exact.Numbering. D-DECIMAL-EXACT-MOMENT's text says "take RS-38" because D-ABSENT-JOIN-DATETIME-WIRE reserved RS-37 for the datetime wire form. This rule landed first, and the registry's gap-free gate refuses an RS-38 with no RS-37, so it is RS-37; the datetime rule takes the next free number when it lands.
Conforms. node, dotnet, java, python, elixir.
Provenance. Raised by the numeric-types audit (F11); ruled as D-DECIMAL-EXACT-MOMENT; built as M-T5.22 (wave C5 moment 5a). Tier: behavioral — the witness's unit
testblock runs on all five unit tiers and itstest e2eblock recordstest/behavioral/wire-golden/decimal-exact.json, diffed by all seven wire-gated legs.
RS-38 · A datetime crosses the wire in milliseconds — three digits when a fraction is present, none on a whole second
Guarantee. Every
datetimea backend serializes is ISO-8601 UTC with aZsuffix and at most three fractional digits: exactly three when the instant has a sub-second part (2024-03-01T10:20:30.120Z— the trailing zero is kept), none at all on a whole second (2024-03-01T10:20:30Z, RS-4's canonical form). Sub-millisecond input is truncated at ingress, never rounded —…30.9996Zis stored and read back as…30.999Z— so the stored value and the wire value agree and a read-back equals the write (RS-4). Rounding would carry.9996into the next second.Trigger.
test/fixtures/corpus/datetime-wire.ddd, every value asserted as a STRING in itstest e2eblock (the spelling is the contract, sodatetime(...), which compares instants, would be the wrong assertion):written wire what it separates …10:20:30Z…10:20:30Zan unconditional three digits ( .000Z)…08:00:00.500Z…08:00:00.500Za minimal trim ( .5Z)…10:20:30.120Z…10:20:30.120Zminimal trim ( .12Z), microseconds (.120000Z), second precision (…30Z)…10:20:30.9996Z…10:20:30.999Ztruncation vs rounding ( …31Z)…09:00:00.050Zvia an operation param…09:00:00.050Zthe second ingress path (and elixir's force_changeseam)plus a query-time projection joining a
datetimeand anintoff a soft-deleted venue — the RS-34 absent branch, wirenullfor both.The split when raised (ledger
F2-W-06). One stored instant, four spellings, one mechanism each: nodetoISOString().replace(/\.?0+Z$/, "Z")stripped EVERY trailing zero (.12Z); javaInstant.toString()printed groups of three (.120Z) but a microsecond column value in six; pythonisoformat()printed six (.120000Z); .NET trimmed its seven-digit"o"form the node way; and elixir's column was:utc_datetime— SECOND precision — so the fraction a client wrote was lost on the way into the database.Why it hid. The wire-golden normaliser collapsed every ISO-8601 spelling (
.12Z,.120000Z,+00:00, a space separator, no fraction) to one<timestamp>token by design, so a differential tier comparing five backends could not see a form divergence at all — "normalisation is what hid F2-W-05 and F2-W-06" (D-ABSENT-JOIN-DATETIME-WIRE). It is narrowed with this rule (test/_helpers/response-diff.ts): only the two canonical spellings collapse, anything else stays verbatim and diverges from the golden's<timestamp>.Per-backend shape. node
canonicalIsoExpr→.toISOString().replace(/\.000Z$/, "Z")(a JSDateis millisecond-exact, so only the all-zero group moves); .NETcsCanonicalInstantWire/CanonicalInstant.Format→ the customyyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'format (ffftruncates) with.000Zdropped, and ingress truncates the parsed ticks to the millisecond; javajavaInstantWire→.truncatedTo(ChronoUnit.MILLIS).toString(), andWireFormatException.instanttruncates at ingress; pythoniso()→isoformat(timespec="milliseconds")(truncating) or no fraction below one millisecond, andpyWireToDomaintruncates the parsed microseconds; elixir types every declareddatetimefield asLoom.Datetime— an Ecto type over:utc_datetime_usecon atimestamptzcolumn that casts, loads and normalises to microsecond precision{ms * 1000, 3}(or{0, 0}on a whole second), which is exactly whatDateTime.to_iso8601/1— and so Jason — prints as the canonical form; the second-truncation seams (__truncate_dt/1, the stamp writers, the projection fold, the grouped key) normalise throughLoom.Datetime.normalize/1instead.Correction carried from the ruling. "Three digits on every backend" as first proposed contradicts RS-4 (PINNED): a whole-second instant keeps the
…00Zform, and "exactly three digits" applies only when a fraction is present.Not part of the guarantee (named). The rule covers the HTTP wire. The channel / realtime envelope encodings still spell a datetime their own way on python (
isoformat()) and elixir (to_iso8601of autc_now()), and node's channel envelope uses a baretoISOString()(.000Zon a whole second); none is goldened. Elixir's framework columns (inserted_at, the audit/provenanceat) stay:utc_datetime— second precision, canonically spelled. A deployed elixir database created before this rule keeps itstimestamp(0)columns until an operator alters them — seemigrations.md.Conforms. node, dotnet, java, python, elixir.
Provenance. Ledger
F2-W-06; ruled as D-ABSENT-JOIN-DATETIME-WIRE (which reserved RS-37 for it; RS-37 went to decimal-exact first, so this is RS-38); measured in wave C2 packets 2a / 2f / 2m, built as wave C5 moment 5b. Tier: behavioral —test/behavioral/wire-golden/datetime-wire.json, diffed by all seven wire-gated legs, plus the witness's string assertions on every leg.
RS-39 · An empty request body is {} — a parameterless command accepts a body-less call; a foreign media type is 415
Guarantee. On every body-carrying route, a request with no body (and no
Content-Type, or a JSON one) is answered exactly as{}is. A command that declares no parameters therefore accepts all three of these:curl -X POST /api/appointments/$ID/confirm # 204 curl -X POST /api/appointments/$ID/confirm -H 'Content-Type: application/json' # 204 curl -X POST /api/appointments/$ID/confirm -H 'Content-Type: application/json' -d '{}' # 204and a command with required fields earns the same 422
errors[]an explicit{}does. Malformed JSON is400 "Malformed JSON in request body". A body under a foreign media type (text/plain,application/x-www-form-urlencoded) — or content with noContent-Typeat all — is415 "Content-Type must be application/json", even when the body is empty: a declared media type is the caller's intent. Multipart (the file upload transport) is untouched.Trigger.
operation confirm() when status == Requested { status := Confirmed } workflow Housekeeping { create() { … } }→
POST /api/appointments/{id}/confirm,POST /api/workflows/housekeeping.The contract. The parameterless command's OpenAPI
requestBodyis notrequired(commandBodyOptional,src/ir/util/command-body.ts):// node — http/appointment.routes.ts body: { content: { "application/json": { schema: ConfirmAppointmentRequest } }, required: false },# python — app/http/appointment_routes.py body: Annotated[ConfirmAppointmentRequest, Body(default_factory=ConfirmAppointmentRequest)]The split when measured (the booking DX trial's
confirm(), each backend booted against Postgres):request node python dotnet java elixir no body, no CT 415 422 415 204 204 CT json, 0 bytes 400 422 422 204 204 {}204 204 204 204 204 {}astext/plain415 422 415 204 204 malformed JSON 400 400 400 204 400 Java's zero-arg route never bound a body, so it accepted anything; elixir's
pass: ["*/*"]handed a foreign body to an action that never read it; python had no 415 at all.Where it lives. One normaliser per backend, ahead of routing: hono
emptyBodyAsEmptyObject(http/problem-details.ts), pythonJsonBodyMiddleware(app/http/problem.py), .NETMiddleware/EmptyJsonBodyMiddleware.cs(which also resetsIHttpRequestBodyDetectionFeature— MVC's binder reads that, not the stream), javaconfig/EmptyJsonBodyFilter.javaplus a body-reading zero-arg route, and the PhoenixBodyParser. The 415detailis one constant,UNSUPPORTED_MEDIA_TYPE_DETAILinsrc/ir/util/openapi-errors.ts.An absent required field is one 422 everywhere. A command WITH parameters that receives an empty body (≡
{}) or a{}missing one answers 422 with oneerrors[]entry per absent key —{ "pointer": "/reason", "message": "reason is required" }— before the row is loaded, on operations, workflows androuted handlers. Elixir used to hand the nil to the operation body and answer 500; therequire_command_paramscontroller plug refuses it first. The sentence was each framework's own (nodeInvalid input: expected string, received undefined, pythonField required, .NETThe reason field is required., javamust not be null). The owner ruling (#3881) fixes it as"<field> is required", the field being the pointer's last key:missingFieldMessagein thewirePolicyfact (src/ir/enrich/wire-policy.ts), create and update included. Node renders it; .NET, java, python and elixir still send the one word"Required"(MISSING_FIELD_MESSAGE), waived per golden intest/_helpers/wire-waivers.tsuntil each renders the fact. Acreatewith no input fields publishes an optionalrequestBodylike a parameterless command does.Conforms. node, dotnet, java, python, elixir.
Provenance. The booking DX trial. Tier: behavioral — the wire-golden
__emptyCommandBodyProbes(test/behavioral/wire-differential.mjs) replays the first instance command each case POSTs{}to — against an absent id, so the probe moves no state — with no body, with zero JSON bytes and astext/plain(404, 404, 415), on all seven wire-gated legs; the emitted seams are pinned bytest/generator/empty-command-body.test.ts.
RS-40 · An optimistic-concurrency 409 carries the sentence "<Aggregate> <id> was modified by another request"
- Guarantee. When a write to a
versionedaggregate loses its version check — a staleIf-Match, or a guarded save that matched no row — the answer is 409,title"Conflict", and the RFC 9457detailis the sentence"<Aggregate> <id> was modified by another request": the aggregate's PascalCase name, the conflicting row's id, no trailing punctuation. The row keeps the write that won. Same sentence for an event-sourced stream whose append lost the(stream_id, version)race. - Trigger.
concurrency-conflict.ddd: read a note, update it withIf-Matchset to the version read, then update again with that same — now stale — version. The e2e call spells itapi.notes.update(n, body, { ifMatch: read.version }); the third argument sendsIf-Match: "<v>", and any other shape is refused (loom.e2e-call-options) rather than dropped. - The ruling (M-T6.91). Node's sentence is canonical, the same call RS-27
made for the 404-by-id: it names what conflicted. When ruled, node raised
ConcurrencyError(aggregate, id)while .NET (EF and Dapper), java, python and elixir answered one fixed"The resource was modified by another request; reload and retry.". Status,titleandtypealready agreed, so only adetaildiff could see it — first done by #3224'stransactional-workflow-event-rollbackgolden, which waived it on the two non-node legs it armed. - Per-backend shape. The aggregate and id come from where both are already
in scope. Node and python: the exception raised at each guarded write and
event append (python's message already named both; its handler ignored it).
Java: the
ObjectOptimisticLockingFailureException(<Agg>.class, id)every raise site passes, read back in the advice. .NET Dapper:ConcurrencyConflictException(aggregate, id); .NET EF: the event store's wrapped 23505 carries the sentence, and EF's native concurrency-token failure is described from the entry it reports (CLR type name — a document POCO'sDocumentsuffix dropped — and key value). Elixir:conflict_response(conn, kind, id), shaped likenot_found_response/3. The words after the id are one constant,CONCURRENCY_CONFLICT_SUFFIX. - Not part of the guarantee (named). A two-handle conflict inside a
workflow is not a shared behaviour yet — EF tracks one entity for both loads
(M-T6.90) and elixir answers 500 inside a
transactionalone (M-T6.92). The elixir event-sourced create arm has no id in its path, so its sentence carries none. - Conforms. node, dotnet, java, python, elixir.
- Provenance. M-T6.91. Tier: behavioral —
test/behavioral/wire-golden/concurrency-conflict.json, diffed by every wire-gated leg; the$.detailwaiver ontransactional-workflow-event-rollbackis deleted.
RS-41 · A save is checked against the version its handle was loaded at — a shape: document row and a persistedAs: eventLog stream included
- Guarantee. Every save of a
versionedaggregate is checked against the version the saving handle was loaded at, whatever the saving shape. Ashape: documentupsert is guarded on that version; an event-sourced (persistedAs: eventLog) handle remembers the stream version it was folded to and its save appends the versions after it, so a stream some other append has moved past rejects the first re-used version on the(stream_type, stream_id, version)key. The losing save answers the RS-40 409 and keeps nothing. One handle changed twice is never a conflict with itself. - Trigger.
workflow-two-handles-document-eventlog.ddd: inside atransactionalworkflow, load one document row twice and change both handles; load one stream twice and withdraw through both. Both answer 409 and persist neither. The controls — only the second handle changed, one handle changed twice — commit. - The ruling (M-T6.98): expected-version appends. Before it, all seven
event stores (node drizzle + MikroORM, python, java, .NET EF + Dapper,
elixir) read
max(version)at append time, so two loads of one stream both appended: two withdrawals that each passedbalance >= amountagainst the same balance both landed, and the account overdrew past its precondition. The key caught only two appends racing between their read and their insert. The stream is an event-sourced aggregate's own concurrency control — the reasonversionedis not spliced onto it — so it is checked against what the handle saw, as a state row is. (java and .NET also assert invariants when folding a stream, so an overdrawn stream would have become unloadable.) - Per-backend shape. The folded version lives on the ES root
(
_streamVersionnode/java,_stream_versionpython,_StreamVersion.NET,__stream_version__elixir), set by the fold to the folded count and advanced after the append. Document saves: java's upsert gainedwhere <t>.version = ?+returning version(M-T6.95); EF sets the concurrency token's original value to the handle's version (orIf-Match) instead of bumping the re-read row. - Not part of the guarantee (named). Elixir: its store refuses both writes,
but a stale save inside a workflow answers 500 until M-T6.92 (#3247) adds the
{:error, :conflict}arm, and it persists each op call, so one handle changed twice bumpsversiontwice (M-T6.99). Elixir is a target, not conforming. - Conforms. node (+ MikroORM), dotnet (EF + Dapper), java, python. Target: elixir.
- Provenance. M-T6.95 + M-T6.98 (#3285). Tier: behavioral —
test/behavioral/wire-golden/workflow-two-handles-document-eventlog.json.
RS-42 · A numeric wire value outside its type is refused AT ITS FIELD: one grammar, one range sentence
Guarantee. A request number that does not fit its declared type is a 422 wire-validation refusal (RS-29's
Validation failedrung) with oneerrors[]entry at the field's full pointer (RS-33), and the same message on every backend:input message a moneystring outside node's grammar^-?[0-9]+(\.[0-9]+)?$—"1.2.3","twelve","12,50"Invalid decimal: "12,50"a moneystring whose integer part overflowsNUMERIC(19,4)Money out of range: "…"an intliteral outside int32 —3000000000,-3000000000Integer out of rangeThe money grammar admits no grouping, no exponent, no leading
+and no surrounding whitespace, so"12,50"is never 1250 and never 12.5.Trigger.
test/fixtures/corpus/wire-ingress.ddd:repricewith"1.2.3","twelve"and"12,50";restockwith ±3000000000.The split when raised (wave C3 3d, D22 / D23).
.NETand Dapper parsed money withdecimal.TryParse(…, NumberStyles.Number, …), which carriesAllowThousands, so"12,50"was accepted as 1250 while the other five legs answered 422 — and the static test pinnedTryParse's presence, not its style. An int32 overflow had no contract at all: node 422 with zod's text (Too big: expected number to be <=2147483647, a zod-major-specific sentence), python 422 with pydantic's (Input should be less than or equal to 2147483647), elixir 422 withInteger out of range: 3000000000, and java and .NET 400Malformed JSON in request body— Jackson and System.Text.Json fail the whole body on an integer they cannot hold, and both advice layers read that as an unreadable body.Per-backend shape. The sentence is
INTEGER_RANGE_MESSAGE(src/util/numeric-range.ts), owned by Loom rather than any framework, likeMONEY_RANGE_MESSAGE. node passes it to the int32.min/.maxpair; python'sInt32/Int32Paramraise it from_int32as aPydanticCustomError; elixir's op-param guard and changeset send it bare; java'sHttpMessageNotReadableExceptionarm finds theInputCoercionExceptionin the cause chain and reads the field's path off Jackson's exception; .NET registersInt32RangeJsonConverter, which throws it for an integer LITERAL that does not fit (a fractional1.5still falls through to the reader's own error and the 400), andValidationProblem.FromModelStateanswers it at the converted$.npath. .NET's money parse is gated byWireFormatException.IsMoneyGrammar, a hand loop of node's regex, before aTryParsewhose style admits only what the grammar does.Not part of the guarantee (named).
longkeeps its ruled ceiling (D-LONG-AVG-DEFAULTS: node refuses past 2^53−1, the others carry int64); a fractional value for anintis refused on every backend but its message is each framework's.Conforms. node, dotnet, dapper, java, python, elixir, mikroorm.
Provenance. Wave C3F packet F4. Tier: behavioral —
wire-golden/wire-ingress.json(node-minted; seq 4–6 are the three new refusals), diffed by all seven wire-gated legs; statically pinned bytest/generator/dotnet/wire-numeric-ingress.test.ts(the money style and its grammar gate) andtest/conformance/numeric-ingress-parity.test.ts(the range seam per backend).
RS-43 · A message-less rule's 422 carries node's sentence on every backend
- Guarantee. A rule with no
message "…"that rides the wire validator — aninvariantover request fields, an operationpreconditionover its params — refuses with the sentence node's zod chain sends for it, at the pointer node's chain names, and with noerrors[].code:- a single-field shape (
amount >= 1,code.matches("^[A-Z]{3}$"),label.length <= 16) →singleFieldMessageinsrc/generator/zod-refine.ts("Amount must be at least 1", "Code is not in the expected format"); - any other shape (
name.trim().length > 0, a conjunction) → node's refine defaultInvariant violated: <source>, at the fieldpickErrorPathnames.
- a single-field shape (
- Trigger.
test/fixtures/corpus/validation-messages.dddseq 14/15 (ship(amount: 0),retag(code: "abc")) andtest/fixtures/corpus/intrinsics.ddd's blank-name create. - The split when raised (M-T6.86, M-T6.87, wave C3 D13). Five sentences
for one rule: FluentValidation's
'Amount' must be greater than or equal to '1'.on .NET and Dapper; java's genericInvariant violated: amount >= 1for a single-field shape it had already classified; elixir did not lift a message-less precondition to the wire rung at all and answered the domain floor (Precondition failed: …, noerrors[]); and python prefixed a message-lessmodel_validatorraise with pydantic'sValue error,and, having noloc, pointed at"". - Why node's. Ruled as
D-MESSAGELESS-DEFAULT(which extendsD-MESSAGELESS-RULE-WIRE-MESSAGEfrom python to every backend and from the single-field shape to every message-less rule): node is the wire oracle, its sentence already names the field's label without the server's regex, and a code is the i18n key of AUTHORED text only. - Not part of the guarantee (named). A MISSING required field's sentence is
still each framework's (
M-T6.93). Elixir's create/update changeset sends node's sentence for a message-less single-field rule (validate_number(:seats, greater_than_or_equal_to: 1, message: "Seats must be at least 1")), and appliesvalidate_requiredbefore the invariant, so a whitespace-only string is "can't be blank" there until wave C3F packet F1's D14 fix lands. - Conforms. node, dotnet, dapper, java, python; elixir on the operation (precondition) rung.
- Provenance. Wave C3F packet F4 (the four
M-T6.86waivers intest/_helpers/wire-waivers.tsdeleted). Tier: behavioral —wire-golden/validation-messages.jsonandwire-golden/intrinsics.json.
RS-44 · A string intrinsic counts and cases the same on every backend
- Guarantee.
s.substring(start, len?)counts code points, the unit ofs.length(RS-31):"a😀bc".substring(1, 2)is"😀b"and"a😀bc".substring(2)is"bc"everywhere.s.toUpper()applies the Unicode full mapping'sß→SS:"straße".toUpper()is"STRASSE"everywhere. - Trigger. The evaluated value table
(
test/generator/_expr/expr-value-table.tsrowsstring_substring_astral,string_substring_astral_from,string_upper_eszett) and the derivedmiddle/shoutfields oftest/fixtures/corpus/intrinsics.ddd. - The split when raised (wave C3 3d, D10 / D11). node, java and .NET sliced
UTF-16 units (
"😀"), python sliced code points ("😀b"), elixir'sString.slice/3sliced graphemes; the table recorded both as named divergences. .NET'sToUpperInvariantis a simple one-to-one mapping and keptß. - Per-backend shape. One snippet per language in
src/generator/_expr/code-point.tsbeside the.lengthones ([...s].slice,EnumerateRunes().Skip().Take(),codePoints().skip().limit(),Enum.slice(String.to_charlist(s), …); python's slicing already is); .NET's upper case isToUpperInvariant().Replace("ß", "SS", Ordinal). - Not part of the guarantee (named). The rarer full-mapping expansions
(ligatures such as
fi, polytonic Greek) follow each platform, and an intrinsic in awherefollows the database'supper(). The Feliz and Flutter frontends still countlengthandsubstringin UTF-16 units, consistently with each other (M-T6.94). - Conforms. node, dotnet, java, python, elixir.
- Provenance. Wave C3F packet F4. Tier: unit (the value table runs each
backend's real renderer) plus behavioral (
wire-golden/intrinsics.json).
RS-45 · An audit entry's actor is the principal's declared user shape — by declared name, nothing else — or null
- Guarantee. The
actoranauditedcommand records — and its entity history (GET /<aggregates>/{id}/history) serves — is the request principal projected onto the system's declareduser { … }fields, keyed by the declared names: exactly the body/api/auth/meanswers. No undeclared claim, no derived tenancy member (orgPath/rootOrg), no backend-specific key casing.nullwhen no principal is bound, which is always the case on a deployable with no auth. - Trigger.
test/fixtures/corpus/field-mask.ddd—auth: required,user { id, role, permissions }, an aggregate-wideauditedaggregate; create, the auditedpromote, the crudish update, then the history read as the dev-stub principal, whose injected claims carry two keys theuserblock does not declare (tenantId,orgId). The auth-less null actor isaudit-history.ddd's golden. - The split when raised (wave C3 3d, D1). Four shapes for one principal:
node stored the principal object as-is — under the dev stub the merged claim
bag, undeclared claims included; .NET serialised its
Userrecord, PascalCase keys plus computed tenancy members; python never passed an actor, sonull; java handed Jackson theUserrecord; elixir carried only{ id }. - Per-backend shape. node
audit/actor.ts→auditActor(); .NETRequestContext.PrincipalJson()over a declared-name dictionary; java a per-serviceauditActor()building aLinkedHashMap; pythonapp/db/audit.py→audit_actor(), read insiderecord_audit; elixirAudit.record/2'sactor/0, over the principal the Auth plug stashes in the process dictionary. - Conforms. node, dotnet, java, python, elixir.
- Provenance. Wave C3 packet 3d hand-off D1; fixed in wave C3F packet F5.
Tier: behavioral —
test/behavioral/wire-golden/field-mask.json, diffed by every wire-gated leg.
RS-46 · Every named operation of a versioned aggregate reads If-Match, and two writers carrying one tag yield exactly one 409
- Guarantee. Which version a write is checked against is the
checksVersionfact (src/ir/enrich/version-check.ts), not each backend's choice. Every named operation of aversionedaggregate (the canonicalupdate, any other command, value-returning or not, any saving shape) reads the client'sIf-Match, and falls back to the version it loaded when the header is absent. A stale tag answers the RS-40 409 and the row keeps the write that won. Two writers that send the same tag at the same moment get exactly one 2xx and one 409, and the version moves by one: the loser is refused by the header check when the winner committed first, and by the guarded save when both loaded before either saved. A lost check is a 409 the client may retry; the server never re-runs the command. Acreateand (until it is implemented, though ruledif-match) the canonicaldestroycheck no version; a workflow or handler save is checked against the version its handle loaded (RS-41). - Trigger.
test/fixtures/corpus/concurrency-two-writers.ddd: stale tags onrename(void),retitle(returns a value) and ashape: documentaggregate'srewrite. The concurrent half is the wire tier's two-writer probe (__twoWriterProbe,test/behavioral/wire-differential.mjs). It replays the case's firstIf-Matchwrite as five pairs of simultaneous requests and records each pair's two answers in a fixed order. - The split when raised. Node and elixir read
If-Matchonly on the canonicalupdate, so a stale tag on any other command overwrote newer data with a 204 (#3531). Java compared the header with the row it had just loaded and then saved without the version guard, so two simultaneous writers both landed (#3521). - Conforms. node, dotnet, python. Targets: java (#3521), elixir (#3531),
each a tracked expected failure of the case (
BEHAVIOURAL_XFAIL). Java's entry is araceentry: the lost write needs both requests to load before either saves, which only the server's timing decides, so a run whose pairs all serialise passes and still settles as expected.raceis allowed only on the two-writer probe, and every other leg judges the probe strictly. - Provenance. #3593 item 6. Tier: behavioral:
test/behavioral/wire-golden/concurrency-two-writers.json, diffed by every wire-gated leg.