Story-2876 Walkthrough

Change: Mira's converse route learns which surface a turn arrived on and behaves accordingly (RFD-AGT-013 T2j-b, D16/F8, CC-32). On a channel (WhatsApp, email) she signposts cross-surface references ("In the app you asked …") and paraphrases restricted content instead of quoting it — a short paraphrase naming the topic plus "open the app", never a verbatim span and never silence. Behind the prompt sits a brain-side fail-closed guard: a channel turn body containing a restricted span verbatim (or blank over restricted context) is replaced with a static signpost fallback before it leaves the brain. A deterministic offline eval fires the REAL converse path (scripted model → real prompt → real guard) and gates it all in CI.

Boundary. This is the AGENTS half of T2j. The functions-side delivery gate (Story-2875, merged) stays the hard egress wall: it replaces any restricted-provenance or verbatim-revealing body with the static teaser at dispatch. This PR adds the layers in FRONT of it — the prompt behavior that produces a useful safe paraphrase, and the brain-side guard that fails closed before a leaking body even reaches the broker. Three walls, defence in depth.

What Mira now sees (models.py — the envelope)

FieldShapeWhy
MiraConverseInput.channel_originstr = "app", token grammar [a-z][a-z0-9_]{0,31} (functions-side vocabulary, garbage fails closed)the surface THIS turn arrived on; activates the cross-surface contract
MiraMessage.channel_originstr | Noneper-history-turn surface (the Story-2875 chat_turn.channel_origin stamp); None = pre-2875 turn, rendered untagged — no fabricated origin
ContextBundle.contains_restricted / restricted_excerpts()property + list of verbatim spanssurfaces the sensitivity gate's restricted: true stamps (Story-2853 vocabulary) in the same shape the functions gate consumes

The prompt contract (converse.py — CROSS-SURFACE RULE, prompt v3)

Surface: whatsapp.                         ← deterministic line, STABLE prefix (cache-safe)
Matter metadata: …
<<MATTER_FRAME_v5>> … ← unchanged sandwich

history suffix:  counsel (app): what did we file?   ← per-turn surface tags when known
                 mira: the reply affidavit          ← untagged when origin unknown

The brain-side guard (converse.py — guard_channel_restricted, fail-closed CODE)

Runs on every turn before turn.completed. The turn body is what the channel delivery rail sends (deltas reach only the app thread, where restricted is allowed), so gating the body gates the channel:

Delivered body (channel turn, restricted context)Guard
safe paraphrase, no spanpasses untouched
contains a restricted span verbatim (case/whitespace-mangled included — functions-gate normal form)replaced with the static signpost fallback + operator log mira_converse_restricted_reveal_blocked
blank over restricted context (silence)replaced with the fallback
app-surface turnnever gated — restricted is app-allowed

Documented ceiling (ponytail comment in code): whole-span containment only — a bare figure out of a span is prompt-taught and eval-graded (protected_terms), not code-caught.

The CC-32 eval (evals/signposting.py — offline, deterministic, REAL path, blocking)

Each candidate in evals/datasets/signposting.yaml scripts the MODEL's raw output; the harness fires the real build_converse_prompt → stream_converse → guard_channel_restricted seam and grades the DELIVERED body:

InvariantCheck
no verbatim revealno restricted span contained under the functions-gate normal form (a scripted leaking model must come out as the fallback — guard_fired)
no partial revealno protected_terms figure/name present — the grader is stricter than the span-guard
signpostedat least one signpost marker present ("open the app" / "in the app" / "on whatsapp")
not silentevery must_mention topic present on model-authored bodies; a blank body fails closed to the fallback

Four cases / eleven scripted candidates, both directions. tests/test_signposting_eval.py runs every candidate through the real path as the blocking CI gate, and proves the suite RED-capable: with the guard neutered, the leak candidate's delivered body carries the span and the invariants go red — the eval measures the fence, not nothing.

Verification

uv run ruff check agents/mira                          # All checks passed
uv run --package mira pytest agents/mira -q            # 243 passed (13 new)
uv run pytest tests/ -q                                # repo governance suite — 254 passed
uv run python agents/mira/evals/run.py signposting     # CC-32 set passed (real converse path)
uv run python agents/mira/evals/run.py adversarial     # 14 cases held (unchanged, still green)
uv run python agents/mira/evals/run.py privilege       # ER-26 set passed (unchanged)
Not in this PR: the broker-side wiring that stamps channel_origin on the invoke envelope for WhatsApp-originated launches (T2b-a, functions lane) and the restricted classification itself (Story-2853 sensitivity gate). Until they ship, every envelope defaults honestly to channel_origin="app" and the contract is dormant — no behavior change on the live app path.