Founders-Gate Signup Block
Status: Design — awaiting operator decisions on open questions (see §9)
Owner: software-architect
Date: 2026-05-15 UTC
Parent card: #492 (re-scoped 2026-05-15 UTC)
Parent epic: #204 — Founders Promo
Related ADRs: 0094
Cross-references:
- founders-trial-engine.md — seat-count data model, Founders cohort table
- queue/api-contract.md — WebAuthn registration endpoint (the blocked path)
- console-feature-flags.md — flag promotion pattern
1. Context
The Founders cohort has a finite seat count. When that count is reached, new signups must stop — not be degraded, not show grayed copy, but fully close. The getraxx landing and the in-app signup form must both reflect the closed state and surface a waitlist CTA instead.
The operator locked this mechanism on 2026-05-15 UTC: seat count threshold, not manual flag flip. Queue is the customer source-of-truth per the project architecture decisions.
This design is a precondition gate on the registration path, not a modification to the trial engine's internal state machine.
2. Invariants
- No stored credentials. Gate check does not touch any credential surface; it reads a count only.
- Passkeys / WebAuthn only. The blocked endpoint is
POST /api/v1/auth/webauthn/register/begin. No password flow exists to gate. - Email is the single contact channel. Waitlist CTA links to the existing waitlist path — no new contact surface.
- GDPR by default. Gate-state polling (
GET /api/auth/founders-gate-state) returns no PII. The response contains only{gate_open: bool, waitlist_url: string}. No user data flows on this path. - Audit trail. Every 403 rejection on signup close writes an
audit_logrow (action: founders.gate.rejected, actor: anonymous/system, no PII in context). This enables breach/volume monitoring. - Paper-first gating. Unaffected — Founders who successfully signed up before the gate closed continue through the trial engine.
- Credentials into infra.
FOUNDERS_COHORT_THRESHOLDlives in the env / secret store; the value infeature_flags.yamlis a default override readable without vault access, but production threshold is always env-sourced first. - Hide, don't gray. When
gate_open: false, Antlers hides the signup form entirely. No disabled state, no grayed-out button. - Brand placeholders. All customer-facing copy containing "Founders" or cohort-branded text carries
data-brand-placeholderper the 6-month brand deferral.
3. Data model
Queue side (source of truth)
No new tables. The existing queue_customers table plus the founder_trial table (from founders-trial-engine.md) already contain what is needed.
Queue exposes an internal count endpoint (§4). The count query is:
-- Lifetime-issued count: all customers who have ever been in the Founders cohort
-- (active OR lapsed OR converted_to_paid — not just active)
SELECT COUNT(*) AS founders_count
FROM founder_trial;
Rationale: seat count = lifetime-issued. A refunded/lapsed Founder still consumed a seat (see open question §9.2 for operator override on refund-reopen).
Raptor side (cache layer)
No new tables. Raptor holds an in-process cache keyed founders_gate_state with a ~30-second TTL. The cache stores:
{
"count": <int>,
"threshold": <int>,
"gate_open": <bool>, // precomputed: count < threshold
"cached_at": <utc_timestamp>
}
Cache is populated on miss by calling Queue's internal endpoint. If Queue is unreachable, Raptor fails open (gate treated as open) to avoid blocking legitimate signups during a Queue outage. This is a deliberate tradeoff: the overshoot risk on Queue outage is bounded by the outage duration, not unlimited. Documented below as a design invariant.
Fail-open rationale: A fail-closed posture would block all signups during a Queue outage. At T-8 days before v1 launch, availability of the signup path outweighs the small overshoot risk. This decision is recorded in ADR 0094.
4. APIs / contracts
4.1 Queue internal endpoint (new)
GET /api/v1/internal/customers/founders/count
Auth: HMAC service token (X-Service-Token header, consistent with existing Queue internal endpoints).
Response 200:
{
"count": 47,
"threshold": 100,
"gate_open": true
}
threshold is read from Queue's FOUNDERS_COHORT_THRESHOLD env var. Including it in the response lets Raptor's cache record what threshold was active at the time of the read, without Raptor needing its own copy of the env var (reduces drift risk).
Cache guidance: Response may be up to 30 seconds stale; callers should not expect atomic precision.
Errors: 401 invalid_service_token, 503 database_unavailable.
4.2 Raptor signup precondition (existing endpoint, modified)
Endpoint: POST /api/v1/auth/webauthn/register/begin (proxied through Raptor to Queue, or applied directly at Queue depending on where the registration flow currently gates — SC-FG2 resolves this).
Precondition added (when FLAG_FOUNDERS_GATE=on):
- Read gate state from cache (miss → call Queue
GET /api/v1/internal/customers/founders/count). - If
gate_open: false, return before the WebAuthn challenge is issued:
HTTP 403
{
"error": "signups_closed",
"message": "Founders cohort is full — join the waitlist",
"waitlist_url": "/waitlist"
}
- If
FLAG_FOUNDERS_GATE=off, skip the precondition entirely. Signups always open.
Race condition: Between the cache read (step 1) and the actual INSERT into queue_customers, the count could tip over the threshold. The design accepts a best-effort overshoot of fewer than 5 seats. Distributed-lock complexity is not warranted at this cohort size. Documented in ADR 0094.
4.3 Raptor proxy endpoint for Antlers (new)
GET /api/auth/founders-gate-state
Auth: Public (no session required). Rate-limited at the same tier as other public auth endpoints.
Purpose: Antlers calls this on signup-page/component mount to determine whether to show the signup form or the waitlist CTA. This avoids Antlers calling Queue directly and keeps Queue's internal surface internal.
Response 200:
{
"gate_open": true,
"waitlist_url": "/waitlist"
}
When FLAG_FOUNDERS_GATE=off, always returns {"gate_open": true, "waitlist_url": "/waitlist"}.
Cache: Raptor serves this from the same 30-second in-process cache as the signup precondition. No additional Queue call.
5. State machine / sequence
5.1 Gate-open path (normal signup)
sequenceDiagram
participant ANT as Antlers (signup form)
participant RAP as Raptor /api/auth/founders-gate-state
participant CACHE as Raptor in-process cache
participant Q as Queue /api/v1/internal/customers/founders/count
ANT->>RAP: GET /api/auth/founders-gate-state
RAP->>CACHE: read founders_gate_state
alt cache hit (< 30s old)
CACHE-->>RAP: {gate_open: true, ...}
else cache miss
RAP->>Q: GET /api/v1/internal/customers/founders/count (HMAC token)
Q-->>RAP: {count: 47, threshold: 100, gate_open: true}
RAP->>CACHE: store with TTL 30s
end
RAP-->>ANT: {gate_open: true, waitlist_url: "/waitlist"}
ANT->>ANT: render signup form normally
5.2 Gate-closed path (cohort full)
sequenceDiagram
participant ANT as Antlers (signup component)
participant RAP as Raptor
participant CACHE as Raptor cache
participant Q as Queue
ANT->>RAP: GET /api/auth/founders-gate-state
RAP->>CACHE: read founders_gate_state
alt cache hit
CACHE-->>RAP: {gate_open: false, ...}
else cache miss
RAP->>Q: GET /api/v1/internal/customers/founders/count
Q-->>RAP: {count: 100, threshold: 100, gate_open: false}
RAP->>CACHE: store with TTL 30s
end
RAP-->>ANT: {gate_open: false, waitlist_url: "/waitlist"}
ANT->>ANT: HIDE signup form; show waitlist CTA
5.3 Attempted POST when gate closed
sequenceDiagram
participant Browser as Browser (direct POST attempt)
participant RAP as Raptor
participant AL as audit_log
Browser->>RAP: POST /api/v1/auth/webauthn/register/begin
RAP->>RAP: check FLAG_FOUNDERS_GATE
RAP->>RAP: read gate state from cache
RAP->>AL: INSERT audit_log (action='founders.gate.rejected', actor=anonymous)
RAP-->>Browser: 403 {error: "signups_closed", waitlist_url: "/waitlist"}
6. Feature flag specification
Two flag entries added to feature_flags.yaml in a single PR (per the B1 migration policy — SC-FG6 covers both the YAML entry and the console_flag_promotions migration row):
founders_gate:
default: false
surface: antlers
soak_period_hours: 0 # launch-day flip; no soak needed
area: auth
risk: high # customer-facing AND affects all new signups
description: >
When ON: signup endpoint checks Founders seat count against threshold;
returns 403 when cohort full. Antlers hides signup form and shows
waitlist CTA when gate_open=false.
When OFF (default): gate inactive; signups always open.
Flip via env: FLAG_FOUNDERS_GATE=1. Requires FLAG_QUEUE_V1=1.
B1 migration: console_flag_promotions row NNNN.
Parent: #492.
FOUNDERS_COHORT_THRESHOLD is an env var, not a flag YAML field. It is set via heroku config:set on both staging and production apps. The feature_flags.yaml default field is intentionally absent for this value — threshold is operational config, not a feature toggle. See ADR 0094 §threshold-as-env.
7. Migration path
Schema
No schema migrations required. The founder_trial table and audit_log table already exist from the trial engine design. No new columns.
Rollout
| Stage | Action |
|---|---|
| Dark (now) | SC-FG1 through SC-FG6 implemented, FLAG_FOUNDERS_GATE=off on both envs |
| Staging soak | FLAG_FOUNDERS_GATE=1 on staging, FOUNDERS_COHORT_THRESHOLD=1 → verify 403 fires; then restore threshold to real value |
| Production flip | heroku config:set FLAG_FOUNDERS_GATE=1 FOUNDERS_COHORT_THRESHOLD=<value> --app raxx-api-prod >/dev/null 2>&1 |
| Monitor | Watch audit_log for founders.gate.rejected volume; watch Sentry for 403 spikes |
Rollback
Set FLAG_FOUNDERS_GATE=0 on the Raptor app. The gate precondition is bypassed immediately on next request — no deploy required. Cache clears naturally within 30 seconds.
8. Security and GDPR checklist
| Question | Answer |
|---|---|
| What PII does this collect? | None. The gate-state endpoint returns no user data. The 403 audit row contains action, timestamp, and request_id — no email, no IP (per ADR 0003 IP-logging policy). |
| Retention period? | audit_log rows for founders.gate.rejected: 7 years (consistent with auth-adjacent audit rows, ADR 0003 §retention). |
| Deletion on DSR? | No user-linked data. DSR does not apply to anonymous rejection audit rows. |
| What is logged for audit? | Every signup attempt that hits the gate: action=founders.gate.rejected, timestamp, request_id. No PII. |
| Stored credential risk? | None. The gate check reads a count. No credential surface. |
| Breach response? | Covered by ADR 0003 breach pipeline. The gate itself is not a data surface. A spike in founders.gate.rejected events is an ops signal, not a breach indicator. |
| Where are secrets? | FOUNDERS_COHORT_THRESHOLD in Heroku config (env). HMAC service token for Queue internal endpoint in Infisical/env. Rotatable without redeploy. |
| Kill switch for live execution? | FLAG_FOUNDERS_GATE=0 disables the gate immediately. Cache expires within 30s. No deploy needed. |
9. Open questions — operator decisions needed before sub-cards can be claimed
These block implementation. SC-FG1 and SC-FG2 cannot be claimed until Q1 is answered.
Q1: What is FOUNDERS_COHORT_THRESHOLD?
The seat count value that closes signups. Design uses 100 as a placeholder. Operator's call: 50, 100, 250, or other.
- This value is set via
heroku config:setand does not require a redeploy to change. - It can be raised or lowered after v1 launch without touching code.
- Decision needed from Kristerpher before SC-FG1 can be claimed.
Q2: Refund-reopen behavior
When a Founder's account is refunded and revoked, does their seat reopen (count decrements) or remain consumed (count is permanent)?
- Default in this design: lifetime-issued.
COUNT(*) FROM founder_trialnever decrements. A refunded Founder'sfounder_trialrow is not deleted; only their status changes. - Alternative: active-only count.
COUNT(*) FROM founder_trial WHERE status NOT IN ('lapsed', 'revoked'). This allows seat recycling but adds complexity to the count query and creates an operational surface where revocations must be atomic with count checks. - Decision needed from Kristerpher. Lifetime-issued is the recommended default. If active-only is chosen, SC-FG1 schema must add a
revokedstatus tofounder_trialand the Queue endpoint query changes.
10. Sub-cards
See GitHub issues filed as children of #492. Each is sized for one PR.
| ID | Title | Blocker |
|---|---|---|
| SC-FG1 | Queue: GET /api/v1/internal/customers/founders/count + HMAC service-token auth |
Q1 threshold value |
| SC-FG2 | Raptor: founders-gate precondition on signup + 403 path + 30s cache | SC-FG1 merged |
| SC-FG3 | Raptor: GET /api/auth/founders-gate-state proxy endpoint for Antlers |
SC-FG2 merged |
| SC-FG4 | Antlers: signup form gate — hide/show based on gate state + waitlist CTA transition | SC-FG3 merged |
| SC-FG5 | getraxx: landing CTA copy gating (mirror of SC-FG4 on getraxx React app) | SC-FG3 merged |
| SC-FG6 | feature_flags.yaml entry founders_gate + B1 console_flag_promotions migration |
None |
| SC-FG7 | Smoke + integration tests: threshold transition, both flag states, fail-open on Queue outage | SC-FG2 + SC-FG4 merged |
SC-FG6 is independent and can be claimed immediately. SC-FG1 is the critical-path head.
See ADR 0094 for the fail-open vs. fail-closed and overshoot-tolerance decisions.