ADR-0115 — Beta Phase 2: Join Token Design
Status: Accepted
Date: 2026-06-12 UTC
Deciders: software-architect agent, operator
Scope: Raptor (/api/beta/join/*), Antlers (/beta/join/<token>), Console (/internal/api/beta-join/*), CF Access bypass set
Context
Phase 2 beta puts NDA'd testers into the real Raxx app via a token-gated signup. The system must:
- Let a single HMAC-signed token serve both Phase 1 (walkthrough activation) and Phase 2 (real account creation) without re-minting a new token type.
- Enforce NDA ack before account creation.
- Guarantee one account per invite (single-use join semantics).
- Keep the pre-launch CF Access gate intact for all non-invited visitors.
The walkthrough token type already exists (cohort='beta', verified via Console). The
question is how to add join-specific semantics to it.
Decision
Reuse the existing walkthrough token; add a join_consumed_at column to
beta_walkthrough_tokens in Console as the consumption gate.
A new Raptor endpoint (POST /api/beta/join/<token>/claim) verifies the token, enforces
NDA and geo-blocks, then atomically sets join_consumed_at via a new Console internal
endpoint (POST /internal/api/beta-join/consume). Only after Console confirms consumption
does Raptor mint a beta-scoped bootstrap token. That bootstrap token carries a beta_cohort
claim that triggers writing users.beta_cohort = 'beta' after passkey enrollment.
This design does not require a new token type, a new Console-to-Raptor auth scheme, or changes to the WebAuthn registration ceremony itself.
Language choice rationale
This ADR governs API contracts and data model changes to existing Tier 2 (Python) services (Raptor and Console). No new service is introduced.
Service: N/A (extending existing services)
Language tier: Tier 2 (Python) — existing services; no new process.
Alternatives considered
Alternative A: New dedicated join-token type
Mint a separate HMAC token with type='join' rather than reusing the walkthrough token.
Rejected because: Testers already hold walkthrough tokens. A separate join token requires a second Console admin action (mint + email), introduces a second token verification path, and complicates the single-token invite email. The reuse approach piggybacks on proven infrastructure.
Alternative B: Mark consumption on the existing revoked_at column
Set revoked_at when the token is consumed as a join token, preventing further walkthrough
use.
Rejected because: A Phase 2 tester should still be able to complete the Phase 1 walkthrough activation flow after creating their account. Conflating revocation with consumption removes that ability and breaks the walkthrough if the account creation fails mid-flow.
Alternative C: Raptor-local consumption record
Store the join consumption state in Raptor's own DB (a new beta_join_consumed table)
rather than in Console.
Rejected because: Console owns beta_walkthrough_tokens — it is the authoritative record
for that token's lifecycle. Splitting ownership between Console and Raptor creates a
split-brain risk (double-claim possible if Console and Raptor become inconsistent). The
existing HMAC-authenticated internal call pattern keeps truth in one place.
Alternative D: consume-then-certify (ceremony first, consume after)
Run the WebAuthn ceremony before marking the token consumed, so a failed ceremony doesn't strand the tester on a consumed-but-no-account state.
Rejected because: This creates a TOCTOU window where the same token could be presented simultaneously on two devices and both pass the state check. Consuming the token atomically before the ceremony is the correct serialization point; failed ceremonies become support cases (rare, recoverable).
Consequences
Positive
- No new token type; Console operators use the same admin UI to invite testers for both phases.
- Single-writer atomic consumption via conditional UPDATE eliminates double-claim races.
users.beta_cohortcolumn is a clean, queryable marker for post-beta cleanup and feature scoping without requiring a separate beta-testers join table on Raptor.- The existing
verify_walkthrough_tokenpath handles token validity, expiry, and revocation; the join flow adds only consumption semantics on top.
Negative / risks
- A consumed-but-no-account-created state is a manual support case. This is expected to be rare (WebAuthn ceremony fails after token consumption) but will require operator attention if it occurs.
- Raptor takes an additional synchronous call to Console on the
/claimpath. If Console is degraded, the join flow fails with 503. This is intentional (correct over available), but it couples join availability to Console availability. join_consumed_atcolumn must be nullable in Console's schema; SQLite test environments need the-- POSTGRES-ONLYmigration sentinel to avoid fixture breakage.
Neutral
- The
beta_cohortclaim in the bootstrap token payload is unverified by Raptor (no cryptographic binding between the claim value and the token signature). The claim is trusted because the token was minted by Raptor after Console confirmed valid consumption. If tighter binding is needed post-Phase-2, a separate bootstrap token key for beta can be introduced.
Security / GDPR checklist
- PII collected: tester email (from existing beta_testers record); IP address and user-agent at claim time written to audit log.
- Retention period: audit events per ADR-0022 retention policy;
users.beta_cohortcolumn follows the same 90-day inactive-user retention as the user row itself. - Deletion on DSR: standard
usersrow deletion cascade; no separate delete required forbeta_cohort. - Audit trail:
beta.join.claimedevent —tester_email_hash,jti,ip_prefixwritten to audit_log on every successful claim. - Stored credentials: none — token is verified via HMAC only; WebAuthn stores public key credential only; bootstrap token is single-use HMAC-signed and not persisted.
- Breach notification path: follows existing account breach path; no special handling for beta cohort.
- Secrets location + rotation:
INTERNAL_API_SECRET(shared HMAC key between Raptor and Console) in Infisical;BOOTSTRAP_TOKEN_SIGNING_KEYin Infisical. Both rotatable without redeploy via Infisical + Heroku config var update. - Kill-switch:
FLAG_BETA_PHASE2_ACCESS=0disables all join routes on next dyno restart. Works independently of the walkthrough and preview flags.
Revisit when
- Phase 2 closes and Phase 3 (full open signup) begins — at that point, the CF gate is removed, the flag is retired, and the join flow is superseded by the standard signup.
- If more than ~5% of join attempts result in consumed-but-no-account states — that volume would warrant an automated re-enrollment path rather than a manual support flow.
- If Queue Phase 2 moves customer record ownership and the
beta_cohortmarker needs to live in Queue instead of Raptor.