pramaan-functions · Story-3038 · EPIC-3034

Channel keywords, with or without a leading slash

The urgency. Story-3036’s welcome (PR #842, this branch’s parent) ends with the footer /help · /matters · /current. Until this ships, none of those three worked — the first message every new user receives advertised three dead commands. This PR makes them real, plus /status, /disconnect and its Meta opt-out alias /stop.

The founder ruling: one normalizer, one table, no second path

Every keyword works with or without a leading /. help and /help hit the same pre-saved static answer. There is exactly ONE normalizer (strip an optional leading slash) and ONE keyword table — never two code paths that could drift apart. Everything is intercepted at the ingress seam, the same place that already consumes CONFIRM, so no keyword ever starts an agent run (ADR-166: these are deterministic lookups, and lambdas move).

Types eitherAnswerBuilt here
help · /helpthe capability list, in Mira’s voicenew
status · /statusthis number, firm, connected date, last security checknew
current · /currentwhich matter am I onalias — routes to the existing scope answer
matters · /mattersthe matters this sender can reachbare form was live; the slash now works too
disconnect · /disconnectunlink this numbernew
stop · /stopalias of disconnect (Meta opt-out expectation)new

The single source of truth (owner’s 2026-07-28 addendum)

The command registry is built in pramaan-functions, not reached across into pramaan-agents (its capabilities_markdown() serves the LLM mapping route, not counsel-facing copy). The registry lives in src/shared/whatsapp/commands.py — pure, domain-free, beside formatter.py. It is the ONE source that will feed three consumers:

Voice is a requirement, not polish: if the broker answers /help with a system string it sounds like a system; if it sounds like Mira, it is Mira. So every reply is authored as product copy in Mira’s voice and stored in the registry; the broker renders it verbatim — same voice, zero tokens, byte-identical output.

Hi — I'm Mira. Here's what I can do for you right here:

• *matters* — see the matters you can work on
• *current* — check which matter we're on right now
• *status* — see how this number is connected
• *disconnect* — unlink this number from Mira
• *help* — list everything I can do here

Or just tell me what you need on a matter and I'll get to work. Every
command works with or without a slash, so *help* and */help* both reach me.

The rule that stops this breaking: anchoring

The BARE form must match the WHOLE message (anchored); the SLASH form may carry arguments. Otherwise “help me summarise this order” would fire the static help menu instead of reaching Mira, and “current status of the appeal” would answer the wrong question. A leading slash is an explicit command sigil, so /current status of the appeal is deliberately still current.

MessageClassified as
help, /help, matters?, /mattersa command
help me summarise this ordernot a command → Mira
current status of the appealnot a command → Mira
/current status of the appealcurrent (explicit sigil)

The trap the brief flagged: _LIST_RE in disambiguation.py was ^\s*(?:my\s+)?matters\s*\??\s*$, so /matters failed it. The fix is the normalizer stripping the slash BEFORE any anchored match. The bare matters keyword now belongs to the ONE command table; _LIST_RE was narrowed to its natural-language doors only (“list my matters”, “what matters can I see”, the “my matters” turn), so the keyword is never handled in two places.

/current and /matters are one function, two doors

/current routes to answer_current_scope (the old private _answer_scope, now public) — the same function the natural-language regex already reached. The NL form (“which matter are we on?”) keeps working: this is an additional door, never a replacement, and one function means the two doors can never answer differently. Likewise /matters and “list my matters” both render through _list_decision and persist the same open-matter-menu marker (UAT M-02), so a number typed after /matters resolves exactly as after the NL form. There is one spelling of the RLS-bounded matter read (load_channel_contexts, a thin public wrapper; _load_firm_contexts stays private).

Founder copy change: when no matter is set, the scope answer now ASKS, mirroring the welcome — We're not on a matter yet. Which matter do you want to work on? Reply *matters* to see yours.

/disconnect and /stop: the real capability gap

Today disconnecting requires the app — which fails exactly the person who can only reach WhatsApp. The handler unbinds the sender’s own binding(s) only: live is the sender’s own active per-firm bindings (resolved from the platform-verified subject, never another member’s, never another firm’s), and each is revoked through the app’s own self-unbind path service.unbind — which re-checks caller ownership under the firm-scoped session and kills the binding’s fetch tokens, sticky context and pending confirmations exactly as the app does (R5, no second spelling of revocation). The confirmation is sent AFTER the binding died, via the terminal-notice path (enforce_live_binding=False).

Why an unbound sender learns nothing (EPIC-3034 D6)

Commands are intercepted only for a shield-passed, live-bound sender, at the same seam as CONFIRM and BEFORE disambiguation. A stranger typing help is refused by the zero-amp shield first — the keyword is never an oracle, and the refusal stays byte-identical whether a command word was aimed at an inaccessible matter or a non-existent one. Commands are also never cap-limited (they start no run) and never trigger an interstitial (that timer starts only for turns that reach an agent).

Where it plugs into the seam

record_inbound (dedupe) -> shield -> live bindings -> media
  -> confirmation reply (YES/NO)
  -> handle_channel_command(message_text, live, to)   <-- Story-3038, before disambiguation
        classify_command  (strip '/', one table)
          help      -> capabilities_text()                     (pure)
          status    -> status_text(number, per-firm connections)
          current   -> disambiguation.answer_current_scope
          matters   -> disambiguation.answer_matters_list       (+ menu marker)
          disconnect/stop -> service.unbind per own binding, confirm after death
        True  -> mark inbound processed, no enqueue, continue
        False -> fall through to resolve_channel_turn

What is proven, and what is not