Beta Walkthrough — Persona-Routed Scenarios
Card: #3855 (design) / #3856 (build) / #3857 (content) Parent epic: #3403 Status: Accepted Date: 2026-06-26 UTC
1. Context
The beta activation walkthrough currently serves all full-track testers the same frozen SPX Iron Condor scenario regardless of why they trade. The operator has defined three intent buckets — income, covered call, directional — each of which maps to a different scenario and builder. This document resolves the nine design questions that block implementation and provides the contracts feature-developer needs to write code.
2. Invariants
- No stored credentials of any kind. Walkthrough routes require a valid HMAC-SHA256 token but do not store or echo credentials.
- No live order paths. All writes are to
beta_walkthrough_*tables only. - Structural and retrospective copy throughout. No broker or execution-venue names. No emotion-labeling of users. No forward-looking or predictive framing.
- Paper-first: the walkthrough is explicitly a paper exercise. Live execution is never reachable from any walkthrough route.
- GDPR: intent_bucket is tester-behavioural data, retained under the same
30-day post-program purge policy as the rest of
beta_walkthrough_progress. - Audit trail: every screen POST writes to
beta_walkthrough_progress.updated_atand advancesscreen_completed_max. The intent choice is persisted tointent_bucket(see §3) and therefore appears in any operator query of that table. - Kill-switch:
FLAG_WALKTHROUGH_PERSONA_ROUTING(new, default OFF). Flipping to OFF at any time restores the pre-feature behaviour (all testers get the IC scenario) without a migration or rollback.
3. Design question resolutions
Q1 — Flag topology: one flag or two?
Decision: two independent flags.
FLAG_BETA_WALKTHROUGH_SELF_SELECT already gates the door screen (track choice:
simple vs full). FLAG_WALKTHROUGH_PERSONA_ROUTING (new) gates intent-bucket
routing within the full track. They are orthogonal concerns.
FLAG_BETA_WALKTHROUGH_SELF_SELECT controls whether Screen 1 renders the track-
choice door or the old persona-match questions. FLAG_WALKTHROUGH_PERSONA_ROUTING
controls whether the full-track path presents an intent question and routes to
the matching scenario.
Flag interaction matrix:
| SELF_SELECT | PERSONA_ROUTING | Screen 1 renders | Full-track Screen 2+ |
|---|---|---|---|
| OFF | OFF | Persona-match questions | IC scenario (unchanged) |
| OFF | ON | Persona-match questions | Intent question injected before Screen 2; routing on |
| ON | OFF | Door screen (simple / full) | Full track → IC scenario (unchanged) |
| ON | ON | Door screen (simple / full) | Full track → intent question → routed scenario |
When PERSONA_ROUTING is ON and SELF_SELECT is OFF: the intent question becomes
part of Screen 1 POST (a fourth answer field, intent_bucket) alongside the
existing persona-match answers. When PERSONA_ROUTING is ON and SELF_SELECT is
also ON: the intent question is rendered as a second step immediately after track
selection confirms "full", before advancing to Screen 2. In both cases the backend
persists the value to beta_walkthrough_progress.intent_bucket.
The SELF_SELECT + PERSONA_ROUTING ON/ON case is the target production state.
Q2 — intent_bucket storage: new column or persona_answers serialization?
Decision: new dedicated column intent_bucket on beta_walkthrough_progress.
Rationale: the intent bucket is a first-class routing key that the backend reads
on every Screen 2/3 request. Deserializing a JSON blob to extract a routing
key on every hot-path read is unnecessary overhead and creates silent failure
modes (e.g. if persona_answers is NULL). A single nullable TEXT column with a
CHECK constraint is the right shape: indexed, queryable, and unambiguous.
Column spec:
ALTER TABLE beta_walkthrough_progress
ADD COLUMN intent_bucket TEXT
CHECK (intent_bucket IN ('income', 'covered_call', 'directional', 'unknown'));
-- Default NULL; populated when tester submits the intent question.
-- 'unknown' is the fallback if a client sends an unrecognised value.
This is a nullable column addition — fully backward-compatible, no data migration required for existing rows.
Migration: Raptor 0049 (next after 0048 on main).
Must carry -- POSTGRES-ONLY sentinel if any PL/pgSQL is used (this migration
does not require it — ALTER TABLE ADD COLUMN runs on both dialects, but the
CHECK constraint is Postgres-specific; use the existing if dialect.name == 'postgresql'
guard pattern from 0031 for the constraint, or express it as a server-side
Alembic CheckConstraint which Alembic will skip on SQLite).
Console B1 promotion migration: 0222 (next after 0221).
YAML key: walkthrough_persona_routing.
Q3 — Covered Call builder: template-validate or static guide?
Decision: static guide (same pattern as screen_simple1_get). Do NOT route
through strategy_templates_v2.
Rationale: FLAG_STRATEGY_TEMPLATES_V2 is a separate independent flag (default
OFF, customer-facing, soak 48h). Routing the walkthrough CC path through it
creates a flag dependency that blocks beta testers if strategy_templates_v2 is
OFF. The walkthrough must be self-contained. The CC scenario presents a frozen
single-equity chain and a structural guide with every field explained inline —
no real positions, no live validator call. A simple gate check is implemented
inline in screen3_post for the CC path, enforcing otm_only and min_dte.
Q4 — Vertical Spread builder: reuse simple-track or new screen?
Decision: reuse the simple-track route (/screen/simple/1) with a
scenario-aware payload. No new route.
Rationale: /screen/simple/1 already delivers a structural, field-by-field
trade guide with a static dataset and a structure-gate note. The directional
intent maps naturally to this pattern. The backend detects intent_bucket =
'directional' when serving screen_simple1_get and returns a VS-specific
payload instead of the generic put-spread example. The route URL stays the same;
the frontend consumes the structure and fields keys as before.
This approach has one precondition: FLAG_BETA_WALKTHROUGH_SELF_SELECT must
be ON for screen/simple/1 to be accessible (the current guard). When
PERSONA_ROUTING is ON and SELF_SELECT is OFF, the directional path must be
handled differently. Decision: when PERSONA_ROUTING is ON and SELF_SELECT is
OFF, the directional intent triggers a new inline guidance block returned
directly by screen2_get (static, no separate route). The frontend renders
the VS static guide inline rather than redirecting to screen/simple/1.
In the primary configuration (both flags ON), the directional path is:
door → intent choice → /screen/simple/1 (VS payload) → /screen/4 (tax) →
/screen/5 (survey).
Q5 — Frozen chain / fixture schema
The IC scenario reuses the existing docs/data/spx-frozen-2026-06-11-sample.json.
Covered Call fixture schema (new file):
{
"_meta": {
"description": "Frozen single-equity option chain snapshot — SAMPLE DATA.",
"underlying": "<SYMBOL>",
"underlying_price": <float>,
"snapshot_date": "<YYYY-MM-DD>",
"note": "Synthesized sample data for beta-tester walkthrough demonstrations. Historical end-of-day snapshot — not live or real-time. Do not use for actual trading decisions.",
"format_version": "1",
"contract_count": <int>
},
"suggested_strikes": {
"short_call": <float>, // OTM strike to sell
"expiry": "<YYYY-MM-DD>", // ~14-21 DTE
"dte": <int>
},
"contracts": [
{
"strike": <float>,
"expiry": "<YYYY-MM-DD>",
"expiry_type": "monthly" | "weekly",
"dte": <int>,
"type": "call" | "put",
"bid": <float>,
"ask": <float>,
"last": <float>,
"open_interest": <int>,
"iv": <float>
}
]
}
Symbol: content author picks (AAPL or MSFT are both liquid and well-known).
Strike cluster: 3-5 strikes near the money with at least two OTM call strikes.
Expiry row: one row at 14-21 DTE monthly or weekly.
File path: docs/data/<symbol-lowercase>-cc-frozen-2026-06-11-sample.json
Vertical Spread fixture: not needed as a new file. The directional path
reuses the screen/simple/1 static-guide approach (see Q4). The VS payload
is constructed inline by the route handler using hardcoded sample values (same
pattern as the current simple-track handler, which already contains a fully
static put-spread example). No additional fixture JSON is required.
This narrows #3857's scope: only one new fixture file (CC) is needed, not two.
Q6 — Route structure: uniform /screen/N or scenario-prefixed?
Decision: uniform /screen/N with the backend returning scenario-specific
payloads based on stored intent_bucket. No new route segments.
This is the simpler choice and avoids URL proliferation. The frontend already renders whatever the API returns; no routing logic change required on the Antlers side for Screens 2/3.
Full route map after this feature (PERSONA_ROUTING ON + SELF_SELECT ON):
GET /screen/1 — door screen (two track choices)
POST /screen/1 — write track_selected; if full + PERSONA_ROUTING, return intent question payload
(or: separate sub-step within screen 1 POST response)
GET /screen/simple/1 — directional VS guide (when intent_bucket='directional')
OR generic simple-track guide (when intent_bucket=NULL or 'income')
POST /screen/simple/1 — acknowledge, advance to /screen/4
GET /screen/2 — scenario payload routed by intent_bucket:
'income' → IC scenario (unchanged)
'covered_call' → CC scenario (new fixture)
'directional' → (unreachable in SELF_SELECT ON path — goes to simple/1)
NULL/'unknown' → 'income' fallback + warning log
POST /screen/2 — acknowledge, write scenario_answers, advance to 3
GET /screen/3 — builder routed by intent_bucket:
'income' → IC builder (unchanged)
'covered_call' → CC static guide + structure gate
'directional' → (unreachable; goes to simple/1)
NULL/'unknown' → IC builder fallback
POST /screen/3 — submit fill → paper trade write (see §3.Q7), advance to 4
GET /screen/4 — tax context (uniform across all paths)
POST /screen/4 — acknowledge, advance to 5
GET /screen/5 — hypothesis survey (uniform, intent_bucket in progress row)
POST /screen/5 — submit → completed_at, include intent_bucket in survey row lookup
GET /status — includes intent_bucket in progress response
Q7 — Paper trade table: nullable IC columns, legs_json, or new table?
Decision: use the existing legs_json TEXT column (already present in
beta_walkthrough_paper_trades) to store all leg data for CC and VS fills.
Add one new column strategy_type (TEXT, NOT NULL, default 'iron_condor') to
tag which strategy shape the row represents. Do NOT add nullable IC-specific
columns. Do NOT create a new table.
Rationale: legs_json is already defined and indexed. The IC-specific named
columns (short_call_strike, etc.) are populated for IC fills and are left
NULL for CC/VS fills — which is acceptable because CC/VS consumers read from
legs_json, not from the named columns. strategy_type makes the shape
explicit without schema explosion.
strategy_type values: 'iron_condor', 'covered_call', 'vertical_spread'.
legs_json schema contract (all three scenarios must conform):
[
{
"role": "short_call" | "long_call" | "short_put" | "long_put" | "short_call_cc" | "short_put_vs" | "long_put_vs",
"strike": <float>,
"expiry": "<YYYY-MM-DD>",
"option_type": "call" | "put",
"action": "sell" | "buy",
"premium": <float>
}
]
For Covered Call: one leg, role "short_call_cc", action "sell".
For Vertical Spread: two legs, roles "short_put_vs" (sell) and "long_put_vs" (buy).
For Iron Condor: four legs (existing shape, roles unchanged).
The IC-specific named columns (short_call_strike, long_call_strike,
short_put_strike, long_put_strike, wing_width) remain in the schema and
are populated for IC fills. They are NULL for CC and VS fills. This is documented
and intentional — not silent.
Migration: add strategy_type column in the same Raptor 0049 migration as
intent_bucket on beta_walkthrough_progress. Two ALTER TABLE statements,
one migration file.
ALTER TABLE beta_walkthrough_paper_trades
ADD COLUMN strategy_type TEXT NOT NULL DEFAULT 'iron_condor';
Rollback: DROP COLUMN on both additions (safe pre-launch; no customer data yet).
Q8 — Survey continuity (Q9 in the original card)
Decision: survey (screen 5) is uniform across all three paths. No intent-aware branching in survey questions or scoring.
The intent_bucket value is accessible on the progress row and can be joined
to the survey response for offline analysis — no schema change to
beta_walkthrough_responses is needed.
4. Scenario registry
The backend holds a static registry dict (in beta_walkthrough.py) keyed by
intent_bucket:
_SCENARIO_REGISTRY = {
"income": {
"fixture_loader": _load_frozen_chain, # existing
"screen2_title": "...", # from #3857 content card
"scenario_copy": "...", # from #3857 content card
"builder": "ic", # screen3 IC path
"suggested_strikes": { ... }, # existing
},
"covered_call": {
"fixture_loader": _load_cc_frozen_chain, # new loader pointing to CC fixture
"screen2_title": "...", # from #3857 content card
"scenario_copy": "...", # from #3857 content card
"builder": "cc", # screen3 CC static guide
"suggested_strikes": { ... }, # from CC fixture _meta
},
"directional": {
"fixture_loader": None, # no chain file — static guide
"screen2_title": None, # unreachable via /screen/2 in SELF_SELECT ON
"scenario_copy": None,
"builder": "simple", # routes to /screen/simple/1
"suggested_strikes": None,
},
}
_SCENARIO_FALLBACK = "income"
Feature-developer implements this as a module-level constant. The builder
field drives the branch in screen3_get / screen3_post.
5. Intent question flow
When PERSONA_ROUTING is ON, the intent question is surfaced as follows:
SELF_SELECT ON (primary path): After POST /screen/1 with track='full',
the response includes intent_question payload. The frontend renders the three
choice cards before advancing to /screen/2. The tester POSTs their choice back
to a new endpoint POST /screen/1/intent (or the frontend can send it as part
of a second POST to /screen/1 with a different body shape; see open question OQ-1
below). This writes intent_bucket to the progress row.
Simpler alternative chosen: the existing POST /screen/1 when PERSONA_ROUTING
is ON accepts an optional intent_bucket field. If track='full' and
intent_bucket is present, it is persisted in the same DB transaction. If
intent_bucket is absent (i.e. the frontend makes a second pass), a lightweight
POST /screen/1/intent endpoint persists it. Feature-developer picks which
approach fits the frontend flow; both are acceptable. The DB write is identical.
SELF_SELECT OFF: The existing POST /screen/1 (persona-match path) accepts
an intent_bucket field alongside answers. Backend persists both.
6. Migration plan
Single Raptor migration 0049 (one file, two ALTER TABLE statements):
beta_walkthrough_progress— addintent_bucket TEXT CHECK (...)beta_walkthrough_paper_trades— addstrategy_type TEXT NOT NULL DEFAULT 'iron_condor'
Console B1 promotion migration 0222 for FLAG_WALKTHROUGH_PERSONA_ROUTING.
Rollback: Both columns are additive and nullable (or have defaults). Dropping them reverts the schema. The flag OFF path already degrades gracefully to the IC scenario.
7. Rollout
- Dark: flag OFF on both staging and prod. Migration runs. Both new columns are invisible to existing code paths.
- Staging beta: flip
FLAG_WALKTHROUGH_PERSONA_ROUTING=1on staging withFLAG_BETA_WALKTHROUGH_SELF_SELECT=1. Run through all three intent paths manually. Confirm paper trade rows written with correctstrategy_typeandlegs_json. Confirmintent_bucketappears in progress row. - Prod beta: flip both flags on prod for the next tester cohort.
Soak: 48h on staging before flipping prod.
8. Security considerations
- No credentials stored or echoed at any point. Walkthrough token is HMAC-SHA256;
the raw token is never stored (only
token_hash). intent_bucketis not PII. It is a three-value enum. Retention: 30-day post-program purge (same as allbeta_walkthrough_*data).- Audit trail:
updated_atadvances on every screen POST.intent_bucketandstrategy_typeare readable by operators with access to thebeta_walkthrough_*tables. No additional audit table entry is needed; these are not financial or permission state changes. - No live order paths are touched. The CC static guide does not call
strategy_templates_v2; it runs an inline gate check in the route handler only. - Kill-switch: flag OFF collapses all three paths back to the IC scenario without a rollback or migration.
9. Open questions
OQ-1 (operator decision required before feature-dev starts): Intent capture
sub-step within Screen 1 — single POST vs separate /screen/1/intent endpoint.
The design accepts either; feature-developer picks based on the frontend flow.
No blocking decision needed from the operator for this one.
OQ-2 (content card #3857, parallel): Symbol choice for the CC fixture. AAPL is the default assumption. Operator confirms or changes via a comment on
3857. Implementation must not hardcode a fixture path that doesn't exist yet;
feature-developer uses a fixture-not-found guard that falls back to the IC scenario and logs a warning.
OQ-3 (content card #3857): Door-screen intent copy (three card labels +
descriptions) and per-scenario scenario_copy strings must be reviewed by
operator before #3856 closes. Implementation ships with placeholder strings
that #3857 replaces in the same PR or immediately after.
10. What #3857 must author (content owed, can be done in parallel)
-
CC frozen chain fixture —
docs/data/<symbol>-cc-frozen-2026-06-11-sample.jsonfollowing the schema in §3.Q5. Symbol: AAPL (default) or operator-confirmed. At least one OTM call strike insuggested_strikes._meta.notemust state it is a historical end-of-day snapshot, not live data. -
Three door-screen intent choice strings (one per
intent_bucket): - Short label (3-7 words) - One-sentence description (structural, no broker names, no predictions) - Draft provided in #3857 body for operator review. -
Two new
scenario_copystrings for Screen 2: - Covered Call: one paragraph, structural + retrospective - Directional/VS: one paragraph (may be omitted if directional path always routes toscreen/simple/1and never hits/screen/2) -
Screen 1 intent question copy — the question text displayed before the three intent cards. Example draft: "Which describes what you're trying to do with this account?" — operator reviews.
Vertical Spread fixture file: not needed (see §3.Q4 and §3.Q5). #3857 may close the VS fixture item as superseded.