Field Notes

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/schemas type-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.

TableOwnerPurpose
accounts01Broker accounts with tax treatment (account_type), last_synced_at
transactions01Normalized broker transactions (typed, deduped by source-row hash)
tax_lots01Open lots with basis source (broker or derived FIFO)
lot_closures01Lot ↔ sell links with realized gains
cash_balances01Per-account cash snapshots
positions_current01Derived current positions, materialized nightly
reconciliations01Derived-vs-broker discrepancies (open/explained/resolved)
imports01CSV import bookkeeping: staged→committed, file hash, validation errors
universe01Tickers to fetch, with reasons (held, watchlist, model_portfolio, …)
ticker_snapshot01Latest close/day change/52w per ticker for app display
pipeline_runs01Run 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_stories01Clustered, synthesized stories (headline, bullets, importance, sentiment)
news_story_articles01Story ↔ article membership
news_embeddings01pgvector embeddings for articles/stories
facts02Typed deterministic facts (drift, TLH, liquidity, freshness, …) — the evidence layer
insights02Narrated observations citing evidence_fact_ids[]
actions02Proposed actions: kind/status enums, params, rationale, evidence, expiry, matched transactions
action_match_candidates02As built (phase 5): near-miss verifier matches awaiting one-click user confirmation (doc 02 §6.3)
daily_briefings02Daily briefing artifact (sections with evidence references)
ai_runs02Agent run log: model, tokens, latency, validation retries, prompt version
tlh_equivalents02TLH replacement-candidate equivalence table
cma_sets02Versioned capital-market assumption sets (immutable once referenced)
cma_assumptions02Per-(set, asset class) expected real return and volatility
market_recaps02Shared generic daily market recap, one row per trading date (house key; defined in doc 02 §5.1)
users03Canonical app user row; id = Supabase auth.users UUID, created via trigger or on-first-login upsert
auth.* schemaSupabaseAuth users, identities, sessions, MFA factors — managed by Supabase Auth (GoTrue); app code never writes it and domain FKs never point at it
profiles03Retirement questionnaire results: income, goals, risk, tax rates
model_portfolios03Versioned target portfolios (one active per user)
model_portfolio_targets03Per-portfolio target weights and drift bands
watchlist_items03Watchlist 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_settings03Per-user thresholds and notification prefs
api_keys03BYOK key metadata (provider, last4, status, Vault reference); secret material lives in Supabase Vault (doc 05)
notifications03In-app notification feed
chat_threads04Chat threads with optional artifact context
chat_messages04Messages, tool calls, streamed content
chat_summaries04Rolled-up summaries of older chat turns (thread context budget)
ticker_ai_takes04As built (phase 7): cached Ticker "AI take" per (user, ticker, trading_date) — resolves this doc's open question 3 as a dedicated table
usage_ledger05Per-run LLM spend attribution against virtual-key budgets
eval_results05Eval-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.

ColumnTypeNotes
iduuidpk = Supabase auth.users.id; every other table's user_id FKs here
emailcitextunique; mirrored from Supabase Auth
email_verifiedboolmirrored from Supabase Auth (it owns verification flows)
name, imagetextdisplay only
roletextowner 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).
timezonetextdefault America/New_York; defines the user's "daily" boundary for briefings
base_currencytextdefault USD; display-only v1
created_at, updated_attimestamptzbookkeeping

profiles — one row per user; the questionnaire's durable output. Raw answers kept in jsonb so re-takes can diff and re-derive.

ColumnTypeNotes
user_iduuidpk, fk users
birth_yearintprojections need current age
annual_incomenumericgross, USD/yr
savings_rate_pctnumericshare of income invested annually
dependentsintcount
target_retirement_ageintgoal
target_retirement_incomenumericdesired annual income, today's dollars
risk_scoreint1–10 questionnaire output
risk_profileenumconservative | balanced | growth | aggressive — banded from risk_score
liquidity_floornumericminimum cash the user wants untouched (drives liquidity facts)
cash_target_pctnumerictarget cash allocation
marginal_tax_fed_pct, marginal_tax_state_pctnumericdrive TLH benefit estimates
questionnairejsonbraw answers + questionnaire schema version
questionnaire_completed_at, updated_attimestamptzbookkeeping

