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)
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.
backend_v2/ wholesale; no new exceptions.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.audit_log row. Retention: 7 years (trade-adjacent entitlement per ADR 0003 §retention).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.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).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.
All endpoints under /api/founders/*. Session-cookie authenticated. Admin endpoints require role = 'admin'.
| 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. |
| 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. |
| 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).
| 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 |
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
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}
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')
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
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
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
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})
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
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.
This design makes no changes to existing tables (users, mbt_accounts, sessions, audit_log). All changes are additive.
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.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.on. Beat schedule is configured via env-driven schedule dict, not hard-coded.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.FLAG_FOUNDERS_PROMO=off. Sweep task stops executing. New initialize() calls are rejected. Existing trial rows remain in DB (inert) but no scheduler fires.| 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.
The following sub-cards implement this design. Each is sized for one PR. Linked to parent card #206 and epic #204.
FOUNDERS_BONUS_CAP_DAYS. Does Kristerpher confirm 180 days, or is the cap different? This blocks sub-card B/C.markConvertedToPaid needs a subscription_id to be real, which requires a Stripe product to exist. Flag for product-manager./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).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.enforceCap call for the referred person's trial in grantReferralBonus.End of doc. See ADR 0016 for the Celery-beat-vs-APScheduler decision.