Runbook — stamp test-registered alphanumeric sender IDs

Status: prepared, not yet applied to production. The code fix is forward-only and is already in POST /v1/phone-numbers; the rows written before it are still exposed until the steps below are run.

Why

POST /v1/phone-numbers with kind: "alphanumeric" is the one provisioning path with no provider call to skip — a sender ID is just a row — so a test key was never doing anything wrong at AWS. What leaked was the row itself.

Before the fix the branch inserted status = 'verified' with provider_number_id = NULL in both environments. phone_numbers has no environment column, and the predicate every live consumer separates simulated rows with, notSimulatedNumber() (src/lib/api/sender-guard.ts), has to admit NULL — a genuine live alphanumeric sender has no provider number id, and NOT LIKE against NULL is NULL rather than true. A test-registered row was therefore byte-for-byte a live one:

  • senderVerificationReason accepts it, so a LIVE SMS can originate from a sender ID registered with a sok_test_* key.
  • It reads back as a live registration on GET /v1/phone-numbers.

The fix stamps the same fabricated test-reg-… id the number path already mints, moving new test rows onto the NOT LIKE side while real live senders keep the NULL arm. It changes no existing row. Every alphanumeric row a test key wrote before the deploy still has provider_number_id = NULL and the original exploit still works against it.

Apply order

  1. Audit. Run Step 1 of scripts/stamp-test-alphanumeric-senders.sql against production (read-only). It lists every alphanumeric row whose audit entry attributes it to a key with environment = 'test'.
    • Zero rows → check Step 2, then stop. No customer ever registered a sender ID with a test key and there is nothing to stamp.
  2. Check the residue. Run Step 2 (read-only) — alphanumeric rows with no phone_number.provisioned audit entry at all. These cannot be attributed and must be adjudicated by hand; see below.
  3. Stamp (only if Step 1 found rows). Snapshot first, then run Step 3 inside a transaction and diff its RETURNING set against Step 1 before committing.
  4. Verify. Re-run Step 1; it must return zero rows.

Attribution

The row carries no record of which key wrote it, so the audit log is the only link. logAudit stores the acting key in metadata->>'actor_api_key_id' (src/lib/api/audit.ts), which joins to api_keys.environment. Two things limit that link, both handled by Step 2:

  • The audit write is fire-and-forget (logAudit(...).catch(() => {})), so a DB hiccup at registration time loses the attribution permanently.
  • The retention cron can delete audit entries. It is retention-safe by default — rows go only once archived to Blob, or under an explicit AUDIT_LOG_HARD_DELETE opt-in — so this only reaches registrations older than AUDIT_LOG_TTL_DAYS (365) on a deployment that enabled one of those.

Do not widen the stamp to "every alphanumeric row with a NULL provider id". That set contains every genuine live sender ID. Stamping one takes it off the isNull arm the guard finds live senders by, and the customer's live SMS starts failing SENDER_NOT_REGISTERED — an outage, traded for a leak. For an unattributable row, has_live_key in Step 2 is the only usable signal: an org that has never held a live key cannot have registered a live sender. For any other org, ask the customer.

Dashboard-registered senders are correctly invisible to both steps. POST /api/internal/phone-numbers is session-authenticated and has no environment concept, so its alphanumeric rows are live by construction, and their audit entry carries actor_user_id rather than a key.

Notes

  • No migration. The fix and the backfill both use existing columns; nothing in src/lib/db/schema/phone-numbers.ts changes, so db:generate has nothing to emit. This is a data-only remediation, which is why it is a script rather than a numbered migration.
  • provider_number_id has no unique constraint (phone_numbers is unique on (org_id, e164) and (org_id, sender_id) only), and only the test-reg- prefix is read — isSimulatedProviderId() matches the prefix and nothing parses the suffix.
  • Step 3 writes provider_status = 'ACTIVE' alongside the id, matching what the route now inserts. Setting the id alone would satisfy the sender guard but drops the row into the sms-verify-poll recheck cohort with a mismatched status, making the cron's first pass rewrite the row instead of leaving it alone.
  • An affected sender keeps working in test mode. Only the live path changes, which is the intent.