model_portfolios — versioned, never mutated once active; re-takes and edits create new versions (§5).

ColumnTypeNotes
iduuidpk
user_iduuidfk users
versionintunique per user, monotonically increasing
nametexte.g. "Growth v3"
statusenumdraft | active | archived; partial unique index: one active per user
levelenumasset_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)
sourceenumquestionnaire_template | manual_edit | retake
derived_from_versionintnull for v1 portfolios
template_key, risk_profile_at_creationtextprovenance of the proposal
created_at, activated_at, archived_attimestamptzlifecycle

model_portfolio_targets — the rows the drift engine (doc 02) compares holdings against.

ColumnTypeNotes
iduuidpk
model_portfolio_iduuidfk, cascade delete for drafts
levelenumasset_class | sector | ticker (matches parent)
keytextus_equity / Technology / VTI depending on level; unique (portfolio, level, key)
weight_pctnumerictargets within a portfolio sum to 100 (app-enforced on activate)
band_pctnumericdrift band ± absolute points; null → user_settings.default_drift_band_pct
sortintdisplay 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.

ColumnTypeNotes
watchlists: id, user_id, name, is_default, created_atone default list seeded at onboarding
watchlist_items: watchlist_id, ticker, note, added_atunique (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.

ColumnTypeNotes
user_iduuidpk, fk users
default_drift_band_pctnumericdefault 5.0; fallback when a target has no band
tlh_min_loss_usdnumericdefault 500 — below this, TLH facts aren't emitted
tlh_min_loss_pctnumericdefault 5.0
portfolio_staleness_daysintdefault 7 (doc-01 freshness window)
price_staleness_trading_daysintdefault 2
news_staleness_hoursintdefault 24
briefing_min_importanceintdefault 3; story filter for the daily agent (doc-01 consumption contract)
min_trade_usdnumericas built (phase 3) — default 250; minimum trade size for suggestions
benchmarktextas built (phase 3) — default SPY; primary benchmark override (doc 02)
chat_budget_usd_dailynumericas built (phase 7) — default 2.00; per-day chat spend cap (doc 04)
notify_briefing, notify_action_nudges, notify_import_remindersbooldefault true; in-app prefs (§9)
updated_attimestamptzbookkeeping

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.

ColumnTypeNotes
iduuidpk
user_iduuidfk users
providerenumopenai | anthropic | google | other; unique (user_id, provider) v1
labeltextuser-facing name
vault_secret_iduuidreference to the Supabase Vault secret holding the provider key (custody: doc 05); decrypted view readable by the brain's role only
key_last4textonly plaintext remnant outside Vault, for masked display
statusenumunverified | active | invalid | revoked
gateway_key_reftextLiteLLM 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_attimestamptztest-connection updates last_verified_at

notifications — append-mostly feed behind the bell icon.

ColumnTypeNotes
iduuidpk
user_iduuidfk users
kindenumbriefing_ready | action_nudge | action_expiring | import_reminder | reconciliation_flag | pipeline_failure | system
title, bodytextshort; details live at the link target
hreftextapp path, e.g. /actions/{id}
refjsonbartifact ids for dedupe/deep-link
dedupe_keytextpartial unique index where read_at is null — re-emitting while unread is an upsert, not a duplicate
created_at, read_at, dismissed_attimestamptzlifecycle

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_id on every user-scoped row from day 1, FK to users; all web queries go through a forUser(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 carries user_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 the forUser helper 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 postgres superuser — so the day-1 policies genuinely apply to the CRUD path. Each request's queries run inside a transaction that sets the authenticated role and the user's JWT claims (request.jwt.claims) from the session, so auth.uid() resolves and the policies bind exactly as they would for a supabase-js call — the standard Drizzle-on-Supabase RLS pattern. @supabase/ssr remains 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 in authenticated, nothing else) is created idempotently by supabase/seed.sql; in prod it is created once by the operator, and the web server receives its DSN as WEB_DATABASE_URL — cluster-level roles are deliberately outside the Drizzle migrations.
  • Soft single-user mode: a MAX_USERS=1 config 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/ssr cookie 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 the sub claim → user_id and 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 a user_id header — 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, /welcome allows one signup (email/password or social), then prompts TOTP enrollment and starts onboarding (§5). MAX_USERS=1 closes 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, facts by 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_snapshots history) 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

  1. Sign-up (§3) lands in a full-screen wizard; nothing else works until a profile exists.
  2. Questionnaire, four steps: about you (birth year, income, savings rate, dependents) → goals (target retirement age, income) → risk tolerance (short scenario quiz → risk_score 1–10 → risk_profile band) → constraints (liquidity floor, cash target %, marginal tax rates with a "look it up later" default). Raw answers persist to profiles.questionnaire.
  3. 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 bandUS equityIntl equityBondsReal assetsCash
