ADR-0114: WCB Tier Gate and User Ownership via Queue JWT
Status: Accepted
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.
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.
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.