Selaa lähdekoodia

Squashed commit of the following:

commit 631a6e9e4241bd4995f6152f5881741e76b8b2d5
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Sun Jul 19 13:29:27 2026 -0300

    Coordinate balance-projection appends through storage, not memory

    Address a review of ADR-0019. The background cache-point append had no
    dedup, so a hot account read at high QPS spawned one full fold per read.
    An in-process guard cannot coordinate the multiple independent instances
    the projection is built for, so move the guard into the shared
    balance_projection rows: append_cache_point returns before the fold when
    a cache point already sits within a debounce window below the target
    watermark. Redundant spawns then cost one indexed read, and the dedup
    holds across processes and restarts with no lease, lock, or CAS, matching
    the coordination-free design.

    Log a warning when a background append fails so a projection that
    silently stops advancing is visible.

    Add a deterministic test that folds a non-empty tail onto a real
    snapshot, the watermark-boundary case the existing tests never reached
    (they either created no snapshot or left the tail empty). Correct the
    property-test comment that claimed cache points were appended during the
    run, and the ADR header that still named the rejected lease and projector
    service.

commit b169e1f084375cd6a9658375cfb046ab7019a3f9
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Fri Jul 17 09:20:35 2026 -0300

    Add cached balance projection (ADR-0019)

    Balance was summed from every live posting on each read, which is
    O(N live postings) and unbounded under UTXO fragmentation. Add a
    rebuildable balance projection: a read returns the closest stored
    snapshot plus a Rust-summed tail of committed transfer deltas past the
    snapshot's commit-time watermark. Summing every one of an account's
    transfer created(+)/consumed(-) deltas for an asset equals its
    live-posting sum, so the projection-aware read always agrees with the
    authoritative balance, and it is faster once a snapshot keeps the tail
    short.

    Make the projection append-only rather than a mutable row guarded by a
    lock or a CAS. Each cache point is one snapshot of a
    (account, subaccount, asset) balance plus the watermark it covers,
    tagged with a Rust-minted monotonic id, and rows are only ever inserted.
    A read selects the cache point closest to (at or before) the target time
    (largest watermark not exceeding it, tie-broken by highest id), so a
    stale or duplicate append is harmless and concurrent appends need no
    coordination. Add a BalanceProjectionStore sub-trait
    (append + closest-at-or-before get) with an in-memory and SQL
    implementation (migration 007, balance stored as TEXT like every other
    monetary column) and conformance tests.

    Trigger snapshots lazily off the read path, not on commit. A read whose
    folded tail has accrued at least snapshot-interval new credits/debits
    since the closest cache point spawns a best-effort background append; a
    read that finds no cache point returns the authoritative live sum
    directly and bootstraps one in the background. The append folds only up
    to now minus a grace window, so a snapshot never captures a commit still
    racing to become visible. Both bounds are tunable
    (with_snapshot_interval, with_projection_grace_ms) with sane defaults.
    Commit itself never touches the projection, keeping the write path
    unchanged; correctness never depends on any of this.

    Validation keeps reading the authoritative live sum (compute_balance,
    now public), so the UTXO reservation protocol remains the exact
    concurrency control for no-overdraft accounts and the projection stays a
    pure read accelerator.
Cesar Rodas 2 päivää sitten
vanhempi
säilyke
dc818a6246

+ 77 - 0
crates/kuatia-storage-sql/src/lib.rs

@@ -118,6 +118,10 @@ impl SqlStore {
                 "006_drop_policy",
                 include_str!("migrations/006_drop_policy.sql"),
             ),
+            (
+                "007_balance_projection",
+                include_str!("migrations/007_balance_projection.sql"),
+            ),
         ];
 
         for (name, sql) in migrations {
@@ -1399,3 +1403,76 @@ impl BookStore for SqlStore {
             .collect()
     }
 }
+
+// ---------------------------------------------------------------------------
+// BalanceProjectionStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl BalanceProjectionStore for SqlStore {
+    async fn append_balance_projection(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        balance: Cent,
+        watermark: i64,
+    ) -> Result<(), StoreError> {
+        // Append-only: mint a fresh monotonic id and insert a new cache point.
+        let id = self.autoid.next();
+        sqlx::query(
+            "INSERT INTO balance_projection (id, account, subaccount, asset, balance, watermark) \
+             VALUES ($1, $2, $3, $4, $5, $6)",
+        )
+        .bind(id)
+        .bind(account.id)
+        .bind(account.sub)
+        .bind(asset.0 as i32)
+        .bind(balance.to_string())
+        .bind(watermark)
+        .execute(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_closest_balance_projection(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        as_of: i64,
+    ) -> Result<Option<BalanceProjection>, StoreError> {
+        // Closest at or before `as_of`: the largest watermark not exceeding it,
+        // tie-broken by highest id. Row selection, not an aggregate over values.
+        let row = sqlx::query(
+            "SELECT id, balance, watermark FROM balance_projection \
+             WHERE account = $1 AND subaccount = $2 AND asset = $3 AND watermark <= $4 \
+             ORDER BY watermark DESC, id DESC LIMIT 1",
+        )
+        .bind(account.id)
+        .bind(account.sub)
+        .bind(asset.0 as i32)
+        .bind(as_of)
+        .fetch_optional(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let Some(row) = row else {
+            return Ok(None);
+        };
+        let id: i64 = row
+            .try_get("id")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let balance: String = row
+            .try_get("balance")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let watermark: i64 = row
+            .try_get("watermark")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(Some(BalanceProjection {
+            id,
+            account: *account,
+            asset: *asset,
+            balance: Cent::from_str(&balance).map_err(|e| StoreError::Internal(e.to_string()))?,
+            watermark,
+        }))
+    }
+}

+ 20 - 0
crates/kuatia-storage-sql/src/migrations/007_balance_projection.sql

@@ -0,0 +1,20 @@
+-- Append-only balance cache points (ADR-0019). A derived, rebuildable read
+-- accelerator: each row is one snapshot of a (account, subaccount, asset)
+-- balance plus the commit-time watermark (unix millis) it covers, tagged with a
+-- Rust-minted monotonic id. Rows are only ever inserted, never updated. A read
+-- selects the highest id for the (account, subaccount, asset). The balance is a
+-- Cent stored as TEXT, like every other monetary column, and the store never
+-- does arithmetic on it.
+CREATE TABLE balance_projection (
+    id         BIGINT  NOT NULL,
+    account    BIGINT  NOT NULL,
+    subaccount BIGINT  NOT NULL DEFAULT 0,
+    asset      INTEGER NOT NULL,
+    balance    TEXT    NOT NULL,
+    watermark  BIGINT  NOT NULL,
+    PRIMARY KEY (id)
+);
+-- The closest-at-or-before read filters by (account, subaccount, asset) and
+-- watermark, ordering by watermark then id, so the index leads with those.
+CREATE INDEX idx_balance_projection_closest
+    ON balance_projection (account, subaccount, asset, watermark, id);

+ 51 - 3
crates/kuatia-storage/src/mem_store.rs

@@ -8,7 +8,7 @@ use tokio::sync::RwLock;
 
 use kuatia_types::autoid::AutoId;
 use kuatia_types::{
-    Account, AccountId, AssetId, Book, BookId, EnvelopeId, Posting, PostingFilter, PostingId,
+    Account, AccountId, AssetId, Book, BookId, Cent, EnvelopeId, Posting, PostingFilter, PostingId,
     PostingState, ReservationId,
 };
 
@@ -16,8 +16,8 @@ use crate::error::StoreError;
 use crate::events::{EventStore, LedgerEvent};
 use crate::query::{filter_transfers, paginate};
 use crate::store::{
-    AccountStore, BookStore, EnvelopeRecord, Page, PostingStore, SagaStore, TransferQuery,
-    TransferStore,
+    AccountStore, BalanceProjection, BalanceProjectionStore, BookStore, EnvelopeRecord, Page,
+    PostingStore, SagaStore, TransferQuery, TransferStore,
 };
 
 /// Postings held as an immutable record table plus two index maps that carry
@@ -46,6 +46,8 @@ pub struct InMemoryStore {
     sagas: RwLock<HashMap<i64, Vec<u8>>>,
     events: RwLock<Vec<LedgerEvent>>,
     books: RwLock<HashMap<BookId, Book>>,
+    /// Append-only balance cache points keyed by `(account, asset)` (ADR-0019).
+    projections: RwLock<HashMap<(AccountId, AssetId), Vec<BalanceProjection>>>,
     autoid: AutoId,
 }
 
@@ -66,12 +68,58 @@ impl InMemoryStore {
             sagas: RwLock::new(HashMap::new()),
             events: RwLock::new(Vec::new()),
             books: RwLock::new(HashMap::new()),
+            projections: RwLock::new(HashMap::new()),
             autoid: AutoId::new(),
         }
     }
 }
 
 // ---------------------------------------------------------------------------
