One brain, two doors, one keyhole

Story-2026 gave Mira the talk door and Story-2027 the do door. This Story is the router: it formalises the intent dispatch that decides which door a request walks through, fails closed on a request for a door that does not exist, and wires the escalation handoff — a conversation suggestion that becomes a command without a second write path.
Story-2037 (Story-D) · EPIC-2021 (Mira) · depends on Story-2026 (converse, merged) + Story-2027 (command, merged) · pramaan-docs ADR-110 (thin brain / event stream) · ADR-117 (Pydantic AI) · repo pramaan-agents · design docs/notes/rfd-mira-matter-agent.md §2.3 / §9 Story-D

The stakes. Mira has exactly one invoke seam and two behaviours behind it. If the seam picks the wrong behaviour — or silently invents a default when the caller sends garbage — a conversation could run the frame-rewriting panel, or a malformed invoke could start a run it can never finish. The router is small, but it is the one place that decides "talk vs do", so it has to be total: every input maps to a wired engine or to a typed failure, never to a guess.

The naive fix and why it fails. "Default to converse if the intent is missing." That is the classic silent-fallback bug: a broker that forgets to stamp intent would quietly get a chat turn instead of a loud error, and a typo like "comand" would do the same. Mira has no default route — an unknown or missing intent raises before any frame is emitted, exactly like a malformed input. Fail closed, always.

1 · The dispatch was already wired — this Story makes it a contract

Stories B and C each landed their own intent branch in runtime.handle_request, so the dispatch worked before this Story opened. Story-D's job is not to rebuild it but to formalise it: retire the stale "scaffold emits not_implemented" docstrings that no longer describe the code, and pin the two guarantees the caller relies on as tests.

GuaranteeWhere enforcedTest
Each intent reaches its wired engine, in the frozen event orderruntime.handle_request → _route → converse / commandtest_converse_dispatches… / test_command_route_is_wired…
Unknown / missing intent fails closed, before any frameruntime._route (no default; raises ValueError)test_unknown_intent_fails_closed / test_missing_intent_fails_closed
Why "no default route" lives in _route, not in each caller: the fail-closed check is in the one function both the brain (handle_request) and the watchdog (stream_invoke) resolve the intent through. A guard per branch would be a guard to forget; a guard at the single resolution point cannot be bypassed.

2 · The escalation handoff — a suggestion is just a command

RFD §2.3 describes "ok, do it": a conversation turn suggests a mutation, counsel clicks a chip, and it runs as a command. The temptation is to build a third path for that — a suggestion executor. We didn't. A suggested command is the same command door, pre-filled. The only new code is a pure mapping that proves it:

# models.py — SuggestedCommand.as_command_input
def as_command_input(self, *, matter_id, current_frame, ...) -> MiraCommandInput:
    # scope + text pass through UNCHANGED (text -> command);
    # the caller supplies the FRESH frame (it may have advanced since the turn).
    return MiraCommandInput(matter_id=matter_id, command=self.text,
                            scope=self.scope, current_frame=current_frame, ...)
Why the frame is a parameter, not carried from the suggestion: between Mira suggesting "make the issues bullets" and counsel clicking the chip, a Vega ambient build (or another command) may have advanced the frame. The suggestion carries only the intent (scope + text); the caller binds it to the current frame at fire time, so the command is always built against live truth — the same stale-base discipline base_frame_version exists for (RFD §6).

The round-trip test builds a MiraConverseTurn with a suggested_command, fires it through as_command_input into handle_request, and asserts it lands on the command route producing run.started → model.usage → turn.completed → run.completed with command == {scope:"issues", text:"make the issues bullet points"} and requires_counsel_approval == true. Same door counsel uses directly — no second write path.

3 · v1 escalation is lazy on purpose

The suggested_command field exists on the wire, but in v1 the model does not emit it — the conversation turn ships it as None and the always-available UI "Command" affordance (RFD §2.3 mechanism 1) is the escalation. The typed shape is present so the app and the wire are ready; the upgrade is a cheap second non-streaming classify call after the stream closes (RFD §2.3 option b), which keeps conversation pure-text-streaming and Bug-1370-safe.

Why ship the shape but not the model call: a dedicated pre-classifier or a flat-DTO streamed suggestion adds latency (or Bug-1370 risk) to the very path we optimise for first-token speed. YAGNI until counsel friction is measured — but the contract is frozen now so the app builds against it and the upgrade needs no wire change.

4 · What shipped

FileChange
models.pySuggestedCommand.as_command_input(...) — the escalation handoff as a pure, tested mapping; docstring pins the v1-lazy contract + the option-b upgrade path.
runtime.pyDe-staled the scaffold docstrings to describe the wired dispatch this Story formalises (no behaviour change — dispatch was live from B/C).
converse.pyOne comment at the turn build site: suggested_command stays None in v1 (lazy), upgrade = option b.
tests/test_runtime.py, tests/test_models.pyRound-trip handoff test + the as_command_input unit test; refreshed the stale scaffold docstring.

uv run --package mira pytest → 52 passed. ruff check agents/mira/ → All checks passed.

One-sentence recall. The router is total — every request maps to a wired engine or a typed failure, never a default — and "ok, do it" is not a new path but the command door pre-filled from a suggestion, bound to the live frame at fire time.