Skip to content

ADR-024: Guest phone possession verification is transaction-scoped, not identity-scoped

Status

Accepted (2026-07-13). Depends on ADR-022 (phone-native login/recovery, PR #843) and the scheduler refactor's public guest surfaces (PR #880). Implementation spec: features/guest-phone-verification/spec.md.

Context

ADR-022 makes a verified phone a Supabase-native identity factor: GoTrue owns verification (auth.users.phone_confirmed_at), a trigger mirrors it to public.users.phone_verified_at, and a partial unique index on verified phones backstops one-person-one-account. All of its verification paths (verifyOtp phone_change, signInWithOtp sms) require an existing authenticated user.

PR #880 introduces unauthenticated booking: guest hourly checkout and comp offers, both minting stub users (which do get auth.users rows via the admin API, but the guest never holds a session for them). It ships a requiresVerifiedPhone Offer flag whose enforcement checks auth.users.phone_confirmed_at for the authed caller, so an unauthenticated guest can never pass it. The flag is therefore unusable for the flows it was designed for (free offers redeemed by strangers), and the first-practice promo (PR #878) correctly ships with it off.

Four forces want a guest-usable phone check, not just comp enablement: free-offer abuse economics (stub identities are as cheap as email addresses; the real cap is one redemption per phone), duplicate-account prevention at the guest front door (ADR-022's Part B remediation cleans up exactly the splits guest checkout creates), CM lead quality (a possession-verified phone is the join key to the phone/CRM stack), and SMS program safety (transactional SMS and marketing consent currently attach to numbers nobody proved they own).

Decision

Possession proof and identity verification are different facts with different lifetimes, and they never share storage.

  1. Identity stays exclusively GoTrue's (ADR-022, unchanged). Nothing in the guest path ever writes auth.users.phone, public.users.phone_verified_at, or anything the partial unique index reads.
  2. Possession is API-owned: a thin service calling Twilio Verify directly (send + check only), using the same Twilio account and Verify service SID that GoTrue uses as its SMS provider. This resurrects the ~70-line core unwound in #843 (commit 676244049), scoped down: no identity writes, no /api/auth namespace.
  3. Proof is recorded per transaction in a phone_verifications table (phone, purpose, verified/consumed timestamps, short validity window) and consumed by the flow that required it. The client passes the verification id back with the booking; the server binds id, phone, purpose, window, and single consumption in-transaction.
  4. The real fraud control is per-phone redemption uniqueness: PromoRedemption gains a nullable normalized phone with a partial unique index per offer. Both guest and authed redemptions of a requiresVerifiedPhone offer stamp it, so the cap spans account boundaries. Without this the OTP is theater.
  5. Account existence is revealed only after possession is proven. The check response includes whether a verified member account holds that phone; the client then offers phone-OTP sign-in (ADR-022's login, returnTo back into the URL-native /book flow) as dedup prevention. A pre-verification lookup endpoint would be an account-enumeration oracle; we don't build one.
  6. Paid guest checkout gets no OTP. The card is the fraud control there; the per-offer flag exists precisely so free offers can demand verification while paid flows stay frictionless. Member-detection-by-phone in paid flows is a later consumer of the same primitive.

Alternatives rejected:

  • Anonymous Supabase users to unlock GoTrue verification: handle_new_user rejects email-less users by design (ADR-022 amendment), and an anonymous auth row holding a verified phone would block the real person's later verification via GoTrue's phone uniqueness. Requires trigger exemptions plus cleanup jobs to simulate what a small table does directly.
  • Admin-confirm the phone on the stub's existing auth user (admin.updateUserById with phone_confirm): same poisoning failure by another path. A guest who verifies and never claims leaves a stub owning the phone at the identity layer, blocking the real signup later; GoTrue uniqueness also makes the write fail whenever the phone already belongs to a member, entangling possession with identity errors.
  • Stamp public.users.phone_verified_at on the stub: fights #843's demote trigger and turns the partial unique index into a landmine (stale stub vs. future member mirror write fails inside GoTrue's confirm path).
  • Supabase-only, no direct Twilio: GoTrue has no API to verify a phone that belongs to no session; this option doesn't exist.

Consequences

Benefits: requiresVerifiedPhone becomes real for guests; one-redemption-per-phone holds across stub and member identities; guest phones become trustworthy for CM follow-up and SMS; the same primitive serves future consumers (paid-flow member detection, tour form, guest booking management) without redesign; ADR-022's invariants stay intact.

Tradeoffs: We reintroduce a sliver of direct Twilio coupling that #843 deliberately unwound (accepted: different job, ~70 lines, no identity writes). A member routed to sign-in receives a second OTP (possession code, then login code); accepted for v1 as the rare path. Roughly $0.05 + SMS fees per verification (verify current pricing at implementation).

Risks: The send endpoint spends money per call, so it is an SMS-pumping target: DB-backed per-phone and per-IP rate limits (#880's public_rate_limits), Twilio Fraud Guard, and US/CA geo permissions are required activation steps, not options. Sharing GoTrue's Verify service SID means a code sent by one path can be checked by the other within Twilio's window; both prove possession of the same phone, so this is accepted rather than running a second Verify service.