ADR 0133 — Shape 1 Personal Trade-Context Journal: Key Design Decisions
Status: Accepted
Date: 2026-06-25
Deciders: Software-architect agent + operator
Scope: Raptor (backend_v2), Antlers (frontend/raxx-next), console flag migration
Context
Shape 1 (Personal Trade-Context Journal) introduces user-authored behavioral annotations on trades. Three design choices carry meaningful downstream consequences and required explicit decision: (1) whether to put labels in the existing trades table or a separate table; (2) how to enforce IDOR isolation (lesson from finding #3042); (3) how to phase the feature without risk to existing prod paths.
The compliance baseline (docs/legal/research/shape-1-personal-sentiment-journal-compliance-2026-06-05.md) adds two non-negotiable design constraints that also required decision: no auto-classification and no forward-looking framing in any output.
Decision
D1 — Separate table, not trades extension. Introduce trade_sentiment_labels as a new standalone table with a FK to paper_orders, rather than adding columns to paper_orders. Labels are user-authored annotations; the execution record is a system fact. Separating them preserves independent retention rules (a user can delete their journal without affecting the execution audit trail), keeps the migration additive, and avoids touching any existing table.
D2 — IDOR prevention: user_id in every query, 404 on mismatch. Every API handler that reads or writes a label row applies WHERE user_id = session.user_id. A lookup by row id alone is never sufficient. Mismatch returns 404 (not 403) to prevent enumeration. The UNIQUE index on (user_id, source_table, trade_id) enforces one-label-per-trade and is the DB-layer IDOR boundary. This is a direct response to the IDOR finding in #3042 on the sentiment router.
D3 — FLAG_SENTIMENT_JOURNAL default OFF; migration runs independently. The Alembic migration (0048_trade_sentiment_labels.py) is applied first. With the flag OFF, all new routes return 404 and no customer-visible change occurs. The prod flip is a separate operator action (heroku config:set FLAG_SENTIMENT_JOURNAL=1) requiring no code ship. This provides a clean separation between schema deployment risk and feature activation risk.
D4 — 24h post-trade lock enforced at API layer, not DB triggers. The post_label_locked_at column is set by a background worker once close_at + 24h passes. The PATCH handler additionally checks post_label_locked_at IS NULL at request time as a redundant safety net. DB triggers are not used because they are not part of the existing Raptor pattern and would complicate testing.
D5 — journal_note never returned in list or aggregation responses. The backtest filter response returns journal_note_present: boolean, not the text. The text is returned only by the single-record GET (/api/sentiment-labels/:id). This prevents accidental note text exposure in aggregated query logs and aligns with the compliance posture that notes are private to the user.
D6 — No language-tier escalation. Shape 1 adds routes and a query function to the existing Raptor (Python) service. It does not introduce a new service. No new process, daemon, or independently deployable component. Tier classification is not applicable.
Language choice rationale
Not applicable. Shape 1 extends the existing Raptor Python service — no new service is introduced. See docs/architecture/language-tier-policy.md for tier criteria; neither Tier 1 nor Tier 2 classification is required because no new service boundary is created.
Consequences
Positive
- Migration is fully additive: existing
paper_orders,strategies, andbacktest_runstables are untouched. - Feature flag default-OFF means prod deploy carries zero activation risk; migration and flag flip are decoupled.
- Separate table gives independent retention/deletion control — DSR delete can target the journal without touching the execution record.
- IDOR defense is explicit and testable: every handler has a visible
user_idfilter, not buried in a join. - 404-on-flag-off prevents the surface from being discoverable by unauthenticated probing.
Negative / risks
- The two-table design means the backtest filter query is a JOIN between
trade_sentiment_labelsandpaper_orders. At v1 scale (hundreds of trades per user) this is sub-millisecond with the proposed indexes; at very high scale it would need reconsideration. - The 24h lock background worker is a new scheduled task — feature-developer must decide its placement (Celery beat vs. Heroku Scheduler) based on existing Raptor infrastructure.
- If the pre-label needs to be captured at order-ticket submission time (before the order ID exists), there is a chicken-and-egg: the label row needs a
trade_idFK. Feature-developer must assess whether the order endpoint can return an ID before the label POST, or whether the label is written in the same transaction as the order.
Neutral
- Keeping the internal route path as
/api/sentiment-labels(not/api/trade-context-labels) avoids a URL churn with zero benefit. The product-facing UI copy uses "Trade-Context Journal" vocabulary; the API path is internal. taxonomy_versioncolumn anticipates future label set evolution without a schema change. The config tablesentiment_taxonomy_versionsallows the UI to render the correct picker for historical trades without a code ship.
Alternatives considered
Alternative A: Extend paper_orders with label columns
Rejected because: (1) labels are user-authored annotations, not execution facts — mixing them in the execution record conflates two different concerns with different owners; (2) an additive column to paper_orders requires touching an existing table, violating the additive-only migration constraint; (3) independent DSR deletion of label data without affecting the execution record is not possible if they share a table.
Alternative B: Flag-gate at the middleware router level, not per-handler
Rejected because: per-handler flagging is the existing Raptor pattern, is testable in isolation, and makes the gate visible in code review. A router-level middleware gate would be a new pattern requiring its own design, and doesn't compose cleanly with the existing require_session middleware.
Alternative C: Enforce 24h lock via a DB trigger
Rejected because: DB triggers are not used in the existing Raptor codebase, would make testing harder, and create operational opacity (lock behavior is not visible in application code). The background-worker + API-layer double-check provides the same guarantee with better observability.
Alternative D: Return journal_note text in list and filter responses
Rejected because: (1) free-text note content in an aggregated query response exposes it to any log sink that captures response bodies; (2) the compliance posture treats notes as PII — returning them only in the single-record GET creates a clear, reviewable data-access boundary; (3) the backtest filter response is already verbose; note text would bloat it without aiding the filter result.
Security / GDPR checklist
- PII collected:
journal_note(free-text, user-authored, up to 2000 chars). Pre/post labels are structural trade metadata, not emotion-word data. The compliance doc (BLR PR #3239) established that structural vocabulary sidesteps the CCPA §1798.140(ae) "psychological trends" SPI category. Conservative treatment: disclose as potentially sensitive PI; see compliance doc §3D for requiredPrivacyPolicy.jsxadditions. - Retention period: Same as trading/position data: active + 90 days post-cancellation.
journal_notetext is co-located with the label row; it is deleted with the row. - Deletion on DSR:
DELETE FROM trade_sentiment_labels WHERE user_id = :uidmust be added to the existing DSR worker (migration 034 flow). SHAPE1-1 scope. - Audit trail: Label creation and updates are covered by the existing Raptor request audit trail. The
updated_atcolumn on each row provides a last-modified timestamp.post_label_locked_atis an immutable state-change record. - Stored credentials: None. No credentials are introduced by this feature.
- Breach notification path:
journal_noteis in-scope PII for breach notification. The existing GDPR/CCPA breach-notification automation (if shipped) must include this table. If not yet shipped, flag separately — not a Shape 1 blocker. - Secrets location + rotation:
FLAG_SENTIMENT_JOURNALis an env var (Heroku config), not a secret. No new secrets introduced. - Kill-switch:
heroku config:set FLAG_SENTIMENT_JOURNAL=0immediately disables all routes (404). No redeploy required if flag check is in middleware. Effective as an emergency off-switch for the entire feature.
Revisit when
- Shape 2 (Reasonator/Rack real-time market sentiment) is scoped for implementation — the
source_tablediscriminator anticipateslive_ordersbut does not implement it. trade_sentiment_labelsrow count exceeds 10,000 per user — reconsider the JOIN strategy in the backtest filter query.- The CCPA SPI classification question is resolved by attorney opinion (Q7 in compliance doc) — the privacy policy framing may need updating based on the attorney's answer.
- Team size exceeds 5 or row-level PII is added to audit log — revisit audit log visibility policy per
project_audit_log_full_visibility.md.