Raxx · internal docs

internal · gated

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

  1. No stored credentials. Gate check does not touch any credential surface; it reads a count only.
  2. Passkeys / WebAuthn only. The blocked endpoint is POST /api/v1/auth/webauthn/register/begin. No password flow exists to gate.
  3. Email is the single contact channel. Waitlist CTA links to the existing waitlist path — no new contact surface.
  4. 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.
  5. Audit trail. Every 403 rejection on signup close writes an audit_log row (action: founders.gate.rejected, actor: anonymous/system, no PII in context). This enables breach/volume monitoring.
  6. Paper-first gating. Unaffected — Founders who successfully signed up before the gate closed continue through the trial engine.
  7. Credentials into infra. FOUNDERS_COHORT_THRESHOLD lives in the env / secret store; the value in feature_flags.yaml is a default override readable without vault access, but production threshold is always env-sourced first.
  8. Hide, don't gray. When gate_open: false, Antlers hides the signup form entirely. No disabled state, no grayed-out button.
  9. Brand placeholders. All customer-facing copy containing "Founders" or cohort-branded text carries data-brand-placeholder per 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):

  1. Read gate state from cache (miss → call Queue GET /api/v1/internal/customers/founders/count).
  2. 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"
}
  1. 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.

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)?


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.