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.
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.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.
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.
| Check | What it prevents |
|---|---|
| Missing role model | A seat with no model, resolved by a silent vendor default. Indigo has no default (ADR-151); it raises. |
| Roster clash | A 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 inequality | The arithmetic above. |
| Cost ceiling | An 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 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.
This is the load-bearing distinction in the whole build, so it is worth stating flatly.
modify is re-grounded against the document's bytes before it
counts. A correction is a new claim and earns nothing for having come from the judge.Not by instruction — by construction. The generation DTOs have no field for them.
anchor_id and a verbatim
quote; code resolves offsets. It cannot count bytes and is never asked to.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.)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.
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 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.
instructions, never system_promptThe 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.
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.
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.
"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.
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.
agents/AGENTS.md "Future agents" was stale. It told every new
agent to ship model-catalog.yaml + model-pricing.yaml. That split is
Atlas's and registrar's; every agent built since (framer, Mira, context_judge, now Indigo)
ships ONE llm.yaml. Refreshed per RFD §11 D3, and indigo added to
the catalog gate's exempt list.{run_id}, not {run_id, form_id}.
The clone carried registrar's set. form_id is the Add-Matter form
correlation key, and Indigo is ambient — triggered by a document arriving, never by a counsel
form — so there is no form to correlate to. The brain-egress gate caught it; the fix narrows
the allowlist and strips form_id from the span dimensions.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.
agentcore.json gains an
indigo runtime entry, which is local-dev only — Terraform in
pramaan-infra deploys (ADR-109).dock_*, no route_intent, no
navigation.py, no runtime_axes.py, no catalog/pricing split. Atlas
is a chat agent; Indigo is ambient with exactly one mode.Design: docs/rfd/indigo-rfd.md (RFD-DOC-007). Parent Epic:
pramaan-io/pramaan-spec#2624. Closes
pramaan-io/pramaan-spec#2730.