App database, API & web app
Parent: Architecture · Covers roadmap phases 0–1 (app side) and 6 (pages). Status: as built — all roadmap phases shipped in felix-app; as-built notes are inline.
1. Scope and goals
This piece is the interactive surface of the system: the app-side Postgres schema (users, profile, model portfolio, settings, keys, notifications), auth, the web↔brain API boundary, and every page of the Next.js app. It renders what docs 01–02 produce and captures what the user decides.
- App DB (doc-03 tables): full column-level design for everything the user edits directly, plus a consolidated catalog of every Postgres table in the system with its owning doc.
- Auth: Supabase Auth sign-in (social + TOTP MFA today, passkeys at GA), session model, and how the brain's FastAPI trusts the web session.
- API boundary: what the web reads/writes in Postgres directly vs what goes through the brain, and the
platform/packages/schemastype-sync mechanism. - Onboarding: retirement questionnaire → risk profile → editable template model portfolio → seeded thresholds.
- Pages: Today, Portfolio, Markets, Ticker, Actions, Chat (shell), Settings, Admin — each with data sources, interactions, and empty/stale states driven by doc-01 freshness facts.
Phase mapping: schema, auth, API boundary, and Settings/Portfolio basics are phases 0–1; Today/Actions light up with phase 4–5 artifacts; Markets/Ticker with phase 6; the Chat shell with phase 7. Page specs below are written once, against their eventual data sources.
Goals: every AI claim auditable in the UI ("based on" chips → fact cards); user edits are plain guarded CRUD, never inferred; single user today with nothing single-user-shaped in the schema; the web app stays thin — all math, parsing, and LLM work lives in the brain.
Non-goals (v1): mobile app (responsive web only), multi-currency display (USD; schema carries currency per doc 01), real-time streaming quotes (EOD + short-cache delayed quote on the Ticker page only), notifications beyond in-app (no email/push), admin multi-tenancy console (single-user admin view only), offline/PWA, i18n, public API for third-party clients.
2. Postgres schema catalog
2.1 Index of all tables
One row per Postgres table in the whole system. Doc 03 owns only its rows; others are referenced by their owning doc's names and never redefined here.
| Table | Owner | Purpose |
|---|---|---|
accounts | 01 | Broker accounts with tax treatment (account_type), last_synced_at |
transactions | 01 | Normalized broker transactions (typed, deduped by source-row hash) |
tax_lots | 01 | Open lots with basis source (broker or derived FIFO) |
lot_closures | 01 | Lot ↔ sell links with realized gains |
cash_balances | 01 | Per-account cash snapshots |
positions_current | 01 | Derived current positions, materialized nightly |
reconciliations | 01 | Derived-vs-broker discrepancies (open/explained/resolved) |
imports | 01 | CSV import bookkeeping: staged→committed, file hash, validation errors |
universe | 01 | Tickers to fetch, with reasons (held, watchlist, model_portfolio, …) |
ticker_snapshot | 01 | Latest close/day change/52w per ticker for app display |
pipeline_runs | 01 | Run status, row counts, errors per pipeline execution. As built: there is no separate watermarks table — fetch progress derives from lake state (doc 01 §6) |
news_stories | 01 | Clustered, synthesized stories (headline, bullets, importance, sentiment) |
news_story_articles | 01 | Story ↔ article membership |
news_embeddings | 01 | pgvector embeddings for articles/stories |
facts | 02 | Typed deterministic facts (drift, TLH, liquidity, freshness, …) — the evidence layer |
insights | 02 | Narrated observations citing evidence_fact_ids[] |
actions | 02 | Proposed actions: kind/status enums, params, rationale, evidence, expiry, matched transactions |
action_match_candidates | 02 | As built (phase 5): near-miss verifier matches awaiting one-click user confirmation (doc 02 §6.3) |
daily_briefings | 02 | Daily briefing artifact (sections with evidence references) |
ai_runs | 02 | Agent run log: model, tokens, latency, validation retries, prompt version |
tlh_equivalents | 02 | TLH replacement-candidate equivalence table |
cma_sets | 02 | Versioned capital-market assumption sets (immutable once referenced) |
cma_assumptions | 02 | Per-(set, asset class) expected real return and volatility |
market_recaps | 02 | Shared generic daily market recap, one row per trading date (house key; defined in doc 02 §5.1) |
users | 03 | Canonical app user row; id = Supabase auth.users UUID, created via trigger or on-first-login upsert |
auth.* schema | Supabase | Auth users, identities, sessions, MFA factors — managed by Supabase Auth (GoTrue); app code never writes it and domain FKs never point at it |
profiles | 03 | Retirement questionnaire results: income, goals, risk, tax rates |
model_portfolios | 03 | Versioned target portfolios (one active per user) |
model_portfolio_targets | 03 | Per-portfolio target weights and drift bands |
watchlist_items | 03 | Watchlist membership (feeds universe reason watchlist). As built (phase 6): keyed (user_id, ticker) — one implicit default watchlist per user; the named watchlists container table is deferred |
user_settings | 03 | Per-user thresholds and notification prefs |
api_keys | 03 | BYOK key metadata (provider, last4, status, Vault reference); secret material lives in Supabase Vault (doc 05) |
notifications | 03 | In-app notification feed |
chat_threads | 04 | Chat threads with optional artifact context |
chat_messages | 04 | Messages, tool calls, streamed content |
chat_summaries | 04 | Rolled-up summaries of older chat turns (thread context budget) |
ticker_ai_takes | 04 | As built (phase 7): cached Ticker "AI take" per (user, ticker, trading_date) — resolves this doc's open question 3 as a dedicated table |
usage_ledger | 05 | Per-run LLM spend attribution against virtual-key budgets |
eval_results | 05 | Eval-gate outcomes per prompt/model version (doc 05 §7) |
As built (phase 8): the LiteLLM proxy's own bookkeeping lives in a separate litellm schema in the same Supabase Postgres (doc 05) — outside Drizzle's purview, like auth.*.
Lake tables are cataloged in doc 01 and are not Postgres: market/curated/ prices_eod, corporate_actions, adjustment_factors, trading_days, tickers, fundamentals, quarantine_prices, news_articles, and users/{id}/curated/portfolio_snapshots. The web app never touches them directly — charts and history come through brain endpoints (§4).
2.2 Doc-03 owned tables (column level)
users — our canonical app-user row in public, mirroring Supabase auth.users: id equals the auth UUID (auth.uid()), created by a trigger on auth.users insert with an idempotent on-first-login upsert as backstop. All domain FKs point here, never at auth.users directly — the decision doc's lock-in mitigation. As built (phases 0–1): a single minimal profiles table (user_id pk = auth.uid()) currently plays this canonical-anchor role and is what all phase-1 domain tables FK to; the fuller users/profiles split below lands with onboarding, and the FK target stays a public row either way.
| Column | Type | Notes |
|---|---|---|
id | uuid | pk = Supabase auth.users.id; every other table's user_id FKs here |
email | citext | unique; mirrored from Supabase Auth |
email_verified | bool | mirrored from Supabase Auth (it owns verification flows) |
name, image | text | display only |
role | text | owner v1; future admin/member distinction. As built (phase 8): owner | member on profiles.role, with an owner backfill and a column-scoped grant blocking self-promotion; the Admin surface gates on it (§6.8). |
timezone | text | default America/New_York; defines the user's "daily" boundary for briefings |
base_currency | text | default USD; display-only v1 |
created_at, updated_at | timestamptz | bookkeeping |
profiles — one row per user; the questionnaire's durable output. Raw answers kept in jsonb so re-takes can diff and re-derive.
| Column | Type | Notes |
|---|---|---|
user_id | uuid | pk, fk users |
birth_year | int | projections need current age |
annual_income | numeric | gross, USD/yr |
savings_rate_pct | numeric | share of income invested annually |
dependents | int | count |
target_retirement_age | int | goal |
target_retirement_income | numeric | desired annual income, today's dollars |
risk_score | int | 1–10 questionnaire output |
risk_profile | enum | conservative | balanced | growth | aggressive — banded from risk_score |
liquidity_floor | numeric | minimum cash the user wants untouched (drives liquidity facts) |
cash_target_pct | numeric | target cash allocation |
marginal_tax_fed_pct, marginal_tax_state_pct | numeric | drive TLH benefit estimates |
questionnaire | jsonb | raw answers + questionnaire schema version |
questionnaire_completed_at, updated_at | timestamptz | bookkeeping |
model_portfolios — versioned, never mutated once active; re-takes and edits create new versions (§5).
| Column | Type | Notes |
|---|---|---|
id | uuid | pk |
user_id | uuid | fk users |
version | int | unique per user, monotonically increasing |
name | text | e.g. "Growth v3" |
status | enum | draft | active | archived; partial unique index: one active per user |
level | enum | asset_class | sector | ticker — the level this portfolio's targets are defined at (v1: exactly one level per portfolio; the column lives on targets too per the shared contract, app-enforced equal) |
source | enum | questionnaire_template | manual_edit | retake |
derived_from_version | int | null for v1 portfolios |
template_key, risk_profile_at_creation | text | provenance of the proposal |
created_at, activated_at, archived_at | timestamptz | lifecycle |
model_portfolio_targets — the rows the drift engine (doc 02) compares holdings against.
| Column | Type | Notes |
|---|---|---|
id | uuid | pk |
model_portfolio_id | uuid | fk, cascade delete for drafts |
level | enum | asset_class | sector | ticker (matches parent) |
key | text | us_equity / Technology / VTI depending on level; unique (portfolio, level, key) |
weight_pct | numeric | targets within a portfolio sum to 100 (app-enforced on activate) |
band_pct | numeric | drift band ± absolute points; null → user_settings.default_drift_band_pct |
sort | int | display order |
Holdings→asset-class/sector classification for drift comes from the lake tickers reference table (doc 01); this doc only stores targets.
watchlists and watchlist_items — feed universe reason watchlist (doc 01) so watched tickers get priced.
| Column | Type | Notes |
|---|---|---|
watchlists: id, user_id, name, is_default, created_at | — | one default list seeded at onboarding |
watchlist_items: watchlist_id, ticker, note, added_at | — | unique (watchlist_id, ticker) |
As built (phase 6): only watchlist_items(user_id, ticker) shipped — one implicit default watchlist per user; the named watchlists container (and with it watchlist_id) is deferred until multiple lists are worth having.
user_settings — one row per user, seeded with defaults at onboarding; read by the doc-02 analytics engine and daily agent as per-user config.
| Column | Type | Notes |
|---|---|---|
user_id | uuid | pk, fk users |
default_drift_band_pct | numeric | default 5.0; fallback when a target has no band |
tlh_min_loss_usd | numeric | default 500 — below this, TLH facts aren't emitted |
tlh_min_loss_pct | numeric | default 5.0 |
portfolio_staleness_days | int | default 7 (doc-01 freshness window) |
price_staleness_trading_days | int | default 2 |
news_staleness_hours | int | default 24 |
briefing_min_importance | int | default 3; story filter for the daily agent (doc-01 consumption contract) |
min_trade_usd | numeric | as built (phase 3) — default 250; minimum trade size for suggestions |
benchmark | text | as built (phase 3) — default SPY; primary benchmark override (doc 02) |
chat_budget_usd_daily | numeric | as built (phase 7) — default 2.00; per-day chat spend cap (doc 04) |
notify_briefing, notify_action_nudges, notify_import_reminders | bool | default true; in-app prefs (§9) |
updated_at | timestamptz | bookkeeping |
api_keys — table placement and Settings UX are owned here; key custody (Supabase Vault) and LiteLLM virtual-key mechanics are doc 05's. The secret material never lives in this table: it goes into Supabase Vault, and this row keeps metadata plus the Vault reference.
| Column | Type | Notes |
|---|---|---|
id | uuid | pk |
user_id | uuid | fk users |
provider | enum | openai | anthropic | google | other; unique (user_id, provider) v1 |
label | text | user-facing name |
vault_secret_id | uuid | reference to the Supabase Vault secret holding the provider key (custody: doc 05); decrypted view readable by the brain's role only |
key_last4 | text | only plaintext remnant outside Vault, for masked display |
status | enum | unverified | active | invalid | revoked |
gateway_key_ref | text | LiteLLM virtual-key linkage (mechanics: doc 05). As built (phase 8): intentionally stays NULL — revoke works as a live status re-check plus Vault delete, so no gateway linkage needs storing. |
last_verified_at, created_at, updated_at | timestamptz | test-connection updates last_verified_at |
notifications — append-mostly feed behind the bell icon.
| Column | Type | Notes |
|---|---|---|
id | uuid | pk |
user_id | uuid | fk users |
kind | enum | briefing_ready | action_nudge | action_expiring | import_reminder | reconciliation_flag | pipeline_failure | system |
title, body | text | short; details live at the link target |
href | text | app path, e.g. /actions/{id} |
ref | jsonb | artifact ids for dedupe/deep-link |
dedupe_key | text | partial unique index where read_at is null — re-emitting while unread is an upsert, not a duplicate |
created_at, read_at, dismissed_at | timestamptz | lifecycle |
As built (phase 5): dismiss implies read — the user's dismiss write also sets read_at, and the update policy's WITH CHECK requires read_at IS NOT NULL (which also closes re-mark-unread); without this, dismissed-but-unread rows permanently muted their stable-dedupe notifications. Action-completion and wash-repurchase notifications use kind system — the pinned enum was deliberately not extended. Emission has two semantics: upsert_while_unread (the dedupe_key behavior above) and emit_once_ever (tier transitions, completions).
2.3 Multi-user posture
user_idon every user-scoped row from day 1, FK tousers; all web queries go through aforUser(session.userId)query helper — user identity always comes from the session, never from client input.- RLS is on from day 1, not a deferred switch: every user-scoped table's policy (
user_id = auth.uid()) ships enabled in the migration that creates it — one-liners precisely because every table already carriesuser_id, and table stakes now that the Supabase anon key lives in the browser. The brain's service-role connection bypasses RLS by design; tenancy there is enforced in code by the user-context pattern (§3). On the web side theforUserhelper remains the query-shaping layer with RLS underneath as the backstop; phase 8 becomes an RLS review (coverage + tests), not an enablement. - Web Drizzle connection — role and claims (resolved): the web server's Drizzle pool connects as a dedicated non-privileged Postgres role — not the service role, not the
postgressuperuser — so the day-1 policies genuinely apply to the CRUD path. Each request's queries run inside a transaction that sets theauthenticatedrole and the user's JWT claims (request.jwt.claims) from the session, soauth.uid()resolves and the policies bind exactly as they would for a supabase-js call — the standard Drizzle-on-Supabase RLS pattern.@supabase/ssrremains the auth/session layer (sign-in flows, token refresh) and stays available for client-side features later (e.g. realtime), but it is not the CRUD path (§4). The brain's service-role connection is unchanged: RLS bypassed, tenancy in code (§3). Role provisioning, as built (phase 1): locally the role (web_app, login + membership inauthenticated, nothing else) is created idempotently bysupabase/seed.sql; in prod it is created once by the operator, and the web server receives its DSN asWEB_DATABASE_URL— cluster-level roles are deliberately outside the Drizzle migrations. - Soft single-user mode: a
MAX_USERS=1config closes signup after bootstrap (§3). No code path assumes one user; there is simply nobody else yet.
3. Auth
Decision: Supabase Auth (hosted GoTrue), per the platform decision in decisions/supabase-db-auth.md. The user explicitly wants a managed third-party auth provider, and Supabase Auth comes with the platform we now run the app DB on: RLS integration via auth.uid(), ~20 social providers, TOTP MFA on all plans, and WebAuthn passkeys when they reach GA. The web app integrates via @supabase/ssr.
Alternatives considered: better-auth — this doc's original pick and a fine self-hosted choice (first-party passkey/magic-link plugins, Drizzle adapter, a jwt() plugin for the cross-service bridge), but it is a library we would operate, and the explicit preference for a managed provider supersedes it. Auth.js v5 — mature and widely deployed, but active development has moved to better-auth and WebAuthn support is experimental.
- Methods: email/password + social sign-in with TOTP MFA (Supabase MFA, AAL claims in the JWT); magic links remain available through Supabase's email flows for recovery. Passkeys — this doc's original primary — are beta on Supabase Auth, so they become the day-one primary only when GA; until then social/password leads. Dev email lands in the supabase CLI stack's local mail catcher (Mailpit); prod uses Supabase Auth's SMTP hook with a Resend-class sender.
- Session model: Supabase Auth sessions via
@supabase/ssrcookie handling (httpOnly, secure, sameSite=lax); server components and server actions resolve the session per request through the Supabase server client, which also transparently refreshes tokens. No hand-rolled session table. - Brain trust — decision: the user's Supabase access token, verified via JWKS (not a static service token). Flow: (1) the route-handler/server-side proxy attaches the current session's Supabase access token (or a short-lived token derived from it) as a bearer header; (2) FastAPI middleware verifies the JWT locally against the project JWKS (
https://<project>.supabase.co/auth/v1/.well-known/jwks.json, ES256, PyJWT's JWKS client with kid-based key rotation — no network round-trip per request); as built, the brain's verifier lists no HS256 — prod Supabase must use asymmetric (ES256/RS256) JWT signing keys, a documented runbook preflight; (3) it maps thesubclaim →user_idand sets the request's user context; (4) that context threads into the lake module's per-user functions and all queries. The browser never calls the brain directly v1 (server-side proxying keeps CORS closed and tokens off the client). Brain-internal cron jobs run in-process and need no HTTP auth. Alternative considered: static shared service token plus auser_idheader — fewer moving parts, but any holder of one secret can act as any user; Supabase already issues asymmetric per-user JWTs, so verification costs one PyJWT dependency instead of a new trust root. - Single-user bootstrap: on first run with zero users,
/welcomeallows one signup (email/password or social), then prompts TOTP enrollment and starts onboarding (§5).MAX_USERS=1closes signup afterward (app-enforced; Supabase's own signup setting can be disabled as belt-and-suspenders); the seed script only ensures extensions and defaults, never a hardcoded user.
4. API boundary and type sync
Decision: split boundary. The web app talks to Postgres directly for interactive state, and to the brain for anything involving the lake, Python analytics, file parsing, or an LLM.
- Direct Postgres (Drizzle in RSC + server actions): all doc-03 table CRUD (profile, model portfolio editing, watchlists, settings, notifications read/ack); display reads of doc-01 tables (
positions_current,accounts,transactions,tax_lots,reconciliations,imports,ticker_snapshot) and doc-02 artifacts (daily_briefings,insights,actions,factsby id). One deliberate write into doc-02 territory: the action inbox's accept/dismiss is a guarded update (set status, user_note where id = ? and user_id = ? and status = 'proposed') — user-driven transitions belong to the page, while verify/expire/supersede stay brain-owned (coordination note in §10). As built (phases 4–5): the guarded write is backed by a column-scoped UPDATE grant (RLS restricts rows, not columns) and the full state machine is enforced by a DB trigger for every writer — doc 02 §6.1–6.2. - Through the brain (FastAPI): CSV upload → staged validation report → commit (Python parsing via mapping profiles); chart series (adjusted prices,
portfolio_snapshotshistory) and fundamentals from the lake; delayed-quote passthrough; questionnaire → model-portfolio proposal computation; what-if analytics; on-demand ticker AI take; BYOK test-connection; chat SSE (doc 04); admin re-run triggers. - Rule of thumb: if it's a row the user edits or a table the page renders, the web reads/writes Postgres; if it needs the lake, deterministic math, file parsing, or a model call, it goes through the brain.
Alternative considered: everything-through-FastAPI — a single API surface and one place for authorization, but it doubles latency on every page, duplicates CRUD endpoints that only one caller uses, and fights RSC's direct-read model. With no third client planned (mobile is a non-goal), the split boundary is simpler; revisit if another client appears.
ORM decision: Drizzle (not Prisma). Rationale: SQL-first API and plain-SQL migrations that read like the DDL in this doc; first-class pgvector support (doc-01 embeddings live in the same database); tiny runtime with no codegen step; works unchanged against Supabase's managed Postgres (it is standard Postgres). Alternative considered: Prisma — stronger GUI tooling and an object API friendlier to SQL-averse teams, but a heavier runtime, thinner pgvector story, and a second schema DSL alongside Pydantic.
Migrations ownership: the Drizzle TS schema in platform/packages/db is the single DDL source of truth for the public schema, including doc-01/02/04/05 tables (their docs own semantics; this doc owns where DDL lives); the auth schema is Supabase-managed and outside Drizzle's purview. drizzle-kit generate emits numbered plain-SQL migrations, committed and applied by CI against the Supabase database — locally the supabase CLI stack (supabase start, DB on localhost:54322), in prod the project database. The brain runs no ORM — psycopg with hand-written SQL and Pydantic row models — so there is no second model layer to drift. Alternative considered: Alembic owning DDL on the Python side — natural since pipelines evolve the schema most, but autogenerate requires SQLAlchemy models (an ORM layer the brain otherwise avoids), and hand-written Alembic ops are less reviewable than plain SQL.
Type sync (platform/packages/schemas): Pydantic v2 models are the source of truth for every cross-boundary payload — briefing sections, insight shapes, per-kind action params, per-kind fact payloads, questionnaire, all brain request/responses. The brain mounts them in FastAPI → OpenAPI; a build step exports openapi.json and runs openapi-typescript to generate TS types plus a thin typed fetch client into platform/packages/schemas/ts (committed; CI regenerates and fails on diff). Enums (action kind/status, fact kinds, notification kinds) are defined once in Pydantic and flow into TS; the Drizzle schema mirrors them as Postgres enums with a unit test comparing the two — a cheap drift tripwire. jsonb columns are typed at the edges: the brain validates on write, the web casts with generated types on read.
5. Onboarding: questionnaire → model portfolio
- Sign-up (§3) lands in a full-screen wizard; nothing else works until a profile exists.
- Questionnaire, four steps: about you (birth year, income, savings rate, dependents) → goals (target retirement age, income) → risk tolerance (short scenario quiz →
risk_score1–10 →risk_profileband) → constraints (liquidity floor, cash target %, marginal tax rates with a "look it up later" default). Raw answers persist toprofiles.questionnaire. - Template proposal: the web calls the brain's proposal endpoint, which maps the risk band to a template — deterministic seed data, not an LLM call — and returns a draft
model_portfolio(asset-class level) with default ±5 bands. Illustrative v1 templates:
| Risk band | US equity | Intl equity | Bonds | Real assets | Cash |
|---|---|---|---|---|---|
| Conservative | 25 | 10 | 50 | 5 | 10 |
| Balanced | 40 | 15 | 35 | 5 | 5 |
| Growth | 55 | 20 | 18 | 5 | 2 |
| Aggressive | 65 | 25 | 3 | 5 | 2 |
- Edit and activate: the user tunes weights/bands (or rebuilds at sector/ticker level) in the model-portfolio editor; activation validates sum = 100, sets
status = active, version 1. - Thresholds seeded into
user_settingswith the §2.2 defaults, shown once for confirmation. - First import CTA: onboarding ends by pointing at Settings → Accounts to upload the first broker CSV (doc-01 flow).
Re-takes and versioning: re-running the questionnaire (Settings → Profile) updates profiles, recomputes the proposal, and shows a diff against the active portfolio ("Growth → Balanced suggests −15 US equity…"). Accepting creates version n+1 (source = retake, derived_from_version = n) and archives the old version — never mutates it — so past drift facts and briefings keep pointing at the portfolio version that produced them. Manual edits version identically (source = manual_edit). Ticker-level targets feed universe reason model_portfolio (doc 01) so constituents get priced.
6. Pages
Global chrome: left nav (Today, Portfolio, Markets, Actions, Chat, Settings, Admin) plus a notifications bell. A shared freshness banner component reads the latest doc-01 freshness facts (prices, news, per-user portfolio staleness); the brain computes the status into the fact payload (ok | stale, as_of), so the web renders severity without re-deriving date math. Pages below list only their specific empty/stale behaviors on top of this.
6.1 Today (phase 4)
- Purpose: the daily briefing plus everything that needs a decision.
- Data:
daily_briefings(today, else latest),insights,actionswhere statusproposed, citedfactsbatch-fetched by id; all Drizzle reads. - Interactions: briefing sections with evidence chips (§7); actions inbox — accept or dismiss with an optional note via guarded server action (§4); insight cards expand; "chat about this" on any artifact (doc 04 entry point).
- Empty/stale: no briefing today → show the latest one with a "generated
{date}" banner and, if the run failed, a link to Admin; no briefing ever → onboarding/import CTA. A briefing generated from a stale portfolio renders the staleness chip the agent itself was given ("valuations as of{date}").
6.2 Portfolio (phases 1–3)
- Purpose: what you own and how it compares to the model.
- Data: holdings from
positions_current×ticker_snapshot(value, day change) with account filter; allocation-vs-model from the latest doc-02 drift facts (same numbers the briefing cites — one math implementation, labeled as-of); performance chart fromportfolio_snapshotsvia a brain endpoint (value + cost-basis series, range picker); lots drill-down per holding fromtax_lots(open qty, basis, unrealized, long/short-term badge); openreconciliations. - Two lenses (
viewsearchParam). Direct is the positions as held. X-ray is the same money decomposed through the funds into the companies, sectors and asset classes actually owned — a position in an S&P 500 fund is five hundred companies, and the Apple inside it is as real as the Apple bought outright. The x-ray is a brain read by construction (§4): the constituent graph lives in the lake, so the web app cannot compute it.GET /portfolio/xrayreturns the resolved companies (with direct-vs-through-funds split, implied share count where the security is priced, and the wrappers each exposure arrives by), the look-through sector and asset-class rollups, and a coverage report. It is fetched only when that view is selected. - Coverage is part of the contract, not a footnote. A true-holdings table that silently omits the money it could not resolve would mislead more than it informs, so every unresolved position is named with a reason, terminal holdings (a bullion trust is gold, not a wrapper hiding companies) are distinguished from genuine gaps, and the vendor-weight residual stays visible rather than being normalized away. "Named" reaches inside a fund of funds too: a target-date fund's master sleeves routinely file under no identifier at all, and those are carried by their filed name and asset mix rather than dropped — "56% of this is a Russell 1000 sleeve" is most of the answer even when its own constituent list is out of reach, and a sleeve that has an identity can also be stood in for at
proxyprecision. Where a fund is thinly covered by the market-data vendors — international index funds are the standing case, and the licensed feed publishes 6% of IXUS — its own N-PORT filing supplies the constituent list instead (all 4,190 of them, ~101% of the fund), so "we cannot see inside this" is reserved for funds nobody publishes rather than funds our vendor happens not to. N-PORT carries no ticker, so constituents are identified by ISIN and then resolved to tickers through a symbology memo (security_identifiers) that is built once and kept — an ISIN's ticker is a fact about the security, not a quote, so there is no TTL and nothing expires. Two things follow. A US constituent resolves to the ticker its holder would recognise (US0378331005→AAPL), so Apple inside a fund is the same row as Apple held outright. A foreign one resolves to its home listing (2330) rather than a US symbol, because theUSline of a foreign ordinary share is an unsponsored OTC ticker nobody holds and the real ADR is a separate security with its own ISIN — unreachable from the filing. Either way the filed name is what the surface renders. - Look-through precision (
precisionsearchParam). 401k collective trusts and separate accounts have no published constituents.exact(default) substitutes only a same-portfolio wrapper — a trust for its own ETF share class, which asserts nothing untrue;proxyalso allows the closest tracked equivalent, which is an approximation and is labeled as one per substitution. A stand-in only speaks for a facet it actually covers, so a fund that can lend sector weights but not a usable constituent list answers "which sectors" and leaves "which companies" honestly unknown. - Interactions: drift bars with band markers (target ± band, current weight pin); expand holding → lots; expand an x-ray company → the funds delivering it; reconciliation rows link to Settings → imports.
- Empty/stale: no import yet → CTA with sample CSV link (phase 1: the CTA points at the
brain importCLI until the §6.7 web upload flow lands; a user with only cash imported sees their cash portfolio, not the CTA);last_synced_atbeyond staleness window → amber banner ("holdings as of{date}— re-import"); reconciliation open → persistent warning strip with count.
6.3 Markets (phase 6)
- Purpose: what happened in markets, then why it matters to this portfolio.
- Data: generic recap from
market_recaps(shared, house-key artifact); the personalized "why it matters to you" pass from today's briefing markets section (produced inside the daily agent run on the user key, cached for the day — pinned tenancy rule); stories fromnews_storiesordered by importance. - Interactions: importance filter (default ≥ 3), sector/ticker chips, "held only" toggle; story rows expand to synthesis bullets and link to Ticker pages; personalization sections carry evidence chips (exposure facts).
- Empty/stale: recap not yet generated → stories only with "recap pending"; no daily run yet → generic recap only, personalization slot explains it arrives with the next briefing; news freshness fact stale → banner.
6.4 Ticker (phase 6)
- Purpose: one security in full context — market data first, your stake second.
- Data: quote header from
ticker_snapshotplus optional delayed-quote passthrough via the brain (provider-live, ~60s in-process cache, never stored, labeled "delayed" — honors doc-01 non-goal); adjusted price chart from the brain's lake read (prices(ticker, range, adjusted=True));fundamentals(lake, if enabled); your position frompositions_current+tax_lots+lot_closuresfor this ticker; relatednews_storiesby ticker. As built (phase 6): the quote passthrough is Finnhub; when the provider key is absent it returns a cleanavailable: falseshape and the page renders EOD-only, honestly labeled — the key-absent path is spec, not error. - Interactions: range picker (1M/6M/1Y/5Y/max); "AI take" button → brain endpoint, runs on the user's key, cached per (user, ticker, tradingdate) so repeat visits are free; rendered with evidence chips (position facts, story ids). Watch/unwatch toggles
watchlist_items. _As built (phase 7): the take is a thin cached endpoint (GET /tickers/{t}/take) — a single-turn grounded generation sharing the chat toolbelt and grounding gate, durably cached inticker_ai_takes; a cache hit makes zero LLM calls. - Empty/stale: unpriced ticker (not in universe) → quote absent with "add to watchlist to start tracking"; no position → position section collapses to a watch CTA; prices stale → banner on the chart.
6.5 Actions (phases 4–5)
- Purpose: the full lifecycle of every proposed action, beyond today's inbox.
- Data:
actionsacross all statuses (Drizzle read), joined display of matched transaction ids after verification. - Interactions: kanban with columns Proposed / Accepted / Completed / Closed (dismissed + expired + superseded); filters by kind, account, date. Verification badges: completed cards link their matched
transactions; accepted-but-unverified cards show "awaiting import evidence" with age. Expiry countdown chips on proposed cards (expires_at, e.g. TLH before a wash-sale boundary). Detail drawer: kind-specific params rendering (registry, same pattern as §7), rationale, evidence chips, user note history, "chat about this action", and the guarded accept/dismiss controls. - Empty/stale: no actions ever → "the agent proposes here after your first briefing"; nothing proposed today → "nothing actionable found" with last run date.
6.6 Chat (phase 7) — shell only
- Purpose: entry points and layout; agent design, tools, and message schema are doc 04's.
- Data:
chat_threadslist (doc 04); template prompts ("Am I on track to retire?", "Explain today's drift action"). - Interactions: new thread; threads opened from an artifact ("chat about this" on insights, actions, facts, stories) carry an artifact-context chip pinned to the thread; message stream area renders SSE from the brain with a tool-activity indicator. As built (phase 7): the SSE reaches the browser through a chat-scoped streaming route handler (
/brain/chat/[...path]) that attaches the session's bearer token — built for chat, with a path-traversal guard and a/chat/-prefix assertion; it is not an open/brain/*proxy. Non-streaming brain reads stay server-side RSC fetches. - Empty/stale: no threads → template prompt grid.
6.7 Settings (phase 1 onward)
- Purpose: everything the user configures; all direct Drizzle CRUD except where noted.
- Tabs: Profile (current questionnaire values + re-take wizard, §5); Model portfolio (editor: target rows with weight/band inputs, level switch, sum-to-100 validation, version history with diffs, propose-from-template re-run via brain); Thresholds (
user_settingsform with reset-to-defaults); Accounts & imports (accounts CRUD, per-account CSV mapping profile — preset picker for Fidelity/Schwab/Vanguard/IBKR or custom column mapping editor — upload → brain staged validation report (unparsed rows, unknown tickers/actions) → explicit commit; import history with status and drill-down to each validation report, per doc-01 staged→committed flow); Keys (BYOK per provider: masked input,key_last4display after save, test-connection via brain → gateway with status badge andlast_verified_at, revoke; Vault custody and virtual-key wiring per doc 05); Notifications (§9 toggles). - Empty/stale: no mapping profile for an account → upload disabled with "choose a preset or define mappings"; key
invalid→ inline error with re-test.
6.8 Admin (phase 2 onward)
- Purpose: operational visibility; v1 the single user is the admin (
role = owner), no separate console. - Data + interactions:
pipeline_runstable (pipeline, window, status, rows in/out, duration, error, watermark) with a re-run trigger (brain endpoint); quarantine browser (quarantine_pricesvia brain lake read, reason filter); reconciliations queue with status transitions open → explained/resolved + note (guarded write to the doc-01 table); usage summary fromusage_ledger(doc 05: spend by day/model/agent, budget status);ai_runsbrowser (model, tokens, latency, validation retries, prompt version, links to the artifacts each run produced). As built (phase 8): Admin gained an Alerts view (budget/drift alerts ride the notifications machinery — doc 05 §6); the whole surface is owner-gated at the router (profiles.role), the reconciliations queue shows the owner's own rows (per the no-multi-tenancy-console non-goal), and theai_runsbrowser exposesinput_hash, neverinput_ref— the stored pack/chat content is a cross-user content leak in a multi-user world. - Fresh-data refresh (as built, SLW-3 follow-on): one owner-gated button runs the whole fresh-data chain —
plaid_sync → verification → prices (+ sector overlay) → etf → edgar → news → market_recap → snapshot → analytics → invalidation → briefing. Two properties make it safe to lean on. It is freshness-gated: each step declares how long its data stays good (brain/freshness.py— news and recap 12h, fund look-through and sectors 7d, EDGAR 30d; prices is not a clock window at all but "has the published price head reached the last closed trading session"), and a step inside its window is skipped asfreshand spends nothing, so a second press costs almost nothing. This is a budget decision, not an optimization: Alpha Vantage's free tier is 25 calls a DAY, shared by the fund look-through and thespdr+vanguardsector overlay, so an ungated chain could exhaust a day's allowance re-fetching identical numbers. It distinguishes the two kinds of skip:fresh(nothing to do) versusskipped(could not run — a key is missing, or the day's budget is spent), because the earlier chain collapsed both into silence and still reported the run green. Per-step outcomes are persisted on the wrappingrefreshrun'sparamsand served byGET /admin/refreshalongside the day's metered-provider budget; the chain runs on a background thread that outlives its trigger request, so the Admin page polls while a run is in flight rather than rendering once at trigger time.brain refresh --forceoverrides every window. - Empty/stale: healthy state is an empty quarantine and no open reconciliations — say so explicitly rather than showing bare tables.
7. Evidence-chip UX
The auditability surface for "deterministic facts, generated words" — every AI claim resolves to inspectable computed facts.
- Resolution: artifacts (briefing sections, insights, actions, AI takes) carry
evidence_fact_ids[](doc-02 contract). The RSC batch-fetches all cited facts in one user-scopedINquery and renders a chip strip per artifact: one chip per fact, short-labeled by kind (e.g. "Drift: intl equity −3.8%", "TLH: VEA lot −$1,240", "Prices as of Jul 2"). - Renderer registry:
platform/packages/schemasexports the fact-kind enum; the web maps kind →{shortLabel(payload), FactCard}. Clicking a chip opens the kind-specific card — drift shows current vs target vs band with as-of; TLH shows lot, loss, replacement candidate, wash-sale window; retirement shows projection inputs; freshness shows source and age. Unknown kinds always fall back to a generic card (kind,computed_at, formatted payload), so new fact kinds ship in the brain without breaking the UI. A unit test asserts every enum kind has a registry entry or explicitly opts into the fallback. - Card footer, always:
computed_at, copyable fact id, "chat about this fact" (doc 04 entry with the fact as artifact context). - Briefing text linking: v1 renders chip strips under each briefing section from that section's
evidence_fact_ids. If doc 02 adopts inline[fact:id]markers in narrative text, the renderer swaps them for superscript chips — flagged as a coordination point (§10), and the section-level strip works either way. - The same registry pattern renders
actions.paramsper kind in the §6.5 drawer — one mechanism for "typed jsonb → human-readable card" everywhere.
8. Frontend stack
- UI: shadcn/ui + Tailwind (per locked stack) — components are copied into the repo and themable; dark mode from day 1 since the briefing is a morning-coffee surface.
- Charting — decision: two-lane. TradingView
lightweight-chartsfor the two financial time-series surfaces (Portfolio performance, Ticker price): purpose-built financial time axis (weekend/holiday gaps, crosshair, range selection), canvas performance, ~12 KB. Recharts via the shadcn/ui chart primitives for everything else (allocation donut, drift bars with band markers, sparklines) where design-system theming matters more than time-axis math. Alternative considered: ECharts everywhere — one dependency covering both, but config-driven rather than React-idiomatic and loses shadcn theming; Recharts-only — fine at these data volumes, but its financial crosshair/gap handling is visibly worse. - Data fetching — decision: RSC-first, client queries by exception. All page reads are server components (direct Drizzle, or server-side brain fetches); all writes are server actions calling
revalidatePath. TanStack Query only where the client is genuinely live: the notifications bell (60s poll + focus refetch), delayed-quote widget, import staging status, and chat streaming (doc 04). Alternative considered: TanStack Query everywhere — uniform but reintroduces client data plumbing RSC already solves for pages whose data changes at most daily. - Caching/invalidation: daily artifacts need no active invalidation because their reads are either plain Postgres queries (always fresh) or brain responses cached under keys that include the date — chart series keyed on (ticker, range, latest trading_date), AI takes cached brain-side per (user, ticker, trading_date), recaps per date. Day rolls over, keys change, caches self-invalidate. The only push-style refresh v1 is the notifications poll flagging "briefing ready", which nudges a router refresh on Today.
9. Notifications v1
In-app only (email/push is a non-goal). The brain writes rows (§2.2 notifications) at natural moments: daily run success → briefing_ready; verifier (doc-02 phase 5) → action_nudge (accepted-but-unverified after N days) and action_expiring; portfolio staleness fact → import_reminder; new open reconciliation → reconciliation_flag; failed pipeline → pipeline_failure. The dedupe_key upsert keeps one live row per situation while unread. Web surface: bell with unread badge (60s poll), dropdown feed, mark-read/dismiss server actions, prefs in user_settings. Alternative considered: SSE/WebSocket push — rejected v1; polling is free at this scale and the daily cadence doesn't need sub-minute latency.
10. Open questions
- Resolved — auth provider. ARCHITECTURE.md originally said "Auth.js" and an earlier draft of this section picked better-auth; both are superseded by Supabase Auth per decisions/supabase-db-auth.md (§3). The parent doc has been updated to match.
- Resolved —
market_recapstable. Defined in doc 02 §5.1: one row per trading date (body_md,top_story_ids[],ai_run_id), written by the recap job on the house key; the personalized pass lives insidedaily_briefingsas assumed. The catalog (§2.1) reflects it. - Resolved — Ticker AI take storage (phase 7). A small dedicated table,
ticker_ai_takes, cached per (user, ticker, trading_date) — not aninsightskind; the take is a thin cached brain endpoint (§6.4). - Resolved — action user note (phase 4). Yes:
actions.user_noteshipped, writable through the guarded accept/dismiss path's column-scoped grant. - Resolved — accept/dismiss as a direct guarded web write (phases 4–5). Doc 02 blessed the direct write, hardened twice: a column-scoped UPDATE grant (RLS restricts rows, not columns) and a DB trigger enforcing the full transition table for every writer — the status machine does live in one place, just in the database rather than a brain endpoint.
- Resolved — inline markers (phase 4). Briefing prose carries inline
[f:id]tags and the renderer swaps them for evidence chips; the section-level strip remains the fallback. - Drift viz source — resolved by default: v1 shipped rendering the nightly drift facts; the live-recompute alternative stays a future option if intra-day staleness ever annoys.
- Resolved — delayed-quote passthrough (phase 6). Shipped against Finnhub (a user call overriding the defer recommendation): provider-live, ~60s cache, never stored, labeled "delayed", with a clean
available: falseshape when the key is absent (§6.4). - Resolved — web Drizzle connection role/claims. Previously flagged: with CRUD staying Drizzle-direct (§4) while the decision doc described an anon-key client path, it was unclear what role and claims the server-side Drizzle connection would carry for RLS. Settled in §2.3: a dedicated non-privileged Postgres role, with each request's queries in a transaction that sets the
authenticatedrole andrequest.jwt.claimssoauth.uid()policies bind;@supabase/ssrstays the auth/session layer only. The decision doc (§3.1) has been updated to match.