Disambiguation: ambiguity-only asks + firm-scoped sticky
RFD-AGT-013 D9 / T2g, acceptance CC-04/05/11/26/31 · UAT M-02. Channel conversations are terse; Mira must never guess across matters or firms. This story ships the deterministic disambiguation core for inbound channel turns — a pure decision tree over RLS-bounded firm contexts, the privileged-session loader, and the firm-scoped sticky upsert — AND wires it into the production ingress path: the webhook receiver runs it on every shield-passed message, so only a resolved turn can ever reach the run rail. No model call lives here (ADR-166 — lambdas move, agents think): every rule is a parse, a lookup, or a cardinality check.
The seam — wired where messages actually enter
whatsapp_webhook (POST) — wax-seal → dedupe → re-attest → zero-amp shield
│ shield passed
▼
resolve_live_bindings (T2e-a mirror + epoch check, fail-closed: mirror
error → 500, Meta redelivers) ∩ the shield's attestation-fresh subset
│ empty → refused stamp, zero enqueue (terminal notice is T2e-b's)
▼
resolve_channel_turn(session, bindings, message_text)
│ per binding: privileged session RE-SCOPED to that binding's
│ firm/user/role before ANY tenant read (consume_enrollment_code
│ precedent), so business.matters is filtered by the member-scoped
│ can_read_matter RLS helper (ADR-099) — CC-11: no code path can
│ OFFER an inaccessible matter
▼
decide_channel_turn(contexts, text) — pure, synchronous, fail-closed
│
├─ outcome="resolved" → sticky upsert; envelope ENQUEUED with the
│ resolved scope (firm/user/binding/matter) for the clerk-rail
│ launch edge. ONLY this outcome reaches the queue — "only
│ resolved starts a run" is enforced at the seam (D9).
└─ ask / answered / refused → dispatch_decision_reply: static text
via the T2d-a delivery layer (durable delivery record,
message_kind='disambiguation'), dedupe row stamped
'processed' (R5), ZERO enqueue, zero LLM.
D9 — ask only on ambiguity, never guess
The tree resolves without a question whenever the turn is determined:
an explicit matter code (M016 / M-016 /
m16, D13), a unique firm- or matter-name mention, a menu
number answering the recomputed (deterministically ordered, stateless)
menu, one-firm-one-matter cardinality, or a live sticky scope. Anything
undetermined becomes an ask; anything unresolvable re-asks. There is no
branch that picks a firm or matter the message did not determine.
Acceptance behavior
| Case | Behavior |
|---|---|
| CC-04 multi-firm, no scope named | firm menu (own firms only, capped at 9 + code hint), then matter menu — both RLS-bounded |
| CC-05 follow-up, no scope named | newest still-valid sticky scope reused silently; resolved scope carries scope_label ("M-016 — Sharma v. Sharma (Rao & Associates)") |
| CC-11 menu contents | matter options come from a firm+user+role-scoped session read — the RLS helper drops inaccessible matters before a menu is rendered |
| CC-26 "which matter are we discussing?" | answered statically from sticky, no run; "switch to <code|number|name>" re-resolves against the accessible set and rewrites the sticky row |
| CC-31 matter-code probe | codes are searched ONLY inside the accessible set, so "does not exist" and "not theirs" are the same empty result → one byte-identical UNIFORM_PROBE_REFUSAL |
| F1 deictic re-ask | a sticky matter that fell out of the accessible set (unassigned, closed, revoked) is invalid and ignored — "and the hearing?" re-asks instead of answering about the wrong matter |
| UAT M-02 two taps | a bare number answers the menu the counsel LAST saw: a matter menu leaves an open-menu marker (the firm's sticky row with last_matter_id = NULL), so firm menu → "2" → matter menu → "1" RESOLVES a matter of the chosen firm — never re-enters the firm menu; a firm menu clears the markers |
Firm-scoped sticky context (D16)
A resolved turn upserts business.channel_sticky_context
keyed (firm_id, channel_identity_id) under the resolved
firm's RLS GUCs — flush only, the caller commits with the rest of its
turn. Sticky is per-firm, so the same phone number bound in two firms
keeps independent contexts; follow-up resolution picks the NEWEST valid
sticky across firms and validates it against the freshly loaded
accessible set on every turn.
Red-first evidence
- DB proofs (pytest-bdd, real Postgres, non-superuser runtime role):
CC-04/05/11/26/31 scenarios in
tests/features/channel_disambiguation.feature— the CC-11 and CC-31 denials are enforced by the RLS predicate, not test hope; the CC-31 probe asserts byte-identical refusals for "unknown code" and "inaccessible code". - Pure-half unit proofs
(
tests/unit/test_channel_disambiguation_decision.py): parsing (code/menu-index/switch/scope-query/list), cardinality rules, menu determinism and out-of-range re-ask, sticky recency + invalid sticky ignore, switch resolution, and the defensive empty-contexts refusal.
Files
src/whatsapp_webhook/handler.py— the production wiring: live-binding check + disambiguation post-shield; resolved-only enqueue with scope in the envelopesrc/domains/channel/disambiguation.py— decision tree, RLS-bounded loader, sticky/open-menu persistence,dispatch_decision_replysrc/domains/channel/delivery.py—MESSAGE_KIND_DISAMBIGUATION(durable record per seam reply)src/domains/channel/dtos.py—ChannelTurnDecision/ChannelDisambiguationAsk/ChannelResolvedScope/ optionssrc/shared/formatters/matter_ref.py— the ONEformat_matter_ref;MatterServicenow delegates to it- Tests:
tests/features/channel_disambiguation.feature+tests/steps/test_channel_disambiguation.py(DB proofs incl. the CC-04 two-tap completion),tests/unit/test_channel_disambiguation_decision.py(pure half incl. open-menu precedence),tests/unit/test_whatsapp_webhook_handler.py(seam wiring: resolved-only enqueue)