Shape 1 — Personal Trade-Context Journal: Design
Status: Accepted
Date: 2026-06-25
Scope: Raptor (backend_v2), Antlers (frontend/raxx-next), console flag migration
Refs: Sub-cards SHAPE1-1 through SHAPE1-5; parent epic filed as part of this design run
1. Context
Shape is the codename for Raxx's trade-context and sentiment-augmentation service, staged in three phases. Shape 1 is the only phase that is launch-appropriate: a personal, per-user, retrospective trade-context journal.
The user records structured, self-asserted context on their own trades and positions using a two-axis taxonomy (pre-trade setup thesis, post-trade behavioral label). That context is stored per-user, scoped to that user alone, and surfaced back as a filterable view of their own trade history. No inference, no auto-classification, no cross-user comparison.
Shape 2 (Reasonator/Rack real-time market sentiment scoring) and Shape 3 are explicitly out of scope here and remain deferred post-launch.
The reference data model, taxonomy, and query function are defined in docs/data-science/reference-impl/sentiment_journal/ and are the canonical inputs to this design. The product specification is docs/data-science/2026-06-05-personal-sentiment-journal-shape-1.md. The compliance baseline is docs/legal/research/shape-1-personal-sentiment-journal-compliance-2026-06-05.md.
2. Invariants
These constraints are non-negotiable. If any sub-card implementation would require violating one, stop and escalate.
System-level invariants (always)
- No stored credentials, ever.
- Passkeys/WebAuthn only for auth. No passwords, no SMS OTP.
- GDPR by default: DSR (access, portability, erasure, rectification) must cascade to all tables introduced here.
- Audit trail for every state change that affects money, permissions, or data access.
- Secrets in env/vault, never in code files that ship.
- Paper-first gating: live-trading paths require paper-profitable-for-N-cycles gate. (Shape 1 v1 only touches paper_orders; this invariant is inherently satisfied.)
Shape 1 design invariants
- User always asserts, system never infers. No auto-classification from P/L sign, time-in-position, or behavioral signals. No pre-selected defaults derived from prior user history or population means. No "did you mean?" label suggestions based on trade data.
- No emotion vocabulary. All taxonomy labels use structural/behavioral language. FollowedPlan, OverrodeRule — not Disciplined, Panicked.
- Retrospective only. Every API response, every UI string, every log message describes what occurred. No forward-looking framing.
- Auth-gated always. No label data, no filter results, no journal notes on marketing pages, unauthenticated routes, or demo surfaces.
- 24-hour post-trade lock is immutable. Once post_label_locked_at is set the row is read-only at both the API and DB layers.
- Strictly additive migration. No changes to existing paper_orders, strategies, backtest_runs, or any other table. New tables only.
- Feature-flag default OFF. FLAG_SENTIMENT_JOURNAL defaults OFF. Deploy and migration can run with flag OFF, producing zero customer-facing change and zero risk to existing endpoints.
- No forward-looking framing in copy. "Your Override Rule closes produced..." never "Override Rule closes tend to...".
3. Data Model
Two new tables. Zero changes to any existing table.
3.1 sentiment_taxonomy_versions
Config table. Holds valid label sets per taxonomy version, enabling future schema evolution without code ships.
| Column | Type (Postgres) | Notes |
|---|---|---|
version |
INTEGER PK | v1 for the Shape 1 launch set |
pre_labels |
TEXT NOT NULL | JSON array of valid pre-label strings |
post_labels |
TEXT NOT NULL | JSON array of valid post-label strings |
description |
TEXT | Human-readable description |
effective_from |
TIMESTAMPTZ NOT NULL DEFAULT now() | UTC |
Seeded at migration time with version 1.
3.2 trade_sentiment_labels
One row per (user, source_table, trade_id). The unique constraint enforces this.
| Column | Type (Postgres) | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
user_id |
INTEGER NOT NULL | FK → users; all reads and writes filter by this |
source_table |
TEXT NOT NULL CHECK IN ('paper_orders') | v1: paper_orders only |
trade_id |
INTEGER NOT NULL | FK → paper_orders.id in v1 |
pre_label |
TEXT NOT NULL CHECK IN (...) | Bullish, Bearish, Neutral, HighUncertainty |
pre_label_recorded_at |
TIMESTAMPTZ NOT NULL | UTC; set server-side at POST |
post_label |
TEXT CHECK IN (...) or NULL | FollowedPlan, HeldThroughPressure, AdjustedWithReason, OverrodeRule, UnexpectedOutcome |
post_label_recorded_at |
TIMESTAMPTZ | UTC; NULL until set |
post_label_locked_at |
TIMESTAMPTZ | UTC; NULL until 24h window closes |
journal_note |
TEXT CHECK length <= 2000 | Optional; not indexed; not logged |
taxonomy_version |
INTEGER NOT NULL DEFAULT 1 | FK → sentiment_taxonomy_versions |
created_at |
TIMESTAMPTZ NOT NULL DEFAULT now() | |
updated_at |
TIMESTAMPTZ NOT NULL DEFAULT now() |
Unique constraint: (user_id, source_table, trade_id) — one label row per trade per user.
Indexes:
- UNIQUE on (user_id, source_table, trade_id) — enforces one-label-per-trade, also the primary IDOR defense
- (user_id, post_label) WHERE post_label IS NOT NULL — partial index for backtest filter
- (user_id, pre_label) — for backtest filter
- (user_id, post_label_locked_at) WHERE post_label_locked_at IS NULL — for the 24h lock worker
3.3 IDOR design (learned from #3042)
Every API handler that reads or writes a label row must apply WHERE user_id = current_user_id. The unique index on (user_id, source_table, trade_id) means a lookup by id alone is insufficient — the handler must join or re-filter on user_id. The GET /api/sentiment-labels/:id handler fetches by id AND user_id = session.user_id. A mismatch returns 404, not 403, to avoid enumeration.
3.4 DSR (data subject request) cascade
The existing DSR worker (migration 034 flow) must cascade DELETE FROM trade_sentiment_labels WHERE user_id = :uid as part of account erasure. journal_note text is not separately retained — it is deleted with the row. Sub-card SHAPE1-1 must include a note in the migration file reminding feature-developer to add this cascade to the DSR worker.
4. API Contracts
All routes:
- Require require_session middleware (existing pattern)
- Flag-gated: FLAG_SENTIMENT_JOURNAL = 1; return 404 when flag is off (hides the surface, does not leak its existence)
- user_id is always taken from the session, never from the request body or URL — this is the primary IDOR defense
4.1 POST /api/sentiment-labels
Create the label row at order submission time.
Request body:
{
"source_table": "paper_orders",
"trade_id": 42,
"pre_label": "Bullish"
}
Response 201:
{
"id": 7,
"trade_id": 42,
"pre_label": "Bullish",
"pre_label_recorded_at": "2026-06-25T14:00:00Z",
"post_label": null,
"post_label_locked_at": null,
"journal_note_present": false,
"taxonomy_version": 1,
"created_at": "2026-06-25T14:00:00Z"
}
409 Conflict if a label row already exists for (session.user_id, source_table, trade_id). Note: journal_note text is never returned in list/create responses — only journal_note_present (boolean). The note text is only returned by the single-record GET.
4.2 PATCH /api/sentiment-labels/:id
Update post_label and/or journal_note within the 24h window.
Pre-conditions checked by handler:
1. Row exists with user_id = session.user_id (404 otherwise)
2. post_label_locked_at IS NULL (409 "entry is locked" otherwise)
Request body (at least one field required):
{
"post_label": "FollowedPlan",
"journal_note": "Held through the Tuesday spike as planned."
}
Response 200: same shape as GET.
4.3 GET /api/sentiment-labels/:id
Fetch full label record including journal_note text.
Response 200:
{
"id": 7,
"trade_id": 42,
"source_table": "paper_orders",
"pre_label": "Bullish",
"pre_label_recorded_at": "2026-06-25T14:00:00Z",
"post_label": "FollowedPlan",
"post_label_recorded_at": "2026-06-26T09:00:00Z",
"post_label_locked_at": null,
"journal_note": "Held through the Tuesday spike as planned.",
"taxonomy_version": 1,
"created_at": "2026-06-25T14:00:00Z",
"updated_at": "2026-06-26T09:00:00Z"
}
4.4 GET /api/backtest/sentiment-filter
Query the user's labeled trades with optional filters and receive aggregated statistics.
Query parameters: pre_label, post_label, source_table (default: paper_orders), strategy_type, date_from, date_to, taxonomy_version.
Response: as specified in reference strategy brief §3.3. Key points:
- journal_note_present (boolean) in the trades array, never note text
- baseline always included (all user labeled trades, no filter)
- sample.sample_too_small: true when n < 10; data still returned
- generated_at in UTC ISO-8601
All output strings in the trades array use past tense ("closed at", "recorded as") — never forward-looking.
4.5 Taxonomy endpoint (read-only, no flag gate)
GET /api/sentiment-labels/taxonomy — returns the valid label sets from sentiment_taxonomy_versions for the current version. This is read-only reference data; it may be served without the feature flag so the order-ticket UI can gracefully show "coming soon" copy even when the flag is off, without making network calls that return 404. If this is undesirable, flag-gate it too — open question for feature-developer, flag either way is acceptable.
5. State Machine
stateDiagram-v2
[*] --> Created : POST /api/sentiment-labels\n(order submission)
Created --> PostLabelSet : PATCH with post_label\n(within 24h of trade close)
Created --> Locked : 24h window closes\nwithout post_label
PostLabelSet --> Locked : 24h window closes
PostLabelSet --> PostLabelSet : PATCH (update post_label\nor journal_note, still in window)
Locked --> [*] : Read-only forever\n(or DSR delete)
The 24h lock is applied either by a background worker that sets post_label_locked_at on rows where the close time + 24h has passed, or on-read (the PATCH handler checks the condition at request time). Implementing both (background worker sets the field, PATCH handler checks it) is the safe approach: the background worker makes the lock visible in the UI; the PATCH check is a redundant safety net.
6. Sequence: Order Submission with Pre-Label
sequenceDiagram
participant U as User
participant A as Antlers (raxx-next)
participant R as Raptor (backend_v2)
participant DB as Postgres
U->>A: Opens order ticket
A->>A: Renders pre-label picker (required)\nif FLAG_SENTIMENT_JOURNAL=1
U->>A: Selects pre_label + submits order
A->>R: POST /api/paper-orders {..., pre_label: "Bullish"}
R->>DB: INSERT paper_orders (existing flow)
R->>DB: INSERT trade_sentiment_labels\n(user_id=session, trade_id=new_order_id,\npre_label="Bullish", pre_label_recorded_at=now())
R-->>A: 201 {order: {...}, sentiment_label: {id: 7, ...}}
A-->>U: Order confirmed + label saved
The pre-label picker is presented as a required step in the order ticket. The order and label are created in the same request/transaction where possible. If the existing order endpoint cannot be extended cleanly, a two-step flow (POST order → POST sentiment-label) is acceptable — feature-developer decides based on the current order endpoint shape, and notes the choice in the PR.
7. Migration
Migration number: 0048 (next after 0047_tombstoned_emails_lookup_token.py)
Migration name: 0048_trade_sentiment_labels.py
Console flag migration number: 0219
Console flag migration name: 0219_promote_sentiment_journal.py
The Alembic migration:
- Creates sentiment_taxonomy_versions table
- Seeds version 1 row
- Creates trade_sentiment_labels table with all columns, CHECK constraints, and indexes
- Uses TIMESTAMPTZ for all datetime columns (Postgres-native)
- Uses partial indexes (WHERE post_label IS NOT NULL, WHERE post_label_locked_at IS NULL)
- Must include the -- POSTGRES-ONLY sentinel comment at the top because it uses PG-specific DDL (TIMESTAMPTZ, partial index syntax)
- The SQLite dev path uses the reference schema.sql from docs/data-science/reference-impl/sentiment_journal/schema.sql — TEXT for datetimes, same constraint logic. Feature-developer copies and adapts it for the dev migration path.
Rollback (DOWN):
DROP INDEX IF EXISTS idx_tsl_user_post_label;
DROP INDEX IF EXISTS idx_tsl_user_pre_label;
DROP INDEX IF EXISTS idx_tsl_user_lock;
DROP INDEX IF EXISTS idx_tsl_user_trade;
DROP TABLE IF EXISTS trade_sentiment_labels;
DROP TABLE IF EXISTS sentiment_taxonomy_versions;
DSR cascade addition: feature-developer must update the DSR worker (migration 034 flow) to add DELETE FROM trade_sentiment_labels WHERE user_id = :uid to the account erasure cascade.
8. Feature Flag
Flag name: FLAG_SENTIMENT_JOURNAL
Default: 0 (OFF)
Risk classification: HIGH (customer-facing; touches order ticket flow)
The feature flag gates all four Raptor routes. When OFF, all four routes return 404 (not 401, not 403 — 404 hides the surface). The migration runs flag-OFF — no customer-visible change until the operator flips it via heroku config:set FLAG_SENTIMENT_JOURNAL=1.
Per project rule (memory new_flag_needs_b1_migration_same_pr), the PR adding this flag to feature_flags.yaml must include the same-PR console flag promotion migration 0219_promote_sentiment_journal.py. Feature-developer is responsible for both in SHAPE1-2.
Recommended soak: 7 days on paper accounts only before any live-account exposure.
9. Rollout Plan
- Migration:
0048_trade_sentiment_labels.pyruns against prod Postgres. Additive only. Zero schema change to existing tables. Safe to apply with flag OFF. - Deploy (flag OFF): Code ships, migration already applied. Zero customer-facing change. Existing endpoints unaffected. CI must confirm no new routes appear in the unauthenticated route table.
- Staging verification: Operator flips
FLAG_SENTIMENT_JOURNAL=1on staging only. QA confirms: all four routes return correctly, IDOR check (user A cannot read user B labels), 24h lock fires, DSR cascade deletes label rows. - Prod flip: Operator flips
FLAG_SENTIMENT_JOURNAL=1on prod when ready. No code ship needed. - Privacy policy update: Must ship before or with the prod flip (separate PR, not in the flag sub-cards). See compliance doc §3D for required additions to
PrivacyPolicy.jsx.
Prod-safety statement: With FLAG_SENTIMENT_JOURNAL=0, there is zero customer-facing change and zero risk to any existing endpoint. The two new tables are inert. No existing query plan is affected because no existing table is modified.
10. Security Considerations
- IDOR prevention: Every read/write uses
WHERE user_id = session.user_id. 404 (not 403) on mismatch. Learned from finding #3042. - No credential storage: No credentials involved in this feature.
- PII in journal_note: Free-text journal note is user PII. It is not logged, not indexed, not returned in list responses. Returned only via the single-record GET (which is auth-gated and session-scoped). Included in DSR delete cascade.
- Retention: Label data retained same duration as trading data (active + 90 days post-cancellation), per compliance doc §3D.
- DSR deletion:
DELETE FROM trade_sentiment_labels WHERE user_id = :uidmust be added to the existing DSR worker. Included in SHAPE1-1 scope. - Secrets: No new secrets introduced. Flag is an env var, not a secret.
- Kill-switch:
heroku config:set FLAG_SENTIMENT_JOURNAL=0immediately hides all routes (they return 404). No restart needed if the flag check is in middleware. - Breach notification:
journal_noteis in-scope PII for breach notification per existing GDPR/CCPA breach-notification automation (if not yet implemented, file separately — not a blocker for Shape 1). - Auto-classification prohibition (compliance invariant): No code path may write to
pre_labelorpost_labelwithout an explicit user assertion as the trigger. No inference from P/L sign, time-in-position, or behavioral signals. Enforced at the API layer — the POST and PATCH handlers accept only user-supplied values. - Backtest results auth-gate: The
GET /api/backtest/sentiment-filterendpoint is behindrequire_sessionand the feature flag. It must never be reachable from an unauthenticated path or from the marketing site.
11. Open Questions
These do not block sub-card implementation but should be answered before the prod flip:
-
Privacy policy update timing: The compliance doc (§3D) requires additions to
PrivacyPolicy.jsxbefore Shape 1 ships to any user. This is a legal deliverable, not an engineering one. Who is scheduling the attorney review? This is a genuine operator prerequisite before the prod flag flip. -
Attorney opinion (Q1 in compliance doc): The compliance doc recommends a formal written attorney opinion on §202(a)(11) inanimate-tool framing before launch. Shape 1 is launch-appropriate architecturally; the opinion is a business risk decision for the operator, not a design blocker.
-
Taxonomy endpoint flag-gating: Should
GET /api/sentiment-labels/taxonomybe flag-gated (returns 404 when off) or always available (to allow graceful "coming soon" in the order ticket UI)? Feature-developer should decide and note it in the PR. -
Lock worker placement: Should the 24h lock worker be a Celery beat task, a Heroku scheduler job, or an on-read check? The design supports either; feature-developer chooses based on existing Raptor task infrastructure.
-
Pre-label in order ticket — integration point: Does the existing order-submission endpoint accept additional fields, or does the pre-label need a separate POST after order creation? Feature-developer assesses the current order endpoint shape and notes the decision in SHAPE1-2.
12. Out of Scope
- Shape 2 / Shape 3 (Reasonator/Rack real-time market sentiment) — deferred post-launch, separate design
- Cross-user aggregation or comparison
- Live-orders source table (v1 is paper_orders only; the
source_tablecolumn anticipates future extension) - AI-generated label suggestions or auto-classification of any kind
- Mobile (iOS) surface — journal entry on mobile is a post-launch extension