Story-3036 · EPIC-3034 WhatsApp onboarding

A newly bound number now hears from Mira

When counsel completes the enrollment ceremony — they send their one-time code on WhatsApp and their number binds to their PRAMAAN account — the channel now answers with a welcome. Before, the ceremony minted the binding and then either went silent or (after Bug-2987) sent a terse masked one-liner. Silence after a security ceremony reads as failure; the welcome is the first thing that tells counsel it worked and shows them how to start.

What the design read got wrong, and what this PR actually does. The 2026-07-28 design read said counsel “currently receives nothing.” That was already stale: Bug-2987 (#822, 2026-07-26) shipped a masked post-bind confirmation two days earlier, via a queued post_bind_ping row and dispatch_recorded_whatsapp_text. This PR replaces that masked path with the welcome, and deletes the old mechanism in the same change (R1) — the queued-row INSERT, the ping_delivery_id field, the recorded-dispatch helper, and build_post_bind_confirmation all go.

The message (founder-locked copy, EPIC-3034 D1)

*Mira here. Welcome to PRAMAAN* 👋

You are connected: {number} → {firm}

I work on one matter at a time.

Reply *matters* to see yours and pick one. Then just ask — in plain English.

To move to another, say *switch to {first_matter_code}*.

/help · /matters · /disconnect

Which case gets which message (founder ruling, D-rebind)

CaseSignalMessage
First bind new binding row full welcome
Reconnect after disconnecting rebound=False (a new row — disconnect revoked the old one) full welcome
Re-enrolled while still connected rebound=True Still connected — nothing changed.

The reconnect case deliberately gets the full welcome, not a shortened “welcome back”: the thing most likely to have changed while someone was disconnected is their matter list, which is exactly what the welcome shows. rebound was already computed by the ceremony — no new query.

The zero-matter empty state

A member with zero accessible matters has no first_matter_code to offer, so the full welcome would print an empty switch to example. Instead they get the same greeting and connection line, then the existing disambiguation constant NO_ACCESS_MATTER_TEXT (“You don’t have any active matters I can access here. Check your matter access in the app.”), and the command footer — no switch to line, no crash:

*Mira here. Welcome to PRAMAAN* 👋

You are connected: +91… → Fictional Chambers

You don't have any active matters I can access here. Check your matter access in the app.

/help · /matters · /disconnect

There is exactly one spelling of the matter read (Finding 3): the welcome reuses disambiguation.load_post_bind_welcome_context, a thin public wrapper over the same RLS-bounded read_channel_binding_contexts read the disambiguation menus use. _load_firm_contexts stays private — nothing imports it across modules. Because that read LEFT JOINs matters, a matterless binding still returns its firm row, so the firm name always resolves.

Sent after the bind commits, best-effort (Finding 2)

The ceremony (consume_enrollment_code) runs inside the ingress consumer’s transaction and now mints the binding only. The welcome is dispatched after that transaction commits, from a fresh session re-scoped to the binding’s firm — the exact command_broker._dispatch_refusal precedent. This guarantees a message is never delivered for a bind that then rolls back. It is best-effort: a failed send stamps its own honest terminal failed delivery record (R5) and never unwinds the binding, and one welcome failure never aborts the others or the webhook’s 200.

_intercept_enrollment   -> consume + mark inbound processed (in the ingress txn)
                           returns the EnrollmentResult, sends nothing
[ ingress transaction commits ]
dispatch_post_bind_welcome  -> fresh session, scope to firm, resolve firm+matter,
                               pick body, dispatch_whatsapp_text (post_bind_ping)

Static, one message, zero model

The welcome and the re-enrol ack are static constants beside STATIC_ACK_TEXT in src/shared/whatsapp/formatter.py — zero LLM, zero agent run, zero per-bind variance beyond the three interpolated values. It fits in one WhatsApp message: at the deployed 4096-char cap the body is ~280 characters, so chunk_whatsapp_text returns a single chunk (unit-pinned). The shared formatter takes the empty-state sentence as an argument so it never imports a domain module.

What is proven, and what is not