Story-3557 — typing the one wire door

Every byte a brain sends to durable storage leaves through exactly one function: agents/_shared/result_delivery.py::put_result_artifact. That uniqueness is not a convention — scripts/check_brain_egress.py parses every file under agents/ and fails CI if a second HTTPS PUT site appears. It has held since the rail was built.

And on 2026-08-04 five documents were burnt anyway, across four separate seams, because the door's parameter was dict[str, Any]. The gate proved where the bytes left. Nothing proved what they were.

The governing distinction. A lint is discipline — a future change can bypass it and nothing objects. A type is construction — the wrong thing cannot be expressed. This story moves the drop rail's payload from the first category to the second.

What went wrong, and why every layer agreed

StepDropV1.payload was dict[str, Any]. Bug-3478's carve builder wrote a numbered-member list and no family manifest. That is ordinary Python: it reads correctly, it runs, it produces a drop, and the drop lands at exactly the right key. The consumer a repository away — where CarveDropPayload.family is required — refused every one of them from birth.

SeamWhat the producer wroteWhat noticed
Bug-3478 a rows-only carve payload, no family the consumer, in production
Bug-3518 #1010 effect dumps where the consumer parsed tool intents the consumer, in production
Bug-3518 #1014 document.family.v1, absent from the consumer's union the consumer, in production
Bug-3518 #1016 / Chore-3556 the run stamp flat-merged into the family manifest the consumer, in production

Four defects, one shape: a producer hand-built a dict, and the first thing able to disagree with it was a strict model in another repository, after the bytes were already on S3.

The change, in one picture

1 · The door takes models put_result_artifact(delivery, StepDropV1 | ResultArtifactV1). The model_dump moved INSIDE, so a producer cannot dump early and hand over bytes nobody typed.
2 · The payload is a typed union Four concrete shapes, pinned to the drop's step. family is required with no default — Bug-3478's exact defect, made unrepresentable.
3 · The effect IS the payload A clerk drop's effect holds the payload model. One object, referenced twice; the consumer's mirror rule becomes a property of the construction.

Both halves of "typed", because CI only runs one of them

This repo's CI runs ruff and pytest over agents/. It does not run mypy there. A type annotation alone would therefore be exactly the convention this story exists to replace, so the door enforces both:

if not isinstance(artifact, BaseModel):
    raise TypeError(
        "result delivery refused: needed a contract model "
        f"(StepDropV1 | ResultArtifactV1), found {type(artifact).__name__}; ..."
    )

…and tests/test_typed_wire_door.py shells out to mypy on a five-line snippet that passes a dict, asserting the specific diagnostic:

Argument 2 to "put_result_artifact" has incompatible type "dict[str, str]";
expected "StepDropV1 | ResultArtifactV1"  [arg-type]

That assertion earned its keep immediately. The first version of the annotation imported the union inside the existing if __package__ == "_shared": branch, mypy could not resolve the CodeZip path, and it silently degraded the parameter to Any | Any — a door that looked typed and accepted dicts exactly as before. Asserting the diagnostic rather than a clean run is what caught it.

The stamp rides beside the body — now structurally

Chore-3556 stopped runtime.py merging the run stamp over the card, the segmentation and the family. That was a fix to five call sites. This story makes the same fact a contract:

Beneath that sits a regex tripwire in the egress gate, asked for by the founder. It was measured before it shipped, per AGENTS.md. The obvious detector (**stamp in any dict merge) has 7 non-test hits on this repo today and zero of them is the defect — five are turn={**x, **stamp} on the live event rail, one is Atlas's turn stamp, one is a docstring. It would have shipped with a seven-entry allowlist, which is an allowlist with a regex attached. Narrowed to the shape the defect actually had — a stamp merge landing in a named body slot — it has 0 hits, needs no allowlist, and matches all five lines Chore-3556 deleted.

The wire did not move — proven, not asserted

A producer-side typing change must be invisible to the consumer. The golden fixture (agents/registrar/tests/fixtures/step_drop_wire_golden.json) was captured from the UNTYPED producers and committed in the red-first commit, before the commit that types them. Git history is its provenance: regenerating it alongside a producer change would make it agree with anything.

The bytes come from the REAL door — put_result_artifact against a capturing transport — rather than a locally chosen model_dump flag set. That matters here specifically: a drop's effect dump carries schema, aliased from schema_, so a test picking its own flags could agree with a golden the rail would never produce.

All four steps — carve, classify, card, segment — are byte-identical. No parity window is needed for the wire.

Red first

Ten assertions were written and observed failing before any implementation existed (commit 99207e3b):

DID NOT RAISE TypeError                          (the runtime half of the door)
mypy accepted a dict at the one wire door        (the static half; returncode 0)
16 discriminated-union members default their tag
9 contract fields use default_factory

The two schema-generation footguns

Both are invisible to the type checker and invisible in review. The Python reads correctly, the model behaves correctly, and the damage appears only in the generated JSON Schema — which is what the other repo regenerates its consumer from.

FootgunWhat the artifact saysWhat the consumer becomes
default_factory no default, and absent from required field | None = None — accepts a null the producer rejects
a defaulted discriminator the tag is dropped from required a union that cannot be discriminated; a tag-less member parses

tests/test_contract_model_footguns.py bans both across agent_contracts/ and the three agents/_shared/ wire contracts — the modules whose shapes cross the repo boundary. No allowlist: an allowlist is the escape hatch that turns a construction rule back into a convention, and a contract needing an exemption needs a different shape instead. The six default_factory fields became literal defaults (pydantic deep-copies them, so the mutable-default trap does not apply) and the sixteen union tags became required.

Atlas restates RenderSelection for its CodeZip and is out of the lint's scope by design — its existing parity fence owns that copy, and it duly went red on the canonical change and was moved with it.

What this story did NOT do, and why

Left aloneReason
the payload INTERIORS (family, card, segmentation, gate) stay dict[str, Any] _shared/ is vendored into five CodeZips and may not import an agent's models, so the real BundleFamily / ProductionCard shapes are unreachable from there. Typing them is W2.4's declared scope. What IS decided here is the shape they hang off, which is the layer Bug-3478 got wrong.
stamp stays dict[str, Any] Orchestrator decision: the posture/provenance shape is W2.1's to declare canonically (pramaan-spec#3563), and typing it here is churn that generation replaces.
classify still flat-merges its stamp into the turn The one route Chore-3556 did not reach. Its stamp key set is pruned per run, so declaring the keys would ADD keys to the wire — a byte-visible change needing a parity window, not a quiet fix. ClassifyStepPayload declares the twelve turn fields and allows the stamp as extras, with the reason written on the class.
the egress AST scan root is still agents/ only The scan was not extended, so the agent_contracts/ gap does not open. It holds no egress code. Extending it would need that root added in the same change.

Where the seam moved

Before: a producer hand-builds a dict, every layer agrees, and a strict model in another repository disagrees in production, after the bytes are durable.

After: a producer that hand-builds a dict fails at the type checker, fails at construction, and fails at the door — three times, in this repo, before a byte is encoded.