Story-3563 — the contracts nobody was holding

A contract between two repositories is only a contract if something outside both of them can be pointed at. Until this story, four of Blake's wire shapes had that — the four JSON Schemas under agent_contracts/schemas/. The rest of the document journey did not.

The pipeline audit (pramaan-spec#3518) traced all twenty hops from an upload to a rendered card and marked each end V (vendored, byte-pinned) or L (read from live source, nothing pinning it). Seven hops came back L. Eighteen of pramaan-agents' 296 Python files were vendored; the classify input, the carve input, the D18 journey block, the production card, the segmentation and the run stamp were not among them.

Why that is worse than it sounds. The agents-side vendor-sync gate derives its file list from what pramaan-functions already vendors. An unvendored file therefore generates no gate by construction. Nothing was failing — there was nothing to fail.

What this story ships

Eight new committed artifacts, each generated from its Pydantic original and each gated so it cannot silently drift. pramaan-functions generates its consumer models from these (W2.3, pramaan-spec#3564), so a shape committed here is a shape nobody hand-writes a twin of.

ArtifactSourceAudit hop it closes
agent_contracts/schemas/result_delivery.v1.schema.json agents/_shared/result_delivery.py::ResultDelivery the functions→agents direction — the one contract whose producer is the other repo
registrar/schemas/registrar_document_input.v1.schema.json classify_models.py::RegistrarDocumentInput hop 4 — the broker's classify invoke payload
registrar/schemas/registrar_carve_input.v1.schema.json carve.py::RegistrarCarveInput hop 5 — the broker's carve invoke payload
registrar/schemas/journey_context.v1.schema.json clerk_context.py::JourneyContext (+ MainMemberContext, MemberIndexRow) hop 7a — the D18 family block an annexure clerk reads
registrar/schemas/production_card.v1.schema.json card_models.py::ProductionCard hop 12 — the card drop's payload.card
registrar/schemas/document_sections.v1.schema.json segment_models.py::DocumentSegmentation hop 13 — the segment drop's payload.segmentation
registrar/schemas/run_stamp.v1.schema.json run_stamp.py::RunStamp (new) the stamp itself — the seam the RFD's own list of 23 had missed
registrar/schemas/classify_tool_intents.v1.schema.json classify_intents.py::CLASSIFY_INTENT_TOOLS hop 14 — the tool NAME vocabulary, "not statically pinned across repos"

The stamp: a cross-repo contract with no name

runtime.py assembles a dict from _posture_stamp() + agent.provenance() + four identity/cost keys, and rides it beside the card, the segmentation and the carve family. The audit's words for it: "no name, no model, no version, no vendored copy and no test." Three consumers had independently invented three different postures toward it — one strips a hand-maintained 13-key list, one ignores extras, one forbids them. Two of the three were one stamp key away from breaking. One of them did break: the merged stamp is what turned every real carve drop into 15 validation errors (Bug-3518).

agents/registrar/run_stamp.py gives it a name. What it deliberately does not do is change how runtime.py builds it — runtime.py is vendored byte-for-byte into pramaan-functions, so that is a parity window, not a refactor this story may take. So the declaration is pinned to the producer a different way:

# tests/test_pipeline_schema_custody.py
sites = _stamp_site_keys(ast.parse(_RUNTIME.read_text()))   # every dict that spreads BOTH
                                                            # _posture_stamp AND provenance
emitted = _literal_keys(runtime_tree, "_posture_stamp") | _literal_keys(agent_tree, "provenance")
for lineno, keys in sites:
    emitted |= keys
    assert not keys - set(RunStamp.model_fields)             # producer  → declaration
assert not set(RunStamp.model_fields) - emitted              # declaration → producer

Both directions, read off the producer's own AST — not a hand-kept list beside it. Add a key at any of the seven stamp sites and the test names the line number. Declare a field no site emits and it names that too, because a contract field nothing writes is a field the consumer waits for forever.

Why the vocabulary is an enum and not a list

Hop 14 carries {tool, args} to the broker, which looks the name up in its own table. Both sides are correct today and nothing compares them. Exported as a closed enum, the generated consumer gets a type that refuses an unknown name rather than a str that accepts any — which is the difference between a contract and a coincidence.

The footgun lint, extended to everything that lands in an artifact

Story-3557 banned two constructs on contract models. Both are invisible: the Python reads correctly, the model behaves correctly, and the damage appears only in the generated schema. default_factory emits no default — the field sits outside required with nothing to fall back on, so a consumer regenerated from that artifact comes out field | None = None and accepts a null the producer refuses.

The new lint walks the exported models and their whole $defs closure — 34 models — because a nested child lands in the artifact exactly as much as its parent. Twenty-two fields moved to literal defaults (pydantic deep-copies them, so the mutable-default trap does not apply, and the default now appears in the schema). Zero behaviour change; the four rail schemas regenerate byte-identical.

Four offenders stay, and the deferral is a ratchet. anchor.AnchoredPage.blocks and the three boundary.CarveIndexEvidence lists live in files vendored byte-for-byte into pramaan-functions, where even a behaviour-identical edit changes the bytes the vendor-sync-gate compares. Turning an additive export into a cross-repo pinned change for four list defaults — on models whose wire output does not move — is the wrong trade, so they go into _DEFERRED_TO_PARITY_WINDOW. That set is asserted by equality, not subset: the day the parity window fixes one, the test fails until its entry is deleted.

The vendored list is the TREE, not the Makefile. tests/vendor/pramaan-agents/ in pramaan-functions holds 22 source files; the parity rows in that repo's Makefile are a smaller set, and the gate derives its map from the tree. This PR's first revision edited boundary.py on the strength of the Makefile alone and the gate went red. Read the tree.

What is not in this PR, and why

The ticket's item 3 — trace_id required and regex-pinned on every envelope, traceparent on the async-rail envelopes — is a wire-shape change on contracts that pramaan-functions vendors byte-for-byte (_shared/step_drop.py, _shared/result_artifact.py) or produces itself (agent_invoke.v1). Every version of it needs a coordinated parity window, so it is deferred to Story-3575 (pramaan-io/pramaan-spec#3575, filed under the same Epic-3568) — a decision with a ticket and an owner, not a paragraph in a PR body that stops being reachable the moment this merges.

EnvelopeWhy it cannot land alone
StepDropV1, ResultArtifactV1 vendored byte-for-byte; the consumer's copy is extra="forbid", so a new key from the producer is refused until the vendored copy moves in the same window. This is Bug-3518's exact failure mode.
AgentInvokeV1 pramaan-functions is the producer. A required field the deployed broker does not send fails every live invoke closed — a deploy-ordered lockstep, not just a repo one.
AgentEventEnvelope brain→broker; the broker parses it with its own hand-written model.

One design question rides with it. Both async-rail envelopes already carry trace.traceparent (Story-3306), and a W3C traceparent is 00-<trace-id>-<span-id>-<flags> — the trace id is inside it. A separate required trace_id field would store one fact twice, which is the "two copies kept equal by care" shape this repo has been removing all week. Worth settling before the window opens, rather than during it.

What these custody checks assert — and what they do not

They assert: every committed *.v1.schema.json is byte-identical to model_json_schema() of its Pydantic source in this repo at this commit; no exported model or $defs child uses default_factory or a defaulted discriminator; and RunStamp's fields match the dict-literal stamp sites found in runtime.py's AST, in both directions.

They do NOT assert: that any deployed producer emits these shapes, that a live invoke payload validates against them, or that pramaan-functions' vendored copies conform. Schema custody is a source-tree claim, not a runtime one. That limit matters most for RunStamp, which is declaration-only — no production code imports it, so its whole guarantee rests on the AST scan of runtime.py as committed here. The two-directional pin is what keeps that from decaying, but it pins source, not behaviour.

Verification

uv run pytest tests/ -m unit          → 509 passed
cd agents/registrar && uv run pytest tests  → 650 passed
uv run python -m agent_contracts.schema     → the 4 rail schemas regenerate byte-identical
uv run ruff check agents/ tests/ agent_contracts/  → clean

No vendored file was touched, so the vendor-sync gate does not fire and no parity window is open for this PR.