Skip to content

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) with device_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 Code keyboard 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_hash and phone_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_tier for 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_seconds
  • first_action_after_onboarding
  • visited_settings_day1
  • set_home_work_immediately
  • interview_skip_attempts
  • app_version_matches_previous_trial
  • locale_matches_previous_trial
  • timezone_matches_previous_trial
  • Trigger: per-session telemetry.
  • Data stored: trial_signals rows (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 returns true (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 false on 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." Returning true on error merges both cases and defers authority to the INSERT.
  • Location: lib/auth/username_service.dart (catch → return true;), enforced by user_profiles.username unique 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

  1. 7 defense layers ship v1.0, as listed in §2.
  2. Device ID is hashed and stored silently at signup — no user prompt, no permission surface.
  3. No data export on Trial or Starter. Spark+ only.
  4. Cloud sync is Spark+ only. Local SQLite is the source of truth for Trial/Starter.
  5. Investment score is shown at Day 21 expiry with personalized stats.
  6. RevenueCat enforces store-level trial limits (Apple subscription group + Google account) as a free second line.
  7. Phone verification is MANDATORY at Day 14. Skip = immediate drop to Starter; no continued Pro days.
  8. Only the phone hash is stored, with a server-side salt that the client never sees. Raw numbers never persisted.
  9. Re-trialers see a warm "Welcome back" screen, never an accusation. Free Starter is always offered.
  10. Known-phone hit at Day 14 = immediate Starter. No more Pro or Elite trial days on any device.
  11. Behavioral detection is analytics-only in v1.0. Never used to block or downgrade.
  12. Never punish. Always offer a path forward. Goal is conversion.
  13. Trial shape is locked at 14 Pro + 7 Elite = 21 total. Elite requires phone verify.
  14. In-app disclosure is required on the phone-verify screen — purpose, hashing, link to Privacy Policy.
  15. 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.


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.md and LEGAL_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.