Skip to content

0012 — Referral attribution and email verification

Status: accepted (2026-08-12). Follow-up to 0010 and 0011.

Context

ADR 0010 deleted the affiliate program — and with it users.referred_by — because its earnings model was 100% contest-commission and could never pay out again. Its closing consequence was explicit: if anything of the sort returns, it "starts from … not from dormant code" — a new design, not a revert. The store now wants organic growth tracking: who brought each new user in. Nothing in the affiliate machinery (applications, KYC, commission ledgers, payouts) is wanted back; only attribution is.

Separately, users.email was free-text with no proof of ownership, which blocks any email-based communication from being trustworthy.

Decision

Re-introduce referral tracking as attribution only, plus OTP email verification:

  • Attribution, no rewards — a successful referral sets users.referred_by (restored column) and writes an audit row to referral_attributions (one per user, method install_referrer/manual/ip). No wallet currency is minted anywhere in this flow: per 0011, any referral reward is its own deliberately-rated decision, not a side effect of this one.
  • Share links — every user lazily gets an 8-char code (GET /api/v1/referrals/my-code, alphabet excludes 0/O/1/I) and a share URL https://aimstoreorg.com/r/<CODE>. Caddy routes /r/* on the website vhost to aim_api; GET /r/:code logs a referral_clicks row (code, trustedClientIP, user-agent) and 302s to the Play Store with the code in the install-referrer query. Unknown codes still redirect — a share link never dead-ends — and the route is rate-limited 20/hour/IP.
  • Attribution order at signup (new users only, first touch only, never self-referral, and an invalid code never fails registration): an explicit referral_code on POST /auth/otp/verify or POST /auth/register wins; otherwise the latest referral_clicks row for the client's trustedClientIP within REFERRAL_IP_MATCH_WINDOW_HOURS (default 48) is used (method ip). The IP fallback can mis-attribute behind CGNAT — accepted: the window bounds it, attribution is first-touch and overwrite-proof, and since no money rides on it the blast radius is an incorrect analytics row, not a payout.
  • Email verification — authenticated POST /api/v1/me/email/send-otp / verify-otp: crypto-random 6-digit OTP in Redis (10-min TTL, 60s resend cooldown, capped sends and attempts), success sets users.email_verified_at; editing the email clears it. Fails closed with 503 email_disabled until the SMTP mailer is configured — no fake-verified states.
  • Schema — tracked migration scripts/postgres/migrations/067_referral_and_email_verification.sql (idempotent): referral_clicks (30-day retention — raw IPs must not accumulate), referral_attributions, and the users.referred_by / users.email_verified_at columns.
  • Guards & complianceTestRouteSurfaceFrozen's deliberate-additions allow-list gains the new routes (the baseline stays frozen); the Play Data Safety declaration is updated in the app repo for the install referrer and IP use.

Consequences

  • Admins can answer "who referred this user" (referred_by + referral_attributions) but nobody earns anything — adding a reward later means a new ADR with its own rate, not flipping this one on.
  • referral_clicks stores raw client IPs, so it carries a retention obligation (30 days) rather than living forever like other tables.
  • Email verification stays dormant on a box with blank SMTP_* — the endpoints exist but return 503, matching how every other gated integration in this stack behaves.
  • Old app builds keep working: the new request fields are optional and the new email_verified response field is additive.