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.
| Seam | What the producer wrote | What 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
put_result_artifact(delivery, StepDropV1 | ResultArtifactV1). The
model_dump moved INSIDE, so a producer cannot dump early and hand over bytes
nobody typed.
step. family is required
with no default — Bug-3478's exact defect, made unrepresentable.
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:
stampis a REQUIRED sibling field, so "body only, stamp folded in" no longer builds.- A validator refuses any body field sharing a key with that payload's own stamp — so
{**card_dict, **stamp}is refused too. It hard-codes no key list; the stamp is compared against itself, which is why a pruned or extended stamp needs no edit. The single carve-out isdocument_id, the one name the body and the stamp legitimately share — the same carve-out pramaan-functions ratified consumer-side in #1016.
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.
| Footgun | What the artifact says | What 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 alone | Reason |
|---|---|
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.