FelixAnalytics & daily AI
Field Notes

Deterministic analytics & the daily AI

Parent: Architecture · Covers roadmap phases 3, 4, 5 (and the briefing half of 6). Status: as built — all phases shipped in felix-app; as-built notes are inline.

1. Scope and goals

This piece turns the data layer (doc 01) into judgment: a deterministic analytics engine that computes typed facts, a daily agent that turns a fact pack into a briefing with insights and proposed actions, and an action lifecycle that verifies what the user actually executed at their broker. It is the home turf of the core principle — deterministic facts, generated words: every number is computed by tested code; the LLM never does arithmetic. It prioritizes, narrates, and emits schema-validated structures that cite the fact IDs they came from.

  • Analytics engine (per-user, zero LLM cost): valuation and performance, allocation drift, TLH scanning, cash deployment, liquidity, retirement projection, data hygiene — each module emitting facts.
  • Facts contract: the only numeric interface between computation and generation.
  • Daily AI pipeline: fact-pack assembly, one Pydantic AI run per user per trading day, briefing + insights + structured actions with evidence, template fallback so the product never shows nothing.
  • Action lifecycle: proposed → accepted → verified against subsequently imported transactions, with per-kind expiry and nudges. The AI never executes anything.

Goals: an auditable evidence chain end to end (fact → insight/action → "based on" chips); a briefing every trading day even when the model fails; identical inputs produce identical facts (idempotent re-runs); LLM cost measured in cents per user-day; everything per-user and embarrassingly parallel so multi-user is a loop, not a redesign.

Non-goals (v1): trade execution (permanent, not just v1 — the user executes at the broker); options/derivatives, multi-currency, crypto (inherited from doc 01); intraday re-runs (EOD cadence, one briefing per trading day); ML or fitted forecasting — retirement projections use the stated capital-market assumptions table, nothing estimated from return history; money-weighted returns (TWR only v1); automated wash-sale basis adjustment (detected and reported, never booked); cross-account cash transfer proposals; Social Security and pension income in projections (explicitly flagged as excluded in outputs); spousal-account wash-sale checks (single user v1).

2. The deterministic analytics engine

2.1 Run model

The engine sits inside the pinned nightly chain, after per-user valuation snapshots land: trading-day gate → prices → corporate actions → adjustment factors → quality gates → ticker snapshot → lot split adjustments (doc 01 §5.3) → per-user valuation snapshots → analytics (this engine) → daily agent → notify. News runs independently three times per trading day; after each portfolio import the action verifier (§6.3) runs, followed by the derivation chain (analytics → invalidation → freshness-aware briefing) so no derived surface keeps asserting pre-import numbers until the nightly run — the same chain the Admin refresh composite, the settings-save recompute endpoint, a standalone brain prices run, and the Sunday brain etf/brain edgar reference refresh execute (look-through weights move every drift number even with zero portfolio change, so reference publishes must propagate immediately, not ~23h later).

Each module is a pure function from (Postgres state per doc 01, lake reads through the lake module, doc 03 profile and settings) to fact rows — no LLM calls anywhere in phase 3. Modules are isolated the way doc 01 isolates sources: a module failure emits a data_gap fact (module_failure) and the run continues, so the agent can say "TLH scan unavailable today" instead of silently omitting it. Re-runs are idempotent by upsert on the fact natural key (§3.3).

2.2 Valuation and performance

