Guest Phone Possession Verification — Plan
Status: Phases 0-3 built 2026-07-13 on claude/guest-phone-verification (#843/#880 merged that morning). Phase 4 activation pending merge. Design is canonical in spec.md and ADR-024; this plan is execution only.
Deviations from the original plan, decided during implementation:
- Full unique instead of raw-SQL partial:
@@unique([offerId, phone])(Prisma-expressible). Postgres treats NULLs as distinct, so rows without a phone are unconstrained — identical semantics to the planned partial index, and P2002meta.targetnames the fields for the discrimination step. - Gate branches are fallback, not exclusive: an authed member without a verified phone can pass via an OTP proof like a guest. Without this, a signed-in-but-unverified member had no path through a flagged offer.
- Comp checkout now sends auth headers (it previously never did, so the authed gate path could not fire and signed-in members hit the claimed-email 409 on their own email). Seam fix while editing.
- Proof reuse + reset signal: the form reuses an earned proof across booking retries (e.g. slot taken) instead of re-texting; the parent bumps
verificationResetSignalwhen the server rejects a proof, forcing re-verification.
One PR, branched off main after both prerequisites land. Estimated shape: ~2 migrations, ~4 new API files, ~3 web files touched, test files alongside each.
Phase 0 — Prerequisites (not code)
- [x] #843 merged (brings
PhoneOtpSteps,usePhoneOtp,/auth/phone-login,TWILIO_VERIFY_SERVICE_SIDprovisioning,public.users.phone_verified_at+ partial unique index) - [x] #880 merged (brings comp surfaces,
requiresVerifiedPhone,public_rate_limits,enforcePublicRateLimit, URL-native/bookstate) - [x] Confirm
TWILIO_VERIFY_SERVICE_SIDexists in local.env.localand Vercel mg-api envs (same SID GoTrue uses; #843's activation step 2 provisions the Twilio side)
Phase 1 — API core
- [x] Migration:
phone_verificationstable per spec (include the(phone, purpose, verified_at)index) - [x]
PhoneVerificationService: resurrect the send/check core from commit676244049(git show 676244049:apps/api/src/services/auth/PhoneVerificationService.ts), relocate out ofservices/auth/(it is not auth;services/verification/or similar), keep theisTestMode()short-circuit and add a deterministic test-mode accepted code - [x]
POST /api/phone-verification(send): normalize, rate-limit (phone-otp-send:phone5/hr,phone-otp-send:ip10/hr, committed outside the main tx like the comp route), Twilio send,{ sent: true } - [x]
POST /api/phone-verification/check: rate-limit (phone-otp-check:phone10/hr), Twilio check, insert row, return{ verified, verificationId, accountExists };accountExistsfrompublic.usersverified-phone lookup - [x] Tests: service (Twilio mocked), both routes (rate-limit exhaustion, bad phone, wrong code, accountExists true/false, test-mode path)
Phase 2 — Comp flow integration (API)
- [x] Migration:
PromoRedemption.phone+ raw-SQL partial unique(offer_id, phone) WHERE phone IS NOT NULL - [x]
GET /api/comp/resources: addrequiresVerifiedPhoneto the offer payload - [x]
createCompBookingSchema: optionalphoneVerificationId - [x]
POST /api/comp/reservations: widen the gate (authedphone_confirmed_atOR valid unconsumed verification matching payload phone + purpose + 30-min window); consume in-tx (guarded update, throw on 0 rows); stampPromoRedemption.phoneon both guest and authed paths; per-phone precheck → 409ALREADY_REDEEMEDwith P2002 catch backstop - [x] P2002 target discrimination (required, not optional): the route's post-880 catch swallows unique violations for unlimited-per-user offers (that policy belongs to
(user_id, offer_id)only). Once(offer_id, phone)exists, discriminate onerror.meta.target: phone-constraint violations are ALWAYS 409ALREADY_REDEEMED, never swallowed, regardless of per-user cap config. A target-blind swallow silently bypasses the phone cap for unlimited-per-user offers (finding-1 shape, phone edition). Test: unlimited-per-user offer +requiresVerifiedPhone, two stub emails, one phone → second booking 409s - [x] Tests: gate matrix (authed-verified, guest-verified, missing, expired, consumed, phone mismatch, wrong purpose), per-phone cap across two stub emails, authed+guest cap collision, consumption race
Phase 3 — Web
- [x]
usePhoneGuestOtptransport wrapper inusePhoneOtp.ts(send/check via the new endpoints) - [x] Thread
requiresVerifiedPhonefrom the/comp/resourcesfetch throughHourlyBranchtoCompDetailsForm - [x]
CompDetailsForm: phone required when flagged; interposed code step (PhoneOtpCodeStep) after submit; auto-fire comp checkout withphoneVerificationIdon verify - [x]
accountExistsinterstitial: Sign in (/auth/phone-login?returnTo=<current /book URL>) / Continue as guest - [x]
useHourlyCheckout: passphoneVerificationId; map 403PHONE_VERIFICATION_REQUIREDto a distinct outcome kind - [x] Tests: form substep flow (mock transport), interstitial branches, outcome mapping; typecheck both apps
Phase 4 — Activation (ops, after merge) — COMPLETE 2026-07-13, gate LIVE
- [x] Deploy migrations (pull main first; see prod-migration notes)
- [x] Vercel mg-api: confirm
TWILIO_VERIFY_SERVICE_SID, redeploy - [x] Twilio console: Fraud Guard on, geo permissions US/CA only, confirm Verify service rate settings
- [x] Smoke test with a real phone — ran on prod against the live offer directly (near-zero traffic pre-#878): send, check, guest comp booking e2e; interstitial path deferred to a later re-test (the tester's phone was cleared from its account first)
- [x] Ops script:
scripts/marketing/set-offer-phone-gate.ts— flippedCHERRY_CITY_FIRST_PRACTICEon (also the rollback switch) - Post-smoke revisions shipped same day (#883): standard confirmation card replaces the hand-rolled comp panel (freePaymentLabel override), "On the house" line removed, already-redeemed copy corrected to "one per person"
Phase 5 — Deferred consumers (trigger-driven, decided 2026-07-13)
No dates: each waits for a named forcing function. The primitive was built as a platform capability precisely so each of these is a mount (new purpose value + UI), not a redesign. Asana follow-up task exists (review ~2 weeks post-launch).
- Guest booking management — possession OTP as the guest session ("text a code to view/reschedule my booking"). Largest of the three: needs a manage surface plus a decision on what a proof authorizes (likely: bookings whose stored phone matches). Trigger: guest-checkout support load (guests emailing/calling to change bookings), or the reschedule rework (#859) picking back up — natural to do together.
- Paid guest checkout member-detection — optional verify step in the guest details form, reusing the check endpoint's post-possession
accountExistsrouting. Small build, but it adds a voluntary step to a paying flow: don't add on spec. Trigger: the ADR-022 Part B manual-merge queue showing guest checkout minting duplicate accounts at a rate worth preventing. - Tour form adoption — currently no case, plainly: the tour form produces ~zero scheduled tours (tours come via DMs) and the free first practice IS the tour now. Trigger: the tour form regains a funnel role, or the CM team lands a CRM and starts working form-sourced phone leads.
- Second-OTP wart fix — a member routed through the interstitial gets two codes (possession, then login). Trigger: interstitial branch data shows real "sign in with this phone" traffic; fix = check endpoint branches to the login OTP server-side for member phones.
- First-weeks data feeds every trigger: Twilio Verify spend + delivery failures, interstitial impressions and branch choice, one-per-phone 409 rate, guest booking-change support contacts.
Adjacent, not this PR
- Tours route soft-delete resurrection: the tours route calls
resolveStubUserFromStaffdirectly, bypassingresolveGuestUser's claimed-account and soft-deleted guards (#880 review, fixed for comp/guest-hourly only). Switching tours toresolveGuestUserwould also start 409ing claimed emails on the tour form, a deliberate funnel decision (tour form is low-friction lead gen), not a mechanical fix. Decide separately with Aaron; do not fold into this PR.
Risks / watchpoints
- SMS pumping: the send endpoint costs money; rate limits + Fraud Guard are part of "done", not follow-ups. Watch Twilio spend the first week.
- Window tuning: 30-min consumability is a guess; if guests routinely verify then dawdle past it, the error path re-verifies (annoying but safe).
- Second-OTP wart on the member sign-in path: acceptable v1; if conversion data says otherwise, the fix is using the login OTP itself as the possession proof for member phones (single code), which needs the check endpoint to branch transport server-side.
- Docs: update
docs/features/comp-reservations/and the offers engineering docs when the gate semantics change; reindex triggers don't cover feature specs, no action needed there.