Story-3085 · EPIC-3034 WhatsApp / one conversation

Counsel’s WhatsApp turns now show in the app panel — and we prove it

Counsel asks Vani something on WhatsApp in a taxi, opens the matter in the app twenty minutes later, and the panel shows that exchange. One agent, one conversation, two doors (founder ruling, 2026-07-28). This Story makes that guarantee tested and states the one rule that keeps the door safe.

What the finding got wrong, and what this PR actually does. The issue said resolve_matter_turn_session has “exactly two callers” (both app paths) and the WhatsApp path never calls it. That was already stale: Bug-2989 (#827, merged 2026-07-26) — two days before the issue was filed — added the third caller in src/whatsapp_consumer/handler.py. The channel turn already routes through the ONE shared resolver, already lands in the app panel’s session, and refusals already cannot leak in. So this PR writes no new routing (the correct lazy move: the mechanism exists — don’t re-spell it). It delivers the acceptance criteria that were genuinely unmet: the shared-transcript proof, the fail-closed proof, and a documented + tested rule for which channel bodies enter the transcript.

The path that already exists (Bug-2989)

WhatsApp inbound
  -> whatsapp_webhook: shield, dedupe, disambiguation (sticky scope) -> resolved matter_id
  -> whatsapp_consumer._launch_resolved_turn:
       resolve_matter_turn_session(firm, user, matter_id, agent_key="mira_matter_chat", chat_session_id=None)
         # SAME resolver the WS edge uses; matter fed in, never re-derived
  -> prepare_launch (chat_session_id preserved onto the envelope)
  -> clerk rail -> runner._persist_converse_turns
       -> agents.chat_turn (real question + real answer, channel_origin='whatsapp')
  -> AgentSessionService.load_turns  # the app panel reads these, no channel filter

Because the consumer’s _LAUNCH_AGENT_KEY is mira_matter_chat — the same key the app’s Vani panel lists — and the resolver keys one default_agent_chat per (agent_key, matter_id, user_id), both doors resolve the same session. The turn appends; it never forks.

The rule: which channel bodies enter the shared transcript

Some WhatsApp replies are deliberately content-free (“open the PRAMAAN app”). If one landed in the panel, counsel would read “open the app” while already in the app. The rule, and why it holds by construction:

Channel bodyWhere it’s producedEnters transcript?
The counsel’s question + Vani’s real answer A launched converse run (clerk rail) Yes — this IS the shared conversation
Restricted-provenance teaser Delivery-only swap (enforce_no_verbatim_restricted_reveal in delivery.py) No — the transcript keeps the real answer; only WhatsApp gets the teaser
Over-cap refusal Webhook seam (channel_caps) No — never launches a run, reaches no writer
Not-connected / unregistered / revoked refusal Webhook seam (refusal.py / disambiguation) No — answered at the seam, zero enqueue

So the transcript only ever contains genuinely-run converse exchanges. The restricted case is the subtle one: WhatsApp shows the teaser, the app shows the full answer — the app is the full-fidelity surface (D20). The refusal strings themselves are unchanged; this pins the routing, not the copy (they are byte-identical anti-oracle strings, EPIC-3034 D6).

Fail-closed: the shared door is per-counsel

The consumer feeds identity from the RLS-bounded disambiguation scope and lets the resolver’s ownership check decide the session. A channel turn can only ever append to a session owned by the same firm + user + matter: an explicit foreign session id is rejected (PramaanNotFoundError), and a second counsel on the same matter auto-resolves their own separate thread. Two counsels’ transcripts never merge.

What is proven, and what is not