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.
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 body | Where it’s produced | Enters 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
- Integration (CI DB gate),
test_whatsapp_turn_shares_the_app_panel_transcript: an app turn and a WhatsApp turn on one matter interleave in ONE thread, and the panel read returns both — with the channel launch agent_key pinned to the app panel’s key so a drift that would split the doors fails loudly. Observed red with_LAUNCH_AGENT_KEYdrifted toatlas_matter_chat. - Integration,
test_channel_turn_cannot_append_to_another_users_session: a second counsel cannot append to the first’s session and auto-resolves a different one. Observed red with the resolver’s ownership check planted fail-open. - Unit,
test_shared_transcript_rule.py: for a restricted turn the transcript body is the real answer while only the teaser crosses to WhatsApp — the two bodies differ, the teaser is never the thread body. - Already covered:
test_channel_turn_stamps_origin_and_restriction_provenance(channel_origin + provenance persist at the DB boundary),test_whatsapp_consumer_handler(seam/refused turns never enqueue a run). - Not yet proven — live handset UAT is outstanding. The final proof is a real bound handset: ask Vani a matter question on WhatsApp, open that matter’s Vani panel in the app, and confirm the exchange is there in order. For a restricted matter, confirm WhatsApp shows the teaser while the app panel shows the full answer. That is a manual post-merge step and is not claimed done here.