ADR-0114: WCB Tier Gate and User Ownership via Queue JWT
Status: Accepted (amended 2026-10-31 UTC, see "Amendment" below)
Date: 2026-06-05 UTC
Deciders: software-architect agent
Scope: WCB feature (Raptor backend_v2/); tier-gate on GET /api/positions/<id>/what-could-have-been and PATCH /api/account/settings/wcb
Refs: #1659, #1657; parent design doc docs/architecture/wcb-2026-06-05.md
Supersedes: Tier-gate section of docs/architecture/what-couldve-been-design.md §7.2 (2026-05-12), which assumed Raptor-local session tier
Context
The original WCB design (PR #1787, 2026-05-12) assumed Raptor would read the user's subscription tier from a locally cached session (JWT claims set at login via Raptor's own auth layer). This assumption was correct at the time: Queue was not yet the JWT issuer.
Since then, project_queue_owns_customer_timeline has been locked: Queue is the single
source of truth for customer records and subscription tier. Queue now issues the JWT that
clients present to Raptor. The WCB tier gate must be updated to read tier from the
Queue-issued JWT, not from a Raptor-local cache.
Two concrete questions this ADR resolves:
- Should WCB call Queue's customer API at request time to verify tier, or should it read from the JWT claim?
- What happens when the JWT is missing the
tierclaim?
Decision
WCB reads tier from the tier claim in the Queue-issued JWT. No additional Queue API call
is made at WCB request time. The JWT is the authoritative tier signal.
If the JWT is missing the tier claim, WCB fails closed: returns 403 tier_insufficient.
It never assumes free and never fails open.
The user_id used for ownership checks in WCB queries is the sub claim from the
Queue-issued JWT. No separate user-lookup call to Queue is made.
Amendment (2026-10-31 UTC): the tier gates what is new, never the user's own stored record
Lapse/downgrade data-access policy, audit #4821, epic #4818, invariant I-10
(docs/architecture/stripe-customer-billing.md section 12). The tier gate above scopes
only what is new. GET /api/positions/<id>/what-could-have-been is no longer decorated
with @require_tier: the stored snapshot is the caller's own closed-trade record (symbol,
prices, cost basis, size, commission, actual P&L, the stored would-have P&L) and stays
readable at every tier. A caller below Pro (Free, or lapsed with feature_locked_at set)
gets the stored row read-only, and a stored row is served at every tier whatever horizon it
was tracked at. The tier decides only:
- the live trajectory of an in-progress row (Pro for end of day, Pro+ for monthly; below that the stored fields are served with an empty trajectory);
- an explicit
?horizon=eomrequest from a paying caller (Pro+); - the answer when nothing is persisted (403
tier_insufficientbelow Pro while the tier gate is enforced).
PATCH /api/account/settings/wcb keeps @require_tier("pro") (a write to a Pro setting).
Effect on this ADR: a user downgraded from Pro+ to Free no longer "loses WCB access at their
next JWT refresh" (Consequences, Positive). They lose the live trajectory and the settings
write, never read access to a snapshot they already own. The fail-closed decision for a
missing tier claim stands for what is new; a stored snapshot is never denied on the tier
alone.
Language choice rationale
This ADR governs a feature that extends an existing Tier 2 Python service (Raptor /
backend_v2/). WCB is not a new service; it is a new endpoint and scheduled task in Raptor.
No language classification is required for this ADR.
Consequences
Positive
- No additional latency on the WCB read path. The JWT is already decoded by Raptor's existing
middleware; reading the
tierclaim is O(1). - No Queue availability dependency on the WCB hot path. If Queue is temporarily unreachable, existing JWTs continue to authorize WCB requests until they expire. This is consistent with Queue's I-9 invariant (fail-closed on Queue outage applies to unauthenticated requests, not to requests with a valid JWT already in hand).
- Downgrade propagation is bounded by JWT TTL. A user downgraded from Pro+ to Free loses WCB access at their next JWT refresh. This is the same downgrade propagation model used across all tier-gated features; WCB does not need special handling. (Amended 2026-10-31 UTC, audit #4821: this applies to the live trajectory and the settings write only. Stored snapshots stay readable at every tier, see "Amendment".)
Negative / risks
- A compromised or stolen JWT with a
pro_plustier claim grants WCB access until the JWT expires, even if the underlying subscription has been cancelled. Mitigation: Queue's JWT TTL is short; session revocation via Queue propagates on the next request requiring a token refresh. - The
tierclaim format must exactly match between Queue's JWT issuance and Raptor's WCB tier check. A mismatch (e.g.,PROvspro) would cause all Pro users to receive403. Mitigation: feature-developer confirms claim format against Queue's JWT contract before shipping; documented as open question #8 in the design doc.
Neutral
- The existing WCB ownership check (
WHERE user_id = :jwt_sub) is unchanged in form. The source of theuser_idvalue shifts from Raptor-local session to Queue JWTsubclaim.
Alternatives considered
Alternative A: Call Queue's customer API at WCB request time
At each WCB request, Raptor calls GET /api/v1/customers/{user_id} on Queue to retrieve the
current tier.
Rejected because: adds a synchronous service-to-service call on the WCB hot path, increasing latency by 20–100 ms (intra-Heroku latency). The WCB endpoint is already potentially slow (bar data fetch + P&L computation without Redis). Adding a Queue call makes the p99 worse. The JWT TTL model provides adequate freshness for a display feature with no execution consequences.
Alternative B: Maintain a local tier cache in Raptor with a TTL
Raptor caches the tier in Redis (or in-process) keyed by user_id, refreshed on each
JWT refresh or on a fixed TTL.
Rejected because: introduces a third source of tier truth (Queue DB, Queue JWT, Raptor cache). Cache invalidation on downgrade requires either Queue pushing an event to Raptor or Raptor polling Queue — both are more complex than reading from the JWT, which Queue already keeps fresh via its JWT refresh cycle. The JWT TTL is the right invalidation boundary for this use case.
Security / GDPR checklist
- PII collected: No new PII. The
tierclaim is subscription metadata, not personal data. Theuser_id(subclaim) is already in all authenticated requests. - Retention period: The JWT claim is not persisted. It is read at request time and discarded after the request.
- Deletion on DSR: N/A — no claim is persisted.
- Audit trail: Tier-based 403 responses are not audited (they are access-control
enforcement, not data-access events). WCB setting changes remain audited via
customer_audit_eventsas specified in the original design. - Stored credentials: None. Raptor's JWT signing key (or Queue's public key for verification, depending on the signing scheme) lives in Heroku config vars.
- Breach notification: No new PII surface. Existing Raptor breach notification path applies.
- Secrets location + rotation: Queue JWT signing key in Heroku config vars, rotatable without redeploy (requires a coordinated key rotation across Queue + all Raptor instances, following the standard key-rotation runbook).
- Kill-switch:
FLAG_WCB_ENABLED=0disables the endpoint. Tier checks inside a disabled feature do not execute.
Revisit when
- Queue extracts to a standalone Heroku app (Phase 4). At that point, verify that JWT signing key rotation can be coordinated across two separate Heroku apps without a gap.
- If WCB becomes an execution-path-adjacent feature (e.g., tier check gates an order type). At that point, the JWT-TTL propagation model may be too loose and a synchronous Queue call should be reconsidered.