Skip to main content

PRD: WhatsApp channel — Specialist ↔ number 1:1 binding (managed pool) + pairing-gated access

中文原版(评审基准): whatsapp-per-specialist-number-prd.zh.md

StatusDraft — for review (English mirror; the Chinese original is the review baseline)
OwnerFranky (Product)
Date2026-07-08
Architecture basisADR-002 (Twilio + number pool + pairing codes, current implementation) · ADR-030 WhatsApp subdocument (Draft, per-OSA endpoint direction) · WhatsApp official limits
Sibling specSlack per-Specialist App PRD (same pattern: 1:1 channel identity + state-gated Profile entry)
Related#1471 (ADR-030), #3630 (channel-config UX), #3541/#3542 (channel metrics family)

0. Terminology (what exactly binds to the number)

The "Specialist" a client sees in the portal is that org's operating instance (the live clone materialized from the catalog archetype — i.e. the Specialist behind an OSA). So "Specialist ↔ number 1:1" in this PRD effectively means one dedicated number per (Org × Specialist) — the same archetype Kai serving two clients gets two different numbers. This matches ADR-030 WhatsApp subdocument Invariant I-2 (one dedicated number per OSA) exactly.

1. Problem statement

The current WhatsApp channel (ADR-002) already has a number pool and pairing codes, but three product gaps remain:

  1. Routing collapse. One WhatsApp credential per org means multi-Specialist orgs collapse to the primary OSA on WhatsApp — numbers have no strict 1:1 relationship with Specialists, and a customer cannot "message a specific Specialist."
  2. Invisible configuration, unguarded capacity. Admins have no per-Specialist WhatsApp toggle; the pool's remaining capacity is invisible to the setup flow — an empty pool still lets you "configure," which then fails silently.
  3. Undefined admission. Pairing (HW-XXXX codes) exists but is buried in a settings page; who may converse with a Specialist, and what happens to messages from unpaired numbers, has no confirmed product rule (today unknown senders receive a pairing-prompt auto-reply — never a deliberate product decision).

This PRD tightens the existing machinery into: strict 1:1 number binding + pool-capacity-gated toggle + pairing-whitelist admission + a Profile conversation entry.

2. Goals

  • G1 — Deterministic attribution. Every WhatsApp-enabled Specialist owns a dedicated number; messages to that number land only in that Specialist's conversations. Measured: inbound single-row lookup by To number resolves 100%; primary-OSA fallback hits = 0.
  • G2 — Honest capacity. Admins see at the toggle whether enablement is possible; "switched on but no number" silent failures never occur. Measured: support tickets caused by no-number configuration failures = 0.
  • G3 — Controlled admission. Only paired user WhatsApp accounts can converse with a Specialist; messages from unpaired numbers get no response (no reply, no conversation). Measured: conversations triggered by unpaired inbound = 0.
  • G4 — Self-serve pairing. Client members manage pairing (view / add / remove) from the Specialist Profile entry without admin or AM involvement. Measured: pairing completion rate ≥ 80%; first-message success after pairing ≥ 95%.
  • G5 — Recyclable numbers. When a Specialist is reclaimed/deleted, its number is released back to the pool and reused after a cooling-off period — with no cross-client message bleed during the process.

3. Non-goals (v1)

  • On-demand number purchase (dynamic Twilio acquisition) — v1 only consumes the pre-provisioned pool; pool growth follows the ops process (see §8 capacity ladder).
  • Full ADR-030 endpoint/persona architecture — v1 tightens semantics on the existing whatsapp_number_pool + whatsapp_linked_numbers; migrating to osa_channel_endpoints is a later architecture phase (the design must stay compatible — see R1 technical note).
  • WABA-per-Specialist identity containers & sender-profile sync — per-number sender display name / avatar sync (ADR-030 §2/§6) deferred; v1 numbers carry no persona profile.
  • Org front-door mode (one number per org + topic routing) — ADR-028, explicitly deferred.
  • Guidance replies to unpaired senders — per product requirement v1 is silent; a one-time pairing hint is an open question (O2).
  • Group chats — 1:1 direct messages only.

