Story-3104

Self-healing WhatsApp business number from observed webhook metadata

Story-3062 moved the dialable WhatsApp business number out of the browser into server settings. It was still a hand-copied mirror of Meta: the value is typed in by a human, and a well-formed but wrong number passes validation and produces a scannable, wrong QR — the root cause of Bug-3080. This story makes the number self-healing by capturing the real dialable number Meta already sends us in the metadata of every inbound webhook, and preferring it over the configured seed.

The data is already arriving

Every wax-seal-verified inbound webhook carries both the dialable number and its internal Graph id. The ingress seam read only the id and dropped the number:

"metadata": {
  "display_phone_number": "15559998888",   // the dialable number — was discarded
  "phone_number_id": "111222333444555"      // the stable Graph id
}

No Graph API call, no new credential, no new network path: the authoritative value already flows through traffic we process anyway.

What Changed

ops.whatsapp_business_numbers                (migration 0226 — NEW)
  phone_number_id  PK   the stable Graph id the number lives under
  display_number        observed dialable number, 8-15 E.164 digits (DB CHECK)
  observed_at           first-seen / last-changed timestamp
  platform-global: no RLS, no firm_id (the ops.channel_consent_versions precedent)
  GRANT SELECT, INSERT, UPDATE to pramaan_app; DELETE revoked

Settings.whatsapp_phone_number_id            (NEW plain setting)
  the capture GATE — a public Graph resource id, not a credential

src/domains/channel/ingress.py
  extract_observed_business_numbers()   deduped (id, number) pairs, once per webhook
  capture_observed_business_number()    gated idempotent upsert + drift logs

src/shared/whatsapp/deep_link.py
  resolve_business_number(session)      now async: prefers the observed number,
                                        falls back to the settings seed

src/whatsapp_webhook/handler.py         captures inside the ingress transaction

The constraint that makes this safe, not dangerous

An observed number is trusted only when its phone_number_id matches the id we send enrolment from. Without that gate, any number that receives wax-seal-verified traffic could silently repoint enrolment at itself — self-healing would become self-harming.

The acceptance ticket first specified matching against resolve_whatsapp_credentials().phone_number_id — an SSM read of whatsapp-phone-number-id. But the webhook Lambda role is granted only meta-app-secret and whatsapp-webhook-verify-token; reading a third param there is a new IAM capability the ticket forbids, and exactly the R2/R11 outage pattern (a dependency with no grant, green in CI, dead in staging). The phone_number_id is not a secret — it arrives in plaintext in every webhook and is hard-coded in the deploy canary — so it becomes a plain setting (WHATSAPP_PHONE_NUMBER_ID), the same reasoning that makes the public whatsapp_business_number a plain setting. No SSM, no IAM, single repo.

Fail closed

When WHATSAPP_PHONE_NUMBER_ID is unset the gate is unarmed and captures nothing — it must never degrade to "trust every number that messages us". The resolver then simply falls back to the settings seed, which is also the cold-start path (a fresh deploy has observed nothing yet). The number is demoted from source-of-truth to bootstrap fallback, not deleted. Chore-3105 wires the value alongside WHATSAPP_BUSINESS_NUMBER so it is not a second unset-config surprise.

The drift detector — half the value of the ticket

Capture logs two signals that would have caught Bug-3080 before a user hit a dead link, both on inbound traffic (before any enrolment request):

phone_number_id_mismatch   a verified webhook for a number that is NOT ours —
                           the configured id is wrong, or a second number was
                           provisioned. Nothing is persisted.
drift_from_configured      the observed number differs from the configured seed —
                           the number changed in Meta. The link now self-heals;
                           the log tells ops to update the seed.

Cheap on the hot path

Capture is one idempotent upsert per webhook (observed pairs are deduped), never a read-modify-write. The ON CONFLICT ... WHERE display_number IS DISTINCT FROM EXCLUDED.display_number clause makes an unchanged re-observation a true no-op — no write amplification and no log for the steady state; only a first observation or a real change touches the row.

What Stays Out

The sender's SSM credential resolution (src/shared/whatsapp/credentials.py) is untouched — this story adds a second, independent source of a public identifier for the capture gate; it does not migrate the sender off SSM. No IAM, no infra, no SSM-write. Wiring WHATSAPP_PHONE_NUMBER_ID into deployed environments is Chore-3105.