"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:
-
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. -
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_iddirectly. The behavior of WCB data across a merge must be explicitly defined. -
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:
-
Queue-owned tier gate. WCB tier checks (Pro/Pro+ for row creation; Free = no rows, no feature) are not replicated locally in Raptor. The tier claim comes from the Queue-issued JWT or from a Queue API call. Raptor must not cache tier state locally in a way that survives a downgrade event.
-
Account-merge: data follows the primary. When a merge completes, the tombstoned account's
user_idis retired. WCB rows for the secondary account are re-attributed to the primary'suser_idas part of the merge data-migration step. No WCB row is deleted in a merge. -
Reasonator boundary: no shared write path. Reasonator scores sentiment; WCB tracks position value. They share a symbol, nothing else. No V1 WCB code calls Reasonator; no V1 Reasonator code calls WCB. Any future co-presentation (e.g., "news that coincided with this window") is a V2 feature requiring a separate design card.
-
Retrospective-only invariant strengthened. The dashed sparkline extension (§7.6 of the brief) shows the time dimension only — it contains no data values, no extrapolation, no Reasonator-derived projections. Any surface that renders future-facing data alongside this widget violates this invariant regardless of how it is framed.
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:
- The JWT token issued by Queue includes a
tierclaim:free,pro, orpro_plus. - Raptor's WCB code reads
tierfrom the decoded JWT. No additional Queue API call is needed for the tier check on the read path. - On PATCH
/api/account/settings/wcb: Raptor still reads from the JWT. The JWT is short-lived (Queue's session refresh policy governs TTL). A downgrade propagates to the user's next JWT refresh — the window between downgrade and the JWT expiring is the maximum exposure window. This is acceptable; WCB is a display feature, not an execution path.
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)
- CS initiates merge in Console; both account holders cross-verify with their registered passkeys.
- The primary account survives; the secondary account is tombstoned immediately with PII
nulling. The secondary's email is stored as an argon2-hashed value in
tombstoned_emails. - All history from the secondary account migrates to the primary's
user_id.
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:
- WCB reads:
closed_position_wcb_snapshots, Alpaca bars, Alpaca corporate actions. - Reasonator reads: ingested news articles, FinBERT model output.
- Neither writes to the other's tables. Neither calls the other's API.
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:
- Raptor Postgres Phase 3 cutover confirmed.
- Card B (#1661) Alembic migration
0002_wcb_snapshots.pyfiles. - Merge-engine card (#3248) adds the
user_idre-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.
-
adjustment=spliton paper credentials (forward from original design, Appendix Q2). Feature-developer must verify availability during #1661. If unavailable, fall back toadjustment=raw+ explicit split events. Document on #1661. -
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.
-
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.
-
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.
-
closed_positionstable name. Feature-developer confirms exact table name in Raptor Postgres baseline before writing0002_wcb_snapshots.py. -
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.
-
Merge-engine card (#3248) awareness. The implementor of #3248 must add
closed_position_wcb_snapshotsto the per-table migration matrix. This document is the source for that requirement. PM should add a cross-reference comment on #3248. -
Queue JWT
tierclaim 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.