ANTI_ABUSE_SPEC.md — Trial & Account Abuse Prevention¶
Last updated: April 13, 2026 (Omega v2.7)
Status: CANONICAL. Single source of truth for abuse prevention at v1.0 launch.
Supersedes: files/ANTI_ABUSE_SPEC.md v1.1 (March 30, 2026, Omega v2.4) · all earlier drafts and scattered anti-abuse notes in files/OMEGA_V2_5/V2_6/V2_7_SESSION_HANDOVER.md · SPRINT_W1_BETA2_ABUSE_DEVICE.md · SPRINT_W2_BETA2_ABUSE_PHONE.md. When any of those disagree with this file, THIS FILE WINS.
Related canonicals: CLAUDE.md · TRIAL_AND_ONBOARDING.md · PROGRESSIVE_INTERVIEW_DESIGN_SPEC.md · LEGAL_AND_PRIVACY.md · WEBSITE_SPEC.md · PRICING_AND_TIERS.md.
0. SCOPE & POSITION¶
Defines every abuse-prevention control that ships v1.0: the 7 defense layers, the Supabase schema, the user-visible flows, the legal disclosures, and the 15 locked decisions. The keystone is a mandatory phone verification at Day 14 that gates the Elite half of the trial; it is also the strongest fingerprint we keep (hash only, never the raw number).
We design for conversion, not punishment. Every layer has a "Continue as Starter" exit that keeps the user's on-device data intact.
1. THREAT MODEL¶
1.1 Primary threat — Eternal Trialers¶
A user creates an account with email1, completes the 21-day trial, lets it expire, and creates email2 for a fresh 21 days. Indefinite cycle. Never pays.
Prevalence: High in LatAm (throwaway email is free). Estimate: 10-20% of trial users will attempt at least once.
1.2 Secondary threats¶
| Threat | Description | Severity |
|---|---|---|
| Account sharing | One paid account used across a family's phones | Medium |
| Refund cycling | Subscribe → use a month → refund → re-subscribe | Low (store-enforced) |
| Credential stuffing | Automated mass signups | Low (email verify + RLS) |
| Data scraping | Export all data during trial, then bail | Low (export is Spark+ gated) |
| Username squatting / RLS probe | Probing username_taken endpoint to map accounts |
Low (TAW10 bug fix, §6) |
2. THE 7 DEFENSE LAYERS¶
Ordered from lightest (silent) to heaviest (most friction). All ship v1.0. Summary; details in §3-§9.
Layer 1: Device fingerprint, silent at signup ← catches ~90%
Layer 2: Phone verify, MANDATORY Day 14 (Twilio SMS) ← keystone; skip = Starter
Layer 3: Progressive Interview, mandatory 45 Qs ← 14-day emotional lock-in
Layer 4: No data export on Trial / Starter ← feature-gate lock-in
Layer 5: Investment score at Day 21 expiry ← conversion driver
Layer 6: Behavioral detection (analytics-only) ← intelligence, no blocking
Layer 7: RLS + real collision at INSERT ← username catch-block fix (TAW10)
Note: "Cloud sync Spark+ only" and "RevenueCat store-level trial enforcement" still apply and are referenced inside Layers 4 and 2 respectively — but the canonical 7-layer list is the one above (per CLAUDE.md §LOCKED DECISIONS #18 and GT #5).
3. LAYER 1 — DEVICE FINGERPRINT (SILENT AT SIGNUP)¶
- Purpose: catch re-trialers on the same physical device without any user-visible prompt.
- Trigger: first app launch, before account creation.
TrialGuard.checkEligibility()runs before the Supabase signup INSERT. - Mechanism:
- Android →
Settings.Secure.ANDROID_ID(persists across reinstalls on Android 8+, resets only on factory reset). - iOS →
UIDevice.identifierForVendor(resets on full uninstall of all vendor apps). - Hash with a server-side salt (
sha256(rawId + alaivos_salt_2026)) — raw IDs never leave the device. - Data stored: row in Supabase
trial_devices(see §10) withdevice_id_hash, platform, country, trial-start timestamp. - User visibility: zero. No prompt, no permission, no banner. This is by design — the only surface is the "Welcome back" screen shown to known devices (§3.4).
- Re-trial UX: on a known device, a fresh account is allowed to sign up, but the trial is not granted. The "Welcome back" screen offers Plans or free Starter; warm tone, no accusation.
// lib/core/anti_abuse/trial_guard.dart
final existing = await supabase
.from('trial_devices')
.select()
.eq('device_id_hash', deviceHash)
.maybeSingle();
if (existing != null) {
return TrialEligibility.previousTrial(
previousUserId: existing['user_id'],
trialStartedAt: DateTime.parse(existing['trial_started_at']),
hadElite: existing['elite_unlocked_at'] != null,
);
}
return TrialEligibility.eligible;
Silent-at-signup is a locked decision (§12 #2).
4. LAYER 2 — PHONE VERIFY, MANDATORY DAY 14 (KEYSTONE)¶
- Purpose: defeat device-reset evaders; create the strongest cross-account fingerprint; gate the Elite half of the trial.
- Trigger: Day 14 of the Pro trial, after the Progressive Interview has populated ≥ 6/11 trait categories.
- Mechanism:
- SMS delivery: Twilio (J-provisioned account; locked in v2.7 handover).
- Android:
SMS Retriever API→ zero-tap auto-fill (~3 s friction). - iOS:
One-Time Codekeyboard autofill (~5 s friction). - Client sends the digits; Supabase Edge Function normalizes to E.164 and hashes with a server-side salt never exposed to the client:
sha256(e164 + server_salt). - Only the hash is persisted to
trial_devices.phone_hashandphone_verifications.phone_hash. - Data stored: phone hash + verification attempt count + short-lived one-time codes (auto-deleted on expiry). Raw phone numbers are never written to disk, never logged, never sent to any third party beyond Twilio's ephemeral SMS send.
- User visibility: full-screen unlock card at Day 14 with in-app disclosure ("we hash your phone, never store it in readable form"), link to Privacy Policy, and a
[Skip]button. - Skip = Starter immediately. No Elite phase, no continued Pro days. Data stays on device; user can subscribe any time.
- Known-phone hit (re-trialer with factory-reset device): instant drop to Starter with "This phone number is linked to a previous alaivOS account" copy. No Elite, no remaining Pro trial.
Day 14 Elite Unlock (MANDATORY)
├── Verify phone → Elite activated for 7 days
├── Skip → immediate drop to Starter (trial ends)
└── Known phone → immediate drop to Starter (trial ends)
Phone hashing is irreversible by design (§12 #8). The salt lives in Supabase Edge Function env vars only.
4.1 RevenueCat store-level backstop¶
Apple (one trial per subscription group per Apple ID) and Google (one trial per subscription per Google account) enforce store-level trial limits. RevenueCat is wired in for the IAP upgrade path so the store denies re-trials on the same store account automatically. Server-managed 21-day trial is the primary gate; store-level is a free second line.
5. LAYER 3 — PROGRESSIVE INTERVIEW (MANDATORY 45 Qs, DAYS 1-14)¶
- Purpose: emotional lock-in during the Pro half of the trial, and the gate that earns Elite.
- Mechanism: 45 questions across 11 trait categories (see
PROGRESSIVE_INTERVIEW_DESIGN_SPEC.md). No skip, no cancel, no dismiss — locked GT #10. - Trigger: first launch, resumed at each session, gated checkpoints along Days 1-14.
- Data stored: trait answers on-device SQLite; aggregated trait tags optionally synced for Spark+ cloud sync; never in prompts to Ghost without user consent.
- User visibility: full-screen, gamified, ~2-3 min per session. Mandatory by design.
- Anti-abuse angle: 14 days of answering 45 personal questions is an investment the user will not want to redo for a new email account.
6. LAYER 4 — NO DATA EXPORT ON TRIAL / STARTER¶
- Purpose: make switching accounts cost "everything I've entered."
- Mechanism: feature gate — CSV / PDF / JSON / Supabase cloud sync all gated at Spark+.
- Trigger: any Export button tap while on Trial or Starter.
- Data stored: n/a (it's a gate).
- User visibility: prompt that data export is a Spark+ feature, with an upgrade CTA and a "Not now" button; data stays fully accessible in-app.
| Feature | Trial | Starter | Spark+ |
|---|---|---|---|
| Enter data | yes | yes | yes |
| View in-app | yes | yes | yes |
| Export CSV / PDF / JSON | no | no | yes |
| Supabase cloud sync | no | no | yes |
Cloud-sync-Spark+-only means re-trialers on email2 see an empty app. All events, money, notes, saved places, traits, and Laiv learning are gone — the 14+ days of investment are burned.
7. LAYER 5 — INVESTMENT SCORE AT DAY 21 EXPIRY¶
- Purpose: convert by making the abstract ("subscribe") concrete ("keep 47 events, 23 notes, 12 places, your 14-day streak, Laiv's 8 routines").
- Mechanism: weighted score across content creation, engagement depth, and customization:
score = events*2 + moneyTx*1 + notes*2 + places*3 + trips*5
+ contacts*3 + vaultPhotos*1 + quinielaPreds*2 + capsules*2 + highlights*2
+ consecutiveDays*2 + aiChats*1 + voiceCmds*1 + traitCats*5
+ customModes*3 + (savedHomeWork ? 5 : 0) + (profilePhoto ? 3 : 0)
+ familyShares*5;
// tiers: deeply_invested ≥100, invested ≥50, engaged ≥20, light_user <20
- Trigger: Day 21 when the trial expires.
- Data stored:
trial_devices.investment_score,converted_to_paid,converted_tierfor analytics. - User visibility: full-screen "In 21 days you've built…" recap with live counts,
[Choose a Plan]and[Continue as Starter — your data stays]buttons.
8. LAYER 6 — BEHAVIORAL DETECTION (ANALYTICS-ONLY, v1.0)¶
- Purpose: intelligence gathering. Tighten Layer 1 thresholds over time without false-positive blocking in v1.0.
- Mechanism: passively log signals per session:
onboarding_speed_secondsfirst_action_after_onboardingvisited_settings_day1set_home_work_immediatelyinterview_skip_attemptsapp_version_matches_previous_triallocale_matches_previous_trialtimezone_matches_previous_trial- Trigger: per-session telemetry.
- Data stored:
trial_signalsrows (analytics table, see §10), JSONB value, timestamp. No PII. - User visibility: none. Never used to block or downgrade in v1.0 — locked decision §12 #11. Disclosed in privacy.html (§11).
9. LAYER 7 — RLS + REAL COLLISION AT INSERT (TAW10 USERNAME FIX)¶
- Purpose: close a probe vector and stop the "username always taken" regression.
- Mechanism: the
username_available?check runs under Supabase RLS. The catch block returnstrue(treat as "not taken") on any RLS / unknown error. Real collisions are caught at the INSERT by the unique constraint and surfaced with a specific error. - Rationale: returning
falseon unknown errors caused every new signup to see "username taken" when the RLS read was denied for unauthenticated users. It also let a probe distinguish "error" from "taken." Returningtrueon error merges both cases and defers authority to the INSERT. - Location:
lib/auth/username_service.dart(catch →return true;), enforced byuser_profiles.usernameunique constraint server-side. - Trigger: every signup / username edit.
- Data stored: n/a.
- User visibility: only the real "username taken" message, only when a real collision occurs at INSERT.
This is also in LESSONS_LEARNED.md under Auth.
10. SUPABASE SCHEMA (authoritative)¶
10.1 trial_devices¶
CREATE TABLE trial_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id_hash TEXT NOT NULL,
phone_hash TEXT, -- set at Day 14 verify
user_id UUID REFERENCES auth.users(id),
trial_started_at TIMESTAMPTZ NOT NULL,
trial_tier TEXT NOT NULL DEFAULT 'pro', -- 'pro' | 'elite'
elite_unlocked_at TIMESTAMPTZ,
trial_expired_at TIMESTAMPTZ,
country_code TEXT,
platform TEXT NOT NULL, -- 'android' | 'ios'
investment_score INTEGER,
converted_to_paid BOOLEAN DEFAULT FALSE,
converted_at TIMESTAMPTZ,
converted_tier TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT unique_device UNIQUE (device_id_hash)
);
CREATE INDEX idx_trial_phone ON trial_devices(phone_hash) WHERE phone_hash IS NOT NULL;
CREATE INDEX idx_trial_user ON trial_devices(user_id);
10.2 phone_verifications (ephemeral)¶
CREATE TABLE phone_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
phone_hash TEXT NOT NULL,
code TEXT NOT NULL, -- 6-digit OTP, short-lived
expires_at TIMESTAMPTZ NOT NULL,
verified BOOLEAN DEFAULT FALSE,
attempts INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION cleanup_expired_codes() RETURNS void AS $$
DELETE FROM phone_verifications WHERE expires_at < now();
$$ LANGUAGE SQL;
10.3 trial_signals (analytics)¶
CREATE TABLE trial_signals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
device_id_hash TEXT NOT NULL,
signal_type TEXT NOT NULL, -- 'fast_onboarding' | 'immediate_settings' | …
signal_value JSONB,
detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
10.4 user_profiles — 8 onboarding fields persisted for reinstall recovery (v2.7)¶
After a data wipe or fresh install, the client must recover the user's progressive-interview context without re-asking the mandatory 45 questions. Eight onboarding fields persist server-side in user_profiles and are restored via an RLS-fallback read after signup:
ALTER TABLE user_profiles ADD COLUMN first_name TEXT;
ALTER TABLE user_profiles ADD COLUMN display_name TEXT;
ALTER TABLE user_profiles ADD COLUMN locale TEXT;
ALTER TABLE user_profiles ADD COLUMN country_code TEXT;
ALTER TABLE user_profiles ADD COLUMN timezone TEXT;
ALTER TABLE user_profiles ADD COLUMN onboarding_completed BOOLEAN DEFAULT FALSE;
ALTER TABLE user_profiles ADD COLUMN personality_preset TEXT; -- Coach/Friend/Assistant/Mentor/Custom
ALTER TABLE user_profiles ADD COLUMN interview_progress JSONB; -- { traits: {...}, questions_answered: N }
Rationale (per LESSONS_LEARNED.md): reinstalling used to drop users back to Q1 of the interview and lose their chosen personality. Persisting these eight fields with RLS-permissioned reads (owner-only) closes that gap without weakening privacy. Never synced: health data, raw locations, chat/notes bodies, vault photos.
11. STARTER NUDGE FLOW (post-expiry)¶
After Day 21 (or Day 14 skip → Starter), the app does not spam notifications. Nudges are throttled, contextual, and dismissable:
- In-app only. No push. No SMS. No email.
- Max 1 nudge per 72 hours, and only when the user enters a module whose headline feature is Spark+ gated (e.g., live traffic coloring, CSV export, family location sharing).
- Copy pattern: "This view shows patterns — Core unlocks live traffic.
[See Plans][Not now]." Always includes a "Not now" that suppresses that nudge for 7 days. - Nudge frequency counted in
trial_signals(analytics). If a user dismisses 3 nudges in a row for the same gate, that gate is muted for 30 days. - Never show an investment-score reminder more than once per 14 days post-expiry.
All nudge strings ship in ARB across 21 locales (see §13).
12. 15 LOCKED DECISIONS¶
- 7 defense layers ship v1.0, as listed in §2.
- Device ID is hashed and stored silently at signup — no user prompt, no permission surface.
- No data export on Trial or Starter. Spark+ only.
- Cloud sync is Spark+ only. Local SQLite is the source of truth for Trial/Starter.
- Investment score is shown at Day 21 expiry with personalized stats.
- RevenueCat enforces store-level trial limits (Apple subscription group + Google account) as a free second line.
- Phone verification is MANDATORY at Day 14. Skip = immediate drop to Starter; no continued Pro days.
- Only the phone hash is stored, with a server-side salt that the client never sees. Raw numbers never persisted.
- Re-trialers see a warm "Welcome back" screen, never an accusation. Free Starter is always offered.
- Known-phone hit at Day 14 = immediate Starter. No more Pro or Elite trial days on any device.
- Behavioral detection is analytics-only in v1.0. Never used to block or downgrade.
- Never punish. Always offer a path forward. Goal is conversion.
- Trial shape is locked at 14 Pro + 7 Elite = 21 total. Elite requires phone verify.
- In-app disclosure is required on the phone-verify screen — purpose, hashing, link to Privacy Policy.
- Privacy Policy + Terms of Service must ship the disclosures in §13 before launch. Kappa owns the update.
These 15 match the CLAUDE.md locked-decision callout (§LOCKED DECISIONS #9, #18) and extend it with the v2.7 Twilio / phone-hash / onboarding-recovery specifics.
13. REQUIRED LEGAL DISCLOSURES¶
Cross-reference LEGAL_AND_PRIVACY.md and WEBSITE_SPEC.md. All three pages below must ship before May 28, 2026 launch. Kappa owns the edits.
13.1 privacy.html — phone, device, analytics detection¶
Must disclose, at minimum:
- Phone number is collected only if the user opts into Day 14 Elite unlock; stored as an irreversible SHA-256 hash with a server-side salt; used only to prevent duplicate trials on the same physical phone; never sold, shared, or used for marketing; deleted on account deletion.
- Device identifier (Android ANDROID_ID / iOS identifierForVendor) is collected silently at first launch, hashed, and used only for trial eligibility. It is not linked to advertising IDs.
- Analytics-only behavioral detection is performed client-side and on Supabase for signals listed in §8. Not used for blocking. Aggregated only; no third-party ad network receives this data.
- Third-party AI processing line (Anthropic Batch API for Laiv Checkup) — required pre-launch update noted in
CLAUDE.mdandLEGAL_AND_PRIVACY.md.
13.2 terms.html — trial conditions¶
Must disclose:
- Trial = 14 days Pro + 7 days Elite (21 total).
- Elite access requires phone verification; declining → Starter at Day 14.
- One trial per person. Creating multiple accounts to re-trial violates these Terms.
- Device identifiers and phone-number hashes are used to enforce the limit (see Privacy Policy).
13.3 lawenforcement.html — phone-hash row (MANDATORY v2.7)¶
Add a row to the law-enforcement disclosure table:
| Data requested | What we can provide |
|---|---|
| User's phone number | Hash only; confirm-match only. Given a phone number supplied by law enforcement, we can confirm whether its hash matches a stored record. We cannot recover, decrypt, or otherwise produce the raw phone number — we never stored it. |
Companion prose on the same page must state: "alaivOS stores a salted SHA-256 hash of verified phone numbers. The salt is held in server environment variables and is not exportable. Hashing is one-way: even with a full database compromise, raw phone numbers cannot be reversed. When served a lawful request referencing a specific phone number, we can confirm or deny a match against our hash records; we cannot produce the number itself."
13.4 App-store data-safety disclosures¶
- Google Play Data Safety: Phone number (optional, hashed, account verification); Device IDs (automatic, fraud prevention). Not shared. Deleted on account deletion.
- Apple App Privacy nutrition label: Phone Number → App Functionality; Device ID → Analytics + App Functionality. Not linked to identity (hashed). Not used for tracking.
14. ARB KEYS (EN reference; × 21 locales)¶
| Key | EN |
|---|---|
trialWelcomeBack |
Welcome back! |
trialOneTime |
Trials are a one-time experience |
trialWhatsWaiting |
Here's what's waiting for you |
trialSeePlans |
See Plans |
trialContinueStarter |
Continue as Starter (free) |
trialEliteUnlocked |
You've unlocked Elite! |
trialVerifyPhone |
Verify your phone to secure your account |
trialVerifyUnlock |
Verify & Unlock Elite |
trialPhoneAlreadyUsed |
This phone number is linked to a previous alaivOS account |
trialEliteOnePerPerson |
Elite trials are one per person |
trialSkipToStarter |
Skip — continue as Starter |
trialExpiredTitle |
Your trial has ended |
trialBuiltInDays |
In {days} days, you've built: |
trialEventsCount |
{count} events |
trialMoneyTracked |
{amount} tracked |
trialNotesCount |
{count} notes |
trialSavedPlaces |
{count} saved places |
trialLaivKnows |
Laiv knows your {count} routines |
trialStreak |
{count}-day streak |
trialKeepEverything |
Keep everything + unlock more |
nudgeSparkGate |
This view shows patterns — Core unlocks live traffic. |
nudgeNotNow |
Not now |
phoneHashDisclosure |
We hash your phone number and never store it in readable form. |
15. USER-FACING LANGUAGE GUIDELINES¶
Never use: "abuse", "fraud", "violation", "not eligible", "restricted", "we detected multiple accounts". Always use: "Welcome back", "Trials are a one-time experience", "Verify to secure your account", "Here's what's waiting", "Continue as Starter (free)".
Core principle: Never punish. Always offer a path forward. A re-trialer who eventually subscribes is more valuable than one who rage-deletes.
16. END-TO-END DEFENSE CHAINS¶
16.1 Legitimate user¶
Day 1 signup → device hash stored silently → Pro trial starts. Days 2-13: normal usage, interview progresses. Day 14: Elite unlock → verify phone (~3-5 s auto-read) → Elite active. Day 21: expiry + investment recap → subscribe or Starter.
16.2 Same device, new email¶
Signup → device hash = KNOWN → "Welcome back" → Plans or Starter. No trial granted.
16.3 Factory-reset / new device, same person¶
Signup → device hash = new → Pro trial starts (unavoidable). Days 2-13: user rebuilds everything from scratch (all prior data gone). Day 14: phone verify → hash = KNOWN PHONE → immediate drop to Starter. No Elite, no remaining Pro. New device hash now linked to the known phone hash.
16.4 Determined abuser (new device + new email + new phone)¶
Possible. But: burner phone cost, total data loss each cycle, only 14 Pro days before phone gate, each cycle adds fingerprints. Effort exceeds $3.99/mo after 2-3 cycles. These users would not have paid anyway.
The best defense isn't walls — it's a product valuable enough that starting over feels worse than paying $3.99. Layer 2 (Day 14 phone verify) is the keystone: it feels like a reward, gates the best trial week, produces our strongest fingerprint, and — because we only keep the hash — it is legally clean to disclose exactly what we store and why.