4. User stories

Client admin (org_admin)

  1. As a client admin, I want to switch on WhatsApp for a specific Specialist so my team can reach that Specialist directly on WhatsApp.
  2. As a client admin, when the pool has no free numbers I want the toggle disabled with the reason shown, instead of failing after I switch it on.
  3. As a client admin, I want the config panel to show the Specialist's dedicated number and the list of paired users, so I always know who is using this channel.

Client member 4. As a client member, I want a WhatsApp entry on the Specialist Profile that walks me through pairing and into a conversation. 5. As a client member, I want to view / add / remove my own paired numbers so I can self-serve when I change phones. 6. As a client member, after pairing I want to converse with the Specialist directly in WhatsApp, with the same experience as other channels (AI draft + Expert review).

Ops (SuperAdmin / AM) 7. As a SuperAdmin, I need to maintain the number pool (add numbers; see bound / free / cooling states) and get alerted when free capacity runs low. 8. As an AM, I want to see WhatsApp enablement and pairing status across my clients to drive adoption.

Expert — workflow unchanged: inbound messages arrive in the queue already attributed to the right Specialist; replies go out via that Specialist's dedicated number.

5. UX flows

5.1 Admin configuration (client portal → Settings → Channels → WhatsApp)

The WhatsApp card becomes a per-Specialist list; each row: Specialist avatar/name + toggle + status.

Pool has free numbers:
Toggle enabled → ON → system randomly binds one free pool number to this Specialist
→ [Enabled] dedicated number +15551000001 · N paired users · [Configure ▸]

Pool exhausted:
Toggle disabled, hint: "No WhatsApp numbers are currently available — the WhatsApp
channel cannot be enabled." (SuperAdmin sees the low-pool alert on their side.)

Toggle OFF (with an active binding):
Confirm dialog → unbind → number enters cooling-off → the Specialist's entry is
hidden and its pairings are revoked

5.2 Config panel ([Configure ▸] on a Specialist row)

  • Number info: the dedicated WhatsApp number (with copy button).
  • Paired users list: one row per paired org member — user name · paired WhatsApp number (masked, e.g. +86 138****1234) · paired-at time; the admin can remove any pairing (the user can re-pair).
  • Admins cannot add pairings on a user's behalf (pairing requires possession verification by the user — see 5.3).

