Story-2862

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

CaseBehavior
CC-04 multi-firm, no scope namedfirm menu (own firms only, capped at 9 + code hint), then matter menu — both RLS-bounded
CC-05 follow-up, no scope namednewest still-valid sticky scope reused silently; resolved scope carries scope_label ("M-016 — Sharma v. Sharma (Rao & Associates)")
CC-11 menu contentsmatter 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 probecodes 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-aska 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 tapsa 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

Files