Conservative251050510
Balanced40153555
Growth55201852
Aggressive6525352
  1. 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.
  2. Thresholds seeded into user_settings with the §2.2 defaults, shown once for confirmation.
  3. 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, actions where status proposed, cited facts batch-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 from portfolio_snapshots via a brain endpoint (value + cost-basis series, range picker); lots drill-down per holding from tax_lots (open qty, basis, unrealized, long/short-term badge); open reconciliations.
  • Two lenses (view searchParam). 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/xray returns 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 proxy precision. 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 (US0378331005AAPL), 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 the US line 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 (precision searchParam). 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; proxy also 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 import CLI until the §6.7 web upload flow lands; a user with only cash imported sees their cash portfolio, not the CTA); last_synced_at beyond 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 from news_stories ordered 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_snapshot plus 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 from positions_current + tax_lots + lot_closures for this ticker; related news_stories by ticker. As built (phase 6): the quote passthrough is Finnhub; when the provider key is absent it returns a clean available: false shape 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 in ticker_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: actions across 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_threads list (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_settings form 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_last4 display after save, test-connection via brain → gateway with status badge and last_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_runs table (pipeline, window, status, rows in/out, duration, error, watermark) with a re-run trigger (brain endpoint); quarantine browser (quarantine_prices via brain lake read, reason filter); reconciliations queue with status transitions open → explained/resolved + note (guarded write to the doc-01 table); usage summary from usage_ledger (doc 05: spend by day/model/agent, budget status); ai_runs browser (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 the ai_runs browser exposes input_hash, never input_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 as fresh and 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 the spdr+vanguard sector 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) versus skipped (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 wrapping refresh run's params and served by GET /admin/refresh alongside 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 --force overrides 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-scoped IN query 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/schemas exports 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.params per 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-charts for 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

  1. 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.
  2. Resolved — market_recaps table. 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 inside daily_briefings as assumed. The catalog (§2.1) reflects it.
  3. Resolved — Ticker AI take storage (phase 7). A small dedicated table, ticker_ai_takes, cached per (user, ticker, trading_date) — not an insights kind; the take is a thin cached brain endpoint (§6.4).
  4. Resolved — action user note (phase 4). Yes: actions.user_note shipped, writable through the guarded accept/dismiss path's column-scoped grant.
  5. 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.
  6. 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.
  7. Drift viz sourceresolved by default: v1 shipped rendering the nightly drift facts; the live-recompute alternative stays a future option if intra-day staleness ever annoys.
  8. 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: false shape when the key is absent (§6.4).
  9. 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 authenticated role and request.jwt.claims so auth.uid() policies bind; @supabase/ssr stays the auth/session layer only. The decision doc (§3.1) has been updated to match.