Raxx · internal docs

internal · gated

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

  1. Migration: 0048_trade_sentiment_labels.py runs against prod Postgres. Additive only. Zero schema change to existing tables. Safe to apply with flag OFF.
  2. 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.
  3. Staging verification: Operator flips FLAG_SENTIMENT_JOURNAL=1 on 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.
  4. Prod flip: Operator flips FLAG_SENTIMENT_JOURNAL=1 on prod when ready. No code ship needed.
  5. 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


11. Open Questions

These do not block sub-card implementation but should be answered before the prod flip:

  1. Privacy policy update timing: The compliance doc (§3D) requires additions to PrivacyPolicy.jsx before 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.

  2. 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.

  3. Taxonomy endpoint flag-gating: Should GET /api/sentiment-labels/taxonomy be 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.

  4. 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.

  5. 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