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 toreferral_attributions(one per user, methodinstall_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 URLhttps://aimstoreorg.com/r/<CODE>. Caddy routes/r/*on the website vhost toaim_api;GET /r/:codelogs areferral_clicksrow (code,trustedClientIP, user-agent) and302s to the Play Store with the code in the install-referrerquery. 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_codeonPOST /auth/otp/verifyorPOST /auth/registerwins; otherwise the latestreferral_clicksrow for the client'strustedClientIPwithinREFERRAL_IP_MATCH_WINDOW_HOURS(default 48) is used (methodip). 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 setsusers.email_verified_at; editing the email clears it. Fails closed with503 email_disableduntil 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 theusers.referred_by/users.email_verified_atcolumns. - Guards & compliance —
TestRouteSurfaceFrozen'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_clicksstores 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 return503, 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_verifiedresponse field is additive.