Context
Issuer home domains and SDF reserve balances were hand-maintained config maps because ingestion did not observe AccountEntry changes. Every LedgerCloseMeta already carries them, but the existing dispatcher hooks (events, ops, contract calls) see only transaction artifacts, and Horizon (ADR-0001) and per-request RPC are ruled out.
Decision
Add a dispatcher hook, LedgerEntryChangeDecoder, and one canonical implementation, internal/sources/accounts.AccountEntryObserver, writing the account_observations hypertable (migration 0010).
- Hook.
Matches(change)is a cheap pre-filter on the entry type;Decode(ctx)emits zero or more events for one change, with ledger, close time, tx hash and op index (empty and -1 for fee-meta changes). The walker visits per-op changes and the tx-level fee changes. Errors are skip-and-count, as for the other hooks. - Not an existing hook: an
AccountEntrychange is a side effect of any classic op (and fees), so it is neither an event nor an op-type filter. - Table. One row per
(account_id, ledger)withbalance_stroopsNUMERIC (ADR-0003), nullablehome_domain(NULL, not empty string, when unset),flagsandseq_num; hypertable onobserved_at, 7-day chunks. Readers take the latest row byobserved_at DESC. - Idempotent insert. Identity is
(account_id, ledger, observed_at), because Timescale needs the partition column in the key andobserved_atis the ledger close time. The conflict action isDO UPDATE ... WHERE intra_ledger_seq <= EXCLUDED.intra_ledger_seq: an account is touched several times in a ledger (fee phase, then operations) and the LAST change is the ledger-final state, soDO NOTHINGwould freeze the fee-phase balance. A re-walk re-assigns the same position and rewrites the same value. - Readers over the same table:
metadata.LCMHomeDomainResolver(latest observedhome_domain; an observed absence of one wins, and only an unobserved issuer falls back to[metadata.issuer_home_domains]) andsupply.LCMReserveBalanceReader(sum of each account's latestbalance_stroopsat or before a ledger; an error if any account has no observation by then). - Fallback is permanent. The live reader is chained ahead of
supply.ConfigReserveBalanceReaderin both refresh paths (docs/architecture/supply-pipeline.md, "The chained-fallback reader pattern"); the static map is the bootstrap fallback, not an interim. A reserve account that never changes emits no change, sostellarindex-ops supply seed-observationsseeds each[supply] sdf_reserve_accountsentry's latestAccountEntryfrom the lake'sledger_entries_currentprojection (ADR-0034). Accounts dormant since before the lake's capture window need astate-snapshotrun first, and the seeder reports them rather than fabricating. - Watched set, not global. The observer records accounts named by
[supply] sdf_reserve_accountsand[metadata] watched_issuer_accounts. Watching every account (50M+ accounts times N observations) needs its own ADR. - One canonical decoder, not per-source:
AccountEntryhas one shape network-wide.
Invariant
- Observations are derived from LCM ledger-entry changes only: never Horizon, never per-request RPC.
- The conflict guard has since gained
walk_version(migrations 0120, 0199). The published row for an(account, ledger)is the last intra-ledger change, and a re-walk of a range is idempotent. Enforced by theintra_ledger_seqguard ininternal/storage/timescale/account_observations.go. - Amounts are NUMERIC end to end, never a narrower integer (ADR-0003).
- The live reserve reader never replaces the config reader silently: an account with no observation is an error that falls back, not a zero.
Consequences
- One new hypertable, one hook (
Dispatcher.AddEntryDecoder), two readers; the aggregator can refresh supply snapshots per tick. - The static reserve map stays valid and must be kept for bootstrap.
- The table stays small only while the watched set does.
Evidence
internal/sources/accounts,internal/dispatcher/dispatcher.go(LedgerEntryChangeDecoder),internal/pipeline/sink.go(writer),internal/supply/(LCMReserveBalanceReader).- Migrations 0010 and 0111 (
intra_ledger_seq); ADR-0001, ADR-0003, ADR-0011.