The front door shrinks to seven exact tokens
On 2026-07-31 counsel asked "is the Sugar Pipes hearing tomorrow?"
and got a menu whose line 2 read
M-013 — Rahul Rathode vs Sugar Pipes Industries delhi. The
deterministic front door rejected the sentence containing its own answer:
three English messages, three identical menus, zero agent runs. D17's rule
is now the code: code decides what may be SEEN, the model decides
what was MEANT.
TODAY: unmatched input -> menu SHOULD BE: unmatched input -> Vani
matter_id=None) and MiraConverseInput.matter_id
is a required str until Story-3189 (pramaan-agents) lands.
Merging this first routes counsel into a 500. Review freely; merge second.
The deterministic surface, after
Seven tokens, each matched against the whole message
(fullmatch), bare word only, no synonyms, no phrases. A
leading slash is optional on 1–5 (WhatsApp's / autocomplete).
| # | Token | Where it lives | Action |
|---|---|---|---|
| 1 | stop | command table | two-way: honest can't-halt reply if a run is in flight, else UNBIND (U13) |
| 2 | logout | command table | unbind — aliases disconnect, sign out (aliases are free) |
| 3 | help | command table | static capability list |
| 4 | matters | command table | the RLS-bounded matter menu |
| 5 | current | command table | static scope statement from sticky |
| 6 | a matter code, alone | disambiguation tree | pin that matter (CC-31 fail-closed) |
| 7 | a bare number | disambiguation tree | answers a menu — only when a menu was just shown |
status is not a token (founder ruling,
2026-07-31): account questions are sentences for Vani and the brain-side
account_status tool. The bare word routes to her like any
other text.
Two defects retired, not preserved
-
parse_matter_codeused.search()— a code anywhere hijacked the sentence ("is m16 done?" became a scope command). Now: drop every non-alphanumeric, uppercase, require exactlyM+digits as the whole message.M-013,m13,m 0013, and the en-dash spelling are one token;M13 pleaseandis m16 done?reach Vani. Normalising is not fuzzy matching: one input, one canonical output. -
_name_matchesfuzzy substring with a 3-char floor — the 2019 pattern D17 bans — is deleted, along with the switch phrases, the natural-language scope query, and the list-request phrase doors.
The U8/U9 hinge: menu state gets its own column
Token 7 turns on state: a bare number answers a menu only when a menu
was just shown. Before this change the marker overloaded
last_matter_id = NULL on the sticky row — so showing a menu
destroyed the sticky scope, and a firm menu had no marker
at all (any number from a multi-firm counsel picked a firm, menu or not).
Migration 0235 adds business.channel_sticky_context.pending_menu_kind
('firm'/'matter'): menu state is now orthogonal
to the sticky scope. A launched turn clears the markers — which is exactly
the UAT pair that differs only in what preceded it:
| Preceding turn | 2 means | Case |
|---|---|---|
| a normal answer (run launched) | a sentence for Vani | U8 — must NOT switch |
a menu (matters) | menu entry 2 | U9 — must switch |
How a matter-less turn reaches Vani
decide_channel_turnresolvesfirm_scope— firm pinned,matter_id=None(one live binding, no sticky, not single-matter).- The webhook envelope carries
resolved_scope.matter_id = None. - The ingress consumer skips the chat-session resolver and launches a
converseAgentCommand with no matter and no session (the AgentCommand matrix admits both-None converse launches;agents.agent_runalready admits them: NULL scope +run_origin='user', migration 0216). - The clerk stamps a well-formed matter-less
MiraConverseInput(NULL matter, empty frame) — the payload 3189's brain accepts.
A multi-firm counsel with no sticky still gets the firm menu: the one cardinality question code still owns, because a run cannot launch without a firm. Menus stay RLS-scoped; an inaccessible matter is never offered (CC-11, BDD-proven).
What was verified
- The D17 UAT list as unit tests: U1–U10 automatable cases live in
tests/unit/test_channel_disambiguation_decision.py— including the byte-identicalm-99probe refusal (U10) and the U8/U9 pair. - CC-04/05/11/26/31 BDD scenarios re-wired to the token semantics and green against real Postgres with RLS bound (proves migration 0235 + the reader end-to-end).
- Command-table proofs: aliases (
disconnect,sign out) classify tologout;statusclassifies to nothing; stop's two halves (U13 unbind; honest in-flight reply, no fake halt, no unasked unbind). - Launch-chain proofs: matter-less AgentCommand validation (both-None converse; either-alone rejected), consumer launches without a chat session, clerk stamps the matter-less input shape.
make test, the clean-DB Alembic round-trip (make test-db-alembic-roundtrip, includingalembic check), scoped Ruff, and scoped mypy (zero new errors vs main).
Open question flagged for the PR
Whether Meta enforces STOP as a platform-level opt-out on WhatsApp is UNCONFIRMED — the two-way form (halt in flight, unbind when idle) is safe under either answer, which is why it was chosen. The channel-side cancel path (a real halt) does not exist yet and is its own Story (pramaan-spec#3200); until then the in-flight reply says it cannot stop mid-answer and never claims a halt that did not halt.
The review round — four findings, one theme
Review found four places where state claimed something happened when it
had not — D17's own theme. All four are fixed in this PR; a fifth
(removing the disconnect alias) was rejected: an alias is
another exact token, not a compatibility path, and the founder ruled
aliases are free.
-
The in-flight stop check missed reserved launches.
reserve_launch_run_idwrites only anagents.agent_run_key; the run row appears at claim. The check is now keyed on the reservation, sostopin that window answers honestly instead of unbinding (the race the two-way rule exists to cover). - Markers survived intervening replies. A fully-sent static reply — scope answer, probe refusal, keyword text — now closes the menu; a bare number answers only a menu that was JUST shown (U8).
- The firm menu advertised name selection it no longer honours. It now says "Reply with a number", and overflow directs to the matter code (token 6 pins firm AND matter) instead of the firm name.
-
Markers armed before delivery. Menu state now
persists only after every chunk of the reply crossed — a menu counsel
never saw never arms, and a failed intervening reply leaves the menu
armed (they still last saw it).
dispatch_decision_replyowns the post-delivery persist; resolved turns persist at launch.