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
- 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. - Passkeys / WebAuthn only. Trial initialization is downstream of auth. No new auth surface here.
- GDPR by default (ADR 0003).
founder_trial,founder_feedback, andfounder_referralrows cascade onusers.iddelete. After the 30-day cooling period, PII is hard-deleted.audit_logrows survive with hashed actor id. Referral side records usehashed_actor_idas the referring identity after 30 days. - Audit trail for every state change that affects entitlement. Every status transition and every bonus grant writes an
audit_logrow. Retention: 7 years (trade-adjacent entitlement per ADR 0003 §retention). - 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.
- Kill switch. The entire engine is flag-gated:
FLAG_FOUNDERS_PROMO=offby 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. - Cap is configurable, not hard-coded. The bonus accrual cap is driven by
FOUNDERS_BONUS_CAP_DAYSin 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
- Schema migration (
backend_v2/db/migrations/NNNN_founders_trial.sql): createsfounder_trial,founder_feedback,founder_referral. No destructive changes. Flag gated — migration runs unconditionally (schema is cheap); feature flag gates writes. - Service + tasks land behind
FLAG_FOUNDERS_PROMO=off. Unit tests cover all service methods. The flag check is at theinitialize()entry point and at the scheduler task dispatch — not at every internal method, to keep tests clean. - Celery task registered but not scheduled until flag is flipped to
on. Beat schedule is configured via env-driven schedule dict, not hard-coded. - Flag flip to
ontriggers 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
- Set
FLAG_FOUNDERS_PROMO=off. Sweep task stops executing. Newinitialize()calls are rejected. Existing trial rows remain in DB (inert) but no scheduler fires. - Schema tables can be dropped with a down-migration if needed; they have no FK dependencies from outside the founders schema.
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.
- Sub-card A — #N1: Schema migration + FounderTrialService skeleton (tables, service class stubs, Celery task registration, unit test harness). No business logic yet; flag off.
- Sub-card B — #N2: State machine + daily sweep (initialize, status transitions, warning events, grace→lapsed, audit rows, idempotency tests). Flag still off.
- Sub-card C — #N3: Bonus rules engine (grantFeedbackBonus, grantReferralBonus, enforceCap, Stripe webhook integration point, idempotency tests).
- Sub-card D — #N4: Admin API + flag flip to on (list/detail/extend/revoke/force-expire endpoints, step-up enforcement, integration test: full lifecycle).
12. Open questions — decisions needed before sub-cards can be claimed
- 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. - Grace window length. Design assumes 7 days. Is this correct, or should it be 3 / 14 days? This blocks sub-card B.
- 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
markConvertedToPaidneeds asubscription_idto be real, which requires a Stripe product to exist. Flag forproduct-manager. - 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). - Feedback approval flow (#208) contract. Sub-card C's
grantFeedbackBonusis 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. - 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
enforceCapcall for the referred person's trial ingrantReferralBonus.
End of doc. See ADR 0016 for the Celery-beat-vs-APScheduler decision.