Raxx · internal docs

internal · gated

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


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

  1. beta_walkthrough_progress — add intent_bucket TEXT CHECK (...)
  2. beta_walkthrough_paper_trades — add strategy_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

  1. Dark: flag OFF on both staging and prod. Migration runs. Both new columns are invisible to existing code paths.
  2. Staging beta: flip FLAG_WALKTHROUGH_PERSONA_ROUTING=1 on staging with FLAG_BETA_WALKTHROUGH_SELF_SELECT=1. Run through all three intent paths manually. Confirm paper trade rows written with correct strategy_type and legs_json. Confirm intent_bucket appears in progress row.
  3. Prod beta: flip both flags on prod for the next tester cohort.

Soak: 48h on staging before flipping prod.


8. Security considerations


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)

  1. CC frozen chain fixturedocs/data/<symbol>-cc-frozen-2026-06-11-sample.json following the schema in §3.Q5. Symbol: AAPL (default) or operator-confirmed. At least one OTM call strike in suggested_strikes. _meta.note must state it is a historical end-of-day snapshot, not live data.

  2. 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.

  3. Two new scenario_copy strings for Screen 2: - Covered Call: one paragraph, structural + retrospective - Directional/VS: one paragraph (may be omitted if directional path always routes to screen/simple/1 and never hits /screen/2)

  4. 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.