Story-2730 Walkthrough

Change: Indigo's engine. One AgentCore deploy unit, one mode (read), four passes brain-sequenced inside a single invoke, and exactly one document.understanding effect on the frozen event stream. The contracts, the assembly gate and the eval fixtures landed earlier (Story-2729, PR #252); this is the machinery that fills them.

Merge gate. RFD-DOC-007 §0 blocks engine code until G1 (Blake live end-to-end — carve + card persisted, exposed, on screen) and G2 (the #2497 deep-read smoke green on main after pramaan-functions Bug-2625) are proven live. That verification is the orchestrator's, and it is a live rail, never a merged PR. This PR is complete and green; it does not merge until G1 ∧ G2 is confirmed.

The shape, in one screen

AddressableDocumentPacket  (broker, agent_invoke.v1)
  Pass 1 ORIENT     1 cheap long-context seat   -> plan + provisional story + coverage plan
  Pass 2 DEEP READ  3 diverse seats, fan-out    -> exchanges / findings / assertions / attention
  Pass 3 ANALYSE    same roster, 2nd stance     -> graded attention + silences + revised story
  Pass 4 JUDGE      1 seat, distinct family     -> rulings ... then CODE assembles
  -> document.understanding on agent_event_stream.v1 -> broker -> app

Registrar round-trips one pass per invoke because each of its passes is separately persisted and cost-capped. Indigo's four passes are internal steps toward one envelope, so there is no intermediate truth worth persisting and the whole panel runs inside a single invoke (D6-BRAIN-SEQ, the Mira command-panel precedent). The broker sees exactly one effect.

The consequence nobody can skip: the timeout inequality

One invoke covering four sequential passes means the per-pass budgets must sum under the invoke budget. Registrar's numbers cannot be copied — its 180 + 180 + 540 = 900 sits inside a 600s invoke, which is safe only because registrar gets a fresh lease per pass. Copy them here and a valid slow run reaches a judge allowed 540s with 240s left; the watchdog then kills it after maximum spend, un-resumably, on exactly the long documents this agent exists for.

So config.py enforces the inequality at import:

orient 60 + 2 x proposer 120 + judge 300 + slack 60  =  660  <  invoke_budget_s 720

Note the 2 x. Deep Read and Analyse both spend the proposer budget; counting it once is the arithmetic error itself, and a test pins that specific term.

Four things fail closed at config load, not at first use

CheckWhat it prevents
Missing role modelA seat with no model, resolved by a silent vendor default. Indigo has no default (ADR-151); it raises.
Roster clashA judge id or its family also sitting on a proposer seat. That collapses the panel into self-grading — the judge verifying the proposer's mistakes with the weights that made them, silently, until evals happen to run. The clash unit is the family, because a different id from one vendor shares the blind spots.
Timeout inequalityThe arithmetic above.
Cost ceilingAn Indigo ceiling above the broker's live $0.50 abort rail. A ceiling that can never fire is not a ceiling: the run would die on someone else's rail after full spend. Shipped at 0.45.

The only comparable guard that exists today is eval-side (framer/evals/judge.py). An eval-harness guard cannot stop a mis-rostered production llm.yaml. This is the runtime layer; the eval-side one is now the second.

The roster, re-resolved (not inherited)

The Epic's suggested ids predate the 2026-07-09 OpenRouter pull, so every seat was re-resolved against MODELS.md's vetted table: "Grok 4.5" → x-ai/grok-4.20, "Gemini 3.5 Flash" → google/gemini-3.1-flash-lite. Three proposer vendors, a fourth for orient, a fifth for the judge, and two more again for the eval judges — so no grader shares a family with anything it grades. These are candidates, not crowned seats: each still owes the MODELS.md D6 seat gate on Indigo's own eval. Contract → eval → model.

What is CODE and what is MODEL

This is the load-bearing distinction in the whole build, so it is worth stating flatly.

Three values a seat can never emit

Not by instruction — by construction. The generation DTOs have no field for them.

  1. Character offsets. The model names an anchor_id and a verbatim quote; code resolves offsets. It cannot count bytes and is never asked to.
  2. Envelope confidence. Derived in agent.py: high only when coverage is complete AND the reject rate is zero AND the story survived the judge. A seat allowed to emit it would certify high over an envelope whose quotes had just been thrown out. (Finding.confidence is a different quantity — per-element, downgrade-only. Same type, two rules; they never mix.)
  3. A contradiction label. Absent from the enum. There is no field to put it in.

The registry card is a projection — proven by running the gate twice

The card's keywords must trace to accepted candidates, so the card cannot be known until the survivors are. Assembly therefore gates once to learn the survivors, builds the card and the read-first set from those survivors, then gates a second time — and that second result is the authoritative one whose stats ride the event. The gate is pure and cheap, so the extra pass costs nothing and buys a card that is honestly a projection rather than new prose claiming to be one.

Two fences, not one

Indigo reads two zones with two provenances: the DOCUMENT (untrusted, may be adversarial) and the MATTER FRAME (ours — but model-written by Vega and counsel-edited, so injectable upstream). Each is closed by its own random per-run nonce (secrets.token_hex(8), minted independently).

One shared nonce would let a hostile document print the frame block's closer, and a poisoned frame print the document's. Two independent secrets mean neither can forge the other's boundary. Random, never static and never a content hash: registrar's static <<<END DOCUMENT EVIDENCE>>> can be printed verbatim by any document, and a content hash is computable by anyone holding the content.

When there is no frame, the block is omitted entirely — not rendered empty. An empty block still tells the model a frame exists and invites it to reason about the absence. No frame means generic importance, and the product degrades gracefully instead of pretending.

The byte gate is not an injection defence. It proves a quote exists in the document — which planted text also does. Wall #1 is the fence plus the inert-data constitution; wall #2 is bytes, and it is an anti-fabrication wall. Do not cite one for the other.

The iron rule, in prompt and in code

The frame steers the flashlight; it cannot paint objects on the wall. It may change which episodes rank critical, which three anchors are read first, and how a reason is worded. It may never create a finding, change a kind, supply a quote, raise a confidence, or alter coverage.

What makes this enforceable rather than hopeful: the gate never learns where a candidate's idea came from — only whether its bytes are in the packet. A frame-sourced proposition with no in-document anchor dies exactly like a fabricated quote. That is why sending the frame is safe: it widens attention, not truth.

Prompts: instructions, never system_prompt

The four-layer stack (SOUL → domain base → role overlay → material) composes into pydantic-ai instructions. This is a correctness rule, not a style one: system_prompt persists inside replayed message history, so an old copy of a safety rule could sit frozen for months and resurface to compete with the current one. instructions re-asserts the live contract clean every run. A test greps the whole agent tree for system_prompt= and fails on any hit.

prompt_version is sha256[:12] of the composed instruction text of all four roles, minted in code. Edit one word of any prompt and the version changes — so a prompt edit cannot ship silently, and no eval score can be read across a prompt boundary without noticing.

Few-shot hygiene

The worked examples are a synthetic shipping-arbitration cross-examination (berth confirmation, laytime, a missing berth log) that appears nowhere else in the repo. A prompt carrying a gold label scores by recall, not competence — the fixture stops measuring anything the moment its rows are in the instructions. A parametrised test asserts that no Rajaram term (rs 2 cr, nsdc, board resolution, sebi, q67…) appears in any role's instructions, with a positive control that the synthetic scenario does.

The wire: one effect, and no way to fake one

run.started -> run.progress* -> model.usage* -> document.understanding -> run.completed | run.failed

document_understanding_event takes understanding as a required keyword. There is no null-envelope arm, so a failed panel or gate has nothing to call — it emits run.failed and no product effect at all. The two incompatible failure shapes ("an error event with an empty envelope" vs "run.failed") cannot both exist if only one of them is constructible.

run.progress per pass exists because ONE invoke now covers four of them: without it, a healthy ten-minute read is indistinguishable from a wedged one. It reports; it never extends a deadline.

The canonical amendment (RFD D4)

"One builder + one emit" is true brain-side only. AgentEventType is a closed enum, so document.understanding also needed an additive row in agent_contracts/agent_event_stream.py, a canonical builder, a python -m agent_contracts.schema regen, and the custody test updated — the same path RFD-DOC-006 took for the two deep-read types. The custody tests fail without it.

The watchdog (Bug-1370)

An AgentCore entrypoint async-generator runs on ONE worker-loop thread. A blocking model call freezes every asyncio timer on that loop — so a deadline enforced inside the brain is a no-op exactly when it is needed. stream_invoke runs the brain on an isolated thread with its own loop and enforces invoke_budget_s from the worker loop the brain cannot freeze. A wedged run always ships a typed terminal frame, and never a document.understanding: it produced no understanding, and saying otherwise is the one lie this design exists to prevent.

Two doctrine corrections this PR makes

Verification

cd agents/indigo && uv run pytest          260 passed
uv run ruff check agents/indigo/ agent_contracts/ tests/    All checks passed
uv run pytest tests/ -m unit               202 passed  (custody, error-contract, brain-egress)
uv run python agents/indigo/evals/run.py gates
  fixture #1  proposed=80  rejected=0  reject_rate=0.0000
  PASS: gold replays clean through the assembly gate

Every engine test is offline: seats are pydantic-ai FunctionModels, so what is under test is Indigo's orchestration — fan-out, timeboxes, retries, ruling completeness, re-grounding, and the values code mints — never a vendor's weights. The live eval mode is opt-in (INDIGO_EVAL_LIVE=1) and is never the per-PR gate; it exists so fixture #1 can be metered once the entry gate clears, because that metering is itself a build gate (§9 R4) and you cannot meter a design you never ran.

What is deliberately NOT here

Design: docs/rfd/indigo-rfd.md (RFD-DOC-007). Parent Epic: pramaan-io/pramaan-spec#2624. Closes pramaan-io/pramaan-spec#2730.