Skip to content

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 P2002 meta.target names 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 verificationResetSignal when 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_SID provisioning, public.users.phone_verified_at + partial unique index)
  • [x] #880 merged (brings comp surfaces, requiresVerifiedPhone, public_rate_limits, enforcePublicRateLimit, URL-native /book state)
  • [x] Confirm TWILIO_VERIFY_SERVICE_SID exists in local .env.local and Vercel mg-api envs (same SID GoTrue uses; #843's activation step 2 provisions the Twilio side)

Phase 1 — API core

  • [x] Migration: phone_verifications table per spec (include the (phone, purpose, verified_at) index)
  • [x] PhoneVerificationService: resurrect the send/check core from commit 676244049 (git show 676244049:apps/api/src/services/auth/PhoneVerificationService.ts), relocate out of services/auth/ (it is not auth; services/verification/ or similar), keep the isTestMode() short-circuit and add a deterministic test-mode accepted code
  • [x] POST /api/phone-verification (send): normalize, rate-limit (phone-otp-send:phone 5/hr, phone-otp-send:ip 10/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:phone 10/hr), Twilio check, insert row, return { verified, verificationId, accountExists }; accountExists from public.users verified-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: add requiresVerifiedPhone to the offer payload
  • [x] createCompBookingSchema: optional phoneVerificationId
  • [x] POST /api/comp/reservations: widen the gate (authed phone_confirmed_at OR valid unconsumed verification matching payload phone + purpose + 30-min window); consume in-tx (guarded update, throw on 0 rows); stamp PromoRedemption.phone on both guest and authed paths; per-phone precheck → 409 ALREADY_REDEEMED with 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 on error.meta.target: phone-constraint violations are ALWAYS 409 ALREADY_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] usePhoneGuestOtp transport wrapper in usePhoneOtp.ts (send/check via the new endpoints)
  • [x] Thread requiresVerifiedPhone from the /comp/resources fetch through HourlyBranch to CompDetailsForm
  • [x] CompDetailsForm: phone required when flagged; interposed code step (PhoneOtpCodeStep) after submit; auto-fire comp checkout with phoneVerificationId on verify
  • [x] accountExists interstitial: Sign in (/auth/phone-login?returnTo=<current /book URL>) / Continue as guest
  • [x] useHourlyCheckout: pass phoneVerificationId; map 403 PHONE_VERIFICATION_REQUIRED to 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 — flipped CHERRY_CITY_FIRST_PRACTICE on (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 accountExists routing. 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 resolveStubUserFromStaff directly, bypassing resolveGuestUser's claimed-account and soft-deleted guards (#880 review, fixed for comp/guest-hourly only). Switching tours to resolveGuestUser would 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.