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:
senderVerificationReasonaccepts it, so a LIVE SMS can originate from a sender ID registered with asok_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
- Audit. Run Step 1 of
scripts/stamp-test-alphanumeric-senders.sqlagainst production (read-only). It lists every alphanumeric row whose audit entry attributes it to a key withenvironment = '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.
- Check the residue. Run Step 2 (read-only) — alphanumeric rows with no
phone_number.provisionedaudit entry at all. These cannot be attributed and must be adjudicated by hand; see below. - Stamp (only if Step 1 found rows). Snapshot first, then run Step 3 inside
a transaction and diff its
RETURNINGset against Step 1 before committing. - 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_DELETEopt-in — so this only reaches registrations older thanAUDIT_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.tschanges, sodb:generatehas nothing to emit. This is a data-only remediation, which is why it is a script rather than a numbered migration. provider_number_idhas no unique constraint (phone_numbersis unique on(org_id, e164)and(org_id, sender_id)only), and only thetest-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 thesms-verify-pollrecheck 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.