Story-3564 · pramaan-functions · RFD cross-repo-contracts W2.3

The brain's contracts stop being typed twice

Every hand-mirror of the agent boundary has drifted, and the mirrors that "cannot drift" were the worst of them. Bug-3212 shipped green on a hand-written converse dict. Bug-3478 shipped an eleven-field mismatch every layer agreed with. Bug-3309's header set was mirrored as a constant, so the repo stayed green through a re-run of the 403 the mirror was named after. This change makes the consumer models generated from the schemas pramaan-agents publishes, and puts a required check in front of them that refuses — not warns — on any drift.

The pipeline

pramaan-agents @ 35adb805                 (Story-3563 — 12 canonical JSON Schemas)
   │  byte-diffed, BOTH directions, by the required check
   ▼
src/shared/agent_contracts/_schemas/*.v1.schema.json     ← vendored INPUT
   │  make gen-agent-contracts   (datamodel-code-generator 0.72.1, pinned flags)
   ▼
src/shared/agent_contracts/_generated/*.py               ← generated OUTPUT
   │  git diff --exit-code  →  exit 1 on ANY difference
   ▼
`Agent contracts codegen`  (required status context)

The sha lives in exactly one file, .github/agent-contracts-pin.txt, which is what the generator reads. The workflow carries a literal copy only because tests/unit/test_gate_location.py requires a required job's cross-repo ref: to be a 40-character sha rather than an expression it cannot read — and test_the_workflow_checkout_ref_is_the_pin holds the two equal, so R6's "two writers for one resource" does not reappear here. Never ref: main: that is the mechanism behind the 2026-08-04 repo-wide push blockage.

What this check asserts, exactly

Every file under src/shared/agent_contracts/_generated/ is byte-identical to a regeneration from the canonical schemas vendored under src/shared/agent_contracts/_schemas/, and those schemas are byte-identical to pramaan-agents at the pinned commit, in both directions — a schema vendored here with no producer fails, and a schema published there and not vendored here fails. It does NOT assert that the pinned schemas match the deployed producer (that is W3.2), and it does not assert that any consumer uses the generated models (that is W2.4, pramaan-spec#3565).

A byte diff proves the pipeline, not the meaning

A flag change that quietly dropped every maxLength would regenerate cleanly and diff clean forever. So test_generated_schema_fidelity.py regenerates the JSON Schema from each generated model and compares it to the artifact it came from. Five differences between the two documents are representational and are normalized on both sides — required ordering, 1 vs 1.0, hoisted enum $defs, const vs single-member enum, and the root $schema dialect line. Nothing else: description, default, additionalProperties, every constraint keyword and every required membership are compared verbatim.

SurfaceVerdictWhy
step_drop.v1 (the ticket's named surface)EXACT—
agent_invoke.v1EXACT—
agent_event_stream.v1EXACT—
agent_result_artifact.v1EXACT—
result_delivery.v1EXACT—
registrar_document_input.v1EXACT—
journey_context.v1EXACT—
document_sections.v1EXACT—
run_stamp.v1EXACT—
classify_tool_intents.v1EXACT—
production_card.v115 paths, ledgered$defs NAMING only: pydantic writes Cited[CardPartyValue], the generator writes CitedCardPartyValue. Renaming the four keys on both sides leaves zero divergent paths — measured, not asserted.
registrar_carve_input.v116 paths, ledgeredThe four ratcheted optionals — see below.

The ledger is a ratchet, not an allowlist: a surface absent from it must be exactly equal, and a surface present must diverge at exactly the listed paths. A second divergence on an already-ledgered surface is red, and a fixed one is red until its entry is deleted — the same shape pramaan-agents uses for the identical four fields.

The four ratcheted optionals — decided, not defaulted

anchor.AnchoredPage.blocks and boundary.CarveIndexEvidence.{identities,locations,anomalies} use default_factory producer-side. That emits no default into the artifact and leaves the field outside required, so a consumer generated from it comes out list[…] | None = None. Story-3564 accepts them rather than opening a parity window, for three stated reasons: the window is a coordinated two-repo change that would move a REQUIRED context's pin inside a PR whose subject is a new pipeline; the deferred state is inert, because nothing imports _generated and a test fails if anything starts to; and the acceptance is recorded twice — structurally in the divergence ledger, and by value in test_carve_input_ratcheted_optionals.py.

That value-level test records what is true rather than what is convenient. This repo is the writer of registrar_carve_input.v1, and it never emits an explicit null for any of the four; blocks and anomalies are always present. But carve_index_evidence's UNAVAILABLE branch deliberately omits identities and locations — absence the brain's own model reads as [] and a generated consumer reads as None. Harmless today; a W2.4 blocker, pinned by a test that fails the day the parity window lands (pramaan-spec Chore-3581).

Refuses, never warns

$ printf '\nX = 1\n' >> src/shared/agent_contracts/_generated/step_drop_v1.py
$ python scripts/gen_agent_contracts.py && git diff --exit-code HEAD -- $GENERATED_PATHS
::error::The committed agent-contract consumers are not what
scripts/gen_agent_contracts.py produces from the vendored schemas. This is a
REFUSAL, not a warning ...
exit 1

A warning on drift would be another green-by-omission surface — the tree would read as generated while carrying whatever a hand put in it. Every failure path in the job exits non-zero: a hand edit, a hand-written module in the generated tree, a missing do-not-edit banner, a schema vendored with no producer, and a schema published upstream with no consumer here.

Found while building it

The live ruleset returns fourteen required contexts, not the fifteen this repo's AGENTS.md claimed. Agent invoke + event stream parity (pramaan-agents) is declared in .github/required-status-checks.txt and by Chore-3548 but was never added to the ruleset, so it runs and cannot block. That is the Bug-3547 shape one level up, and it is exactly the limit the committed copy states about itself. Recorded in AGENTS.md and filed as pramaan-spec Chore-3582, rather than quietly repaired on another lane's gate.