Raxx · internal docs

internal · gated ↑ index

Founders Trial Engine — data model, state machine, scheduler, and rules engine

Status: Draft Owner: software-architect Last updated: 2026-04-22 Parent card: #206 — Build Founders trial timer + expiration state machine Parent epic: #204 — Founders Promo Related ADRs: 0003, 0016 Cross-references: mbt-paper-trading-engine.md §8 (Celery + Redis job stack), auth.md §3 (users table, audit_log schema), session-engine.md §4 (tier claim in JWT)


1. Context

The Founders Promo (epic #204) replaces the retired "$19/mo-locked-forever" waitlist offer. A Founder is a user who converted from the waitlist during the promo window and receives a time-boxed Pro-tier paper-trading allowance at no charge.

Two cohorts exist: - direct_signup: converted from waitlist without a referral — 90-day initial window. - referred: joined via a Founder's referral link — 14-day initial window (shorter; referral channel is expected to convert faster).

Time is extended by two bonus mechanisms: - Feedback bonus: +30 days per approved feedback submission (approval gated by #208). - Referral bonus: +90 days to the referrer when the person they referred activates a monetized (paid) subscription — not on signup, not on Founders accept. This prevents farming.

At-cost pricing is offered to Founders who choose to convert to a paid subscription. "At-cost" means the per-user marginal cost of hosting, infrastructure, and third-party licences — no markup. The exact SKU is out of scope here; this doc records the invariant and defers pricing configuration to a product sub-card.

This service lives entirely inside Raptor (backend_v2/). It has no Antlers surface of its own; Antlers consumes the /api/founders/* endpoints. It has no dependency on MBT internals — it is a timer + entitlement engine.


2. Invariants that apply

  1. No stored credentials. Trial records contain no broker token, no API key, no password. ADR 0002 CI grep covers backend_v2/ wholesale; no new exceptions.
  2. Passkeys / WebAuthn only. Trial initialization is downstream of auth. No new auth surface here.
  3. GDPR by default (ADR 0003). founder_trial, founder_feedback, and founder_referral rows cascade on users.id delete. After the 30-day cooling period, PII is hard-deleted. audit_log rows survive with hashed actor id. Referral side records use hashed_actor_id as the referring identity after 30 days.
  4. Audit trail for every state change that affects entitlement. Every status transition and every bonus grant writes an audit_log row. Retention: 7 years (trade-adjacent entitlement per ADR 0003 §retention).
  5. Paper-first gating. Founders receive Pro-tier paper trading only. Live-trading paths are not touched by this engine; graduation is governed by MBT's paper-first gate.
  6. Kill switch. The entire engine is flag-gated: FLAG_FOUNDERS_PROMO=off by default. The flag is checked at trial initialization, bonus grant, and scheduler dispatch. Existing users and MBT paths are unaffected when the flag is off.
  7. Cap is configurable, not hard-coded. The bonus accrual cap is driven by FOUNDERS_BONUS_CAP_DAYS in the secret/env store. The current design value is 180 days (6 months), pending explicit confirmation (see §10 open questions).

3. Data model

New tables in backend_v2/db/. Schemas are design sketches; the canonical migration is in sub-card #N1.

founder_trial
  id                       TEXT PK             -- uuid v4
  user_id                  TEXT NOT NULL UNIQUE FK -> users.id ON DELETE CASCADE
  cohort                   TEXT NOT NULL       -- 'direct_signup' | 'referred'
  initial_days             INTEGER NOT NULL    -- 90 (direct) or 14 (referred)
  accrued_days_feedback    INTEGER NOT NULL DEFAULT 0
  accrued_days_referrals   INTEGER NOT NULL DEFAULT 0
  referrer_founder_id      TEXT NULL FK -> founder_trial.id ON DELETE SET NULL
                                               -- self-referential; null for direct_signup
  started_at               TIMESTAMP NOT NULL  -- UTC; set on initialization
  expires_at               TIMESTAMP NOT NULL  -- UTC; recomputed on every bonus grant
  status                   TEXT NOT NULL DEFAULT 'active'
                           -- 'active' | 'warning_30d' | 'warning_14d' | 'warning_7d'
                           --  | 'warning_1d' | 'grace_window' | 'converted_to_paid'
                           --  | 'lapsed'
  grace_ends_at            TIMESTAMP NULL      -- set when grace_window entered; +7d from expires_at
  converted_at             TIMESTAMP NULL      -- set on converted_to_paid
  lapsed_at                TIMESTAMP NULL      -- set on lapsed
  created_at               TIMESTAMP NOT NULL
  updated_at               TIMESTAMP NOT NULL
  CHECK (cohort IN ('direct_signup','referred'))
  CHECK (status IN ('active','warning_30d','warning_14d','warning_7d','warning_1d',
                    'grace_window','converted_to_paid','lapsed'))
  UNIQUE (user_id)                             -- one active trial record per user

founder_feedback
  id                       TEXT PK             -- uuid v4
  founder_trial_id         TEXT NOT NULL FK -> founder_trial.id ON DELETE CASCADE
  user_id                  TEXT NOT NULL FK -> users.id ON DELETE CASCADE
  submitted_at             TIMESTAMP NOT NULL
  approved_at              TIMESTAMP NULL      -- null until #208 approval flow fires
  bonus_granted_at         TIMESTAMP NULL      -- null until bonus applied; idempotency gate
  bonus_days               INTEGER NOT NULL DEFAULT 30
  created_at               TIMESTAMP NOT NULL

founder_referral
  id                       TEXT PK             -- uuid v4
  referrer_trial_id        TEXT NOT NULL FK -> founder_trial.id ON DELETE CASCADE
  referred_user_id         TEXT NOT NULL FK -> users.id ON DELETE CASCADE
  referred_trial_id        TEXT NULL FK -> founder_trial.id ON DELETE SET NULL
                                               -- set when referred user initializes their trial
  signup_at                TIMESTAMP NOT NULL  -- when the referred user joined
  monetized_at             TIMESTAMP NULL      -- when the referred user activated a paid subscription
  referral_bonus_granted_at TIMESTAMP NULL     -- idempotency gate for referrer bonus
  bonus_days               INTEGER NOT NULL DEFAULT 90
  hashed_referred_id       TEXT NULL           -- SHA-256(referred_user_id || salt); populated at 30d
  created_at               TIMESTAMP NOT NULL
  UNIQUE (referrer_trial_id, referred_user_id)

Key constraints: - expires_at is always recomputed as started_at + (initial_days + accrued_days_feedback + accrued_days_referrals) days. It is never directly set by callers — only by the service layer after validating the cap. - UNIQUE (referrer_trial_id, referred_user_id) prevents duplicate referral rows. The bonus idempotency gate (referral_bonus_granted_at IS NOT NULL) prevents double-crediting even if the webhook fires twice. - founder_feedback.bonus_granted_at is the idempotency gate for feedback bonuses — same feedback ID cannot grant twice.

Schema alignment with existing tables: - No columns are duplicated from users (email, display_name, role). The trial engine reads users.id only as a foreign key. - The audit_log table (defined in auth.md §3) is written to but not modified. New action namespaces: founder.trial.init, founder.trial.status_transition, founder.bonus.feedback, founder.bonus.referral, founder.trial.extend_admin, founder.trial.revoke_admin, founder.trial.force_expire. - mbt_accounts (mbt-paper-trading-engine.md §5) is not touched by this engine. MBT account provisioning for Founders is a separate concern — the trial engine emits an event; MBT account creation is downstream.


4. APIs / contracts

All endpoints under /api/founders/*. Session-cookie authenticated. Admin endpoints require role = 'admin'.

User-facing

Method Path Purpose
GET /api/founders/trial Return the caller's trial record: {status, expires_at, days_remaining, initial_days, accrued_days_feedback, accrued_days_referrals, cohort}. Used by Antlers countdown widget.
GET /api/founders/referral-link Return the caller's unique referral URL. Generates on first call; idempotent on subsequent.

Internal / service-to-service (called by other Raptor services, not from Antlers directly)

Method Path Purpose
POST /api/internal/founders/trial/init Initialize a trial. Body: {user_id, cohort, referrer_user_id?}. Called by the waitlist-conversion flow.
POST /api/internal/founders/bonus/feedback Grant feedback bonus. Body: {user_id, feedback_id}. Idempotent on feedback_id. Called by #208 approval webhook.
POST /api/internal/founders/bonus/referral Grant referral bonus to referrer. Body: {referrer_user_id, referred_user_id, subscription_id}. Idempotent on subscription_id. Called by Stripe webhook handler.

Admin (console.raxx.app, #212)

Method Path Purpose
GET /api/admin/founders Paginated list. Query params: status, cohort, cursor. Returns minimal PII (user_id, cohort, status, expires_at, days_remaining).
GET /api/admin/founders/{trial_id} Full detail for one trial.
POST /api/admin/founders/{trial_id}/extend Manual extension. Body: {days, reason}. Writes audit row.
POST /api/admin/founders/{trial_id}/revoke Revoke trial immediately (transitions to lapsed). Body: {reason}. Writes audit row.
POST /api/admin/founders/{trial_id}/force-expire Force into grace_window immediately. Body: {reason}.

All mutating admin endpoints require a fresh WebAuthn step-up (per session-engine.md §2).


5. State machine

States

State Meaning
active Trial running; no warning yet
warning_30d 30 or fewer days remain; warning email sent once
warning_14d 14 or fewer days remain
warning_7d 7 or fewer days remain
warning_1d 1 or fewer days remain
grace_window expires_at passed; 7-day grace period to convert or export
converted_to_paid User activated a paid subscription before or during grace
lapsed Grace window ended without conversion; entitlement withdrawn

Transition rules

active          -> warning_30d      when days_remaining <= 30 (scheduler)
active          -> grace_window     when days_remaining <= 0  (scheduler, if warnings skipped)
warning_30d     -> warning_14d      when days_remaining <= 14
warning_14d     -> warning_7d       when days_remaining <= 7
warning_7d      -> warning_1d       when days_remaining <= 1
warning_1d      -> grace_window     when days_remaining <= 0
grace_window    -> converted_to_paid  on paid subscription activation (Stripe webhook)
grace_window    -> lapsed           when grace_ends_at passed (scheduler)
any warning_*   -> converted_to_paid  on paid subscription activation (early conversion)
any warning_*   -> grace_window     when days_remaining <= 0 (catch-up on missed runs)
active|warning* -> active           when bonus granted (expires_at extended; status resets
                                    if new days_remaining > threshold; see §5 note)

Idempotency: The scheduler job checks the current status and days_remaining before writing. If the status is already warning_30d and days_remaining is still <= 30 but > 14, no transition fires and no duplicate audit row is written. Transitions are append-only and monotonically forward — lapsed and converted_to_paid are terminal.

Bonus and status reset: When a bonus grant pushes expires_at far enough forward that days_remaining > 30, the status is reset to active in the same transaction as the bonus grant. This is the only valid backward movement of the warning ladder. Written as a single DB transaction to avoid partial state.

stateDiagram-v2
    [*] --> active : init (waitlist conversion)
    active --> warning_30d : scheduler: days_remaining <= 30
    warning_30d --> warning_14d : scheduler: days_remaining <= 14
    warning_14d --> warning_7d : scheduler: days_remaining <= 7
    warning_7d --> warning_1d : scheduler: days_remaining <= 1
    warning_1d --> grace_window : scheduler: days_remaining <= 0
    active --> grace_window : scheduler: days_remaining <= 0 (catch-up)
    warning_30d --> grace_window : scheduler: days_remaining <= 0 (catch-up)
    warning_14d --> grace_window : scheduler: days_remaining <= 0 (catch-up)
    warning_7d --> grace_window : scheduler: days_remaining <= 0 (catch-up)
    grace_window --> converted_to_paid : Stripe webhook: paid activation
    grace_window --> lapsed : scheduler: grace_ends_at passed
    active --> converted_to_paid : early conversion (any time)
    warning_30d --> converted_to_paid : early conversion
    warning_14d --> converted_to_paid : early conversion
    warning_7d --> converted_to_paid : early conversion
    warning_1d --> converted_to_paid : early conversion
    active --> active : bonus grant (expires_at extended)
    warning_30d --> active : bonus grant resets status if days_remaining > 30

6. Sequence diagrams

6.1 Direct-signup trial start

sequenceDiagram
    participant WL as Waitlist conversion flow
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial)
    participant AL as audit_log
    participant CEL as Celery (founders.notify)

    WL->>FTS: init(user_id, cohort='direct_signup')
    FTS->>FTS: check FLAG_FOUNDERS_PROMO
    FTS->>DB: INSERT founder_trial (initial_days=90, expires_at=now+90d, status='active')
    FTS->>AL: INSERT audit_log (action='founder.trial.init', actor=system)
    FTS->>CEL: enqueue founders.provision_mbt_entitlement(user_id)
    FTS-->>WL: {trial_id, expires_at}

6.2 Referred-signup trial start

sequenceDiagram
    participant WL as Waitlist conversion flow
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial + founder_referral)
    participant AL as audit_log

    WL->>FTS: init(user_id, cohort='referred', referrer_user_id=X)
    FTS->>DB: resolve referrer_user_id -> referrer_trial_id
    FTS->>DB: INSERT founder_trial (initial_days=14, expires_at=now+14d,\n  referrer_founder_id=referrer_trial_id, status='active')
    FTS->>DB: UPDATE founder_referral.referred_trial_id (links the two records)
    FTS->>AL: INSERT audit_log (action='founder.trial.init', cohort='referred')

6.3 Feedback bonus grant

sequenceDiagram
    participant APP as #208 Approval webhook
    participant FTS as FounderTrialService
    participant DB as DB (founder_feedback + founder_trial)
    participant AL as audit_log

    APP->>FTS: grantFeedbackBonus(user_id, feedback_id)
    FTS->>DB: SELECT founder_feedback WHERE id=feedback_id AND bonus_granted_at IS NULL
    alt already granted
        FTS-->>APP: {ok: true, idempotent: true}
    else not yet granted
        FTS->>FTS: enforceCap(user_id) — compute headroom
        FTS->>DB: BEGIN TRANSACTION
        FTS->>DB: UPDATE founder_feedback SET bonus_granted_at=now
        FTS->>DB: UPDATE founder_trial SET accrued_days_feedback+=30,\n  expires_at=recomputed, status=recomputed_status
        FTS->>AL: INSERT audit_log (action='founder.bonus.feedback', days=30)
        FTS->>DB: COMMIT
        FTS-->>APP: {ok: true, new_expires_at}
    end

6.4 Referral bonus grant (fires on paid subscription activation)

sequenceDiagram
    participant STR as Stripe webhook handler
    participant FTS as FounderTrialService
    participant DB as DB (founder_referral + founder_trial)
    participant AL as audit_log

    STR->>FTS: grantReferralBonus(referrer_user_id, referred_user_id, subscription_id)
    FTS->>DB: SELECT founder_referral WHERE referrer=X AND referred=Y\n  AND referral_bonus_granted_at IS NULL
    alt already granted
        FTS-->>STR: {ok: true, idempotent: true}
    else not yet granted
        FTS->>FTS: enforceCap(referrer_user_id) — compute headroom
        FTS->>DB: BEGIN TRANSACTION
        FTS->>DB: UPDATE founder_referral SET monetized_at=now,\n  referral_bonus_granted_at=now
        FTS->>DB: UPDATE founder_trial SET accrued_days_referrals+=90,\n  expires_at=recomputed, status=recomputed_status
        FTS->>AL: INSERT audit_log (action='founder.bonus.referral', days=90,\n  context={subscription_id})
        FTS->>DB: COMMIT
        FTS-->>STR: {ok: true, new_expires_at}
    end

6.5 30-day warning fire (daily scheduler)

sequenceDiagram
    participant CEL as Celery beat (founders.daily_sweep)
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial)
    participant AL as audit_log
    participant EVTS as Internal event bus (warning email card)

    CEL->>FTS: run_daily_sweep()
    FTS->>DB: SELECT rows WHERE status NOT IN ('converted_to_paid','lapsed')\n  AND expires_at <= now + 30d AND status != 'warning_30d' (etc.)
    loop each candidate row
        FTS->>FTS: determine target_status from days_remaining
        FTS->>DB: UPDATE founder_trial SET status=target_status, updated_at=now
        FTS->>AL: INSERT audit_log (action='founder.trial.status_transition',\n  old_status, new_status)
        FTS->>EVTS: emit founders.warning_triggered {trial_id, new_status, expires_at}
    end

6.6 Conversion to paid

sequenceDiagram
    participant STR as Stripe webhook handler
    participant FTS as FounderTrialService
    participant DB as DB (founder_trial)
    participant AL as audit_log

    STR->>FTS: markConvertedToPaid(user_id, subscription_id)
    FTS->>DB: SELECT founder_trial WHERE user_id=X\n  AND status NOT IN ('converted_to_paid','lapsed')
    FTS->>DB: UPDATE founder_trial SET status='converted_to_paid',\n  converted_at=now
    FTS->>AL: INSERT audit_log (action='founder.trial.status_transition',\n  old_status, new_status='converted_to_paid',\n  context={subscription_id, at_cost_sku})

7. Rules engine — service method signatures

These are interface-level contracts for feature-developer; not implementation code.

FounderTrialService.initialize(user_id, cohort, referrer_user_id=None)
  Precondition: FLAG_FOUNDERS_PROMO is on
  Precondition: no existing founder_trial row for user_id (idempotent if called twice: return existing)
  Effect: inserts founder_trial, links founder_referral if referrer given, writes audit row,
          enqueues mbt entitlement provisioning task

FounderTrialService.grantFeedbackBonus(user_id, feedback_id)
  Precondition: founder_feedback row exists, approved_at IS NOT NULL, bonus_granted_at IS NULL
  Idempotency: if bonus_granted_at IS NOT NULL, return ok without side effects
  Effect: adds min(30, cap_headroom) days, recomputes expires_at + status, writes audit row
  Returns: {ok, idempotent, new_expires_at, days_granted}

FounderTrialService.grantReferralBonus(referrer_user_id, referred_user_id, subscription_id)
  Precondition: founder_referral row links referrer to referred; monetized_at IS NULL
  Idempotency: if referral_bonus_granted_at IS NOT NULL, return ok without side effects
  Effect: adds min(90, cap_headroom) days to REFERRER, writes audit row
  Returns: {ok, idempotent, new_expires_at, days_granted}

FounderTrialService.enforceCap(user_id)
  Reads: initial_days + accrued_days_feedback + accrued_days_referrals
  Reads: FOUNDERS_BONUS_CAP_DAYS env var (default 180)
  Returns: headroom_days — how many days can still be added before cap is hit
  Note: cap applies to TOTAL time (initial + accrued), not accrued alone

FounderTrialService.runDailySweep()
  Idempotent: safe to call multiple times in a day; uses status + computed days_remaining
  Catch-up capable: processes any rows that should have transitioned on prior missed runs
  Effect: transitions statuses, emits warning events, transitions grace->lapsed

FounderTrialService.markConvertedToPaid(user_id, subscription_id)
  Terminal: once converted_to_paid, no further transitions possible

FounderTrialService.computeDaysRemaining(user_id)
  Returns: int (floor of (expires_at - utcnow()) / 86400); negative when expired
  Note: always computed from stored UTC expires_at; never cached on the object

8. Scheduler: Celery beat (ADR 0016)

The Founders daily sweep runs as a Celery beat task, consistent with the MBT job stack (mbt-paper-trading-engine.md §8). See ADR 0016 for the decision record.

Task: founders.daily_sweep — scheduled nightly at 01:00 UTC (after market close, before US morning).

The task is catch-up capable: it selects all non-terminal rows where the computed days_remaining differs from what the current status implies. A row with status='active' and days_remaining=5 missed its warning transitions — the sweep corrects it in one pass.

Kill switch: FOUNDERS_PROMO_SCHEDULER_DISABLED=1 prevents the sweep task from executing. State reads (API) still work; only the automated sweep is paused.


9. Migration path

This design makes no changes to existing tables (users, mbt_accounts, sessions, audit_log). All changes are additive.

Steps

  1. Schema migration (backend_v2/db/migrations/NNNN_founders_trial.sql): creates founder_trial, founder_feedback, founder_referral. No destructive changes. Flag gated — migration runs unconditionally (schema is cheap); feature flag gates writes.
  2. Service + tasks land behind FLAG_FOUNDERS_PROMO=off. Unit tests cover all service methods. The flag check is at the initialize() entry point and at the scheduler task dispatch — not at every internal method, to keep tests clean.
  3. Celery task registered but not scheduled until flag is flipped to on. Beat schedule is configured via env-driven schedule dict, not hard-coded.
  4. Flag flip to on triggers the first trial initializations (from the waitlist conversion flow in a separate sub-card). The daily sweep begins the next morning at 01:00 UTC.

Rollback


10. Security + GDPR checklist

Question Answer
What PII does this collect? user_id (FK to PII in users). No email, name, or contact info stored here. founder_referral.referred_user_id is PII-adjacent (linking relationship).
Retention period? founder_trial: lifetime of account, then cascade-deleted. After account deletion + 30-day cooling, rows are hard-deleted. audit_log rows with founder actions: 7 years (trade-adjacent entitlement).
Deletion on DSR? ON DELETE CASCADE on users.id for all three tables. The 30-day cooling from ADR 0003 applies. After 30 days, the rows are gone.
Audit logging? Every status transition and bonus grant writes an audit_log row. Action namespaces documented in §3. context field contains {old_status, new_status, days_granted} — never email/name/raw ID beyond user_id (which is pseudonymized post-erasure per ADR 0003).
Stored credential risk? None. No API key, token, or secret stored. ADR 0002 CI grep applies.
Breach response? Covered by ADR 0003 breach pipeline. No additional surface here; the most sensitive data is entitlement metadata (non-financial, low risk).
Where are secrets? FOUNDERS_BONUS_CAP_DAYS, FLAG_FOUNDERS_PROMO, FOUNDERS_PROMO_SCHEDULER_DISABLED — all env vars, not in code. Rotatable without redeploy.
Kill switch for live execution? FOUNDERS_PROMO_SCHEDULER_DISABLED=1 halts the sweep. FLAG_FOUNDERS_PROMO=off gates initialization. No live-trading code path is affected (paper-first gating is MBT's concern).

Referral record pseudonymization: founder_referral.hashed_referred_id is populated 30 days after signup_at with SHA-256(referred_user_id || per-user-salt). After population, referred_user_id in that row is nulled. The salt rotation schedule follows ADR 0003. This allows referral analytics without raw user linking after the cooling window.


11. Sub-cards + epic #204 checklist

The following sub-cards implement this design. Each is sized for one PR. Linked to parent card #206 and epic #204.


12. Open questions — decisions needed before sub-cards can be claimed

  1. Bonus cap value (6 months / 180 days). Design uses 180 days as the provisional cap, configurable via FOUNDERS_BONUS_CAP_DAYS. Does Kristerpher confirm 180 days, or is the cap different? This blocks sub-card B/C.
  2. Grace window length. Design assumes 7 days. Is this correct, or should it be 3 / 14 days? This blocks sub-card B.
  3. At-cost pricing SKU. "At-cost" is an invariant here but the pricing detail (what the per-user marginal cost is, what the Stripe product looks like) is deferred. Sub-card D can be claimed without this, but markConvertedToPaid needs a subscription_id to be real, which requires a Stripe product to exist. Flag for product-manager.
  4. Referral link generation. Is the referral link format /join?ref=<token> with a short token, or something else? Does the referral token expire? This blocks sub-card B (trial init for referred cohort needs to know how to resolve the referrer).
  5. Feedback approval flow (#208) contract. Sub-card C's grantFeedbackBonus is triggered by the approval webhook from #208. What is the webhook shape? This is a dependency between #206 and #208 that needs alignment before sub-card C can be fully implemented.
  6. Referral bonus for the referred person. The locked decisions state the referrer gets +3 months when the referred person converts to paid. Does the referred person also get any bonus on conversion? The card is silent on this. If yes, it needs an additional enforceCap call for the referred person's trial in grantReferralBonus.

End of doc. See ADR 0016 for the Celery-beat-vs-APScheduler decision.