Inputs: users/{id}/curated/portfolio_snapshots (doc 01's daily valuation series), transactions (external flows), prices_eod + adjustment_factors via the lake module for benchmark series, trading_days; benchmark selection from doc 03 user_settings.

Facts: portfolio_value (per account and total), performance (per scope × daily/MTD/YTD, with benchmark deltas), position_moves (top contributors).

Rules. Daily return per scope is flow-aware: r = (Vtoday − V_prev − F) / (V_prev + F), where F is net external flows (deposits, withdrawals, transfers) dated that day and treated as start-of-day; dividends, interest, and fees are internal to performance, not flows. MTD/YTD chain-link daily returns across trading days — the standard daily-valuation TWR approximation, effectively exact here because the portfolio is revalued at every EOD snapshot. Benchmark deltas use total-return series (adjusted closes include dividend factors per doc 01) over identical windows, against two references: a primary benchmark (default SPY) and a model-weighted blend built from model_portfolio_targets × class proxy ETFs — so "vs your plan" is separable from "vs the market". _Alternative considered: money-weighted return (IRR) — deferred. TWR is benchmark-comparable and doesn't punish or reward deposit timing; MWR is a later addition for "your actual dollars" framing.

Edge cases: an account funded mid-period starts its chain at first funding (no divide-by-zero on empty bases); transfers between the user's own accounts are flows per account but cancel in the total; a held ticker missing today's price is valued at last close and flagged via data_staleness (and excluded from position_moves ranking); when |F| exceeds 10% of prior value the payload carries an approximation_note; the first-ever snapshot emits value but no return.

2.3 Allocation and drift

Inputs: positions_current, cash_balances, ticker_snapshot closes, tickers reference (asset_type, sector), doc 03 model_portfolios + model_portfolio_targets (level: asset_class | sector | ticker, weight, band), user_settings minimum trade size.

Facts: allocation (one per level), drift_breach (one per breached item — stable scope keys make new/resolved tagging work), rebalance_suggestion (per breached item, with quantities).

Rules. Weights are market value ÷ total household portfolio, cash included as an asset class, computed at every level the model defines targets for; the household is the unit because the model portfolio is a household plan. Asset-class rollup uses tickers reference data plus an engine-owned classification map for ETFs (AGG → bond, BIL/SGOV → cash-equivalent, sector SPDRs → US equity + sector). As built (phase 3): VT classifies as us_equity whole (no international split), and XLRE maps to real_assets — the one SPDR that isn't US equity; sector-level models leave non-SPDR holdings unclassified v1. Unclassified holdings land in an explicit unclassified bucket and emit a data_gap. Absolute drift = weight − target in percentage points; relative drift = absolute ÷ target. A breach requires |absolute drift| > band and ≥ 1pp, so a 0.5% target that doubles doesn't scream. Suggested quantities are tax-aware: in taxable accounts rebalance to the band edge (minimum trade, minimum realized gains); in tax-advantaged accounts to target; buys fill the most underweight items first; sells pick highest-basis lots first (specific-ID, deliberate synergy with the TLH scanner); whole shares, respecting minimum trade size. Every sell suggestion carries its estimated realized gain so the agent never guesses tax consequences. Alternative considered: always rebalance to target — cleaner portfolios, but in taxable accounts it manufactures capital gains for cosmetic precision. The hybrid is the default.

Edge cases: no model portfolio configured → one data_gap fact and module exit (the briefing nudges setup instead of fabricating drift); holdings outside a ticker-level model are reported as an explicit unmodeled weight, never force-mapped; targets not summing to 100% (±0.5pp tolerance) → data_gap; suggestions are placed in the account where the position or cash lives — cross-account moves are a non-goal.

2.4 Tax-loss harvesting scanner

Inputs: tax_lots + lot_closures, transactions (buys and dividend_reinvest rows for wash windows), accounts (harvesting in taxable only; wash checks span all the user's accounts including IRAs, per doc 01), latest closes, tlh_equivalents (below), doc 03 profile marginal tax rates (federal + state) and user_settings thresholds, trading_days for year-end proximity.

Facts: tlh_candidate (per account × ticker: loss lots itemized, harvestable loss, estimated tax benefit, ranked replacements, wash blocks, DRIP risk), wash_sale_violation (post-trade detection), realized_gains_ytd (context: ST/LT realized so far, harvested YTD).

Rules. Scan open lots in taxable accounts; a lot qualifies when its unrealized loss clears both a dollar and a percent-of-basis threshold (defaults $500 and 5%, config). Harvests are specific-lot: sell only the loss lots, never the position wholesale. Wash-sale logic uses the 61-day window — 30 days before and after the sale. Any purchase of the same security inside the trailing 30 days — including dividend-reinvest lots, including in IRAs and 401ks — reduces the harvestable quantity share-for-share (partial disallowance) or blocks the candidate outright; a replacement purchase in an IRA permanently disallows the loss (Rev. Rul. 2008-5), so IRA-side buys are hard blocks, not warnings — as built (phase 3), the hard block extends to all tax-advantaged account types, not just IRAs. The forward window can't be observed, so it is defended three ways: the action's params carry a no_repurchase_before date; a DRIP pattern on the ticker (recent dividend_reinvest rows) with a likely ex-date inside 30 days sets drip_risk plus a recommendation to disable reinvestment first; and later scans check executed harvests for violating buys, emitting wash_sale_violation with the estimated disallowed loss — v1 reports it, it does not rewrite lot basis. Replacements come from tlh_equivalents, ranked, then filtered against the user's own wash windows (never propose buying something the user loss-sold 20 days ago). Estimated benefit = loss × (marginal ordinary rate for short-term lots, LT capital-gains rate for long-term, plus state), presented alongside realized_gains_ytd and the $3,000 ordinary-income offset so the narration stays grounded. Scanning runs year-round; within 45 days of year-end candidates get a priority boost. As built (phase 3): the LT capital-gains rate is a fixed named constant (15%) v1, and a harvested lot's own opening buy is excluded from wash reduction (open_transaction_id exclusion) — without it the canonical buy→drop→harvest case self-washed.

tlh_equivalents (Postgres, owned here):

ColumnNotes
sell_ticker, replacement_tickerdirected pair; symmetric relationships are two rows
rankpreference order among replacements for a ticker
rationalee.g. "S&P 500 → total market: high overlap, different index"
source, active, reviewed_atseed (shipped curated list) or user; periodically re-reviewed

The operative rule — similar but not substantially identical — is encoded once, by a human: two funds tracking the same index are never allowed into the table. New entries feed doc 01's universe under the tlh_candidate reason so replacements have price history before they can be recommended; a replacement without loaded history is filtered out of candidates.

Edge cases: splits inside the window compare split-adjusted quantities via corporate_actions; lots whose basis is under an open reconciliations dispute are excluded (hygiene outranks opportunity); mixed-term positions itemize per lot so ST/LT benefit math stays honest; a candidate that conflicts with an open accepted buy on the same ticker is suppressed by the coherence pass (§2.9).

2.5 Cash deployment

Inputs: cash_balances, allocation facts (underweights and bands), doc 03 profile cash_target_pct and liquidity_floor, user_settings minimum trade size.

Facts: excess_cash (per account), cash_deployment_plan (per account: buys with ticker, amount, quantity, post-trade weight, residual cash).

Rules. Excess = household cash − max(cashtarget_pct × household value, liquidity floor). Below a hysteresis gate — max($500, 0.5% of portfolio) — nothing is proposed, so daily dividend drips don't nag. Buys target the most underweight model items first, sized never to overshoot the band, whole shares at or above minimum trade size, drawn from the account where the cash actually sits. Deployment never digs below the liquidity floor, and the liquidity monitor outranks it (§2.9). Leftover cash below the minimum trade simply stays cash. _As built (phase 3): the hysteresis gate is evaluated at the household level, and the protected reserve is prorated to accounts by their share of household cash; deployment buys are additionally wash-filtered against 30-day loss closures so a deployment never re-buys something a TLH just sold.

Edge cases: nothing underweight → excess is reported with no plan (the agent may narrate "consider revisiting targets", but there are no invented buys); cash stranded in an account with no purchasable underweight → the plan states the constraint rather than proposing a transfer; dollar-cost-averaging staging is narration territory, not numbers — the plan is single-shot v1.

2.6 Liquidity monitor

Inputs: cash_balances, cash-equivalent positions via the classification map, doc 03 profile liquidity_floor, account tax treatment.

Facts: liquidity_status (every run), liquidity_breach (on breach: shortfall plus ranked sell candidates).

Rules. Liquid = cash + cash-equivalents in taxable accounts only — IRA dollars are not emergency dollars (Roth-contribution accessibility is a future refinement). Warning state below 110% of floor, breach below floor. On breach, sell candidates sufficient to cover the shortfall are ranked by a composite: tax impact first (losses beat long-term small gains beat short-term gains — a loss-sale here is a TLH twofer), then drift-correction benefit (selling overweights fixes two problems at once), then lot age. Each candidate carries quantity, estimated proceeds, estimated tax cost, and drift effect, so the agent ranks nothing itself.

Edge cases: floor unset → data_gap nudge (no invented default for something this personal); a breach while a deploy_cash action is open → the coherence pass suppresses deployment and the invalidation pass expires the open action; when every candidate is a short-term gain, the fact says so and the agent frames the trade-off honestly instead of hiding it.

2.7 Retirement projection

Inputs: doc 03 profile (current age from the questionnaire, income, savings rate, target retirement age and target retirement income, risk profile), current household value and asset-class mix, the versioned capital-market assumption tables (cma_sets / cma_assumptions, below).

Facts: retirement_projection (funded ratio, success probability, percentile balances, deltas vs prior run, sensitivity, assumption-set id).

Rules. Two projections from one set of inputs, both in real (inflation-adjusted) terms so inflation is handled structurally rather than as a bolt-on. The deterministic glide path: annual steps; contributions = savings rate × income, constant-real; the equity weight tapers linearly from the current mix to a retirement allocation (default 40% equity) at target retirement age — as built (phase 3), the glide starts from the actual household mix, not the model weight: the projection describes the portfolio the user has, and unclassified dollars grow at the classified blend; expected real returns per asset class come from the assumptions set; retirement draws the target income through age 95 (annuity-due — draws at the start of each of the 95 − retirement-age years). It yields projected assets at retirement and the funded ratio — projected assets ÷ assets required to fund the target income to 95 at retirement-phase expected returns. The Monte Carlo runs 1,000 paths (config) of correlated lognormal annual real returns drawn from the per-class expected return / volatility / correlation matrix, same glide path and cash flows, rebalanced annually; success probability = share of paths solvent through 95, plus p10/p50/p90 balances at retirement. The RNG is seeded from (user, assumption-set version, input hash): identical inputs reproduce identical outputs, which keeps facts idempotent and makes deltas meaningful. The delta vs the prior run is reported with its driver class — market/flows, profile change, or assumption-set change — never silently mixed. Sensitivity is computed, not vibes: success probability at ±1pp equity real return ships in the payload, and the narration contract requires "projection, not prediction" framing. Alternative considered: nightly recompute — rejected; day-to-day success-probability wiggle is false precision. The module runs on the first trading day of each week and on any profile or assumption change; the daily briefing cites the latest fact with its as-of.

TableKey points
cma_setsid, name, source label, published_at, inflation_pct, correlations (jsonb matrix), active, notes. Immutable once referenced — new numbers mean a new set.
cma_assumptions(set_id, asset_class) → expected_real_return_pct, volatility_pct

Every projection fact pins its cma_set_id, so a probability jump is always attributable to data or to assumptions, explicitly. A conservative seed set ships with the app — as built (phase 3): felix-house-v1, a conservative house set, not user-editable, with a guard against mutating a referenced set (open question 2 resolved).

Edge cases: missing profile fields → data_gap listing exactly what's needed (no partial projection on guessed inputs); target retirement age at or below current age → drawdown-only mode; Social Security and pensions are excluded v1 and the payload carries an excluded list so the words stay honest; success probabilities beyond 99% or below 1% are narrated as "very likely / very unlikely" — the tails of 1,000 paths aren't resolution.

2.8 Data hygiene

Inputs: reconciliations (open rows), accounts.last_synced_at, pipeline_runs / watermarks (prices and news freshness per doc 01), quarantine counts, classification gaps, model-target validation, profile completeness.

Facts: data_staleness (domain: portfolio | prices | news; as-of, age, threshold, breached — the prices domain additionally carries per-held-ticker coverage: worst held-ticker close age and stale/total held counts, so a partial prices run can't hide behind one fresh ticker; breached is worst-of head lag and coverage), reconciliation_issue (per account × ticker), data_gap (typed: unclassified_ticker, missing_basis, targets_sum, missing_profile_field, module_failure, stale_price — one per held ticker over the price threshold, worst first, capped at 10 — and snapshot_vintage_seam, a day-over-day quantity jump in the snapshot series that no transaction explains, so any period return chaining across that boundary is meaningless; see doc 01 §5.4).

Rules. Doc 01 emits freshness signals; this module promotes them to first-class facts on every run so the AI is honest about data quality. Staleness thresholds default to 7 days for the portfolio (doc 01's window), 2 trading days for prices, 24 hours for news. These facts lead the fact pack (§4) and gate language: a stale portfolio makes the agent date every claim ("as of Jun 27") and propose a data_hygiene re-import action rather than narrate confidently on old data. Reconciliation discrepancies surface verbatim — derived vs broker quantity, per doc 01's nothing-silently-auto-corrected policy.

Edge cases: an import that fixes staleness but opens reconciliation issues produces both facts — freshness and correctness are independent axes; the module_failure gap (§2.1) guarantees the agent discloses a missing module instead of hallucinating continuity from yesterday's numbers.

2.9 Coherence pass

After all modules run, a final deterministic pass reconciles suggestions so the agent is never handed contradictions: liquidity outranks deployment (never raise and deploy cash in the same run); at most one directional suggestion per ticker (a TLH sell and a rebalance sell on the same ticker merge; a TLH sell suppresses deployment buys of that ticker and of anything inside its wash window); suggestions conflicting with accepted open actions are suppressed and noted in the pack instead. The pass mutates suggestion facts before they are written, so the constraint holds equally for the agent, the fallback renderer, and the UI. As built (phase 4): the accepted-actions clause marks rather than drops — suppressed_by_action_id on rebalance/TLH payloads, a suppressed reason accepted_action:{id} on cash-deployment plans — so a suppressed suggestion stays citable via the pack's open-actions section; the real block is the dedupe engine plus the accepted action's existence.

3. Facts

3.1 The table

Postgres facts, user-scoped like everything else:

ColumnNotes
idbigint; stable across recomputes of the same observation — this is what insights and actions cite
user_id, kind, scope_key, as_ofthe natural key; scope_key is a deterministic string, e.g. total, acct:7, acct:7|ticker:MSFT, level:asset_class|key:bond
payloadjsonb; one Pydantic model per kind in platform/packages/schemas (the source of truth), with schema_version alongside
content_hashsha256 over canonical payload + kind + scope + as_of + schema_version
pipeline_run_id, computed_at, revisionprovenance and a same-day correction counter

3.2 Fact-kind registry

KindModuleScopePayload sketch
portfolio_valuevaluationtotal, accountvalue, cash, invested, cost_basis, unrealized_gain, prices_as_of
performancevaluationscope × periodreturn_pct, net_flows, start/end value, benchmark, benchmark_return_pct, delta_pp
position_movesvaluationtotaltop/bottom items: ticker, day_pct, value_change, contribution_pp
allocationdriftlevelitems: name, weight_pct, target_pct, drift_pp, drift_rel, band_pp, breached
drift_breachdriftlevel + nameweight, target, drift_pp, band_pp, severity, direction
rebalance_suggestiondriftlevel + namedirection, amount, qty, account, mode (to_band or to_target), est_realized_gain, sell_lot_ids
tlh_candidatetlhaccount + tickerloss lots (id, qty, term, loss), harvestable_loss, loss_pct, est_tax_benefit, replacements, wash_blocks, drip_risk
wash_sale_violationtlhaccount + tickersale txn, violating buy txn, est_disallowed_loss
realized_gains_ytdtlhtotalst_gain, lt_gain, harvested_ytd
excess_cashcashaccountcash, target_cash, floor, excess, hysteresis_met
cash_deployment_plancashaccountbuys (ticker, amount, qty, post_weight), residual_cash
liquidity_statusliquiditytotalliquid_total, floor, headroom_pct, components
liquidity_breachliquiditytotalshortfall, candidates (ticker, account, qty, proceeds, est_tax_cost, drift_effect, rank)
retirement_projectionretirementtotalcma_set_id, funded_ratio, success_prob, p10/p50/p90, deltas, drivers, sensitivity, excluded
data_stalenesshygienedomaindomain, as_of, age_days, threshold_days, breached, worst_held_age_days, held_stale, held_total
reconciliation_issuehygieneaccount + tickerderived_qty, broker_qty, delta, age_days, status
data_gaphygienevariesgap_type, details

_As built (phase 3): the sketches above are the shipped payloads' core; the built models carry a handful of extra fields the sketches omit — performance.blend_\*andapproximation*note, a numeric drift_breach.severity, rebalance_suggestion.ticker, tlh_candidate.also_overweight/no_repurchase_before/priority_boost/excluded, cash_deployment_plan.constrained/suppressed/wash_filtered, liquidity_breach.all_candidates_short_term, and retirement_projection.input_hash/extreme_tail. The Pydantic models in platform/packages/schemas remain the source of truth.*

3.3 Identity, as_of, idempotence

as_of is the trading date the fact describes, not when it was computed; payloads carry underlying source as-ofs where they differ (a valuation fact knows its prices as-of). Re-running a day upserts by natural key: an unchanged content_hash leaves the row — and its id — untouched; a changed payload updates in place and bumps revision. A same-day recompute is a correction, not a new observation, so citation ids stay stable either way; a new trading day is a new row. Alternative considered: append-only fact versions — purer, but citation targets would fork and the "current facts" query gets fiddly; the raw zone plus deterministic code already make any historical value rebuildable.

3.4 Retention

Facts are small (roughly 50–200 rows per user-day). Keep 24 months hot in Postgres, then archive to users/{id}/curated/facts in the lake; rows still referenced by live insights or non-terminal actions are pinned and never archived out from under their citations.

3.5 Why facts are the only numbers the LLM sees

One boundary buys everything at once: auditability — every numeric claim resolves to a fact id, which is what powers the "based on" chips; correctness — numbers come from tested code with golden fixtures, and the model is never asked to subtract; evals — the deterministic layer defines known-correct answers to grade generation against (§7); cost — compact packs, low temperature, and re-generation without recomputation; safety — facts are computed through user-scoped repositories, so no prompt can reach another user's numbers. News stories are the one non-fact input, and they are words: a story's numbers may be quoted only as attributed story content cited [s:id], never blended into portfolio arithmetic. As built (phase 6), the boundary is validator-enforced: a number in markets_md passes only if it fact-matches a cited [f:id] within rendering tolerance or appears verbatim in a story cited [s:id] in that block — strict verbatim (18% ≠ 18.0%), with a magnitude suffix (K/M/B/T) treated as part of the token so $2.5M can't pass as $2.5B.

4. The fact pack

Assembled by code, not the model — deterministic content, ordering, and budget, reproducible from ai_runs.input_hash.

Contents and order (stable headings, materiality-first):

  1. Profile summary — age band, target retirement, risk profile, key thresholds; compact and label-like, from doc 03.
  2. Data quality header — staleness and hygiene facts up front, so the agent qualifies language before making claims.
  3. Portfolio state — total and per-account value, top weights.
  4. What changed — performance facts and position_moves; every pack fact is tagged new | changed | unchanged | resolved by comparing natural keys and payload hashes against the prior pack, so deltas vs yesterday are computed, never inferred. As built (phase 4): the comparison uses an as_of-free payload identity hash on both sides — the stored content_hash bakes in as_of, so comparing it would tag every fact changed every day.
  5. Breaches and suggestions — liquidity, drift, TLH, cash, ordered by severity; suggestion facts carry all quantities.
  6. Retirement — the latest projection fact with its as-of (weekly cadence acknowledged).
  7. Open actions — id, kind, status, dedupe key, age, nudge tier: the substrate for dedupe and nudging (§5.3, §6.5). As built (phase 5): the tier rides OpenAction.nudge_tier; the expiry date renders only on final-tier lines, so the model structurally can't invent deadlines for calmer tiers.
  8. News — stories filtered per doc 01's consumption contract (importance ≥ 3 with a held-ticker intersection, importance ≥ 4 with a sector intersection, or importance 5): id, headline, bullets, tickers, importance. As built (phase 6): the user's briefing_min_importance setting is an overall floor ANDed onto that disjunction; story ids live in a story manifest separate from the fact-id space; the selection window starts at the prior briefing date's midnight, so day-over-day overlap is deliberate — re-surfaced beats dropped.
  9. Generic market recap text — the shared recap job's output (tier-cheap, house key), included as raw material for the personalized markets section.

Every number renders with its fact id tag [f:123], stories with [s:456]. Budget: soft 8k tokens, hard 12k, trimmed in a fixed ladder — unchanged non-material facts collapse to one line, news caps at the top 8 stories, movers at the top 5, lot-level detail collapses to counts (the drill-down tools recover anything trimmed). As built (phase 4): exhausting the ladder raises a loud PackBudgetExceededError rather than silently emitting an oversized pack. The rendered pack and its hash are stored on the ai_runs row for exact replay. Alternative considered: no pack — let the agent pull everything through tools. Rejected: coverage becomes nondeterministic, materiality judgment needs the whole picture, and token spend rises. Tools remain for the tail; the pack is the floor.

5. The daily agent

One Pydantic AI run per user per trading day, on tier-mid via the user's virtual key — this prompt is full of user data, so the tenancy rule applies; the generic recap it consumes was generated once on the house key. As built (phase 8): with multi-user enabled, a non-owner user without a BYOK key still gets a briefing — the run falls back to the house key and the published text says so; chat, by contrast, refuses without BYOK (doc 05).

As built (staleness fix, 2026-08): "one run per day" is per-inputs, not per-calendar-slot. The runner's skip-if-exists is freshness-aware: it rebuilds the deterministic fact pack and compares its hash to the generating run's ai_runs.input_hash — equal means nothing the briefing cites changed and the stored briefing stands; a mismatch (a corrected analytics run, a user-triggered refresh, a settings change) regenerates the briefing in place. LLM spend still only happens when the inputs actually changed, so the cost posture is unchanged. A failed auto-regeneration keeps the existing generated prose (never a silent downgrade to fallback); the web surface banners a briefing whose cited facts have moved since it was written. The pack itself reads only the day's latest analytics batch — facts a newer same-day run stopped emitting (a cleared breach) are invisible to the agent, matching the invalidation pass's batch scoping (§6.4).

5.1 Structured output

DailyBriefing, the validated output type:

  • headline and overview_md — the one-glance state.
  • portfolio_md — what happened and why it does or doesn't matter.
  • markets_md — the personalized "why the market matters to you": rewrites the generic recap through the user's actual exposures, citing allocation and mover facts plus story ids.
  • retirement_md — on-track framing, dated with the projection's as-of.
  • insights: Insight[] — category, severity 1–3, title, body_md, evidence_fact_ids[].
  • proposed_actions: ProposedAction[] — the pinned contract: kind ∈ rebalancebuy | rebalance_sell | tlh_swap | deploy_cash | raise_cash | review_position | data_hygiene; params jsonb; rationale; evidence_fact_ids[]; priority. Params are copied from suggestion-fact payloads — the agent chooses _which suggestions become actions and how to argue them, never the quantities — and expires_at is set by code per kind (§6.4), not by the model. As built (phase 4, Task H): the agent-output shape is reference-based — the model emits {kind, source_fact_id, rationale, evidence_fact_ids, priority} plus a few non-numeric scalar choices (replacement_ticker, ticker/account/note for review/hygiene kinds), and code builds the typed per-kind params by copying/computing them from the cited source fact (tlh_swap qty is summed from the cited loss lots; raise_cash's sell subset is code-determined). This makes "the LLM never invents numbers" structural — there is no numeric field for the model to fill — rather than validator-enforced. The original kind-tagged discriminated union was dropped because union-typed array slots defeat some providers' function-calling (Gemini emitted null). The stored actions.params jsonb and the per-kind params models are unchanged — only the brain-internal agent-output contract moved.
TableKey points
daily_briefingsunique (userid, briefing_date); status generated or fallback; headline; sections jsonb (markdown per section with citation tags); ai_run_id. Regeneration replaces the row; history stays in ai_runs. _As built (phase 6): markets_md persists into sections on the generated path only — fallback briefings legally omit it (the fallback renderer proposes no markets narrative).
insightsbriefingid, category, severity, title, body_md, evidence_fact_ids[] — the cards behind the UI's evidence chips. _As built (phase 4): carries a denormalized user_id (for RLS) plus sort; the insight→action link is by action_index into the briefing's proposed actions, not an action_id FK; category is a closed vocabulary (performance | allocation | tax | cash | liquidity | retirement | data_quality).
market_recapsshared artifact, no user_id: one row per recap_date (unique) with body_md, top_story_ids[], ai_run_id, created_at. Written by the generic recap job on the house key (tier-cheap); the personalized markets pass lives in daily_briefings.sections, not here. Regeneration replaces the row; history stays in ai_runs. Resolves doc 03's open question 2. As built (phase 6): a failed regeneration leaves the prior row untouched and records an ai_runs error — recap absence is a legal pack state, and a recap failure never blocks briefings.

5.2 Read-only drill-down tools

Five tools, all funneled through the same user-scoped repositories and lake module as the API — the model cannot address another user's data because the tools can't: get_fact(id) (full payload when the pack collapsed it), get_lots(ticker), get_position(ticker), get_story(id), get_price_history(ticker, days). Budget of 10 calls per run with capped result sizes. There are no write tools: actions are output, not side effects.

5.3 Generation contract: materiality and dedupe

Materiality guidance lives in the versioned prompt: lead with what changed; prefer silence over noise (unchanged allocations are not news); every breach fact must be addressed — mentioned, actioned, or explicitly deferred; at most 3 proposed actions per day unless a liquidity breach forces more; retirement gets one calm paragraph, not daily drama; stale data means dated claims. As built (phase 4): the action budget is two-layered — prompt guidance of 3 plus a post-parse hard cap of 5, with liquidity-breach-evidenced actions exempt (they consume cap slots first and are never cut).

Dedupe is prompt plus code, defense in depth. The pack lists open actions with their dedupe_key — a hash of (user, kind, scope, direction), deliberately coarse so quantity drift refreshes rather than duplicates. Rules: never re-propose an equivalent of an accepted action (it is in flight; the verifier owns it); a materially changed recommendation (quantity moved ±20%, or a different replacement ticker) is emitted with supersedes_action_id; equivalents of recently dismissed actions are suppressed for 7 days unless severity rose. Post-parse code enforcement drops violations regardless of what the model did.

5.4 Validation, fallback, provenance

Pydantic AI validates structure; two custom validators feed errors back for self-correction (max 2 retries): citation validity — every fact and story id must exist in the pack manifest or a tool result from this run; numeric faithfulness — numbers extracted from generated text must match a cited fact's payload within rendering tolerance (§7). A single invalid action drops that action, not the run. If the run still fails validation or the gateway errors out, the fallback renderer — plain templates over the same fact pack — publishes a facts-only briefing (status fallback): data-quality header, value and performance tables, breach list. It proposes no actions; the deterministic numbers remain visible in the Portfolio UI, and proposals wait for the next healthy run. The product never shows nothing. Alternative considered: letting the fallback auto-propose breach-driven actions (they are deterministic anyway) — deferred; actions carry authority, and the judgment layer (dedupe, framing, restraint) is part of their quality bar.

ai_runs (owned here; every gateway-touching job writes one — daily agent, market recap, story synthesis, chat):

ColumnNotes
agent, user_iddaily_briefing, market_recap, story_synthesis, chat, eval; user_id null for house jobs
model_alias, resolved_model, prompt_versiontier alias requested (e.g. tier-mid), concrete model the gateway resolved, prompt version
input_hash, input_refpack hash plus the stored rendered pack, for exact replay. As built: input_ref stores the rendered pack text inline, not a reference.
tokens_in, tokens_out, cost_usdfrom the gateway response; doc 05's usage_ledger is the billing view per key — this is per-run application provenance. As built (phase 8): cost_usd capture is layered — gateway response header on non-streaming calls, an async spend-log settle for streaming chat, price-table estimate as last resort (doc 05 §2).
status, validation_report, errorok, retried_ok, fallback, error; faithfulness score and violations
trace_id, started_at, finished_atOTel linkage

5.5 Cost and routing

Pinned routing, restated: story synthesis = tier-cheap + house key (doc 01); generic market recap = tier-cheap + house key (freeform prose over pre-ranked stories — no structured-extraction risk to pay mid-tier for; aligned with the cost model); daily agent, including the personalized markets section = tier-mid + user key; chat = tier-best + user key (doc 04); embeddings = shared model + house key, always. A typical daily run is 5–9k tokens in, 2–3k out, at most 10 small tool calls — cents per briefing on a mid-tier model, low single-digit dollars per user-month even with retries. The gateway's per-key budgets (doc 05) are the hard backstop; a per-run token ceiling is the soft one. The analytics engine itself costs zero LLM dollars — that is the point of the split.

6. Action lifecycle

6.1 The table

ColumnNotes
kindrebalance_buy, rebalance_sell, tlh_swap, deploy_cash, raise_cash, review_position, data_hygiene (pinned)
statusproposed, accepted, dismissed, completed, expired, superseded (pinned)
paramsjsonb per kind: ticker, qty, amounts, target weights as applicable; tlh_swap adds replacement ticker, sell-lot ids, no_repurchase_before
rationale, evidence_fact_ids[], prioritythe argument, its evidence, 1 (urgent) to 3 (routine)
source, briefing_id, ai_run_iddaily_agent, chat (doc 04 drafts flow through this same lifecycle), system
dedupe_key, supersedes_id§5.3 semantics
expires_at, resolved_at, resolution_reasonTTL backstop plus why it left the board
accepted_at, matched_transaction_ids[], matched_qtyverification state — matched transaction ids land here per the pinned contract. As built: matched_qty is overloaded — dollars for deploy_cash/raise_cash, shares otherwise; the web renders accordingly.
user_noteas built (phase 4) — free-text note the user may attach on accept/dismiss (doc 03's open question resolved yes)

As built (phase 4): the user's accept/dismiss write path needs a column-scoped UPDATE grant, not just RLS — RLS restricts rows, never columns, so a blanket grant would let a legal proposed→accepted transition also forge brain-owned columns (params, dedupe_key, evidence_fact_ids, matched_qty). The grant covers exactly status/accepted_at/resolved_at/resolution_reason/user_note.

6.2 State machine

Only proposed can be superseded — an accepted action is a user commitment; if its premise dies first it expires with a reason and a notification (doc 03), and any replacement starts life as a fresh proposal. completed, dismissed, expired, and superseded are terminal.

As built (phase 5): the state machine is enforced by a BEFORE UPDATE database trigger, not prose + RLS. Permissive RLS policies OR their WITH CHECKs together and WITH CHECK never sees the old row, so per-old-state transition rules can't live in policies — a live-probed privilege escalation proved it. The trigger's tier 1 enforces the allowed-transitions table for every writer (terminal-is-terminal now binds even the service role); tier 2 pins what authenticated may do; felix_schemas.ALLOWED_TRANSITIONS and the trigger carry lockstep-duplication notes.

6.3 Verification job

Runs after every portfolio import (pinned) — that is when new truth arrives — and reads only normalized transactions, so doc 01's connector contract keeps it broker-agnostic. For each accepted action, candidates are unmatched transactions with trade_date on or after the acceptance date, matching account (when the action pins one), ticker, and side; each transaction matches at most one action; assignment is greedy by closest quantity, then earliest date. Tolerances: quantity within ±10% for share-based kinds; amount within ±10% for amount-based kinds (deploy_cash, raise_cash), which may aggregate several transactions. tlh_swap is two-legged: the sell leg (ticker + quantity) and the buy leg (replacement ticker, roughly the proceeds, ±20%) within 5 trading days of the sell. A sell-only match keeps the action accepted with the buy leg flagged outstanding — cash is sitting, and the repurchase window is ticking — and the verifier also watches for a repurchase of the sold ticker inside 30 days. As built (phase 5): the verifier does not file a wash_sale_violation fact — the phase-3 scanner is the single fact owner; the verifier annotates the action (a status-preserving resolution_reason note) and sends a once-ever notification citing the scanner's fact. Partial fills: matches accumulate in matched_transaction_ids[] and matched_qty; reaching 90% of target completes the action; anything less at expiry expires with the partial match preserved. As built: a below-target single fill is a partial (it accumulates; expiry preserves it) — near-misses are overshoots beyond +10% — and the user may declare a partial their whole fill via a guarded accepted→completed write that requires matched evidence (the "partial + user completion" decision). Near-misses outside tolerance are recorded as unconfirmed candidates for one-click manual confirmation — never silently matched; each transaction still matches at most one action on both the automatic and manual paths (when two actions could claim one transaction, the earliest-accepted wins and losing confirmations are pruned — the winner's completion notification is the signal). review_position and data_hygiene don't match transactions; they auto-complete when their underlying fact resolves (reconciliation closed, re-import done) or by manual completion.

6.4 Expiry and invalidation

Nightly, after analytics and before the agent, an invalidation pass re-evaluates every open action's premise against fresh facts:

KindDefault TTLInvalidated when
tlh_swap5 trading daysharvestable loss falls below 50% of threshold, or a new wash block appears (e.g. a DRIP executed)
rebalance_buy / rebalance_sell10 trading daysdrift back inside the band — the market self-corrected
deploy_cash10 trading daysexcess cash below the hysteresis gate
raise_cash15 trading daysliquidity restored above the floor
review_position / data_hygiene30 daysunderlying fact resolved (also auto-completes)

Predicates are the real mechanism; the TTL is the backstop for premises that quietly persist while the user ignores them. While a proposed action's premise holds, the daily run refreshes its evidence and rationale in place (same dedupe key, no duplicate cards) up to a 30-day hard age, after which it expires and any still-valid recommendation returns as a fresh proposal. Accepted actions are never silently refreshed — the user said yes to specific numbers; premise drift beyond tolerance expires the action with a notification explaining why. A raise_cash breach persisting at expiry is re-proposed at escalated priority rather than extended forever.

As built (phase 5), two recorded contracts: premise-drift expiry depends on the agent having cited the premise fact in evidence_fact_ids (rebalance params carry no level/key to reconstruct scope from) — an accepted action with no usable citation closes only via the TTL / 30-day hard-age backstop. And executed actions are exempt from premise predicates: once an accepted action has matched transactions, the premise vanishing is indistinguishable from the user having obeyed it (selling the loss lot consumes the TLH premise), so predicates are skipped and the TTL is the sole arbiter — without this guard the canonical TLH flow orphaned its buy leg mid-execution.

6.5 Nudges

Open accepted actions appear in every subsequent fact pack with their age and a code-computed nudge tier: quiet (under 3 trading days — listed only), reminder (3 or more — one line), escalation (7 or more — a quantified consequence from current facts, e.g. "drift persists at 6.2pp"), final (within 2 days of expiry). The agent writes the words; the tiering, like every number, is deterministic. Dismissed actions are never nudged.

As built (phase 5): NudgeTier is schemas-only — computed into OpenAction.nudge_tier for the pack, never persisted. The expiry date renders only on final-tier lines (structural deadline containment), the escalation consequence must cite a fact resolved by natural key against the current day's facts, and an action_nudge notification fires once per tier transition for reminder and escalation only — the final window is covered by the expiry pass's own action_expiring bell, deliberately avoiding a double-notify.

7. Testing and evals

  • Golden synthetic portfolios — fixtures (accounts, lots, transactions, prices) with expected facts hand-verified in a spreadsheet and committed: drifted_60_40 (band breaches with tax-aware quantities), tlh_trap_ira (wash blocks via IRA and DRIP lots inside the window), tlh_clean (harvest, replacement, and benefit math), liquidity_breach_st_gains (ranking when every option is bad), new_account_midmonth (TWR inception and flow handling), split_in_window (corporate action inside a wash window), stale_import (hygiene facts and dated language). CI asserts emitted facts equal expected facts exactly.
  • Property tests — injecting a flow with zero market movement leaves TWR unchanged; weights sum to 100%; post-suggestion allocations land inside bands; wash checks are account-symmetric (moving the violating buy from taxable to IRA can only tighten the block, never loosen it); the coherence pass never leaves two directional suggestions on one ticker.
  • Monte Carlo checks — seeded determinism (same inputs, byte-identical outputs); monotonicity (more contributions or lower spending never lowers success probability); degenerate sanity (zero volatility collapses to the deterministic path).
  • Faithfulness audit — extract numeric tokens from generated text (currency, percent, pp; normalized for rounding and formatting) and require each to match a value in the union of cited facts' payloads, or a cited story for story-attributed numbers. The score lands in ai_runs.validation_report; 100% is required on the eval set to ship a prompt; production runs are sampled nightly with alerting below 98%.
  • Prompt versioning and regression — prompts live in the repo, versioned, and ai_runs.prompt_version ties every output to its prompt. Any model or prompt bump replays frozen golden fact packs and must hold: output parses, faithfulness 100%, mandatory coverage (a liquidity breach is always addressed), dedupe respected, action params exactly equal to suggestion-fact values. An LLM judge for tone and length is a later layer, run on synthetic packs with the house key.

8. Open questions

All eight were settled during the build; resolutions are recorded here.

  1. Marginal tax ratesResolved (phase 3): manual federal + state rates are fine v1; NIIT is not modeled and is named in the payload's excluded list so the words stay honest.
  2. Capital-market assumption sourceResolved (phase 3): a conservative house set, felix-house-v1, ships as the seed; not user-editable.
  3. Default thresholdsResolved (phase 3): confirmed as proposed (TLH $500 and 5%; minimum trade $250; cash hysteresis max($500, 0.5%); liquidity warning at 110% of floor).
  4. BenchmarksResolved (phase 3): yes — user_settings.benchmark shipped as an override column, default SPY, alongside the model-weighted blend.
  5. Retirement fidelityResolved (phase 3): the v1 fidelity was accepted as specified; Social Security stays excluded and flagged, with no landing date picked.
  6. Briefing voiceResolved (phase 4): a hybrid voice, ~250–400 words; the §6.5 nudge tiering shipped as proposed.
  7. Action budgetResolved (phase 4): both — guidance of 3 in the prompt plus a post-parse hard cap of 5, liquidity-breach actions exempt.
  8. Verification toleranceResolved (phase 5): confirmed at defaults — ±10% quantity (share kinds), ±10% amount (cash kinds, aggregating), ±20% on the tlh_swap buy leg.