Guest Phone Possession Verification — Spec
Status: design locked 2026-07-13, implementation not started. Governed by ADR-024. Execution phases in plan.md. Hard prerequisites: PR #843 (ADR-022 phone auth) and PR #880 (scheduler refactor / guest surfaces) merged.
What this is
A platform capability: prove an unauthenticated visitor controls a phone number, record that proof per transaction, and let flows consume it. First consumer is the comp booking flow (requiresVerifiedPhone offers, i.e. CHERRY_CITY_FIRST_PRACTICE). The capability also detects "this phone belongs to an existing member" after possession is proven and routes the person to phone-OTP sign-in, preventing duplicate accounts at the guest front door.
What it is not: identity verification. GoTrue owns identity phone (ADR-022). Nothing here writes auth.users.phone, public.users.phone_verified_at, or users.phone verified state. See ADR-024 for the rejected alternatives.
Data model
model PhoneVerification {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
phone String // E.164, normalized via normalizePhoneToE164
purpose String // 'comp_booking' for v1; future: 'guest_checkout', 'booking_management'
verifiedAt DateTime? @map("verified_at") // set on successful Twilio check
consumedAt DateTime? @map("consumed_at") // set once by the consuming flow, in-tx
consumedBy String? @map("consumed_by") // e.g. promo_redemptions.id
ipAddress String? @map("ip_address")
createdAt DateTime @default(now()) @map("created_at")
@@index([phone, purpose, verifiedAt])
@@map("phone_verifications")
}
- Validity window: a verification is consumable for 30 minutes after
verifiedAtand exactly once. Long enough to finish the booking form, short enough that a leaked id is near-useless. - Retention: rows hold phone + IP (PII); the check route opportunistically prunes rows older than 30 days on its success path (same trick as the rate limiter). Consumed proofs keep their audit value via
PromoRedemption.phone. PromoRedemptiongainsphone String?(E.164) with@@unique([offerId, phone])— as built, a plain unique rather than the originally planned raw-SQL partial; Postgres NULLS DISTINCT gives identical semantics (see plan.md deviations). This is the cross-identity redemption cap.
API
POST /api/phone-verification (public: send)
Body: { phone, purpose }. Normalizes to E.164 (400 on failure), enforces rate limits, calls Twilio Verify verifications.create, returns { sent: true }. Reveals nothing about accounts (nothing to leak pre-possession; see oracle note below).
Rate limits via enforcePublicRateLimit (#880's DB-backed helper), committed outside the main work like the comp route does:
phone-otp-send:phone:<e164>— 5 / hourphone-otp-send:ip:<ip>— 10 / hour
POST /api/phone-verification/check (public: check)
Body: { phone, code, purpose }. Calls Twilio Verify verificationChecks.create. On approved: insert a phone_verifications row and return
{ "verified": true, "verificationId": "<uuid>", "accountExists": false }
accountExists = a row in public.users with this phone, phone_verified_at IS NOT NULL, deleted_at IS NULL (at most one, guaranteed by #843's partial unique index). Post-possession only: the caller just proved they own the phone, so learning their own phone has an account leaks nothing. There is deliberately no pre-verification lookup endpoint (account-enumeration oracle).
On wrong code: { verified: false } 200 (the shared OTP UI maps retry copy). Rate limit checks too: phone-otp-check:phone:<e164> 10 / hour (Twilio also caps attempts per verification at 5).
Service
PhoneVerificationService (resurrect the core from commit 676244049, unwound in #843): start(phone) / check(phone, code) against client.verify.v2.services(TWILIO_VERIFY_SERVICE_SID). Env: existing TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN (SmsService already uses them) plus TWILIO_VERIFY_SERVICE_SID (#843 provisions it; same service SID GoTrue uses). Test mode short-circuits like SmsService (isTestMode()), with a test-only accepted code so route tests run without Twilio.
Do not place it under /api/auth/* or name it anything identity-flavored; it proves possession for a transaction.
Comp flow integration (first consumer)
API side
GET /api/comp/resourcesaddsrequiresVerifiedPhoneto its offer payload (it already selects window/allowlists; the client currently has no way to know the offer requires a phone before hitting the 403).createCompBookingSchemaadds optionalphoneVerificationId.- Gate widening in
POST /api/comp/reservations(replaces the authed-only check at therequiresVerifiedPhonebranch): pass if either - authed caller with
auth.users.phone_confirmed_at(existing behavior), or phoneVerificationIdresolves to a row matching the payload phone (E.164-normalized) and purposecomp_booking,verifiedAtwithin 30 min,consumedAt IS NULL. Otherwise 403PHONE_VERIFICATION_REQUIRED(code already exists inCOMP_ERROR_CODES).- Consumption in-tx: inside
executeLogic,UPDATE ... SET consumed_at = now(), consumed_by = <redemption id> WHERE id = ? AND consumed_at IS NULLand throw if 0 rows (double-consume race). - Per-phone cap in-tx, mirroring the existing per-user pattern (friendly precheck + P2002 catch): when the offer
requiresVerifiedPhone, stampPromoRedemption.phonewith the verified E.164, guest and authed paths (authed phone read frompublic.users.phone). Existing redemption for(offerId, phone)→ 409ALREADY_REDEEMED(client already handles that code).
Web side
- Guest OTP transport: a third
usePhoneOtpwrapper (usePhoneGuestOtp) whosesendOtp/verifyCodehit the two endpoints above.PhoneOtpPhoneStep/PhoneOtpCodeStepare presentational and reused as-is. CompDetailsForm: when the offer (from the extended/comp/resourcespayload, threaded throughHourlyBranch) requires a verified phone: phone field becomes required, and submit interposes a code step before the booking call (same confirm-after-submit shape as #843's profile phone edit). On verified, the comp checkout fires automatically withphoneVerificationId.- Member routing: if the check returns
accountExists, show an interstitial before booking: "This number is verified on an existing account. Sign in to book with it." Actions: Sign in →/auth/phone-login?returnTo=<current /book URL>(#880's URL-native state resumes the flow authed; the comp route accepts authed callers and the gate passes via theirphone_confirmed_at), or Use a different number (back to the details step). ~~Continue as guest~~ — removed 2026-07-14 (Aaron): an account-owned phone must book on the owning account. The comp endpoint enforces this server-side with 409PHONE_ACCOUNT_MISMATCHwhenever the gate phone is verified on a live account other than the booking user (covers signed-in members proving possession of someone else's number too; the interstitial is client-side UX over that rule). Known wart, accepted for v1: the sign-in path sends a second OTP (login code after the possession code). useHourlyCheckout: comp params carryphoneVerificationId; map 403PHONE_VERIFICATION_REQUIREDto a distinct outcome kind (currently falls through to the generic error) as the belt-and-suspenders path for stale clients or mid-session flag flips.
Consent
Unchanged. The OTP SMS is transactional (no consent needed); the marketing consent checkbox and sourceDetail segmentation from #880 stay as they are. A verified number makes the consent row more trustworthy; it doesn't change its collection.
Abuse and cost posture
The send endpoint spends money per call (Twilio Verify ≈ $0.05/successful verification + SMS fees; re-check pricing at implementation). Required at activation, not optional: the DB rate limits above, Twilio Fraud Guard enabled, and geo permissions restricted to US/CA on the Verify service. Same-service-SID sharing with GoTrue means a code from either path checks against either verifier within Twilio's window; both prove the same possession, accepted (ADR-024).
Activation order
- Merge after #843 and #880 (hard deps:
PhoneOtpSteps/usePhoneOtp, phone login,public_rate_limits, comp surfaces,TWILIO_VERIFY_SERVICE_SID). - Deploy migrations; set
TWILIO_VERIFY_SERVICE_SIDon Vercel mg-api (redeploy to apply); configure Fraud Guard + geo permissions in Twilio console. - Verify end-to-end on a preview with a real phone (Twilio test creds can't exercise Verify meaningfully).
- Flip
requiresVerifiedPhoneonCHERRY_CITY_FIRST_PRACTICEvia ops script (dry-run first). Independent of #878's promo activation; the offer works without the flag until then.
Rollback: flip the offer flag off. The endpoints are inert without a consumer.
Future consumers (explicitly deferred)
- Paid guest checkout member detection: same check-then-offer-sign-in, no OTP requirement; needs only a
purposevalue and a mount in the guest details step. - Tour booking form: same shape if tour lead quality warrants it.
- Guest booking management ("text me a code to view/reschedule my booking"): the possession primitive is the session mechanism; pairs with the post-#859 guest story.
- Account claim prefill: claim flow already collects phone via
/auth/complete-profile(identity-level, GoTrue); prefilling the possession-verified number is a UX nicety.