ADR-0011: Unified ledger projection (single per-kind reducer)
- Status: Accepted
- Date: 2026-06-10
Context
ADR-0004 derives every read model from the immutable transaction history, so
each model must interpret the booking kinds. That interpretation had grown
into three independent case kind blocks: Ledger.cash_balances (signed
cash deltas plus the ADR-0009 snapshot anchoring), Ledger.Positions
(quantity deltas), and the Portfolios.Performance daily walk (cash,
quantities and external-flow classification for TTWROR).
When the balance_adjustment kind (ADR-0009) was introduced, several of
these had to be changed in lockstep — a silent-drift risk: forgetting one
place would not fail loudly, it would quietly make cash, positions and
performance disagree about the same ledger. Every future kind (e.g. one
flagged for IRR/cash-flow purposes) multiplies that risk.
Decision
Define the effect of each booking kind once, in
Portfolixir.Ledger.Projection, and make every derived read model a generic
fold over that effect.
Projection.effects/1maps a transaction to its canonical effect: signed cash legs ({:add, delta}, or{:set, absolute}for an ADR-0009 balance snapshot), signed quantity legs per{securities_account, security}, and an external flag stating whether the booking is an external flow that a time-weighted return must neutralise.- The projection also owns the replay-order rule that snapshots apply after
the other bookings of their day (
intra_day_order/1) and the pure cash-balance fold (Projection.cash_balances/1). Ledger.cash_balances,Ledger.Positions.calculateand thePortfolios.Performancedaily walk consume effects; none of them dispatches on the kind anymore.- A kind the projection has not been taught raises instead of being silently ignored, so an incompletely added kind fails loudly in every read model at once.
Deliberately outside the reducer: the moving-average holdings view and the
FIFO trade matcher. They are cost-basis views that by definition consider
only the priced buy/sell kinds — they filter to two kinds rather than
interpreting all of them, so routing them through the projection would
change behaviour (deliveries carry no own cost).
Consequences
- Adding a booking kind means one
effects/1clause (plus theTransactionvalidation); cash balances, positions and performance pick it up consistently with no further code. The projection test suite iterates overTransaction.kinds/0generically, so it covers new kinds without modification. - The read models can no longer drift apart on what a kind means; the trade-off is that a kind whose semantics cannot be expressed as cash legs, quantity legs and an external flag would force the effect shape to grow (a deliberate speed bump — that is a semantic change worth an ADR amendment).
- This is a refactor, not a feature: TTWROR, valuation and allocation results are unchanged, and balances remain derived on read (ADR-0004). Caching or persisting projections stays a future decision.