Raxx · internal docs

internal · gated

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

Negative / risks

Neutral


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


Revisit when