5.3 End-user flow (WhatsApp entry on the Specialist Profile)

  1. A client member clicks "Chat on WhatsApp" on the Specialist Profile (hidden/disabled with a hint when the channel isn't enabled — same gated-entry pattern as the Slack PRD).
  2. My-pairings panel opens:
    • Paired: lists my paired WhatsApp numbers (removable); primary button "Open WhatsApp chat"wa.me/{dedicated number} deep link straight into the chat with this Specialist.
    • Not paired: guided add — "Add pairing" generates a one-time HW-XXXX code (15-minute TTL, existing mechanism) plus a wa.me/{dedicated number}?text=HW-XXXX deep link pre-filled with the code; the user sends it from their own WhatsApp, completing possession-verified pairing (impossible to bind someone else's number).
    • On success the panel refreshes in place (webhook-driven) and the primary button switches to "Open WhatsApp chat."
  3. Conversation admission: messages from a paired number → enter this Specialist's conversation normally (inbound → agent draft → Expert review → reply). Messages from unpaired numbers → no response: no auto-reply, no conversation; internal quarantine record only (see R7).

5.4 Copy (v1)

SurfaceText
Disabled toggle hintNo WhatsApp numbers are currently available — the WhatsApp channel cannot be enabled.
Profile entryChat on WhatsApp
Entry (channel not enabled, member view)tooltip: This Specialist doesn't have WhatsApp yet — contact your admin.
Pairing guideSend the pairing code HW-XXXX to {number} to link your account (valid for 15 minutes).
Pairing successPaired! You can now chat with {Specialist} on WhatsApp.

6. Requirements

Must-have (P0)

R1 — Number pool & 1:1 binding. Maintain the WhatsApp number pool (tightened semantics on the existing whatsapp_number_pool): number state machine available → bound(specialist_id) → cooling → available. A number binds to at most one Specialist at a time (DB unique constraint); a Specialist holds at most one number.

  • Given a number bound to Specialist A, then any attempt to bind it to B is rejected (constraint level, not application-only).
  • Technical note: shape read/write paths after ADR-030 osa_channel_endpoints (number = external_id) so the later migration is smooth.

R2 — Pool-capacity-gated toggle. In admin channel config, toggle availability = count of available pool numbers > 0.

  • Given an empty pool, when the admin views channel config, then the toggle is disabled with the hint copy (§5.4), and the backend rejects enable requests (disabled UI is not a security boundary).
  • Given an empty pool with orgs wanting to enable, then the SuperAdmin pool panel shows the unmet demand (low-capacity alert in P1).

R3 — Activation binds a random number (concurrency-safe, idempotent). Toggle ON → atomically bind one randomly-chosen available number (UPDATE … WHERE status='available' … LIMIT 1 RETURNING; active-active rules, no in-process locks).

  • Given two admins enabling two Specialists simultaneously with one number left, then exactly one succeeds and the other gets the empty-pool hint — never a double-bind.
  • Toggling repeatedly leaks no numbers: re-activation for an already-bound Specialist is idempotent (returns the existing binding).

R4 — Config-panel visibility. The panel shows: the dedicated number + the paired-users list (user · masked number · paired-at); admins can remove a pairing (audit-logged).

R5 — Gated Profile entry. The Specialist Profile shows a WhatsApp entry that never lies: channel enabled → active; not enabled → hidden or disabled with hint (same pattern as Slack PRD R3). Clicking opens the my-pairings panel (§5.3).

R6 — Self-serve pairing management (possession-verified). Users view / add / remove their own pairings: add = generate an HW-XXXX code (15-min TTL; rate limit unchanged: 5/hour) + pre-filled wa.me deep link, send from the user's own WhatsApp (reuses whatsapp_pairing_codes / whatsapp_linked_numbers); removal takes effect immediately.

  • Given an expired or wrong code, then the inbound is treated as unpaired (R7) and consumes no conversation.
  • Pairing is scoped to (user, org): one pairing allows conversing with all WhatsApp-enabled Specialists of that org (the To number routes to the specific Specialist) — per-Specialist granularity is open question O3.

R7 — Inbound admission (the core rule). Inbound routing: To number → bound Specialist (org + specialist determined), From number → pairing table → user. Same org + valid pairing → the message enters that Specialist's conversation. Otherwise (unpaired / pairing removed / cross-org) → no response: no reply, no conversation, no queue item; write an internal quarantine record (unresolved_ingress, reason=unpaired_sender, masked From/To) for ops triage and abuse monitoring.

  • Invariant tests: a conversation's specialist_id/org_id always equal the number's binding; unpaired inbound produces 0 rows in conversations/messages.
  • Pairing-code messages (HW-XXXX) are the sole exception: they run the pairing flow and never count as conversation messages.

R8 — Outbound & conversation semantics. Expert-released replies dispatch via the Specialist's dedicated number (Twilio; 3-layer pattern: circuit → retry → DLQ); conversations are isolated per (user, Specialist); HITL defaults unchanged. Replies outside the 24-hour customer-service window require template messages (billing per whatsapp-limits §3).

R9 — Reclaim & release. Release triggers: Specialist unassignment (OSA termination), Specialist deletion/archival, org deactivation, admin toggling OFF. Release actions (transactional):

  1. Unbind (number → cooling);
  2. Revoke all pairings under that binding (so former correspondents can never reach the number's "next owner");
  3. After the cooling-off period (default 30 days, configurable) the number returns to available; inbound during cooling is silently dropped;
  4. Fully audited (who, when, why).
  • Given a cooled number re-bound to a new Specialist, then no old pairing can converse with the new Specialist (revoked in step 2).
  • Twilio numbers stay owned by the platform (pool model) — no Twilio-side release, avoiding 63051-style re-registration costs and number loss.

R10 — Metrics & audit (server-side). Consistent with the channel metrics family (#3541/#3542): whatsapp_pool_size{status} (gauge), whatsapp_binding_total{outcome}, whatsapp_pairing_total{outcome}, whatsapp_unpaired_inbound_total; audit log for toggle on/off, bind/release, pairing add/remove (actor + org + specialist). Every §11 metric is computable from these.

Nice-to-have (P1)

  • SuperAdmin pool panel: pool list (number · state · bound-to · cooling remaining), manual number add, low-capacity threshold alert (Slack notification to AM/ops).
  • Admin notifications: binding succeeded / enable rejected on empty pool / unusual pairing activity.
  • QR code in the pairing panel (scan to open the wa.me deep link — desktop experience).
  • Unpaired-inbound abuse monitoring: alert on a single From hammering many numbers.

Future (P2)

  • Per-Specialist pairing granularity (currently org-level; see O3).
  • Auto-replenishing the pool via the Twilio API (maintain a capacity waterline).
  • Migration to full ADR-030 osa_channel_endpoints + WABA-per-Specialist personas + sender-profile sync.
  • One-time pairing-guidance reply to unpaired senders (if O2 changes the decision).
  • Org front-door mode (ADR-028).

7. Current baseline (what changes)

AspectToday (ADR-002 as-is)After v1
Number ↔ SpecialistPool exists but no strict 1:1; routing falls back to primary OSAStrict 1:1 (constraint-level); deterministic routing by To number
Admin configNo per-Specialist toggle; pool capacity invisiblePer-Specialist toggle + pool-capacity gate + config panel (number + pairings)
PairingHW-XXXX codes exist, buried in settings; send-code-to-bindSame possession-verified mechanism, upgraded to a self-serve pairing panel (view/add/remove) inside the Profile entry
Unpaired inboundQuarantine + pairing-prompt auto-replySilent no-response (internal quarantine record only) — behavior change!
Legacy whitelist (whatsapp_sender_whitelist)Legacy routing fallbackNo longer part of admission in v1 (pairing only); kept read-only pending cleanup
Number reclaimNo productized flowR9 full lifecycle (release → revoke pairings → cooling → reuse)
Profile entryNoneGated "Chat on WhatsApp" + pairing panel

8. Platform constraints (face them squarely)

  1. The pool's ceiling is Meta's portfolio cap: 2 numbers unverified, 20 after Business Verification, manual tickets beyond (whatsapp-limits §1 / capacity ladder). Total WhatsApp-enabled Specialists ≤ pool cap — Business Verification is the hard prerequisite for scale; pool growth follows the capacity ladder (tickets → ISV per-client portfolios).
  2. Messaging limits are portfolio-shared: all pool numbers share one messaging-limit budget (250 → 2,000 → …); one high-volume Specialist can crowd out the whole pool's business-initiated quota. In-window replies are unconstrained — this feature is user-initiated-first, so the risk is manageable but must be monitored.
  3. Template pricing: replies outside the 24h service window are billed per template message (since 2025-07); costs roll up by osa_id.
  4. 63051 idle lock: pool numbers sitting available with no activity for 30 days can be locked by Meta — pool maintenance needs periodic keep-alive or accepts re-activation cost (engineering to confirm, O4).

9. Security & privacy

  1. Pairing = possession verification: the code must be sent from the user's own phone; the UI never accepts free-typed numbers (prevents binding someone else's number). 15-min TTL + 5/hour rate limit (existing mechanism).
  2. Permissions: toggle/config panel = org_admin; pairing panel = the member themself (own pairings only); admins may remove any pairing but never add one. Backend guards are the authoritative boundary.
  3. Number-reuse bleed: R9's "revoke pairings + cooling-off" is the core mitigation; inbound during cooling is silently dropped and recorded.
  4. Masking: phone numbers are masked in pairing lists and logs; full numbers exist only in the routing layer.
  5. Tenant isolation: ADR-020 invariants — conversations filtered by (org, specialist); unpaired inbound leaves no cross-tenant-visible trace.

10. Rollout & rollback

  • Feature flag, org allowlist: 1 internal test org + 1–2 friendly client orgs; expand after funnel metrics (R10) run clean for 2 weeks.
  • Rollback = flag off: UI reverts to today's card; existing bindings stay bound (no auto-release); inbound admission reverts to current behavior (including the pairing-prompt auto-reply).
  • Behavior-change notice: when "silent unpaired" ships, pilot orgs already using WhatsApp must be told in the release notes (users who would receive a pairing prompt today will get no reply at all).

11. Success metrics

Leading (2 weeks post-launch): enable success rate (toggle → bound) ≥ 99%; pairing completion (initiated → success) ≥ 80%; first-message-into-queue success after pairing ≥ 95%; unpaired inbound handled silently 100% (0 responses, 0 conversations); enable-rejected-on-empty-pool count (feeds capacity planning).

Lagging (quarter): WhatsApp conversation volume per org; WhatsApp-enabled Specialist count and pool utilization; paired-user retention (still conversing 30 days after pairing); WhatsApp-related support tickets down.

Measurement: entirely from R10 counters + audit logs; no manual pulls.

12. Open questions

#QuestionOwnerBlocking?
O1Initial pool size & growth trigger (given Meta's 2→20 cap: only 2 numbers pre-verification — pilot is constrained immediately. Make Business Verification a launch prerequisite?)Product + opsYes
O2UX risk of fully-silent unpaired inbound: a real customer who mistypes/changes numbers messages into a void. Give the first unpaired inbound a one-time pairing hint (silent thereafter)?Product (Franky)Yes — decides R7's final shape
O3Pairing granularity: v1 is org-level (one pairing converses with all enabled Specialists of the org). Need per-Specialist admission (user A may chat with Kai but not Mei)?ProductNo — P2 reserved
O4Real impact of the 63051 idle lock on parked pool numbers + keep-alive approach (periodic self-ping? accept re-activation?)EngineeringNo
O5Cooling-off duration (default 30 days proposed) and whether SuperAdmin may manually thaw earlyProduct + opsNo
O6Same user phone paired in multiple orgs: confirm routing is unambiguous (whatsapp_linked_numbers carries orgId and the To number already fixes the org — verify)EngineeringNo
O7Unified design for the WhatsApp and Slack Profile entries (the multi-channel "Contact via …" component, see Slack PRD §5.2 design note)DesignNeeded before UI build

13. Phasing & dependencies

  • v1 (this PRD): R1–R10 in full + the P1 pool panel (R2 is not operable without a SuperAdmin pool view). Prerequisites: Meta Business Verification (O1 — decides pool ≤ 2 vs ≤ 20); pool pre-provisioned (Twilio purchase + seeding).
  • v1.1: remaining P1 (alerts, QR code, abuse monitoring) + O2/O3 decisions landed.
  • v2: ADR-030 endpoint/persona migration, on-demand purchase, sender-profile sync, per-Specialist pairing granularity.

Dependencies: Twilio number purchase (ops); Meta Business Verification (ops — start immediately; review takes days to weeks); Profile-entry component shared with the Slack per-Specialist PRD (O7).

14. References