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.
| Artifact | Source | Audit 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 editedboundary.pyon 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.
| Envelope | Why 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.