ADR 0009. Gold at daily grain, durations at query time

Status: accepted. Implemented 2026-08-24 in kevinvu184/up. Re-grains ADR 0005's marts and supersedes ADR 0008's monthly balance mart (the reconstruction decision there stands; only its grain changes).

Context

The monthly marts answered exactly one duration. The product on top of gold needs daily, weekly, monthly, quarterly and arbitrary custom ranges, and a mart per duration would multiply tables while still failing the custom case. The dataset is about 6,000 transactions, so a daily grain holds low thousands of rows and any rollup over it runs in milliseconds.

Decisions

Store the finest grain, derive every duration

Gold stores one row per UTC calendar day; a week, month, quarter or custom range is a GROUP BY over days computed by the consumer. No table stores a duration, no table name mentions one, and no schema column encodes one. Migration 10 drops the three monthly tables (gold rebuilds wholesale every run, so nothing is lost) and creates gold_category_spend, gold_cashflow and gold_balance, keyed on day.

Gold is the product's only query surface

At daily grain the spend and cashflow marts barely compress silver. They are kept anyway, because their value is the contract, not the compression: metric definitions (what counts as spend, what counts as flow, how balance is reconstructed via BALANCE_EXPR) live in the gold SELECTs alone, consumers including the future MCP server read gold and never silver, and silver stays free to change underneath.

Dense days for balance

gold_balance gives every account a row for every day from its first transaction through today, carrying the balance forward through quiet days, so "balance on day D" never misses an inactive account. The other two marts stay sparse; absence of a row means zero activity, which is the correct reading for flows.

Consequences

Consumers own bucketing. The canonical rollup patterns, so every consumer copies these rather than inventing its own:

  • Monthly: GROUP BY substr(day, 1, 7)
  • Quarterly: group by year and (CAST(substr(day, 6, 2) AS INTEGER) + 2) / 3
  • Weekly: GROUP BY strftime('%Y-W%W', day), noting %W weeks start Monday and week 0 exists; use with care around year boundaries
  • Custom range: WHERE day BETWEEN ? AND ?
  • Balance at a boundary: the gold_balance row at MAX(day) <= boundary per account; net worth at a boundary is the sum of those rows

Day boundaries are UTC, so a Melbourne evening purchase lands on the next calendar day; this was an accepted blur at monthly grain and is more visible at daily grain. It remains accepted and fixable in one place, the bucketing expressions in models/goldModels.ts. The balance spine grows by one row per account per day, roughly a thousand rows per account per three years, which the gold cron budget absorbs without change.