← Back to home

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.ts and pinned by test/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:

  1. Each past fix becomes a named, provenance-linked contract clause, not tribal knowledge buried in a commit.
  2. The next regression is "RS-N violated," reviewable against a spec, instead of a fresh archaeology dig.
  3. 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

RS-2 · Enum values use declared casing on the wire

RS-3 · No persistence-internal columns leak to the wire

RS-4 · Declared temporal fields round-trip

RS-5 · Union-variant absence match is a presence check everywhere

RS-6 · Boolean create defaults materialize at the wire boundary

RS-7 · Value-object subfields survive a jsonb round-trip

RS-8 · Associations persist and preload on round-trip

RS-9 · Error bodies converge on RFC 7807 with the 400/422 split

RS-10 · Rehydration trusts the store — invariants guard transitions only

RS-11 · A created versioned aggregate reads back at version 1

RS-12 · Money wire scale is consistent across backends

RS-13 · A create POST returns the id envelope, not the whole aggregate

RS-14 · version increments on every persisted mutation, document shapes included

RS-15 · A domain-floor rejection is 422, not 400

RS-16 · The RFC 7807 type member is always present

RS-17 · A when state-gate rejection names the operation it refused

RS-18 · A provenanced field's lineage rides the wire as <field>_provenance

RS-19 · A declared error variant's fields ride the problem body

RS-20 · version counts persisted mutations, not entity-graph dirtiness

RS-21 · A union response carries its type discriminator

RS-22 · The RFC 7807 envelope is exactly five members plus declared extensions

RS-23 · An absent collection is [] on every persistence adapter

RS-24 · A plain decimal is a JSON number; only money is a string

RS-25 · internal / secret fields never reach the read wire

RS-26 · An omitted UPDATE field is a client error, never a silent default

RS-28 · An unrecognised error term is a sanitized 500, never a 400 that echoes it

RS-27 · A 404-by-id carries the sentence "<Aggregate> <id> not found"

RS-29 · The wire-validation rung is Validation failed, distinct from the domain floor


Adding a rule

When a cross-backend runtime bug is fixed:

  1. Add an RS-N entry to test/conformance/semantics-rules.ts (id, title, .ddd trigger sketch, conforming backends, provenance PR, lowest gating tier).
  2. Add the human clause here, mirroring the registry.
  3. If the rule is T0-gateable, add the static assertion in test/conformance/. If T1/T2, add the round-trip to test/behavioral/corpus.json (and, once A6.2 lands, it gates the Nth backend too).
  4. semantics-rules.test.ts fails until the registry entry is well-formed and its backends are a subset of the five — so a rule can't be added as prose only.
  5. Regenerate the committed spec mirror: UPDATE_SEMANTICS_SPEC=1 npx vitest run test/conformance/semantics-spec-sync.test.ts and commit test/conformance/semantics-spec.json — semantics-spec-sync.test.ts gates 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.

RS-30 · A declared error's fields are camelCase extension members on the problem body


RS-31 · A string .length bound counts Unicode code points, not the host's native string length

RS-32 · A malformed path {id} answers the declared 422, not a framework default

RS-33 · An errors[] pointer names the whole path to the offending field

RS-34 · A query-time projection join is LEFT, not INNER — the joined field is wire null when the target is absent

RS-35 · An absent optional is null on the wire, never an omitted key

RS-36 · .first on an EMPTY collection fails on every target; .firstOrNull is the total form

RS-37 · decimal arithmetic is EXACT — 0.1 + 0.2 is 0.3 on the wire and in storage

RS-38 · A datetime crosses the wire in milliseconds — three digits when a fraction is present, none on a whole second

RS-39 · An empty request body is {} — a parameterless command accepts a body-less call; a foreign media type is 415

RS-40 · An optimistic-concurrency 409 carries the sentence "<Aggregate> <id> was modified by another request"

RS-41 · A save is checked against the version its handle was loaded at — a shape: document row and a persistedAs: eventLog stream included

RS-42 · A numeric wire value outside its type is refused AT ITS FIELD: one grammar, one range sentence

RS-43 · A message-less rule's 422 carries node's sentence on every backend

RS-44 · A string intrinsic counts and cases the same on every backend

RS-45 · An audit entry's actor is the principal's declared user shape — by declared name, nothing else — or null

RS-46 · Every named operation of a versioned aggregate reads If-Match, and two writers carrying one tag yield exactly one 409