+// BalanceProjectionStore
+// ---------------------------------------------------------------------------
+
+#[async_trait]
+impl BalanceProjectionStore for InMemoryStore {
+    async fn append_balance_projection(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        balance: Cent,
+        watermark: i64,
+    ) -> Result<(), StoreError> {
+        let id = self.autoid.next();
+        let mut projections = self.projections.write().await;
+        projections
+            .entry((*account, *asset))
+            .or_default()
+            .push(BalanceProjection {
+                id,
+                account: *account,
+                asset: *asset,
+                balance,
+                watermark,
+            });
+        Ok(())
+    }
+
+    async fn get_closest_balance_projection(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        as_of: i64,
+    ) -> Result<Option<BalanceProjection>, StoreError> {
+        let projections = self.projections.read().await;
+        Ok(projections.get(&(*account, *asset)).and_then(|points| {
+            points
+                .iter()
+                .filter(|p| p.watermark <= as_of)
+                .max_by_key(|p| (p.watermark, p.id))
+                .cloned()
+        }))
+    }
+}
+
+// ---------------------------------------------------------------------------
 // AccountStore
 // ---------------------------------------------------------------------------
 

+ 70 - 4
crates/kuatia-storage/src/store.rs

@@ -10,10 +10,11 @@
 //! - [`SagaStore`] — saga state for crash recovery
 //! - [`EventStore`] — the ledger event log
 //! - [`BookStore`] — book persistence
+//! - [`BalanceProjectionStore`] — the cached balance projection (ADR-0019)
 
 use async_trait::async_trait;
 use kuatia_types::{
-    Account, AccountId, AssetId, Book, BookId, Envelope, EnvelopeId, Posting, PostingFilter,
+    Account, AccountId, AssetId, Book, BookId, Cent, Envelope, EnvelopeId, Posting, PostingFilter,
     PostingId, PostingState, Receipt, ReservationId,
 };
 
@@ -242,17 +243,82 @@ pub trait BookStore: Send + Sync {
     async fn list_books(&self) -> Result<Vec<Book>, StoreError>;
 }
 
+/// One append-only cache point (ADR-0019): a balance snapshot for one
+/// `(account, asset)` and the commit-time watermark it covers, tagged with a
+/// monotonic `id`. Cache points are never updated; a read selects the highest
+/// `id`. A disposable, rebuildable accelerator, never the source of truth.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BalanceProjection {
+    /// Monotonic cache-point id (a store-minted snowflake). Higher means newer.
+    pub id: i64,
+    /// Account (base id plus subaccount) this cache point is for.
+    pub account: AccountId,
+    /// Asset this cache point is for.
+    pub asset: AssetId,
+    /// Cached balance: the sum of every committed transfer's delta for this
+    /// `(account, asset)` with commit time at or before `watermark`.
+    pub balance: Cent,
+    /// Commit-time cutoff (unix millis) the snapshot covers. A read folds in
+    /// committed transfers with a commit time strictly greater than this.
+    pub watermark: i64,
+}
+
+/// Append-only balance cache points (ADR-0019). A disposable, rebuildable read
+/// accelerator; the append-only postings stay the source of truth, and this is
+/// never consulted for the validate-time overdraft check. Cache points are only
+/// ever appended (never updated), and a read takes the one closest to (at or
+/// before) the target time, so concurrent appends need no lock.
+#[async_trait]
+pub trait BalanceProjectionStore: Send + Sync {
+    /// Append a new cache point for `(account, asset)`, minting a fresh monotonic
+    /// `id`. A dumb instruction: it inserts one row and never updates an existing
+    /// one.
+    async fn append_balance_projection(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        balance: Cent,
+        watermark: i64,
+    ) -> Result<(), StoreError>;
+
+    /// Fetch the cache point for `(account, asset)` closest to `as_of`: the one
+    /// with the largest `watermark` at or before `as_of` (tie-broken by highest
+    /// `id`). This is the freshest snapshot a read as of `as_of` may use without
+    /// covering transfers committed after `as_of`. Returns `None` if no cache
+    /// point is at or before `as_of`. The caller supplies `as_of` (the store has
+    /// no clock); the ledger passes the current time by default.
+    async fn get_closest_balance_projection(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        as_of: i64,
+    ) -> Result<Option<BalanceProjection>, StoreError>;
+}
+
 // ---------------------------------------------------------------------------
 // Composite trait
 // ---------------------------------------------------------------------------
 
 /// Async storage abstraction composing all sub-traits.
 pub trait Store:
-    AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore
+    AccountStore
+    + PostingStore
+    + TransferStore
+    + SagaStore
+    + EventStore
+    + BookStore
+    + BalanceProjectionStore
 {
 }
 
-impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore> Store
-    for T
+impl<
+    T: AccountStore
+        + PostingStore
+        + TransferStore
+        + SagaStore
+        + EventStore
+        + BookStore
+        + BalanceProjectionStore,
+> Store for T
 {
 }

+ 87 - 0
crates/kuatia-storage/src/store_tests.rs

