Raxx · internal docs

internal · gated

"What Could've Been" (WCB) — Design Revision 2026-06-05

Issue: #1659 (Card A — architecture) Epic: #1657 Date: 2026-06-05 UTC Status: Design v2 — replaces preamble of what-couldve-been-design.md (2026-05-12) for Queue-cutover, account-merge v2, and sub-card breakdown Prior design: docs/architecture/what-couldve-been-design.md (PR #1787, 2026-05-12) ADR: docs/architecture/adr/0079-wcb-snapshot-storage.md (storage decision — unchanged) New ADR: docs/architecture/adr/0114-wcb-queue-awareness.md (tier gate + user-ownership via Queue) UX parallel: #1660 (UX mockups — running in parallel) Implementation target: #1661 (Raptor backend), #1662 (Antlers UI)


1. Context

This document is a revision of the original WCB architecture (filed 2026-05-12 in PR #1787). Three events since that design require explicit treatment:

  1. Queue cutover confirmed (project_queue_owns_customer_timeline). Queue is the single source of truth for customer records, subscription tier, and session JWTs. The original design assumed Raptor would read tier from a local session cache. With Queue live, tier lookups and user-ownership checks must go through Queue's API contract. This revision locks that boundary.

  2. Account-merge v2 locked (PR #3256, 2026-06-05). When two accounts merge, one account is tombstoned and all history migrates to the surviving primary account. WCB snapshot rows reference user_id directly. The behavior of WCB data across a merge must be explicitly defined.

  3. Reasonator (sentiment service) staging. Reasonator scores news sentiment on closed positions' underlying symbols. WCB and Reasonator operate on the same symbol universe post-close. This revision documents the separation boundary: they do not share data paths in V1.

The original design doc (what-couldve-been-design.md) remains authoritative for: - The closed_position_wcb_snapshots DDL (§3 of that doc) - The Celery job interface and AlpacaMarketDataService method contracts (§4) - The cache strategy and Redis graceful degradation (§6) - The full API contract including all error codes (§7) - Sequence diagrams for the three main flows (§8) - Migration sequencing hard gate (§9)

This document adds to — and in one place supersedes — that design for the topics listed above. Feature-developer reads both documents; where they conflict, this document governs.


2. Invariants

All invariants from the original design remain in force. Additions specific to this revision:


3. Queue-Aware Tier Gate

3.1 Pre-Queue state (original design)

The original design reads tier from the Raptor session cache (JWT claims set at login) and re-validates on the PATCH settings endpoint. This is acceptable while Queue is not yet the auth source-of-truth.

3.2 Post-Queue-cutover state (this revision)

Once Queue is the JWT issuer:

3.3 Ownership check (Queue-aware)

The original design says: "the endpoint must verify that position_id belongs to the requesting user." With Queue as auth issuer, the user_id in the JWT is authoritative. Raptor's query remains:

SELECT * FROM closed_position_wcb_snapshots
WHERE position_id = :position_id AND user_id = :jwt_user_id

This query is unchanged. The Queue cutover does not change the WCB data model; it only changes where the user_id claim originates (Queue JWT instead of Raptor local session).

3.4 Tier-to-horizon mapping (unchanged)

Tier JWT claim Row creation permitted Horizons available
free No row created Feature does not exist for this user
pro Yes close_of_market_only only
pro_plus Yes close_of_market_only or monthly

4. Account-Merge v2 Interaction

4.1 Merge model summary (from PR #3256)

4.2 WCB rows at merge time

The merge engine's data-migration step must include:

UPDATE closed_position_wcb_snapshots
SET user_id = :primary_user_id, updated_at = NOW()
WHERE user_id = :secondary_user_id;

This re-attribution runs inside the merge transaction, before the secondary account is tombstoned. It is not a WCB-internal operation; it is a merge-engine operation on a table that WCB owns. The merge engine must call this as part of its per-table migration matrix.

The WCB implementation sub-card (#1661) does not implement this. It belongs to the merge-engine implementation card (#3248). This document records the requirement so the merge-engine implementor knows about the closed_position_wcb_snapshots table.

No WCB row is deleted during a merge. The ON DELETE CASCADE on closed_positions(id) is scoped to position deletion (DSR / pruning), not merge operations.

4.3 Post-merge display

After merge, the primary user sees their own historical WCB rows plus the migrated rows from the secondary account. The migrated rows may show symbols and positions the primary user did not personally trade. This is expected and correct — the data belongs to the merged account.

No special UI treatment is required for migrated WCB rows in V1. If V2 introduces a "these positions came from your merged account" provenance label, that is a separate design card.

4.4 Merge during in-progress EOM tracking

If a secondary account has WCB rows with horizon_reached = FALSE at merge time, those rows are re-attributed to the primary. The scheduled jobs will finalize them normally — the user_id change does not affect job execution (jobs query by tracking_horizon and horizon_reached, not by user_id).


5. Reasonator Boundary

5.1 No V1 integration

Reasonator scores news-article sentiment for a symbol at a point in time. WCB tracks retrospective P&L for a closed position over a window. Both operate on the same symbol universe. In V1, they are entirely independent:

5.2 V2 co-presentation (not in scope)

A future "what was the news during this WCB window" feature would: 1. Require a new API contract between Raptor and Reasonator. 2. Require a UX design for the news-alongside-sparkline surface. 3. Be gated behind Pro+ or a separate add-on.

This is explicitly a V2 design concern. Any implementation that causes WCB code to reference Reasonator data in V1 must be stopped and escalated.

5.3 Shape sentiment flag note

The Reasonator "Shape" scoring batch (referenced in the Reasonator design as staged) does not affect the WCB feature. If Shape is running at the same time as the WCB EOD or EOM job, there is no contention: they query different tables and hit Alpaca at different cadences. The only shared resource is Alpaca rate limits, which the existing AlpacaMarketDataService LRU cache mitigates.


6. Storage and Caching (addendum to original §6)

The original design covers the Redis cache strategy for trajectory responses. This section adds context specific to the Queue-cutover and merge scenarios.

6.1 Cache key contains user_id

The existing cache key is wcb_trajectory:{position_id}:{current_date_utc}. After a merge, position_id is unchanged (UUIDs on closed_position_wcb_snapshots do not change). The cache key is therefore stable across a merge — no cache invalidation is needed post-merge.

6.2 Tier change invalidation

When a user is downgraded (Pro+ → Pro or Pro → Free), there is no need to invalidate the trajectory cache. The tier gate is evaluated at request time from the JWT; if the JWT reflects the new tier, the 403 response is returned before the cache is checked. Stale cached trajectories for finalized positions have no security implication because the gate fires before the cache read.


7. API Contract (addendum to original §7)

The original design's full API contract (§7 of what-couldve-been-design.md) is authoritative and unchanged. This section adds Queue-specific notes.

7.1 JWT source

Post-Queue-cutover, the JWT on inbound requests is issued by Queue. Raptor's existing JWT middleware must be updated to accept Queue-issued tokens if the signing key changes. This is a Queue cutover concern, not a WCB concern, but feature-developer (#1661) must confirm the middleware accepts Queue-issued tokens before the WCB endpoint is deployed to staging.

7.2 Tier 403 source of truth

The tier_insufficient 403 responses are evaluated from the tier claim in the Queue JWT. If the JWT is missing the tier claim (e.g., a pre-Queue legacy token), the endpoint must reject with 403 { "error": "tier_insufficient" } — fail-closed. Never assume free if the claim is absent; always deny.


8. Sequence Diagrams

8.1 Row creation (Queue-aware)

sequenceDiagram
    participant Client
    participant Raptor
    participant Queue
    participant DB as Postgres

    Client->>Raptor: POST /api/trading/close-position (JWT from Queue)
    Raptor->>Raptor: Decode JWT — read tier claim
    Raptor->>DB: INSERT INTO closed_positions (...)
    alt tier = pro or pro_plus (from JWT)
        Raptor->>DB: INSERT INTO closed_position_wcb_snapshots (snapshot fields)
        Note over DB: horizon_reached=FALSE; user_id from JWT sub claim
    end
    Raptor-->>Client: 200 OK
    Note over Client,Queue: No Queue API call needed at close time — tier from JWT

8.2 WCB render with Queue JWT

sequenceDiagram
    participant Client
    participant Raptor
    participant Redis
    participant DB as Postgres

    Client->>Raptor: GET /api/positions/<id>/what-could-have-been (Queue JWT)
    Raptor->>Raptor: Decode JWT — assert tier != free (else 403)
    Raptor->>DB: SELECT * FROM closed_position_wcb_snapshots WHERE position_id=? AND user_id=?
    alt row not found
        Raptor-->>Client: 404 wcb_not_available
    else row found
        Raptor->>Redis: GET wcb_trajectory:{position_id}:{today}
        alt cache hit
            Redis-->>Raptor: trajectory
        else cache miss
            Raptor->>Raptor: compute_trajectory(row)
            Raptor->>Redis: SET wcb_trajectory:{id}:{today} TTL=3600
        end
        Raptor-->>Client: 200 {snapshot, trajectory, metadata}
    end

8.3 Post-merge re-attribution (merge engine, not WCB)

sequenceDiagram
    participant Console
    participant MergeEngine
    participant DB as Postgres

    Console->>MergeEngine: execute merge (primary=A, secondary=B)
    MergeEngine->>DB: UPDATE closed_positions SET user_id=A WHERE user_id=B
    MergeEngine->>DB: UPDATE closed_position_wcb_snapshots SET user_id=A WHERE user_id=B
    Note over DB: WCB rows re-attributed in same transaction as position rows
    MergeEngine->>DB: INSERT INTO tombstoned_emails (email_hash=hash(B.email))
    MergeEngine->>DB: UPDATE customers SET tombstoned_at=NOW(), email=NULL WHERE id=B
    MergeEngine-->>Console: merge complete

9. Feature Flag

The FLAG_WCB_ENABLED flag introduced in the original design remains the gate. No new flag is needed for the Queue-awareness changes — those are infrastructure-level, not feature-flag-level.

Any PR introducing FLAG_WCB_ENABLED to feature_flags.yaml must include a console_flag_promotions Alembic migration in the same PR per feedback_new_flag_needs_b1_migration_same_pr.md.


10. Migration Path

The Alembic migration sequencing hard gate from the original design is unchanged:

  1. Raptor Postgres Phase 3 cutover confirmed.
  2. Card B (#1661) Alembic migration 0002_wcb_snapshots.py files.
  3. Merge-engine card (#3248) adds the user_id re-attribution step to its table matrix.

The WCB migration itself does not change as a result of this revision. The user_id column already exists. The merge engine uses it as a standard UPDATE column — no schema change needed.


11. Rollout Plan

dark    → WCB rows created silently at position close; FLAG_WCB_ENABLED=0.
flag    → FLAG_WCB_ENABLED=1 on staging; EOD + EOM jobs verified; render endpoint live.
beta    → FLAG_WCB_ENABLED=1 on prod for a pro/pro+ subset set by operator.
ga      → Full rollout to all pro/pro+ users. Flag retained as kill-switch.

Queue cutover dependency: the WCB flag rollout should not precede Queue cutover on staging. If WCB is flagged on before Queue cutover, the tier-gate reads from Raptor-local session data (original design behavior) — this is acceptable as a transient state but must not persist to GA.


12. Failure Modes Catalog

Failure Detection Behavior
Alpaca bars endpoint unavailable at job time Job catches HTTP 5xx; logs error; row remains horizon_reached=FALSE Next job run retries; row finalized on next successful run
Corporate-actions data missing for ex-date T Job logs warning; dividends_in_window holds previous value 1-business-day lag rule fires; re-evaluated on T+1 job
After-hours close with no next trading day found Calendar endpoint returns empty range Job skips row with ERROR-level log; alerts via Sentry
Redis unavailable redis.exceptions.ConnectionError caught Trajectory recomputed on every render; no data loss
Queue JWT missing tier claim JWT decode finds no tier Fail-closed: return 403 tier_insufficient
Queue outage at request time JWT already held by client; tier from JWT claim WCB read path continues; only Queue-dependent calls fail
Merge engine fails after re-attributing closed_positions but before re-attributing WCB rows Merge transaction rolls back WCB rows retain secondary user_id; merge retried; no partial state
EOD job misses a day (Celery beat outage) Rows remain horizon_reached=FALSE; Sentry alert on missed beat Manual re-trigger via runbook; rows finalized on next run
Position with no market data (delisted symbol) Alpaca bars returns empty list Job sets data_source='unavailable'; horizon_reached stays FALSE; UI shows "Market data unavailable for this position"

13. Privacy and Compliance

13.1 PII scope

closed_position_wcb_snapshots holds financial position data (symbol, size, P&L) tagged by user_id. This is financial PII. It is governed by the same GDPR data-subject rights as closed_positions.

13.2 DSR erasure

ON DELETE CASCADE from closed_positions(id) handles WCB row deletion when a position is erased via DSR. The DSR flow that targets closed_positions automatically removes WCB rows. No separate WCB-specific DSR step is required.

After a merge: DSR for the primary account erases both primary-origin and secondary-origin (re-attributed) WCB rows together via the same cascade. The tombstoned secondary account has no remaining PII after merge; its WCB rows now belong to the primary and are erased only on the primary's DSR request.

13.3 Retention after tier downgrade

Free-tier 90-day retention window applies after Pro→Free downgrade. Confirm exact window against the billing/Queue policy before implementing the pruning job in Card B.

13.4 Audit log

Setting changes (wcb_setting_changed) are logged to customer_audit_events. Scheduled job writes are logged at job level (rows updated, timestamp, job ID) — not per-row to customer_audit_events. Merge re-attribution is logged by the merge engine, not by WCB.

13.5 Breach notification

No new breach surface. WCB rows are in the same Postgres instance as closed_positions. Existing GDPR 72-hour breach notification automation covers both tables.


14. Security Considerations

14.1 Position-ID enumeration

The endpoint returns 404 (not 403) for position IDs that exist but belong to another user. This prevents enumeration of valid position IDs.

14.2 No credentials stored

Alpaca API keys are in Heroku config vars only, consumed by AlpacaMarketDataService. WCB adds no new credential surface.

14.3 Kill switch

FLAG_WCB_ENABLED=0 disables the endpoint. Celery beat schedule entries for wcb_eod_job and wcb_eom_job can be commented out independently without redeploy. Both are rotatable without code deployment.

14.4 Fail-closed on tier claim

Per §7.2: any JWT without a tier claim is treated as Free. The feature does not exist for Free users. This prevents a token-stripping attack from elevating access.


15. Open Questions

These require operator or feature-developer resolution before implementation is complete.

  1. adjustment=split on paper credentials (forward from original design, Appendix Q2). Feature-developer must verify availability during #1661. If unavailable, fall back to adjustment=raw + explicit split events. Document on #1661.

  2. Dividend data lag buffer (forward from original, Appendix Q1). 1-business-day buffer is conservative. Feature-developer verifies Alpaca's actual cadence and removes buffer if unnecessary. Document on #1661.

  3. Near-EOM single-point display (Appendix Q3 — operator decision). When position closes on last trading day of month, EOM = EOD. Design renders single-point result transparently. Operator (Kristerpher) confirms before Card C (Antlers UI) ships.

  4. 90-day free-tier downgrade window (Appendix Q4 — confirm against Queue policy). Billing retention policy is owned by Queue/billing design. Feature-developer confirms before implementing pruning job.

  5. closed_positions table name. Feature-developer confirms exact table name in Raptor Postgres baseline before writing 0002_wcb_snapshots.py.

  6. Redis provisioning timeline. Cache degradation is designed in (§6.2 of original design). Performance is materially better with Redis provisioned before WCB GA. Operator confirms timeline.

  7. Merge-engine card (#3248) awareness. The implementor of #3248 must add closed_position_wcb_snapshots to the per-table migration matrix. This document is the source for that requirement. PM should add a cross-reference comment on #3248.

  8. Queue JWT tier claim format. Feature-developer confirms the exact claim name and value set (free, pro, pro_plus) against Queue's JWT contract before implementing the tier gate. If Queue uses different strings (e.g., FREE, PRO, PRO_PLUS), the WCB code must match exactly.