@@ -1207,6 +1207,89 @@ pub async fn list_books(store: &(impl Store + 'static)) {
 }
 
 // ---------------------------------------------------------------------------
+// BalanceProjectionStore tests
+// ---------------------------------------------------------------------------
+
+/// No cache point yet reads back as `None`.
+pub async fn balance_projection_absent_is_none(store: &(impl Store + 'static)) {
+    let got = store
+        .get_closest_balance_projection(&AccountId::new(1), &AssetId::new(1), i64::MAX)
+        .await
+        .unwrap();
+    assert!(got.is_none());
+}
+
+/// An appended cache point round-trips: value and watermark come back intact and
+/// carry a store-minted id.
+pub async fn balance_projection_append_and_get_closest(store: &(impl Store + 'static)) {
+    store
+        .append_balance_projection(&AccountId::new(1), &AssetId::new(1), Cent::from(1234), 500)
+        .await
+        .unwrap();
+    let got = store
+        .get_closest_balance_projection(&AccountId::new(1), &AssetId::new(1), i64::MAX)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(got.account, AccountId::new(1));
+    assert_eq!(got.asset, AssetId::new(1));
+    assert_eq!(got.balance, Cent::from(1234));
+    assert_eq!(got.watermark, 500);
+}
+
+/// Append-only cache points: `get_closest` returns the freshest one at or before
+/// `as_of` (largest watermark), never a cache point covering past `as_of`. Ids
+/// increase across appends.
+pub async fn balance_projection_closest_at_or_before_as_of(store: &(impl Store + 'static)) {
+    let acc = AccountId::new(1);
+    let asset = AssetId::new(1);
+    store
+        .append_balance_projection(&acc, &asset, Cent::from(100), 200)
+        .await
+        .unwrap();
+    let first = store
+        .get_closest_balance_projection(&acc, &asset, i64::MAX)
+        .await
+        .unwrap()
+        .unwrap();
+
+    store
+        .append_balance_projection(&acc, &asset, Cent::from(300), 400)
+        .await
+        .unwrap();
+
+    // As of a time at/after both, the freshest (watermark 400) is returned.
+    let latest = store
+        .get_closest_balance_projection(&acc, &asset, 1000)
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(
+        latest.id > first.id,
+        "cache-point id must increase on append"
+    );
+    assert_eq!(latest.balance, Cent::from(300));
+    assert_eq!(latest.watermark, 400);
+
+    // As of a time between them, the watermark-400 point is excluded (it would
+    // cover transfers after as_of), so the watermark-200 point is returned.
+    let earlier = store
+        .get_closest_balance_projection(&acc, &asset, 300)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(earlier.balance, Cent::from(100));
+    assert_eq!(earlier.watermark, 200);
+
+    // As of before any cache point, none is returned.
+    let none = store
+        .get_closest_balance_projection(&acc, &asset, 100)
+        .await
+        .unwrap();
+    assert!(none.is_none());
+}
+
+// ---------------------------------------------------------------------------
 // Macro
 // ---------------------------------------------------------------------------
 
@@ -1278,6 +1361,10 @@ macro_rules! store_tests {
             create_duplicate_book_fails,
             get_missing_book_fails,
             list_books,
+            // BalanceProjectionStore
+            balance_projection_absent_is_none,
+            balance_projection_append_and_get_closest,
+            balance_projection_closest_at_or_before_as_of,
         );
     };
 

+ 33 - 0
crates/kuatia/src/ledger.rs

@@ -29,11 +29,21 @@ mod envelope_saga;
 mod balance;
 mod commit;
 mod lifecycle;
+mod projection;
 mod query;
 
 pub use balance::SubAccountBalance;
 pub use commit::LoadedState;
 
+/// Default grace window (milliseconds) for the balance projection watermark: how
+/// far behind live a snapshot is allowed to advance, covering commit-to-visibility
+/// lag. See ADR-0019.
+pub const DEFAULT_PROJECTION_GRACE_MS: i64 = 60_000;
+
+/// Default number of credits/debits that must accrue for a `(account, asset)`
+/// since its latest cache point before a read appends a new one. See ADR-0019.
+pub const DEFAULT_SNAPSHOT_INTERVAL: u64 = 128;
+
 /// Return the current time as Unix milliseconds.
 pub(crate) fn now_millis() -> Result<i64, LedgerError> {
     Ok(std::time::SystemTime::now()
@@ -45,6 +55,12 @@ pub(crate) fn now_millis() -> Result<i64, LedgerError> {
 /// Async ledger resource composing the commit pipeline.
 pub struct Ledger {
     store: Arc<dyn Store>,
+    /// Grace window (ms) a cache point keeps behind live when its watermark is
+    /// set (ADR-0019).
+    projection_grace_ms: i64,
+    /// Credits/debits that must accrue since the latest cache point before a read
+    /// appends a new one (ADR-0019).
+    snapshot_interval: u64,
 }
 
 impl Ledger {
@@ -52,9 +68,26 @@ impl Ledger {
     pub fn new(store: impl Store + 'static) -> Self {
         Self {
             store: Arc::new(store),
+            projection_grace_ms: DEFAULT_PROJECTION_GRACE_MS,
+            snapshot_interval: DEFAULT_SNAPSHOT_INTERVAL,
         }
     }
 
+    /// Set the cache-point grace window (ms). Larger is safer against
+    /// commit-to-visibility lag but keeps the read tail longer. See ADR-0019.
+    pub fn with_projection_grace_ms(mut self, grace_ms: i64) -> Self {
+        self.projection_grace_ms = grace_ms;
+        self
+    }
+
+    /// Set how many credits/debits accrue before a read appends a new cache
+    /// point. Smaller snapshots more often (shorter tails, more rows); larger
+    /// snapshots less often. See ADR-0019.
+    pub fn with_snapshot_interval(mut self, interval: u64) -> Self {
+        self.snapshot_interval = interval;
+        self
+    }
+
     /// Returns a reference to the underlying store.
     pub fn store(&self) -> &dyn Store {
         self.store.as_ref()

+ 8 - 11
crates/kuatia/src/ledger/balance.rs

@@ -21,9 +21,14 @@ pub struct SubAccountBalance {
 }
 
 impl Ledger {
-    /// Compute balance from the live (active or reserved) postings for an
-    /// account/asset pair.
-    pub(crate) async fn compute_balance(
+    /// The authoritative balance: the sum of the live (active or reserved)
+    /// postings for one `(account, asset)`, computed in Rust. This bypasses the
+    /// cached projection and always recomputes from the source of truth, so it is
+    /// what validation reads, what the projector folds into snapshots, and what
+    /// reconciliation checks against. Cost is `O(live postings)`; prefer
+    /// [`balance`](Ledger::balance) for the everyday read.
+    #[instrument(skip(self), name = "ledger.compute_balance")]
+    pub async fn compute_balance(
         &self,
         account: &AccountId,
         asset: &AssetId,
@@ -40,14 +45,6 @@ impl Ledger {
         Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
     }
 
-    /// Query the current balance of one subaccount for a given asset. This reads
-    /// exactly the `account` passed (base id and subaccount) and never rolls up
-    /// other subaccounts.
-    #[instrument(skip(self), name = "ledger.balance")]
-    pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
-        self.compute_balance(account, asset).await
-    }
-
     /// Report the per-subaccount balances of a base account for one asset.
     ///
     /// One entry per non-closed subaccount. `sub == None` spans every

+ 2 - 0
crates/kuatia/src/ledger/commit.rs

@@ -241,6 +241,8 @@ impl Ledger {
         self.save_pending(&envelope, reservation, SagaPhase::Reserving)
             .await?;
 
+        // Commit does not touch the balance projection (ADR-0019): cache points
+        // are appended lazily on read, once enough credits/debits have accrued.
         let result = self.drive_envelope_saga(envelope, reservation).await;
 
         // Delete the pending record only when it is safe: on success, or on a

+ 231 - 0
crates/kuatia/src/ledger/projection.rs

@@ -0,0 +1,231 @@
+//! Balance cache points (ADR-0019).
+//!
+//! Balance is `snapshot + Σ(creates − consumes)` over committed transfers past
+//! the closest cache point's commit-time watermark. Summing every one of an
+//! account's transfer deltas equals its live-posting sum, so this read always
+//! agrees with [`compute_balance`](Ledger::compute_balance). Cache points are
+//! append-only: a read takes the one closest to (at or before) now and, once
+//! enough new credits/debits have accrued since it, appends a fresh one off the
+//! read path. They are a pure optimization that shortens the tail; correctness
+//! never depends on them.
+
+use std::collections::HashSet;
+use std::sync::Arc;
+
+use tracing::instrument;
+
+use kuatia_core::{AccountId, AssetId, Cent, PostingId};
+use kuatia_storage::store::{Store, TransferQuery};
+
+use super::{Ledger, now_millis};
+use crate::error::LedgerError;
+
+/// Fold the per-`(account, asset)` delta of a set of committed transfers,
+/// `Σ created(+) − Σ consumed(−)` restricted to postings owned by `account` in
+/// `asset`, and count how many such postings (credits + debits) were seen.
+/// Consumed postings are resolved from the immutable table (the envelope carries
+/// only their ids). All arithmetic is checked, in Rust.
+async fn fold_account_delta(
+    store: &dyn Store,
+    account: &AccountId,
+    asset: &AssetId,
+    records: &[kuatia_storage::store::EnvelopeRecord],
+) -> Result<(Cent, u64), LedgerError> {
+    let mut delta = Cent::ZERO;
+    let mut count: u64 = 0;
+
+    // Created side (credits): the envelope carries owner/asset/value directly.
+    for record in records {
+        for np in record.envelope.creates() {
+            if np.owner == *account && np.asset == *asset {
+                delta = delta.checked_add(np.value)?;
+                count += 1;
+            }
+        }
+    }
+
+    // Consumed side (debits): gather every consumed id across the window, resolve
+    // the postings in one batch, and subtract those owned by this (account, asset).
+    let mut consumed_ids: Vec<PostingId> = Vec::new();
+    let mut seen: HashSet<PostingId> = HashSet::new();
+    for record in records {
+        for id in record.envelope.consumes() {
+            if seen.insert(*id) {
+                consumed_ids.push(*id);
+            }
+        }
+    }
+    if !consumed_ids.is_empty() {
+        for posting in store.get_postings(&consumed_ids).await? {
+            if posting.owner == *account && posting.asset == *asset {
+                delta = delta.checked_sub(posting.value)?;
+                count += 1;
+            }
+        }
+    }
+
+    Ok((delta, count))
+}
+
+/// Load the committed transfers involving `account` with commit time in
+/// `[from_ts, to_ts)` (both optional), then fold their `(account, asset)` delta
+/// and credit/debit count. `from_ts == None` spans from the beginning;
+/// `to_ts == None` spans to the newest committed transfer.
+async fn fold_tail(
+    store: &dyn Store,
+    account: &AccountId,
+    asset: &AssetId,
+    from_ts: Option<i64>,
+    to_ts: Option<i64>,
+) -> Result<(Cent, u64), LedgerError> {
+    let query = TransferQuery {
+        account: Some(account.id),
+        sub: Some(account.sub),
+        from_ts,
+        to_ts,
+        ..Default::default()
+    };
+    let records = store.query_transfers(&query).await?.items;
+    fold_account_delta(store, account, asset, &records).await
+}
+
+/// Append a fresh cache point for `(account, asset)`: fold the window since the
+/// closest cache point's watermark up to `now − grace` onto its snapshot, and
+/// append the result. Only appends when that window has at least `min_new`
+/// credits/debits, so a hot account that is read constantly does not append a
+/// near-duplicate row on every read (`min_new == 0` forces an append). Append-only
+/// and best effort; a stale or duplicate append is harmless because a read takes
+/// the closest-at-or-before cache point.
+///
+/// `debounce_ms` is the storage-based single-flight guard. When the newest cache
+/// point already sits within `debounce_ms` below the target watermark, this
+/// returns before the fold, so a hot account read at high QPS does not fan out a
+/// full fold per read. The guard lives in the shared `balance_projection` rows,
+/// not in process memory, so it dedups across every ledger instance and survives
+/// a restart, and it needs no lease, lock, or CAS (ADR-0019). `debounce_ms == 0`
+/// disables it (the reconcile path forces an append regardless).
+async fn append_cache_point(
+    store: &dyn Store,
+    grace_ms: i64,
+    min_new: u64,
+    debounce_ms: i64,
+    account: &AccountId,
+    asset: &AssetId,
+) -> Result<(), LedgerError> {
+    let new_watermark = now_millis()?.saturating_sub(grace_ms);
+    let closest = store
+        .get_closest_balance_projection(account, asset, new_watermark)
+        .await?;
+    let (snapshot, from_ts) = match closest {
+        // Storage-based debounce: a cache point within `debounce_ms` below the
+        // target already shortens the tail enough, so skip the fold. This is what
+        // collapses a per-read fan-out into at most one fold per debounce window.
+        Some(p) if new_watermark.saturating_sub(p.watermark) < debounce_ms => return Ok(()),
+        // A cache point already covers this watermark: nothing to add. (Also the
+        // debounce == 0 stop, so an exact-or-newer watermark is never duplicated.)
+        Some(p) if p.watermark >= new_watermark => return Ok(()),
+        Some(p) => (p.balance, Some(p.watermark.saturating_add(1))),
+        None => (Cent::ZERO, None),
+    };
+    let (fold, count) = fold_tail(
+        store,
+        account,
+        asset,
+        from_ts,
+        Some(new_watermark.saturating_add(1)),
+    )
+    .await?;
+    // Not enough new activity since the closest cache point to earn a new row.
+    if count < min_new {
+        return Ok(());
+    }
+    let balance = snapshot.checked_add(fold)?;
+    store
+        .append_balance_projection(account, asset, balance, new_watermark)
+        .await?;
+    Ok(())
+}
+
+impl Ledger {
+    /// The everyday balance read for one subaccount and asset (ADR-0019): the
+    /// closest cache point (at or before now) plus the folded tail of transfers
+    /// committed after its watermark. Always equal to
+    /// [`compute_balance`](Ledger::compute_balance) at rest, and faster once a
+    /// cache point keeps the tail short. With no cache point yet it returns the
+    /// authoritative live-posting sum directly (rather than folding the whole
+    /// history) and bootstraps a cache point in the background. Once enough
+    /// credits/debits have accrued since the closest cache point, it also appends
+    /// a new one in the background for later reads.
+    #[instrument(skip(self), name = "ledger.balance")]
+    pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
+        let now = now_millis()?;
+        let closest = self
+            .store
+            .get_closest_balance_projection(account, asset, now)
+            .await?;
+        match closest {
+            Some(p) => {
+                let (tail, count) = fold_tail(
+                    self.store(),
+                    account,
+                    asset,
+                    Some(p.watermark.saturating_add(1)),
+                    None,
+                )
+                .await?;
+                if count >= self.snapshot_interval {
+                    self.spawn_append(*account, *asset);
+                }
+                Ok(p.balance.checked_add(tail)?)
+            }
+            // No cache point: the authoritative live sum is O(live postings),
+            // cheaper than folding the whole history. Bootstrap a cache point in
+            // the background (the append itself gates on `snapshot_interval`, so a
+            // small account never actually appends) so later reads use the tail.
+            None => {
+                let balance = self.compute_balance(account, asset).await?;
+                self.spawn_append(*account, *asset);
+                Ok(balance)
+            }
+        }
+    }
+
+    /// Spawn a best-effort background append gated on `snapshot_interval` new
+    /// credits/debits. Uses only the store handle and config, so it needs no
+    /// `Arc<Self>` and can run from a `&self` read.
+    ///
+    /// Redundant spawns are cheap, not suppressed here: the storage-based debounce
+    /// inside [`append_cache_point`] (keyed on the newest shared cache-point row,
+    /// with `debounce_ms == grace`) returns before the fold when a recent cache
+    /// point already exists, so a hot account read at high QPS does at most one
+    /// fold per grace window, coordinated across every instance rather than in
+    /// this process's memory.
+    fn spawn_append(&self, account: AccountId, asset: AssetId) {
+        let store = Arc::clone(&self.store);
+        let grace = self.projection_grace_ms;
+        let min_new = self.snapshot_interval;
+        tokio::spawn(async move {
+            if let Err(err) =
+                append_cache_point(store.as_ref(), grace, min_new, grace, &account, &asset).await
+            {
+                // Best effort: a failed append only lengthens a later read's tail.
+                // Log it so a projection that silently stops advancing is visible.
+                tracing::warn!(?account, ?asset, error = %err, "balance projection append failed");
+            }
+        });
+    }
+
+    /// Append a cache point for one `(account, asset)` now (folding up to
+    /// `now − grace`), unconditionally. The read path appends lazily in the
+    /// background; this forces one (no debounce), exposed for tests and
+    /// reconciliation. Append-only: a repeat within the same grace-adjusted
+    /// millisecond is a no-op (the watermark is already covered), otherwise it
+    /// adds a fresh row.
+    pub async fn append_cache_point(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+    ) -> Result<(), LedgerError> {
+        append_cache_point(self.store(), self.projection_grace_ms, 0, 0, account, asset).await
+    }
+}

+ 469 - 0
crates/kuatia/tests/projection.rs

@@ -0,0 +1,469 @@
+//! Balance-projection correctness (ADR-0019): the projection-aware read must
+//! always equal the authoritative live-posting sum, and the projector must
+//! advance the snapshot without changing the answer.
+
+#![allow(missing_docs)]
+
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia::mem_store::InMemoryStore;
+use kuatia_core::*;
+
+fn usd() -> AssetId {
+    AssetId::new(1)
+}
+
+fn account(id: i64) -> AccountId {
+    AccountId::new(id)
+}
+
+fn external() -> AccountId {
+    AccountId::new(99)
+}
+
+async fn no_overdraft(ledger: &Arc<Ledger>, id: i64) {
+    ledger
+        .store()
+        .create_account(Account::debit_must_not_exceed_credit(account(id)))
+        .await
+        .unwrap();
+}
+
+async fn overdraft(ledger: &Arc<Ledger>, id: i64) {
+    ledger
+        .store()
+        .create_account(Account::new(account(id)))
+        .await
+        .unwrap();
+}
+
+async fn deposit(ledger: &Arc<Ledger>, to: i64, amount: i64) {
+    let transfer = TransferBuilder::new()
+        .deposit(account(to), usd(), Cent::from(amount), external())
+        .unwrap()
+        .build();
+    ledger.commit(transfer).await.unwrap();
+}
+
+/// Current time as Unix milliseconds, the same clock the ledger stamps commits
+/// and cache-point watermarks with, so a test can pin a watermark to "now".
+fn unix_millis_now() -> i64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap()
+        .as_millis() as i64
+}
+
+async fn pay(ledger: &Arc<Ledger>, from: i64, to: i64, amount: i64) {
+    let transfer = TransferBuilder::new()
+        .pay(account(from), account(to), usd(), Cent::from(amount))
+        .build();
+    ledger.commit(transfer).await.unwrap();
+}
+
+/// Assert the projection-aware read equals the authoritative live-posting sum
+/// for every account, at every asset it might hold.
+async fn assert_projection_matches(ledger: &Arc<Ledger>, ids: &[i64]) {
+    for &id in ids {
+        let authoritative = ledger.compute_balance(&account(id), &usd()).await.unwrap();
+        let projected = ledger.balance(&account(id), &usd()).await.unwrap();
+        assert_eq!(
+            projected, authoritative,
+            "projected balance for account {id} diverged from the live sum"
+        );
+    }
+}
+
+/// With no projector run at all (every projection absent, so the read folds the
+/// whole history), the projection-aware read still equals the live sum through
+/// deposits, change-making pays, and an overdraft offset posting.
+#[tokio::test]
+async fn projected_balance_matches_live_sum_without_refresh() {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    no_overdraft(&ledger, 1).await;
+    no_overdraft(&ledger, 2).await;
+    overdraft(&ledger, 10).await;
+    overdraft(&ledger, 99).await;
+
+    deposit(&ledger, 1, 1000).await;
+    // Several pays fragment account 1 into many change postings.
+    pay(&ledger, 1, 2, 150).await;
+    pay(&ledger, 1, 2, 70).await;
+    pay(&ledger, 1, 2, 30).await;
+    pay(&ledger, 2, 1, 40).await;
+    // Overdraft: account 10 (empty balance) pays into a negative offset posting.
+    pay(&ledger, 10, 2, 500).await;
+
+    assert_projection_matches(&ledger, &[1, 2, 10, 99]).await;
+}
+
+/// Appending a cache point stores the balance directly, and the read still equals
+/// the live sum. Appending only after all commits keeps this deterministic: grace
+/// 0 puts the watermark at "now", so every committed transfer folds into the cache
+/// point with an empty tail.
+#[tokio::test]
+async fn append_cache_point_snapshots_balance_and_preserves_answer() {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()).with_projection_grace_ms(0));
+    no_overdraft(&ledger, 1).await;
+    no_overdraft(&ledger, 2).await;
+    overdraft(&ledger, 99).await;
+
+    deposit(&ledger, 1, 1000).await;
+    pay(&ledger, 1, 2, 250).await;
+    pay(&ledger, 1, 2, 100).await;
+
+    ledger
+        .append_cache_point(&account(1), &usd())
+        .await
+        .unwrap();
+
+    // The cache point holds account 1's balance (1000 - 250 - 100) directly.
+    let cache_point = ledger
+        .store()
+        .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
+        .await
+        .unwrap()
+        .expect("a cache point exists after append");
+    assert_eq!(cache_point.balance, Cent::from(650));
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        Cent::from(650)
+    );
+    assert_projection_matches(&ledger, &[1, 2]).await;
+}
+
+/// Commit never writes a cache point: after commits with no read, none exists.
+#[tokio::test]
+async fn commit_does_not_write_cache_point() {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    no_overdraft(&ledger, 1).await;
+    no_overdraft(&ledger, 2).await;
+    overdraft(&ledger, 99).await;
+
+    deposit(&ledger, 1, 1000).await;
+    pay(&ledger, 1, 2, 250).await;
+
+    // No read has happened, so the lazy append never fired.
+    assert!(
+        ledger
+            .store()
+            .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
+            .await
+            .unwrap()
+            .is_none()
+    );
+}
+
+/// A read appends a cache point once `snapshot_interval` credits/debits have
+/// accrued (the append is spawned in the background; on the current-thread test
+/// runtime it runs when we yield).
+#[tokio::test]
+async fn read_appends_cache_point_after_interval() {
+    let ledger = Arc::new(
+        Ledger::new(InMemoryStore::new())
+            .with_projection_grace_ms(0)
+            .with_snapshot_interval(1),
+    );
+    no_overdraft(&ledger, 1).await;
+    no_overdraft(&ledger, 2).await;
+    overdraft(&ledger, 99).await;
+
+    deposit(&ledger, 1, 1000).await;
+    pay(&ledger, 1, 2, 250).await;
+
+    // This read folds >= 1 credit/debit for account 1, so it spawns an append.
+    let _ = ledger.balance(&account(1), &usd()).await.unwrap();
+
+    // Let the background append run, then confirm a cache point exists.
+    let mut appeared = false;
+    for _ in 0..1000 {
+        tokio::task::yield_now().await;
+        if ledger
+            .store()
+            .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
+            .await
+            .unwrap()
+            .is_some()
+        {
+            appeared = true;
+            break;
+        }
+    }
+    assert!(
+        appeared,
+        "a read past the interval should append a cache point"
+    );
+    assert_eq!(
+        ledger.balance(&account(1), &usd()).await.unwrap(),
+        ledger.compute_balance(&account(1), &usd()).await.unwrap()
+    );
+}
+
+/// A read below the interval never appends a cache point: the background append
+/// gates on new credits/debits, so a low-activity account accrues no rows (this
+/// is what keeps a hot, frequently-read account from appending near-duplicates).
+#[tokio::test]
+async fn read_below_interval_appends_nothing() {
+    let ledger = Arc::new(
+        Ledger::new(InMemoryStore::new())
+            .with_projection_grace_ms(0)
+            .with_snapshot_interval(1_000),
+    );
+    no_overdraft(&ledger, 1).await;
+    no_overdraft(&ledger, 2).await;
+    overdraft(&ledger, 99).await;
+
+    deposit(&ledger, 1, 1000).await;
+    pay(&ledger, 1, 2, 250).await;
+
+    // A handful of credits/debits, far below the 1000 interval.
+    let bal = ledger.balance(&account(1), &usd()).await.unwrap();
+    assert_eq!(
+        bal,
+        ledger.compute_balance(&account(1), &usd()).await.unwrap()
+    );
+
+    // Even after the background task has every chance to run, no cache point was
+    // appended (the append gates on >= interval new credits/debits).
+    for _ in 0..1000 {
+        tokio::task::yield_now().await;
+    }
+    assert!(
+        ledger
+            .store()
+            .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
+            .await
+            .unwrap()
+            .is_none(),
+        "a below-interval read must not append a cache point"
+    );
+}
+
+/// Snapshot plus a genuinely non-empty tail. A cache point is pinned to the
+/// boundary between two commit batches, then more commits land strictly after
+/// its watermark, so the read must fold `snapshot + tail`: not a whole-history
+/// live sum (no snapshot) and not a snapshot that already holds everything (empty
+/// tail). This is the watermark boundary the grace-0 and no-snapshot tests never
+/// reach; an off-by-one at `watermark + 1` would drop or double-count a
+/// tail transfer here.
+#[tokio::test]
+async fn snapshot_plus_nonempty_tail_matches_live_sum() {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    no_overdraft(&ledger, 1).await;
+    no_overdraft(&ledger, 2).await;
+    overdraft(&ledger, 99).await;
+
+    // Batch 1: the snapshot will cover exactly this.
+    deposit(&ledger, 1, 1000).await;
+    pay(&ledger, 1, 2, 250).await;
+
+    // Pin a cache point at the batch-1/batch-2 boundary. Its balance is the
+    // authoritative batch-1 sum (folded by the ledger, not by hand); its watermark
+    // is a time at or after every batch-1 commit. Appended directly so the
+    // watermark is controlled, independent of the lazy read-path grace.
+    let snapshot = ledger.compute_balance(&account(1), &usd()).await.unwrap();
+    let watermark = unix_millis_now();
+    ledger
+        .store()
+        .append_balance_projection(&account(1), &usd(), snapshot, watermark)
+        .await
+        .unwrap();
+
+    // Cross the millisecond boundary so every batch-2 commit is stamped strictly
+    // after the watermark and lands in the tail, never the snapshot.
+    tokio::time::sleep(std::time::Duration::from_millis(3)).await;
+
+    // Batch 2: folded onto the snapshot as a non-empty tail (both credits and
+    // debits for account 1).
+    pay(&ledger, 1, 2, 100).await;
+    pay(&ledger, 2, 1, 40).await;
+    deposit(&ledger, 1, 500).await;
+
+    // The read is snapshot + folded tail, and still equals the live sum.
+    let projected = ledger.balance(&account(1), &usd()).await.unwrap();
+    let authoritative = ledger.compute_balance(&account(1), &usd()).await.unwrap();
+    assert_eq!(
+        projected, authoritative,
+        "snapshot + non-empty tail diverged from the live sum"
+    );
+    // Guard the test itself: the snapshot must have been partial, so the tail
+    // actually carried value. Otherwise this silently degrades to the empty-tail
+    // case the other tests already cover.
+    assert_ne!(
+        snapshot, authoritative,
+        "the snapshot already held the full balance; the tail was empty"
+    );
+    assert_projection_matches(&ledger, &[1, 2]).await;
+}
+
+/// Deterministic xorshift64 PRNG so the property test is reproducible.
+struct Rng(u64);
+impl Rng {
+    fn next(&mut self) -> u64 {
+        let mut x = self.0;
+        x ^= x << 13;
+        x ^= x >> 7;
+        x ^= x << 17;
+        self.0 = x;
+        x
+    }
+    fn below(&mut self, n: u64) -> u64 {
+        self.next() % n
+    }
+}
+
+/// Property test: across a long random sequence of every UTXO-shaped operation
+/// (deposits, change-making pays, overdraft offsets, multi-asset, subaccounts,
+/// and reversals), the projection-aware read equals the authoritative live-posting
+/// sum for every (account, asset) after every step. This is the empirical form of
+/// the telescoping argument: whole-posting spends plus change-as-new-posting make
+/// `snapshot + tail` fold exactly to the live set, in every shape the ledger can
+/// produce.
+#[tokio::test]
+async fn projection_matches_live_sum_across_random_utxo_history() {
+    // Default grace (60s) over a sub-second run keeps every watermark before all
+    // commits, so no cache point is appended and each read folds the full tail
+    // (equal to the live sum). That makes the run deterministic and free of
+    // same-millisecond append races; the invariant holds with or without a cache
+    // point. The snapshot-plus-non-empty-tail path (a real snapshot with commits
+    // folded on top) is covered deterministically by
+    // `snapshot_plus_nonempty_tail_matches_live_sum`.
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()).with_snapshot_interval(4));
+
+    // Accounts under test, including two subaccounts and both overdraft kinds.
+    let no_overdraft_ids = [
+        AccountId::new(1),
+        AccountId::new(2),
+        AccountId::new(3),
+        AccountId::with_sub(1, 7),
+    ];
+    let overdraft_ids = [
+        AccountId::new(10),
+        AccountId::new(11),
+        AccountId::with_sub(11, 3),
+    ];
+    for id in no_overdraft_ids {
+        ledger
+            .store()
+            .create_account(Account::debit_must_not_exceed_credit(id))
+            .await
+            .unwrap();
+    }
+    for id in overdraft_ids {
+        ledger
+            .store()
+            .create_account(Account::new(id))
+            .await
+            .unwrap();
+    }
+    let ext = external();
+    ledger
+        .store()
+        .create_account(Account::new(ext))
+        .await
+        .unwrap();
+
+    let accounts: Vec<AccountId> = no_overdraft_ids
+        .iter()
+        .chain(overdraft_ids.iter())
+        .copied()
+        .collect();
+    let assets = [AssetId::new(1), AssetId::new(2)];
+
+    let mut rng = Rng(0x9e3779b97f4a7c15);
+    let mut receipts: Vec<EnvelopeId> = Vec::new();
+
+    for _ in 0..300 {
+        let asset = assets[rng.below(assets.len() as u64) as usize];
+        match rng.below(5) {
+            // Deposit into a random account.
+            0 => {
+                let to = accounts[rng.below(accounts.len() as u64) as usize];
+                let amount = 1 + rng.below(500) as i64;
+                let t = TransferBuilder::new()
+                    .deposit(to, asset, Cent::from(amount), ext)
+                    .unwrap()
+                    .build();
+                if let Ok(r) = ledger.commit(t).await {
+                    receipts.push(r.transfer_id);
+                }
+            }
+            // Withdraw from a random account to the boundary.
+            1 => {
+                let from = accounts[rng.below(accounts.len() as u64) as usize];
+                let amount = 1 + rng.below(200) as i64;
+                let t = TransferBuilder::new()
+                    .withdraw(from, asset, Cent::from(amount), ext)
+                    .build();
+                if let Ok(r) = ledger.commit(t).await {
+                    receipts.push(r.transfer_id);
+                }
+            }
+            // Reverse a previously committed transfer.
+            2 if !receipts.is_empty() => {
+                let id = receipts[rng.below(receipts.len() as u64) as usize];
+                if let Ok(r) = ledger.reverse(&id).await {
+                    receipts.push(r.transfer_id);
+                }
+            }
+            // Pay between two random accounts (change / overdraft / cross-subaccount).
+            _ => {
+                let from = accounts[rng.below(accounts.len() as u64) as usize];
+                let to = accounts[rng.below(accounts.len() as u64) as usize];
+                if from == to {
+                    continue;
+                }
+                let amount = 1 + rng.below(300) as i64;
+                let t = TransferBuilder::new()
+                    .pay(from, to, asset, Cent::from(amount))
+                    .build();
+                if let Ok(r) = ledger.commit(t).await {
+                    receipts.push(r.transfer_id);
+                }
+            }
+        }
+
+        // Invariant: at rest after every step, the projection-aware read equals
+        // the authoritative live-posting sum for every (account, asset).
+        for account in accounts.iter().chain(std::iter::once(&ext)) {
+            for asset in &assets {
+                let authoritative = ledger.compute_balance(account, asset).await.unwrap();
+                let projected = ledger.balance(account, asset).await.unwrap();
+                assert_eq!(
+                    projected, authoritative,
+                    "projected != live sum for {account:?} / {asset:?}"
+                );
+            }
+        }
+    }
+}
+
+/// Concurrent commits from one funded account: after the dust settles, the
+/// projection-aware read agrees with the live sum for every participant.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn projection_matches_under_concurrent_commits() {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    no_overdraft(&ledger, 1).await;
+    for id in 2..=9 {
+        no_overdraft(&ledger, id).await;
+    }
+    overdraft(&ledger, 99).await;
+    deposit(&ledger, 1, 1000).await;
+
+    let mut handles = Vec::new();
+    for payee in 2..=9 {
+        let ledger = Arc::clone(&ledger);
+        handles.push(tokio::spawn(async move {
+            let transfer = TransferBuilder::new()
+                .pay(account(1), account(payee), usd(), Cent::from(10))
+                .build();
+            let _ = ledger.commit(transfer).await;
+        }));
+    }
+    for h in handles {
+        h.await.unwrap();
+    }
+
+    assert_projection_matches(&ledger, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 99]).await;
+}

+ 180 - 0
doc/adr/0019-cached-balance-projection.md

@@ -0,0 +1,180 @@
+# Cached balance projection maintained by a projection service
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-17
+* Targeted modules: `kuatia` (`ledger/balance.rs`, `ledger/projection.rs`),
+  `kuatia-storage` (`Store`), `kuatia-storage-sql` (a new `balance_projection`
+  table). The chosen outcome leaves the commit path untouched and adds no lease
+  table or projector service (see Decision Outcome).
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+Balance is never stored; `compute_balance` (`crates/kuatia/src/ledger/balance.rs`)
+fetches every live posting for a `(account, subaccount, asset)` and sums it in
+Rust on every read. Under the signed-posting UTXO model (ADR-0001) each `pay`
+mints a change posting and nothing coalesces or caps the active set, so a read
+is `O(N live postings)` and `N` grows unbounded for hot accounts. The commit-time
+overdraft check pays the same cost.
+
+We want fast reads without weakening correctness, without doing arithmetic in the
+store, and without a central authority handing out sequence numbers.
+
+## Decision Drivers
+
+* **Read cost**: remove the unbounded `O(N live postings)` sum from the read path.
+* **Correctness independent of the projector**: reads must be correct even if the
+  projection service is stopped, lagging, crashed, or running many instances.
+* **No math in the store**: the store keeps a scalar and serves rows; all summing
+  happens in Rust, in the service (honours the arithmetic-in-Rust rule).
+* **No central sequence authority**: keep decentralized, time-ordered snowflake
+  ids (AutoId); do not add a global counter every commit must serialize on.
+* **Authority stays with the postings**: the append-only postings remain the
+  source of truth (ADR-0017); the cache is a disposable, rebuildable projection,
+  never authoritative for the validate-time overdraft check.
+
+## Key insight (why no sequence is needed)
+
+A balance fold is commutative: `snapshot + Σcreates − Σconsumes`. Applying
+committed transfers in any order yields the same total, so the cursor needs only
+**completeness** (never miss a committed transfer) and **exactly-once** (never
+double-apply), never a total order.
+
+Correctness is carried entirely by the read rule:
+
+```
+balance(account, asset) = projection.snapshot
+                        + Σ over committed transfers with commit_time > projection.watermark
+                              of creates(+) / consumes(−) for (account, asset)
+```
+
+Both `creates` and `consumes` live on each committed `EnvelopeRecord`, and
+`transfer_accounts` indexes every transfer under its involved set (created ∪
+consumed owners), so a single per-account stream contains both the create and the
+spend. This read is correct whatever the projector is doing; the projector only
+shortens the tail.
+
+## Considered Options
+
+#### Option 1: Status quo (sum live postings on every read)
+
+* Bad, because reads and the validate path are `O(N live postings)`, unbounded
+  under fragmentation.
+
+#### Option 2: Central strictly-increasing commit sequence
+
+* Bad, because it needs a coordination point every commit serializes on, and has
+  an assignment-order-vs-visibility hazard (a slow `seq=5` visible after `seq=6`
+  is skipped by a reader past 6). Rejected: it reintroduces the central authority
+  we are avoiding.
+
+#### Option 3: Projection service + per-account locking + commit-time watermark
+
+An out-of-store service maintains a `balance_projection` cache. Reads are
+lock-free (`snapshot + tail`). The service rebuilds an account's snapshot under a
+per-account lease; the watermark is a commit-time cutoff with a grace window, so
+nothing new ever appears below it. No sequence.
+
+* Good, because correctness is independent of the service (reads always work).
+* Good, because there is no central authority: ordering is the existing
+  decentralized AutoId time order, and coordination is per-account, not global.
+* Good, because it fits ADR-0017 (a disposable, rebuildable hot index) and keeps
+  arithmetic out of the store.
+* Bad, because it is the first aggregate cache, adds a lease/coordination
+  primitive, and rests on a bounded commit-to-visibility lag assumption (with the
+  reconciliation rebuild as the backstop).
+
+## Decision Outcome
+
+Chosen option: **append-only cache points with a lazy, volume-based trigger** (a
+refinement of Option 3 that keeps its watermark-with-grace and lock-free reads
+but drops the service, lease, and CAS in favor of append-only rows and a
+read-triggered append). Concretely:
+
+1. **Append-only `balance_projection` table.** One row per cache point:
+   `(id, account, subaccount, asset, balance, watermark)`, where `id` is a
+   Rust-minted monotonic snowflake and `balance` is a `Cent` stored as TEXT.
+   Rows are only ever inserted, never updated; a cache point is history. This is
+   the append-only value-table pattern (ADR-0017).
+
+2. **Reads select the closest cache point at or before the target time.**
+   `get_closest_balance_projection(account, asset, as_of)` returns the row with
+   the largest `watermark ≤ as_of` (tie-broken by highest `id`), so a read never
+   uses a snapshot that covers transfers committed after `as_of`. The ledger
+   passes the current time by default; a past `as_of` yields an as-of balance.
+
+3. **Commit-time watermark with grace.** A cache point's watermark is
+   `now − Δ`, measured in commit/visibility time (`EnvelopeRecord.created_at`,
+   stamped at `store_transfer`), never intent time, so nothing new appears below
+   it. `Δ` is not business latency: an inflight hold that settles hours later
+   commits (and is stamped) at settlement time, landing in the tail. Default: 60
+   seconds, configurable.
+
+4. **Lazy, volume-based trigger.** No commit hook, no background loop. When a
+   read folds the tail and finds at least `snapshot_interval` credits/debits
+   (created + consumed postings owned by the account) accrued since the closest
+   cache point, it appends a new one off the read path (a background task). The
+   threshold is configurable (default 128).
+
+5. **Append-only makes coordination free.** Concurrent appends just add rows; a
+   read takes the closest-at-or-before, so there is no lock, CAS, or lease. A
+   redundant or slightly-stale append is harmless history that a later read
+   ignores. Reads return `closest snapshot + Rust-folded tail` and are correct
+   regardless of whether any cache point exists; `compute_balance` (the
+   authoritative full live-posting sum) is retained and remains what the validate
+   path reads.
+
+6. **Authority unchanged; UTXO stays the concurrency control.** The
+   validate-time overdraft check keeps reading the authoritative live-posting
+   (UTXO) sum, never the cache. The projection is a read accelerator only; a
+   stale snapshot must never admit an overdraft. This matters most for an account
+   with `DEBIT_MUST_NOT_EXCEED_CREDIT`: the signed-posting UTXO model plus the
+   reservation protocol (ADR-0006/0016) is what makes the no-overdraft invariant
+   exact under concurrency. A commit atomically reserves the specific postings it
+   spends, so two concurrent debits cannot both consume the same value, and the
+   balance floor is enforced against the real live set at commit time. An
+   eventually-consistent cache cannot provide that guarantee, so the UTXO path is
+   retained internally as the concurrency-control mechanism for these accounts,
+   with the projection layered on top purely to speed up reporting reads.
+
+7. **Reconciliation.** Appending a cache point (or reading with a very small
+   interval) folds from the append-only postings and detects no drift by
+   construction, since a read always equals the live sum. `compute_balance` is
+   the from-scratch recompute available for an explicit audit.
+
+### Positive Consequences
+
+* Balance reads drop from `O(N live postings)` toward `O(tail since the latest
+  cache point)`, bounded by how recently a read last appended, not by lifetime
+  fragmentation.
+* Correctness lives in "closest snapshot + full tail" over the append-only log;
+  the cache points are pure optimization. Best-effort and crash-safe by
+  construction: a lost or absent cache point only lengthens a tail.
+* No central sequence, no lease, no background service; append-only rows plus
+  closest-at-or-before selection need no coordination.
+
+### Negative Consequences
+
+* First aggregate cache: more surface than the row-copy hot indexes.
+* Append-only cache points accumulate; pruning old ones (by count or age) is
+  future work.
+* A write-heavy account that is never read never appends a cache point, so its
+  first read folds a long tail (then appends). Acceptable: the cost tracks reads.
+* Rests on a bounded commit-to-visibility lag assumption (the grace `Δ`); the
+  watermark must be commit-time, not intent time, or a late settlement below the
+  watermark would be lost.
+* The projection must never become authoritative for the overdraft check.
+
+## Links
+
+* Builds on [ADR-0017](0017-correctness-first-append-only-hot-indexes.md)
+  (disposable, rebuildable hot indexes) and
+  [ADR-0016](0016-immutable-postings-index-tables.md).
+* Balance model: [ADR-0001](0001-modified-utxo-signed-postings.md).
+* Dumb-storage primitives and recovery:
+  [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Inflight settlement timing (why the watermark is commit-time):
+  [ADR-0014](0014-inflight-holds-via-holding-accounts.md).
+* Background: [accounts.md](../accounts.md), balance read path in
+  `crates/kuatia/src/ledger/balance.rs`.

+ 1 - 0
doc/adr/README.md

@@ -28,6 +28,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0016](0016-immutable-postings-index-tables.md) | Immutable postings with active/reserved index tables | accepted (hot-table representation refined by 0017) | Postings become an insert-only immutable table; lifecycle state moves to two index tables (`active_postings`, `reserved_postings`). Append-only integrity + least privilege (no `UPDATE`) + hot working set by segregation. Supersedes the storage representation of 0006. |
 | [0017](0017-correctness-first-append-only-hot-indexes.md) | Correctness-first storage: append-only value tables, disposable hot indexes | accepted | The guiding principle: value/audit tables (`postings`, `accounts`) are strictly append-only source of truth; disposable hot tables (`active_postings`, `reserved_postings`, `account_head`) index the live subset by `INSERT`/`DELETE` only and are rebuildable. Correctness first; no `UPDATE` anywhere, enforceable by DB grants. Generalizes 0016 (hot tables now hold full row copies). |
 | [0018](0018-single-debit-must-not-exceed-credit-flag.md) | Collapse account policy into a single debit-must-not-exceed-credit flag | accepted | The `AccountPolicy` enum is removed; the only per-account balance constraint is the `AccountFlags` bit `DEBIT_MUST_NOT_EXCEED_CREDIT`. Overdraft is allowed by default (unbounded); the flag forbids a negative balance and negative postings. Credit-line floors become an application concern. Supersedes 0004. |
+| [0019](0019-cached-balance-projection.md) | Cached balance projection via append-only cache points | accepted | Append-only `balance_projection` cache points (id, watermark, balance); a read takes the closest one at or before `now` and folds the tail of committed movements since its commit-time watermark (grace Δ). Correctness never depends on the cache (equals the live sum at rest). Cache points are appended lazily on read once N credits/debits accrue (configurable); no per-commit hook, no lease, no background loop. |
 
 ## Recommended future ADRs