1
0

16 Revīzijas e8859de701 ... 94cfe03ec9

Autors SHA1 Ziņojums Datums
  Cesar Rodas 94cfe03ec9 Collapse the shallow envelope saga into the commit path 2 mēneši atpakaļ
  Cesar Rodas fe86d7ee1a Merge branch 'refactor/write-ahead-record-owner' 2 mēneši atpakaļ
  Cesar Rodas 1146f97f5f Merge active and reserved postings into one live_postings hot index 2 mēneši atpakaļ
  Cesar Rodas c37a4bacf1 Break the kuatia-types and storage-sql god-modules into files 2 mēneši atpakaļ
  Cesar Rodas cb5cf819cf Give the commit write-ahead record a single owner 2 mēneši atpakaļ
  Cesar Rodas d4a6bff209 Release 0.3.0 2 mēneši atpakaļ
  Cesar Rodas 04193969e3 Cover the Postgres backend and i128 backing in CI, and fill the changelog 2 mēneši atpakaļ
  Cesar Rodas 2101d2ed49 Give the balance concept a single home 2 mēneši atpakaļ
  Cesar Rodas d460dc2505 Give the write-ahead recovery record a single home 2 mēneši atpakaļ
  Cesar Rodas 068e1624c7 Give the commit-safety invariant a single audit anchor 2 mēneši atpakaļ
  Cesar Rodas 90f7bea3b4 Import items rather than spell out fully-qualified paths inline 2 mēneši atpakaļ
  Cesar Rodas 3e040dba69 Single-source the overdraft decision across resolve and validate 2 mēneši atpakaļ
  Cesar Rodas 0b65c01f55 Bring doc comments in line with the current code 2 mēneši atpakaļ
  Cesar Rodas 319be8332d Narrow the saga/commit seam now that the ledger is never re-injected 2 mēneši atpakaļ
  Cesar Rodas 69be1c6038 Unify the storage seam on the affected-row-count contract 3 mēneši atpakaļ
  Cesar Rodas 111880f294 Relocate the insufficient-funds error beside the selection code 3 mēneši atpakaļ
61 mainītis faili ar 5335 papildinājumiem un 3668 dzēšanām
  1. 40 1
      .github/workflows/ci.yml
  2. 46 0
      CHANGELOG.md
  3. 7 7
      Cargo.lock
  4. 6 6
      Cargo.toml
  5. 5 4
      crates/kuatia-core/src/lib.rs
  6. 87 48
      crates/kuatia-core/src/posting_resolution.rs
  7. 0 38
      crates/kuatia-core/src/posting_selection.rs
  8. 254 9
      crates/kuatia-core/src/validate.rs
  9. 2 2
      crates/kuatia-dashboard/Cargo.toml
  10. 11 12
      crates/kuatia-dashboard/src/data.rs
  11. 2 1
      crates/kuatia-dashboard/src/main.rs
  12. 7 6
      crates/kuatia-dashboard/src/seed.rs
  13. 9 9
      crates/kuatia-dashboard/src/ui.rs
  14. 4 3
      crates/kuatia-money/src/lib.rs
  15. 5 0
      crates/kuatia-storage-sql/Cargo.toml
  16. 206 0
      crates/kuatia-storage-sql/src/account.rs
  17. 82 0
      crates/kuatia-storage-sql/src/book.rs
  18. 39 0
      crates/kuatia-storage-sql/src/dialect.rs
  19. 89 0
      crates/kuatia-storage-sql/src/event.rs
  20. 35 1457
      crates/kuatia-storage-sql/src/lib.rs
  21. 97 0
      crates/kuatia-storage-sql/src/migrate.rs
  22. 29 0
      crates/kuatia-storage-sql/src/migrations/008_live_postings.sql
  23. 501 0
      crates/kuatia-storage-sql/src/posting.rs
  24. 81 0
      crates/kuatia-storage-sql/src/projection.rs
  25. 124 0
      crates/kuatia-storage-sql/src/row.rs
  26. 70 0
      crates/kuatia-storage-sql/src/saga.rs
  27. 232 0
      crates/kuatia-storage-sql/src/transfer.rs
  28. 55 0
      crates/kuatia-storage-sql/tests/postgres.rs
  29. 109 5
      crates/kuatia-storage-sql/tests/sqlite.rs
  30. 8 27
      crates/kuatia-storage/src/error.rs
  31. 26 21
      crates/kuatia-storage/src/mem_store.rs
  32. 21 6
      crates/kuatia-storage/src/store.rs
  33. 44 25
      crates/kuatia-storage/src/store_tests.rs
  34. 227 0
      crates/kuatia-types/src/account.rs
  35. 6 4
      crates/kuatia-types/src/account_code.rs
  36. 81 0
      crates/kuatia-types/src/book.rs
  37. 114 0
      crates/kuatia-types/src/envelope.rs
  38. 233 0
      crates/kuatia-types/src/ids.rs
  39. 17 890
      crates/kuatia-types/src/lib.rs
  40. 100 0
      crates/kuatia-types/src/posting.rs
  41. 136 0
      crates/kuatia-types/src/transfer.rs
  42. 29 13
      crates/kuatia/src/error.rs
  43. 49 223
      crates/kuatia/src/inflight.rs
  44. 577 0
      crates/kuatia/src/inflight/projection.rs
  45. 1 9
      crates/kuatia/src/ledger.rs
  46. 246 9
      crates/kuatia/src/ledger/balance.rs
  47. 152 299
      crates/kuatia/src/ledger/commit.rs
  48. 0 8
      crates/kuatia/src/ledger/envelope_saga.rs
  49. 5 3
      crates/kuatia/src/ledger/lifecycle.rs
  50. 265 0
      crates/kuatia/src/ledger/pending.rs
  51. 0 231
      crates/kuatia/src/ledger/projection.rs
  52. 21 29
      crates/kuatia/src/ledger/query.rs
  53. 18 41
      crates/kuatia/src/ledger/transition.rs
  54. 2 2
      crates/kuatia/src/lib.rs
  55. 138 208
      crates/kuatia/src/saga.rs
  56. 1 4
      crates/kuatia/tests/saga.rs
  57. 86 0
      doc/adr/0021-commit-safety-map.md
  58. 151 0
      doc/adr/0022-merged-live-postings-hot-index.md
  59. 1 0
      doc/architecture.md
  60. 15 8
      doc/glossary.md
  61. 331 0
      doc/storage-schema.md

+ 40 - 1
.github/workflows/ci.yml

@@ -43,10 +43,49 @@ jobs:
       - run: cargo doc --workspace --all-features --no-deps
 
   test:
-    name: Test
+    name: Test (SQLite, i64)
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
       - uses: dtolnay/rust-toolchain@stable
       - uses: Swatinem/rust-cache@v2
+      # Default features: the SQLite backend with the i64 Cent backing.
       - run: cargo test --all
+
+  test-i128:
+    name: Test (i128 backing)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      # Swap the Cent backing to i128 across the whole chain. Dashboard has no
+      # i128 feature, so exclude it from the feature-scoped run.
+      - run: cargo test --workspace --exclude kuatia-dashboard --features i128
+
+  test-postgres:
+    name: Test (PostgreSQL)
+    runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgres:16
+        env:
+          POSTGRES_USER: kuatia
+          POSTGRES_PASSWORD: kuatia
+          POSTGRES_DB: kuatia
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U kuatia"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+    env:
+      DATABASE_URL: postgres://kuatia:kuatia@localhost:5432/kuatia
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - uses: Swatinem/rust-cache@v2
+      # Run only the SQL backend's conformance suite against real Postgres, so
+      # the Postgres-only code paths (FOR UPDATE locks, ON CONFLICT) are covered.
+      - run: cargo test -p kuatia-storage-sql --no-default-features --features test-postgres

+ 46 - 0
CHANGELOG.md

@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+## [0.3.0] - 2026-07-30
+
+### Added
+
+- Subaccounts: `AccountId` gains a subaccount leg (`{ id, sub }`), so several
+  independent balances live under one owner, each individually addressable,
+  drained, and closed. Aggregate reads take a base id plus an optional
+  subaccount filter; exact operations take the full `AccountId`; balances are
+  never summed across subaccounts. (ADR-0012)
+- Inflight holds: authorize, confirm, and void value through per-destination
+  holding subaccounts, so an authorization reserves funds without committing
+  them and a void returns them. (ADR-0014)
+- IBAN-style account identifiers: `AccountId` has a fixed 20-character
+  `Display`/`FromStr` form (a base-36 body plus two mod-97 check digits) for
+  presentation and routing, while storage keeps the two integer legs. (ADR-0015)
+- Cached balance projection: an append-only balance snapshot, refreshed lazily
+  off the read path once enough activity accrues, shortens the everyday balance
+  read; the authoritative live-posting sum stays the source of truth. (ADR-0019)
+- `SagaStore::get_saga`, a keyed read for a single write-ahead record, so
+  recovery no longer scans every pending record.
+- Continuous integration now runs the storage conformance suite against
+  PostgreSQL (exercising the Postgres-only `FOR UPDATE` and `ON CONFLICT` paths)
+  and runs the test suite with the i128 Cent backing.
+
+### Changed
+
+- Storage write contract: every `Store` write method returns the number of
+  affected rows and makes no domain decision. The saga interprets counts and
+  owns idempotency and compensation, with a phase-tracked write-ahead record and
+  roll-forward crash recovery in place of a monolithic commit transaction.
+  (ADR-0003) *(breaking for `Store` implementors)*
+- Account balance constraint collapsed to the single
+  `DEBIT_MUST_NOT_EXCEED_CREDIT` flag; overdraft is allowed by default. (ADR-0018)
+- The SQL schema separates append-only value tables from disposable
+  active/reserved index tables. (ADR-0016, ADR-0017)
+- Intent resolution is pure and preserves typed errors across the saga.
+- Internal structure: the `Ledger` was split into concern-named submodules; the
+  commit-safety invariant is documented and its double-spend guard named
+  (ADR-0021); the write-ahead recovery record was concentrated into one
+  `pending` module; balance computation was consolidated into one module.
+
+### Removed
+
+- The vestigial `UserData` type and the orphaned withdraw saga step. *(breaking)*
+
 ### Documentation
 
 - Document that the ledger supports journaling: a committed transfer is a
@@ -65,5 +110,6 @@ Initial release.
 - `kuatia-storage-sql` — SQLite/PostgreSQL backend.
 - `kuatia` — async `Ledger` resource and saga commit pipeline.
 
+[0.3.0]: https://github.com/crodas/kuatia/releases/tag/v0.3.0
 [0.2.0]: https://github.com/crodas/kuatia/releases/tag/v0.2.0
 [0.1.0]: https://github.com/crodas/kuatia/releases/tag/v0.1.0

+ 7 - 7
Cargo.lock

@@ -1030,7 +1030,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "async-trait",
  "ciborium",
@@ -1048,7 +1048,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia-core"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "kuatia-types",
  "serde",
@@ -1057,7 +1057,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia-dashboard"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "axum",
  "clap",
@@ -1077,7 +1077,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia-money"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "serde",
  "serde_json",
@@ -1085,7 +1085,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia-storage"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "async-trait",
  "kuatia-types",
@@ -1096,7 +1096,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia-storage-sql"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "async-trait",
  "kuatia-storage",
@@ -1110,7 +1110,7 @@ dependencies = [
 
 [[package]]
 name = "kuatia-types"
-version = "0.2.0"
+version = "0.3.0"
 dependencies = [
  "bitflags",
  "kuatia-money",

+ 6 - 6
Cargo.toml

@@ -3,7 +3,7 @@ resolver = "2"
 members = ["crates/kuatia-money", "crates/kuatia-types", "crates/kuatia-core", "crates/kuatia-storage", "crates/kuatia-storage-sql", "crates/kuatia", "crates/kuatia-dashboard"]
 
 [workspace.package]
-version = "0.2.0"
+version = "0.3.0"
 edition = "2024"
 rust-version = "1.85"
 license = "Apache-2.0"
@@ -14,11 +14,11 @@ categories = ["finance", "database-implementations"]
 
 [workspace.dependencies]
 # Internal crates
-kuatia-money = { path = "crates/kuatia-money", version = "0.2.0" }
-kuatia-types = { path = "crates/kuatia-types", version = "0.2.0" }
-kuatia-core = { path = "crates/kuatia-core", version = "0.2.0" }
-kuatia-storage = { path = "crates/kuatia-storage", version = "0.2.0" }
-kuatia-storage-sql = { path = "crates/kuatia-storage-sql", version = "0.2.0" }
+kuatia-money = { path = "crates/kuatia-money", version = "0.3.0" }
+kuatia-types = { path = "crates/kuatia-types", version = "0.3.0" }
+kuatia-core = { path = "crates/kuatia-core", version = "0.3.0" }
+kuatia-storage = { path = "crates/kuatia-storage", version = "0.3.0" }
+kuatia-storage-sql = { path = "crates/kuatia-storage-sql", version = "0.3.0" }
 
 # External crates
 serde = { version = "1", features = ["derive"] }

+ 5 - 4
crates/kuatia-core/src/lib.rs

@@ -6,7 +6,6 @@
 
 pub mod hash;
 pub mod posting_resolution;
-pub mod posting_selection;
 pub mod validate;
 
 pub use kuatia_types::*;
@@ -16,7 +15,9 @@ pub use hash::{
     double_sha256, envelope_id,
 };
 pub use posting_resolution::{
-    Debit, MovementDraft, ResolveError, ResolveInput, draft_movements, resolve_envelope,
+    Debit, InsufficientFunds, MovementDraft, ResolveError, ResolveInput, draft_movements,
+    resolve_envelope,
+};
+pub use validate::{
+    Plan, PlanInput, RequiredState, ValidationError, required_state, validate_and_plan,
 };
-pub use posting_selection::SelectionError;
-pub use validate::{Plan, PlanInput, ValidationError, validate_and_plan};

+ 87 - 48
crates/kuatia-core/src/posting_resolution.rs

@@ -5,38 +5,67 @@
 //!
 //! 1. [`draft_movements`] aggregates movements into output postings and
 //!    per-(account, asset) net debits. It tells the async layer exactly which
-//!    postings to load and which debit accounts permit overdraft.
+//!    postings to load and which accounts to fetch.
 //! 2. [`resolve_envelope`] selects postings for each debit, computes change, and
-//!    covers an overdraft shortfall with a negative offset posting.
+//!    covers an overdraft shortfall with a negative offset posting. Whether an
+//!    account may overdraw is read straight from its [`Account::forbids_overdraft`]
+//!    flag, the same accessor validation reads, so the two passes cannot disagree.
 //!
 //! The async ledger loads state; this module decides. The change-making and
 //! shortfall branches are the parts most worth property-testing, and living here
 //! they are reachable without standing up a store.
 
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
+use std::error::Error;
+use std::fmt;
 
-use crate::posting_selection::SelectionError;
 use kuatia_types::{
-    AccountId, AssetId, Cent, Envelope, EnvelopeBuilder, NewPosting, OverflowError, Posting,
-    PostingId, Transfer,
+    Account, AccountId, AssetId, Cent, Envelope, EnvelopeBuilder, NewPosting, OverflowError,
+    Posting, PostingId, Transfer,
 };
 
 // ---------------------------------------------------------------------------
 // Errors
 // ---------------------------------------------------------------------------
 
+/// Available postings do not cover a requested amount.
+///
+/// When a caller uses `pay` or `withdraw` they specify an amount, not which
+/// postings to consume. Selection picks the postings; when a non-overdraft
+/// account cannot cover the amount it fails with this. Selection has this one
+/// failure mode, so it is a struct, not an enum; overflow is reported separately
+/// by [`ResolveError`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InsufficientFunds {
+    /// Total value of eligible postings.
+    pub available: Cent,
+    /// Amount the caller asked for.
+    pub requested: Cent,
+}
+
+impl fmt::Display for InsufficientFunds {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "insufficient funds: available {}, requested {}",
+            self.available, self.requested
+        )
+    }
+}
+
+impl Error for InsufficientFunds {}
+
 /// Failure from resolution pass 2 ([`resolve_envelope`]).
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum ResolveError {
-    /// Posting selection failed: insufficient funds for a non-overdraft account,
-    /// or an overflow while summing candidate postings.
-    Selection(SelectionError),
+    /// Posting selection failed: insufficient funds for a non-overdraft account.
+    Selection(InsufficientFunds),
     /// Monetary arithmetic overflowed while computing change or a shortfall.
     Overflow,
 }
 
-impl std::fmt::Display for ResolveError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl fmt::Display for ResolveError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::Selection(e) => write!(f, "selection: {e}"),
             Self::Overflow => write!(f, "monetary amount overflow"),
@@ -44,8 +73,8 @@ impl std::fmt::Display for ResolveError {
     }
 }
 
-impl std::error::Error for ResolveError {
-    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+impl Error for ResolveError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
         match self {
             Self::Selection(e) => Some(e),
             Self::Overflow => None,
@@ -53,12 +82,6 @@ impl std::error::Error for ResolveError {
     }
 }
 
-impl From<SelectionError> for ResolveError {
-    fn from(e: SelectionError) -> Self {
-        Self::Selection(e)
-    }
-}
-
 impl From<OverflowError> for ResolveError {
     fn from(_: OverflowError) -> Self {
         Self::Overflow
@@ -130,8 +153,8 @@ pub fn draft_movements(transfer: &Transfer) -> Result<MovementDraft, OverflowErr
 // ---------------------------------------------------------------------------
 
 /// Pre-loaded state for resolution pass 2. The async layer gathers `available`
-/// and `overdraft_allowed` for the debits produced by [`draft_movements`]; this
-/// pass is pure.
+/// and `accounts` for the debits produced by [`draft_movements`]; this pass is
+/// pure.
 pub struct ResolveInput<'a> {
     /// The transfer being resolved (for its book and metadata).
     pub transfer: &'a Transfer,
@@ -141,13 +164,14 @@ pub struct ResolveInput<'a> {
     /// Active postings available for each debit's (account, asset). A missing or
     /// empty entry means no positive postings to draw on.
     pub available: &'a HashMap<(AccountId, AssetId), Vec<Posting>>,
-    /// Accounts that permit overdraft (i.e. that do *not* carry
-    /// `DEBIT_MUST_NOT_EXCEED_CREDIT`). A debit short of positive postings gets a
-    /// negative offset posting only if its account is in this set; otherwise it
-    /// fails with [`SelectionError::InsufficientFunds`]. A missing account is
-    /// treated as forbidding overdraft, so an unknown account never gets an
-    /// offset position on unknown terms.
-    pub overdraft_allowed: &'a HashSet<AccountId>,
+    /// The debit accounts, keyed by id. A debit short of positive postings gets a
+    /// negative offset posting only if its account permits overdraft (does *not*
+    /// carry `DEBIT_MUST_NOT_EXCEED_CREDIT`, read via
+    /// [`Account::forbids_overdraft`]); otherwise it fails with
+    /// [`InsufficientFunds`]. This is the same flag validation reads, so resolve
+    /// and validate cannot disagree. A missing account is treated as forbidding
+    /// overdraft, so an unknown account never gets an offset posting.
+    pub accounts: &'a HashMap<AccountId, Account>,
 }
 
 /// Pass 2: for each debit, either select postings and compute change, or (for an
@@ -160,7 +184,7 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
         transfer,
         draft,
         available,
-        overdraft_allowed,
+        accounts,
     } = input;
     let MovementDraft {
         mut creates,
@@ -209,8 +233,12 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
         } else {
             // Not enough positive postings. An account that permits overdraft
             // covers the shortfall with a negative posting (an offset position);
-            // one that forbids it — or an unknown account — fails.
-            if overdraft_allowed.contains(&debit.account) {
+            // one that forbids it, or an unknown account, fails. The decision is
+            // the account's own flag, the same one validation reads.
+            let permits_overdraft = accounts
+                .get(&debit.account)
+                .is_some_and(|a| !a.forbids_overdraft());
+            if permits_overdraft {
                 let positives: Vec<PostingId> = avail
                     .iter()
                     .filter(|p| p.value.is_positive())
@@ -225,7 +253,7 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
                     payer: None,
                 });
             } else {
-                return Err(ResolveError::Selection(SelectionError::InsufficientFunds {
+                return Err(ResolveError::Selection(InsufficientFunds {
                     available: total_positive,
                     requested: debit.amount,
                 }));
@@ -268,6 +296,15 @@ mod tests {
             .build()
     }
 
+    fn accounts(list: impl IntoIterator<Item = Account>) -> HashMap<AccountId, Account> {
+        list.into_iter().map(|a| (a.id, a)).collect()
+    }
+
+    /// An account that forbids overdraft (carries `DEBIT_MUST_NOT_EXCEED_CREDIT`).
+    fn no_overdraft(id: AccountId) -> Account {
+        Account::debit_must_not_exceed_credit(id)
+    }
+
     #[test]
     fn draft_aggregates_net_debit_and_output() {
         let draft = draft_movements(&pay(acct(1), acct(2), 100)).unwrap();
@@ -310,12 +347,12 @@ mod tests {
             (acct(1), AssetId::new(1)),
             vec![posting(acct(1), 0, 60), posting(acct(1), 1, 40)],
         )]);
-        let overdraft_allowed = HashSet::new();
+        let accounts = accounts([no_overdraft(acct(1))]);
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap();
         assert_eq!(env.consumes().len(), 2);
@@ -330,12 +367,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let available =
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 100)])]);
-        let overdraft_allowed = HashSet::new();
+        let accounts = accounts([no_overdraft(acct(1))]);
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap();
         assert_eq!(env.consumes().len(), 1);
@@ -363,12 +400,12 @@ mod tests {
                 posting(acct(1), 2, 50),
             ],
         )]);
-        let overdraft_allowed = HashSet::new();
+        let accounts = accounts([no_overdraft(acct(1))]);
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap();
         assert_eq!(env.consumes().len(), 1);
@@ -387,17 +424,17 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let available =
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 40)])]);
-        let overdraft_allowed = HashSet::new();
+        let accounts = accounts([no_overdraft(acct(1))]);
         let err = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap_err();
         assert_eq!(
             err,
-            ResolveError::Selection(SelectionError::InsufficientFunds {
+            ResolveError::Selection(InsufficientFunds {
                 available: Cent::from(40),
                 requested: Cent::from(100),
             })
@@ -409,17 +446,19 @@ mod tests {
         let transfer = pay(acct(1), acct(2), 100);
         let draft = draft_movements(&transfer).unwrap();
         let available = HashMap::new();
-        let overdraft_allowed = HashSet::new();
+        // The payer account is absent from the map, so it must be treated as
+        // forbidding overdraft.
+        let accounts = accounts([]);
         let err = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap_err();
         assert_eq!(
             err,
-            ResolveError::Selection(SelectionError::InsufficientFunds {
+            ResolveError::Selection(InsufficientFunds {
                 available: Cent::ZERO,
                 requested: Cent::from(100),
             })
@@ -432,12 +471,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let available =
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 30)])]);
-        let overdraft_allowed = HashSet::from([acct(1)]);
+        let accounts = accounts([Account::new(acct(1))]);
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap();
         // The single positive posting is consumed.
@@ -459,12 +498,12 @@ mod tests {
         let transfer = pay(acct(1), acct(2), 100);
         let draft = draft_movements(&transfer).unwrap();
         let available = HashMap::new();
-        let overdraft_allowed = HashSet::from([acct(1)]);
+        let accounts = accounts([Account::new(acct(1))]);
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })
         .unwrap();
         assert!(env.consumes().is_empty());

+ 0 - 38
crates/kuatia-core/src/posting_selection.rs

@@ -1,38 +0,0 @@
-//! The error raised when an intent cannot be covered by the available postings.
-//!
-//! When a caller uses `pay` or `withdraw`, they specify an amount — not which
-//! postings to consume. Resolution ([`crate::posting_resolution::resolve_envelope`])
-//! picks the postings; when a non-overdraft account cannot cover the requested
-//! amount it fails with [`SelectionError::InsufficientFunds`].
-
-use kuatia_types::Cent;
-
-/// Error returned when an amount cannot be covered by the available postings.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum SelectionError {
-    /// Available postings do not cover the requested amount.
-    InsufficientFunds {
-        /// Total value of eligible postings.
-        available: Cent,
-        /// Amount the caller asked for.
-        requested: Cent,
-    },
-}
-
-impl std::fmt::Display for SelectionError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::InsufficientFunds {
-                available,
-                requested,
-            } => {
-                write!(
-                    f,
-                    "insufficient funds: available {available}, requested {requested}"
-                )
-            }
-        }
-    }
-}
-
-impl std::error::Error for SelectionError {}

+ 254 - 9
crates/kuatia-core/src/validate.rs

@@ -1,12 +1,14 @@
 //! Pure, sync validation — the auditable heart of the ledger.
 //!
 //! [`validate_and_plan`] enforces every invariant (conservation, double-spend,
-//! ownership, overdraft) and produces a [`Plan`] describing the effects to
-//! apply. It takes no IO, no clock, and no randomness, so it is deterministic
+//! account state, overdraft, book policy) and produces a [`Plan`] describing the
+//! effects to apply. It takes no IO, no clock, and no randomness, so it is deterministic
 //! and testable with golden vectors. The caller provides pre-loaded state via
 //! [`PlanInput`]; this module never touches storage.
 
 use std::collections::{HashMap, HashSet};
+use std::error::Error;
+use std::fmt;
 
 use crate::hash::{account_hash, envelope_id};
 use kuatia_types::*;
@@ -39,13 +41,72 @@ pub struct PlanInput<'a> {
 pub struct Plan {
     /// Content-addressed id of the validated transfer.
     pub transfer_id: EnvelopeId,
-    /// Postings to mark as inactive (consumed).
+    /// Postings the consumer must spend (mark `Spent`).
     pub postings_to_deactivate: Vec<PostingId>,
     /// New postings to persist.
     pub postings_to_create: Vec<Posting>,
 }
 
 // ---------------------------------------------------------------------------
+// Required state: the single source of truth for what the loader must fetch
+// ---------------------------------------------------------------------------
+
+/// The exact stored state [`validate_and_plan`] reads for a given envelope.
+///
+/// Validation's correctness rests on being handed *complete* state: a missing
+/// balance key silently defaults to zero and would flip an overdraft decision.
+/// Deriving the key-set here, in the pure core, gives that "load everything
+/// validation reads" rule one home and one test surface. The async loader
+/// iterates these keys rather than re-deriving its own, so it cannot
+/// under-fetch.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RequiredState {
+    /// Posting ids validation reads: exactly `envelope.consumes()`.
+    pub consumed_postings: Vec<PostingId>,
+    /// Accounts validation reads: owners of created and consumed postings, plus
+    /// every snapshot-pinned account.
+    pub accounts: Vec<AccountId>,
+    /// Balance keys validation reads: `(owner, asset)` of every consumed and
+    /// created posting.
+    pub balances: Vec<(AccountId, AssetId)>,
+}
+
+/// Derive the state key-set [`validate_and_plan`] will read.
+///
+/// The consumed postings must be passed in because their owner and asset (which
+/// drive the account and balance key-sets) live in the store, not the envelope.
+/// The loader therefore fetches `envelope.consumes()` first, then calls this to
+/// own every remaining key. Kept in lockstep with what `validate_and_plan`
+/// actually reads: change one, change the other.
+pub fn required_state(envelope: &Envelope, consumed_postings: &[Posting]) -> RequiredState {
+    let mut accounts: Vec<AccountId> = envelope.creates().iter().map(|np| np.owner).collect();
+    for p in consumed_postings {
+        accounts.push(p.owner);
+    }
+    for snap in envelope.account_snapshots() {
+        accounts.push(snap.account);
+    }
+    accounts.sort();
+    accounts.dedup();
+
+    let mut balances: Vec<(AccountId, AssetId)> = Vec::new();
+    for p in consumed_postings {
+        balances.push((p.owner, p.asset));
+    }
+    for np in envelope.creates() {
+        balances.push((np.owner, np.asset));
+    }
+    balances.sort();
+    balances.dedup();
+
+    RequiredState {
+        consumed_postings: envelope.consumes().to_vec(),
+        accounts,
+        balances,
+    }
+}
+
+// ---------------------------------------------------------------------------
 // Errors
 // ---------------------------------------------------------------------------
 
@@ -129,8 +190,8 @@ pub enum ValidationError {
     Overflow,
 }
 
-impl std::fmt::Display for ValidationError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl fmt::Display for ValidationError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::EmptyTransfer => write!(f, "transfer has no postings"),
             Self::DuplicateConsumedPosting(id) => write!(f, "duplicate consumed posting {id:?}"),
@@ -200,7 +261,7 @@ impl std::fmt::Display for ValidationError {
     }
 }
 
-impl std::error::Error for ValidationError {}
+impl Error for ValidationError {}
 
 impl From<OverflowError> for ValidationError {
     fn from(_: OverflowError) -> Self {
@@ -240,9 +301,11 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
     let consumed_by_id: HashMap<PostingId, &Posting> =
         input.consumed_postings.iter().map(|p| (p.id, p)).collect();
 
-    // 3. Every consumed posting exists (its lifecycle state is enforced by the
-    // reserve CAS and the finalize "all spent" guard, not here — a `Posting`
-    // carries no state).
+    // 3. Every consumed posting exists. Its lifecycle state (not already
+    // spent / owned by this saga) is not decidable here: a `Posting` carries no
+    // state and this check is a snapshot-in-time read. The authoritative
+    // double-spend guard is the reserve CAS plus `consume_reserved` in the
+    // finalize step (ADR-0021 maps the full commit-safety invariant).
     for pid in envelope.consumes() {
         consumed_by_id
             .get(pid)
@@ -1124,4 +1187,186 @@ mod tests {
         let plan = validate_and_plan(input).unwrap();
         assert_eq!(plan.postings_to_create.len(), 2);
     }
+
+    // -- required_state golden vectors -------------------------------------
+    //
+    // The key-set required_state names must equal exactly what validate_and_plan
+    // reads. These pin the derivation for the shapes load exercises.
+
+    #[test]
+    fn required_state_deposit_nets_to_system_account() {
+        // A deposit consumes nothing; it creates on account 1 and the system
+        // account 99. No consumed postings, so no owners come from the store.
+        let envelope = deposit_envelope();
+        let required = required_state(&envelope, &[]);
+
+        assert!(required.consumed_postings.is_empty());
+        assert_eq!(
+            required.accounts,
+            vec![AccountId::new(1), AccountId::new(99)]
+        );
+        assert_eq!(
+            required.balances,
+            vec![
+                (AccountId::new(1), AssetId::new(1)),
+                (AccountId::new(99), AssetId::new(1)),
+            ]
+        );
+    }
+
+    #[test]
+    fn required_state_internal_transfer_with_change() {
+        // account1 spends a 100 posting, sends 60 to account2, keeps 40 change.
+        // account1's key is only reachable through the consumed posting, so it
+        // must appear even though the envelope's creates never name it as a
+        // debit source directly.
+        let pid = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let posting = Posting {
+            id: pid,
+            owner: AccountId::new(1),
+            asset: AssetId::new(1),
+            value: Cent::from(100),
+        };
+        let envelope = Envelope {
+            consumes: vec![pid],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(2),
+                    asset: AssetId::new(1),
+                    value: Cent::from(60),
+                    payer: Some(AccountId::new(1)),
+                },
+                NewPosting {
+                    owner: AccountId::new(1),
+                    asset: AssetId::new(1),
+                    value: Cent::from(40),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+
+        let required = required_state(&envelope, std::slice::from_ref(&posting));
+
+        assert_eq!(required.consumed_postings, vec![pid]);
+        assert_eq!(
+            required.accounts,
+            vec![AccountId::new(1), AccountId::new(2)]
+        );
+        assert_eq!(
+            required.balances,
+            vec![
+                (AccountId::new(1), AssetId::new(1)),
+                (AccountId::new(2), AssetId::new(1)),
+            ]
+        );
+    }
+
+    #[test]
+    fn required_state_multi_asset() {
+        // Two consumed postings of different assets from account 1, creating on
+        // accounts 2 and 3. Balance keys are per (owner, asset), so account 1
+        // contributes one key per asset it spends.
+        let pid_a = PostingId {
+            transfer: EnvelopeId([1; 32]),
+            index: 0,
+        };
+        let pid_b = PostingId {
+            transfer: EnvelopeId([2; 32]),
+            index: 0,
+        };
+        let consumed = vec![
+            Posting {
+                id: pid_a,
+                owner: AccountId::new(1),
+                asset: AssetId::new(1),
+                value: Cent::from(100),
+            },
+            Posting {
+                id: pid_b,
+                owner: AccountId::new(1),
+                asset: AssetId::new(2),
+                value: Cent::from(50),
+            },
+        ];
+        let envelope = Envelope {
+            consumes: vec![pid_a, pid_b],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(2),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: Some(AccountId::new(1)),
+                },
+                NewPosting {
+                    owner: AccountId::new(3),
+                    asset: AssetId::new(2),
+                    value: Cent::from(50),
+                    payer: Some(AccountId::new(1)),
+                },
+            ],
+            book: BookId(0),
+            account_snapshots: vec![],
+            metadata: BTreeMap::new(),
+        };
+
+        let required = required_state(&envelope, &consumed);
+
+        assert_eq!(required.consumed_postings, vec![pid_a, pid_b]);
+        assert_eq!(
+            required.accounts,
+            vec![AccountId::new(1), AccountId::new(2), AccountId::new(3)]
+        );
+        assert_eq!(
+            required.balances,
+            vec![
+                (AccountId::new(1), AssetId::new(1)),
+                (AccountId::new(1), AssetId::new(2)),
+                (AccountId::new(2), AssetId::new(1)),
+                (AccountId::new(3), AssetId::new(2)),
+            ]
+        );
+    }
+
+    #[test]
+    fn required_state_includes_snapshot_only_accounts() {
+        // A snapshot may pin an account that appears in neither creates nor any
+        // consumed posting. validate_and_plan reads it (step 5b), so
+        // required_state must name it or the loader would miss it.
+        let envelope = Envelope {
+            consumes: vec![],
+            creates: vec![
+                NewPosting {
+                    owner: AccountId::new(1),
+                    asset: AssetId::new(1),
+                    value: Cent::from(100),
+                    payer: None,
+                },
+                NewPosting {
+                    owner: AccountId::new(99),
+                    asset: AssetId::new(1),
+                    value: Cent::from(-100),
+                    payer: None,
+                },
+            ],
+            book: BookId(0),
+            account_snapshots: vec![AccountSnapshotId {
+                account: AccountId::new(7),
+                snapshot_id: [0; 32],
+            }],
+            metadata: BTreeMap::new(),
+        };
+
+        let required = required_state(&envelope, &[]);
+
+        assert_eq!(
+            required.accounts,
+            vec![AccountId::new(1), AccountId::new(7), AccountId::new(99)]
+        );
+    }
 }

+ 2 - 2
crates/kuatia-dashboard/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "kuatia-dashboard"
-version = "0.2.0"
+version = "0.3.0"
 edition = "2024"
 rust-version = "1.85"
 license = "Apache-2.0"
@@ -12,7 +12,7 @@ name = "kuatia-dashboard"
 path = "src/main.rs"
 
 [dependencies]
-kuatia = { path = "../kuatia", version = "0.2.0" }
+kuatia = { path = "../kuatia", version = "0.3.0" }
 kuatia-core = { workspace = true }
 kuatia-storage = { workspace = true }
 kuatia-storage-sql = { workspace = true, features = ["postgres"] }

+ 11 - 12
crates/kuatia-dashboard/src/data.rs

@@ -3,6 +3,8 @@
 //! Everything here is read-only. Monetary values stay as raw [`Cent`] (minor
 //! units); presentation formats them.
 
+use std::cmp::Reverse;
+use std::fmt;
 use std::sync::Arc;
 
 use axum::{
@@ -10,6 +12,7 @@ use axum::{
     http::StatusCode,
     response::{IntoResponse, Response},
 };
+use kuatia::error::LedgerError;
 use kuatia::ledger::Ledger;
 use kuatia_core::{Account, AccountId, AssetId, Cent, PostingId, PostingState};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
@@ -18,7 +21,7 @@ use serde::Serialize;
 use tera::Tera;
 
 use crate::assets::AssetMeta;
-use crate::seed::account_label;
+use crate::seed::{EXTERNAL, account_label};
 
 /// Shared handler state.
 #[derive(Clone)]
@@ -219,10 +222,7 @@ pub async fn overview(state: &AppState) -> Result<OverviewDto, ApiError> {
     // mirrors everything in circulation.
     let mut issued = Vec::new();
     for asset in state.assets.iter() {
-        let external = state
-            .ledger
-            .balance(&crate::seed::EXTERNAL, &asset.id)
-            .await?;
+        let external = state.ledger.balance(&EXTERNAL, &asset.id).await?;
         let issued_value = external
             .checked_neg()
             .map_err(|_| ApiError::internal("overflow"))?;
@@ -281,7 +281,7 @@ pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDe
             status: posting_state_label(state).to_string(),
         })
         .collect();
-    postings.sort_by_key(|p| std::cmp::Reverse(p.value));
+    postings.sort_by_key(|p| Reverse(p.value));
 
     let transfers = state
         .ledger
@@ -315,7 +315,7 @@ pub async fn transfers(state: &AppState, limit: Option<u32>) -> Result<Vec<Trans
     };
     let page = state.ledger.query_transfers(&query).await?;
     let mut out: Vec<TransferDto> = page.items.iter().map(transfer_dto).collect();
-    out.sort_by_key(|t| std::cmp::Reverse(t.created_at));
+    out.sort_by_key(|t| Reverse(t.created_at));
     Ok(out)
 }
 
@@ -345,13 +345,13 @@ impl ApiError {
     }
 
     /// Build a 500 from any displayable error (used by the HTML render path).
-    pub fn from_display(err: impl std::fmt::Display) -> Self {
+    pub fn from_display(err: impl fmt::Display) -> Self {
         Self::internal(err.to_string())
     }
 
     /// Build a 400 from a displayable error (used for a malformed account id in
     /// the URL).
-    pub fn bad_request(err: impl std::fmt::Display) -> Self {
+    pub fn bad_request(err: impl fmt::Display) -> Self {
         Self {
             status: StatusCode::BAD_REQUEST,
             message: err.to_string(),
@@ -359,9 +359,8 @@ impl ApiError {
     }
 }
 
-impl From<kuatia::error::LedgerError> for ApiError {
-    fn from(err: kuatia::error::LedgerError) -> Self {
-        use kuatia::error::LedgerError;
+impl From<LedgerError> for ApiError {
+    fn from(err: LedgerError) -> Self {
         let status = match err {
             LedgerError::AccountNotFound(_) => StatusCode::NOT_FOUND,
             _ => StatusCode::INTERNAL_SERVER_ERROR,

+ 2 - 1
crates/kuatia-dashboard/src/main.rs

@@ -16,6 +16,7 @@ mod data;
 mod seed;
 mod ui;
 
+use std::error::Error;
 use std::sync::Arc;
 
 use axum::Router;
@@ -59,7 +60,7 @@ struct Cli {
 }
 
 #[tokio::main]
-async fn main() -> Result<(), Box<dyn std::error::Error>> {
+async fn main() -> Result<(), Box<dyn Error>> {
     tracing_subscriber::fmt()
         .with_env_filter(
             tracing_subscriber::EnvFilter::try_from_default_env()

+ 7 - 6
crates/kuatia-dashboard/src/seed.rs

@@ -2,6 +2,7 @@
 //! them from an external boundary account, then runs payments and a
 //! multi-asset trade so the dashboard has something to visualize.
 
+use std::error::Error;
 use std::sync::Arc;
 
 use kuatia::ledger::Ledger;
@@ -45,7 +46,7 @@ pub fn account_label(id: AccountId) -> Option<&'static str> {
 /// connection its own separate database, so more than one would split the
 /// ledger; one connection is also fine for a low-traffic dashboard on a file or
 /// Postgres backend.
-pub async fn connect(db_url: &str) -> Result<Arc<Ledger>, Box<dyn std::error::Error>> {
+pub async fn connect(db_url: &str) -> Result<Arc<Ledger>, Box<dyn Error>> {
     sqlx::any::install_default_drivers();
     let pool = sqlx::any::AnyPoolOptions::new()
         .max_connections(1)
@@ -73,7 +74,7 @@ fn sqlite_creatable(db_url: &str) -> String {
 /// it seeded, `false` if the ledger was already populated (so re-running with
 /// `--seed` against a persistent database is a safe no-op rather than a
 /// duplicate-id error).
-pub async fn seed_if_empty(ledger: &Arc<Ledger>) -> Result<bool, Box<dyn std::error::Error>> {
+pub async fn seed_if_empty(ledger: &Arc<Ledger>) -> Result<bool, Box<dyn Error>> {
     if !ledger.list_accounts().await?.is_empty() {
         return Ok(false);
     }
@@ -82,7 +83,7 @@ pub async fn seed_if_empty(ledger: &Arc<Ledger>) -> Result<bool, Box<dyn std::er
 }
 
 /// Populate the ledger with demo accounts and a spread of transfers.
-pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Error>> {
+pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn Error>> {
     // Two-decimal assets (USD, EUR) and an 8-decimal asset (BTC).
     let fiat = Amount::new(2);
     let btc = Amount::new(8);
@@ -130,7 +131,7 @@ async fn create(
     ledger: &Arc<Ledger>,
     id: AccountId,
     debit_must_not_exceed_credit: bool,
-) -> Result<(), Box<dyn std::error::Error>> {
+) -> Result<(), Box<dyn Error>> {
     let account = if debit_must_not_exceed_credit {
         Account::debit_must_not_exceed_credit(id)
     } else {
@@ -145,7 +146,7 @@ async fn deposit(
     to: AccountId,
     asset: AssetId,
     amount: Cent,
-) -> Result<(), Box<dyn std::error::Error>> {
+) -> Result<(), Box<dyn Error>> {
     let transfer = TransferBuilder::new()
         .deposit(to, asset, amount, EXTERNAL)?
         .build();
@@ -159,7 +160,7 @@ async fn pay(
     to: AccountId,
     asset: AssetId,
     amount: Cent,
-) -> Result<(), Box<dyn std::error::Error>> {
+) -> Result<(), Box<dyn Error>> {
     let transfer = TransferBuilder::new().pay(from, to, asset, amount).build();
     ledger.commit(transfer).await?;
     Ok(())

+ 9 - 9
crates/kuatia-dashboard/src/ui.rs

@@ -13,7 +13,7 @@ use axum::{
     response::{Html, IntoResponse, Response},
     routing::get,
 };
-use kuatia_core::{Amount, AssetId, Cent};
+use kuatia_core::{AccountId, Amount, AssetId, Cent};
 use serde::Serialize;
 use tera::{Context, Tera};
 
@@ -111,14 +111,14 @@ struct BalanceView {
 
 #[derive(Serialize)]
 struct AccountView {
-    /// Base account id (used only for the route; the display uses `display_id`).
+    /// Raw numeric base id. The display uses `display_id`; routes use `link`.
     id: i64,
     sub: i64,
-    /// `"5"` for a main account, `"5.7"` for subaccount 7 of base 5.
+    /// The account's IBAN-style code, grouped for display (five blocks of four).
     display_id: String,
-    /// Full-page detail link (`/accounts/5` or `/accounts/5/7`).
+    /// Full-page detail link, keyed by the machine-format code (`/accounts/<code>`).
     link: String,
-    /// htmx partial link (`/ui/accounts/5` or `/ui/accounts/5/7`).
+    /// htmx partial link (`/ui/accounts/<code>`).
     ui_link: String,
     name: String,
     version: u64,
@@ -156,7 +156,7 @@ struct TransferView {
 struct EventView {
     seq: u64,
     kind: &'static str,
-    /// Account display id (`"5"` or `"5.7"`), if the event names an account.
+    /// Account's grouped IBAN-style code, if the event names an account.
     account: Option<String>,
     transfer_short: Option<String>,
     time: String,
@@ -261,13 +261,13 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
 // ---------------------------------------------------------------------------
 
 /// Display an account id as its IBAN-style code in grouped (spaced) format.
-fn acct_display(id: kuatia_core::AccountId) -> String {
+fn acct_display(id: AccountId) -> String {
     id.to_grouped()
 }
 
 /// The detail route path for an account, keyed by the machine-format code:
 /// `/accounts/<code>`.
-fn acct_link(id: kuatia_core::AccountId) -> String {
+fn acct_link(id: AccountId) -> String {
     format!("/accounts/{id}")
 }
 
@@ -388,7 +388,7 @@ async fn accounts_ctx(state: &AppState) -> Result<Context, ApiError> {
     Ok(ctx)
 }
 
-async fn account_ctx(state: &AppState, id: kuatia_core::AccountId) -> Result<Context, ApiError> {
+async fn account_ctx(state: &AppState, id: AccountId) -> Result<Context, ApiError> {
     let dto = data::account_detail(state, id).await?;
     let mut ctx = Context::new();
     ctx.insert("nav", "accounts");

+ 4 - 3
crates/kuatia-money/src/lib.rs

@@ -13,6 +13,7 @@
 //! never silently round or overflow.
 
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use std::error::Error;
 use std::fmt;
 use std::str::FromStr;
 
@@ -148,7 +149,7 @@ impl fmt::Display for OverflowError {
     }
 }
 
-impl std::error::Error for OverflowError {}
+impl Error for OverflowError {}
 
 /// Returned when a string cannot be parsed into a [`Cent`].
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -160,7 +161,7 @@ impl fmt::Display for ParseCentError {
     }
 }
 
-impl std::error::Error for ParseCentError {}
+impl Error for ParseCentError {}
 
 impl Cent {
     /// The zero amount.
@@ -326,7 +327,7 @@ impl fmt::Display for ParseAmountError {
     }
 }
 
-impl std::error::Error for ParseAmountError {}
+impl Error for ParseAmountError {}
 
 impl Amount {
     /// Create an `Amount` formatter with the given number of decimal places.

+ 5 - 0
crates/kuatia-storage-sql/Cargo.toml

@@ -33,5 +33,10 @@ serde_json.workspace = true
 default = ["sqlite"]
 sqlite = ["sqlx/sqlite"]
 postgres = ["sqlx/postgres"]
+# Opt-in gate for the Postgres conformance suite (`tests/postgres.rs`), which
+# needs a live `DATABASE_URL`. Kept distinct from `postgres` so that a plain
+# `cargo test` (which unifies `postgres` on via the dashboard's dependency) does
+# not try to run it; only the dedicated CI job enables `test-postgres`.
+test-postgres = ["postgres"]
 # Pass through to the domain crates: swap the Cent backing to i128.
 i128 = ["kuatia-types/i128", "kuatia-storage/i128"]

+ 206 - 0
crates/kuatia-storage-sql/src/account.rs

@@ -0,0 +1,206 @@
+//! [`AccountStore`]: append-only account versions with a head pointer.
+
+use async_trait::async_trait;
+use sqlx::Row;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::*;
+use kuatia_types::*;
+
+use crate::SqlStore;
+use crate::row::{row_to_account, serialize_json};
+
+#[async_trait]
+impl AccountStore for SqlStore {
+    async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
+        // The head points at the current version, so this is a single indexed
+        // lookup into the immutable history — no scan of the version chain.
+        let row = sqlx::query(
+            "SELECT a.* FROM accounts a \
+             JOIN account_head h \
+             ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version \
+             WHERE h.id = $1 AND h.subaccount = $2",
+        )
+        .bind(id.id)
+        .bind(id.sub)
+        .fetch_optional(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?
+        .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
+        row_to_account(&row)
+    }
+
+    async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError> {
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            result.push(self.get_account(id).await?);
+        }
+        Ok(result)
+    }
+
+    async fn create_account(&self, account: Account) -> Result<u64, StoreError> {
+        // Pessimistic locking: inside one transaction, lock the account's head
+        // row with `SELECT ... FOR UPDATE` so a concurrent creator waits. The
+        // head is the single row per account; its `ON CONFLICT (id, subaccount)
+        // DO NOTHING` insert is the portable backstop that decides the winner
+        // (SQLite has no `FOR UPDATE`, and it turns a concurrent double-create
+        // into a clean affected-row count instead of a unique violation).
+        let lock = self.dialect.lock_clause();
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let existing = sqlx::query(&format!(
+            "SELECT 1 FROM account_head WHERE id = $1 AND subaccount = $2 LIMIT 1{lock}"
+        ))
+        .bind(account.id.id)
+        .bind(account.id.sub)
+        .fetch_optional(&mut *tx)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if existing.is_some() {
+            return Ok(0);
+        }
+
+        // Append the immutable first version, then point the head at it.
+        sqlx::query(
+            "INSERT INTO accounts (id, subaccount, version, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id, subaccount, version) DO NOTHING"
+        )
+            .bind(account.id.id)
+            .bind(account.id.sub)
+            .bind(account.version as i64)
+            .bind(account.flags.bits() as i32)
+            .bind(account.book.0)
+            .bind(serialize_json(&account.metadata)?)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let res = sqlx::query(
+            "INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3) ON CONFLICT (id, subaccount) DO NOTHING",
+        )
+        .bind(account.id.id)
+        .bind(account.id.sub)
+        .bind(account.version as i64)
+        .execute(&mut *tx)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if res.rows_affected() == 0 {
+            return Ok(0);
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(1)
+    }
+
+    async fn append_account_version(&self, account: Account) -> Result<u64, StoreError> {
+        // Pessimistic locking: inside one transaction, lock the account's head
+        // row with `SELECT ... FOR UPDATE` so a concurrent appender waits here
+        // until we commit, then check the version, append the new immutable row,
+        // and move the head. `ON CONFLICT` is the portable backstop (SQLite has
+        // no `FOR UPDATE`, and it covers the append phantom-insert a row lock
+        // does not). The head is maintained by delete + insert, never `UPDATE`,
+        // so the write path issues only inserts and deletes.
+        let lock = self.dialect.lock_clause();
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // A guarded write: no such account, or a version that is not exactly one
+        // past the head, matches nothing and reports 0. This is what keeps the
+        // chain gap-free (a stale or skipped version never lands) and makes a
+        // replay of an already-applied version a no-op.
+        let current = sqlx::query(&format!(
+            "SELECT version FROM account_head WHERE id = $1 AND subaccount = $2{lock}"
+        ))
+        .bind(account.id.id)
+        .bind(account.id.sub)
+        .fetch_optional(&mut *tx)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let Some(current) = current else {
+            return Ok(0);
+        };
+
+        let current_version: i64 = current
+            .try_get("version")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let expected = current_version
+            .checked_add(1)
+            .ok_or_else(|| StoreError::Internal("account version overflow".to_string()))?;
+
+        if account.version as i64 != expected {
+            return Ok(0);
+        }
+
+        let res = sqlx::query(
+            "INSERT INTO accounts (id, subaccount, version, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id, subaccount, version) DO NOTHING"
+        )
+            .bind(account.id.id)
+            .bind(account.id.sub)
+            .bind(account.version as i64)
+            .bind(account.flags.bits() as i32)
+            .bind(account.book.0)
+            .bind(serialize_json(&account.metadata)?)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if res.rows_affected() == 0 {
+            return Ok(0);
+        }
+
+        // Move the head to the new version (delete + insert, never update).
+        sqlx::query("DELETE FROM account_head WHERE id = $1 AND subaccount = $2")
+            .bind(account.id.id)
+            .bind(account.id.sub)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        sqlx::query("INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3)")
+            .bind(account.id.id)
+            .bind(account.id.sub)
+            .bind(account.version as i64)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(1)
+    }
+
+    async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
+        let rows = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version ASC",
+        )
+        .bind(id.id)
+        .bind(id.sub)
+        .fetch_all(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if rows.is_empty() {
+            return Err(StoreError::NotFound(format!("account {id:?}")));
+        }
+        rows.iter().map(row_to_account).collect()
+    }
+
+    async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
+        // One row per account via the head; no read-all-versions + dedup.
+        let rows = sqlx::query(
+            "SELECT a.* FROM accounts a \
+             JOIN account_head h \
+             ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version",
+        )
+        .fetch_all(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        rows.iter().map(row_to_account).collect()
+    }
+}

+ 82 - 0
crates/kuatia-storage-sql/src/book.rs

@@ -0,0 +1,82 @@
+//! [`BookStore`]: book definitions stored as JSON `TEXT`.
+
+use async_trait::async_trait;
+use sqlx::Row;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::*;
+use kuatia_types::*;
+
+use crate::SqlStore;
+use crate::row::{deserialize_json, serialize_json};
+
+#[async_trait]
+impl BookStore for SqlStore {
+    async fn create_book(&self, book: Book) -> Result<u64, StoreError> {
+        // Pessimistic locking, same shape as create_account: lock any existing
+        // book row with `SELECT ... FOR UPDATE` inside the transaction, then
+        // insert with `ON CONFLICT DO NOTHING` as the portable backstop.
+        let lock = self.dialect.lock_clause();
+        let data = serialize_json(&book)?;
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let existing = sqlx::query(&format!("SELECT 1 FROM books WHERE id = $1 LIMIT 1{lock}"))
+            .bind(book.id.0)
+            .fetch_optional(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if existing.is_some() {
+            return Ok(0);
+        }
+
+        let res = sqlx::query(
+            "INSERT INTO books (id, name, data) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
+        )
+        .bind(book.id.0)
+        .bind(&book.name)
+        .bind(&data)
+        .execute(&mut *tx)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if res.rows_affected() == 0 {
+            return Ok(0);
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(1)
+    }
+
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
+        let row = sqlx::query("SELECT data FROM books WHERE id = $1")
+            .bind(id.0)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?
+            .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))?;
+        let data: String = row
+            .try_get("data")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        deserialize_json(&data)
+    }
+
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
+        let rows = sqlx::query("SELECT data FROM books")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        rows.iter()
+            .map(|row| {
+                let data: String = row
+                    .try_get("data")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                deserialize_json(&data)
+            })
+            .collect()
+    }
+}

+ 39 - 0
crates/kuatia-storage-sql/src/dialect.rs

@@ -0,0 +1,39 @@
+//! The SQL dialect seam: the one place the SQLite/PostgreSQL divergence lives.
+//!
+//! Resolved once at construction from the pool's connection URL (no query), so
+//! the write paths read a plain enum instead of re-probing the backend. A third
+//! backend becomes a new variant here, not edits across every `impl`.
+
+use sqlx::{Any, Pool};
+
+/// Which SQL backend a [`SqlStore`](crate::SqlStore) is talking to.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Dialect {
+    /// PostgreSQL: supports `SELECT ... FOR UPDATE` row locking.
+    Postgres,
+    /// SQLite: no `FOR UPDATE`; it serializes writers itself.
+    Sqlite,
+}
+
+impl Dialect {
+    /// Resolve the dialect from the pool's connection URL scheme. Synchronous
+    /// and issues no query. Anything that is not `sqlite` is treated as
+    /// PostgreSQL, matching the prior runtime probe (which classified any
+    /// non-SQLite backend as Postgres).
+    pub(crate) fn from_pool(pool: &Pool<Any>) -> Self {
+        match pool.connect_options().database_url.scheme() {
+            "sqlite" => Self::Sqlite,
+            _ => Self::Postgres,
+        }
+    }
+
+    /// Row-locking clause appended to a `SELECT` that takes a pessimistic lock:
+    /// ` FOR UPDATE` on Postgres, empty on SQLite (which has no such clause and
+    /// serializes writers itself).
+    pub(crate) fn lock_clause(self) -> &'static str {
+        match self {
+            Self::Postgres => " FOR UPDATE",
+            Self::Sqlite => "",
+        }
+    }
+}

+ 89 - 0
crates/kuatia-storage-sql/src/event.rs

@@ -0,0 +1,89 @@
+//! [`EventStore`]: the append-only ledger event log, deduped on a stable key.
+
+use async_trait::async_trait;
+use sqlx::Row;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::events::{EventStore, LedgerEvent, event_dedup_key};
+
+use crate::SqlStore;
+use crate::row::{deserialize_json, serialize_json};
+
+#[async_trait]
+impl EventStore for SqlStore {
+    async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError> {
+        let kind_str =
+            serde_json::to_string(&event.kind).map_err(|e| StoreError::Internal(e.to_string()))?;
+        let data = serialize_json(event)?;
+        let seq = self.autoid.next() as u64;
+
+        // Idempotent on the dedup key: a replayed transfer or lifecycle-transition
+        // event conflicts on `dedup_key` and returns the existing seq instead of a
+        // duplicate row.
+        match event_dedup_key(&event.kind) {
+            Some(dedup_key) => {
+                let res = sqlx::query("INSERT INTO events (seq, timestamp, kind, data, dedup_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (dedup_key) DO NOTHING")
+                    .bind(seq as i64)
+                    .bind(event.timestamp)
+                    .bind(&kind_str)
+                    .bind(&data)
+                    .bind(&dedup_key)
+                    .execute(&self.pool)
+                    .await
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                if res.rows_affected() == 0 {
+                    let row = sqlx::query("SELECT seq FROM events WHERE dedup_key = $1")
+                        .bind(&dedup_key)
+                        .fetch_one(&self.pool)
+                        .await
+                        .map_err(|e| StoreError::Internal(e.to_string()))?;
+                    let existing: i64 = row
+                        .try_get("seq")
+                        .map_err(|e| StoreError::Internal(e.to_string()))?;
+                    return Ok(existing as u64);
+                }
+                Ok(seq)
+            }
+            None => {
+                sqlx::query(
+                    "INSERT INTO events (seq, timestamp, kind, data) VALUES ($1, $2, $3, $4)",
+                )
+                .bind(seq as i64)
+                .bind(event.timestamp)
+                .bind(&kind_str)
+                .bind(&data)
+                .execute(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+                Ok(seq)
+            }
+        }
+    }
+
+    async fn get_events_since(
+        &self,
+        after_seq: u64,
+        limit: u32,
+    ) -> Result<Vec<LedgerEvent>, StoreError> {
+        let rows = sqlx::query("SELECT seq, data FROM events WHERE seq > $1 ORDER BY seq LIMIT $2")
+            .bind(after_seq as i64)
+            .bind(limit as i32)
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let mut events = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let seq: i64 = row
+                .try_get("seq")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let data_json: String = row
+                .try_get("data")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let mut event: LedgerEvent = deserialize_json(&data_json)?;
+            event.seq = seq as u64;
+            events.push(event);
+        }
+        Ok(events)
+    }
+}

+ 35 - 1457
crates/kuatia-storage-sql/src/lib.rs

@@ -4,1475 +4,53 @@
 //! `sqlite` or `postgres` to select the backend.
 //!
 //! ```text
-//! let pool = sqlx::any::Pool<Any>Options::new()
+//! let pool = sqlx::any::AnyPoolOptions::new()
 //!     .connect("sqlite::memory:").await?;
 //! let store = SqlStore::new(pool);
 //! store.migrate().await?;
 //! ```
-
-use std::collections::{HashMap, HashSet};
-use std::str::FromStr;
-use std::sync::atomic::{AtomicU8, Ordering};
-
-use async_trait::async_trait;
-use sqlx::{Any, Pool, Row};
-
-use kuatia_storage::error::StoreError;
-use kuatia_storage::events::{EventStore, LedgerEvent};
-use kuatia_storage::query::{filter_transfers, paginate};
-use kuatia_storage::store::*;
-use kuatia_types::*;
-
-// Cached backend kind for `SqlStore::backend`.
-const BACKEND_UNKNOWN: u8 = 0;
-const BACKEND_POSTGRES: u8 = 1;
-const BACKEND_SQLITE: u8 = 2;
-
-/// Row-locking clause appended to a `SELECT` on backends that support it
-/// (PostgreSQL). SQLite has no `FOR UPDATE` and serializes writers itself, so it
-/// gets an empty clause.
-const FOR_UPDATE: &str = " FOR UPDATE";
-
-/// SQL-backed [`Store`] implementation.
+//!
+//! The [`Store`](kuatia_storage::store::Store) sub-traits are each implemented in
+//! their own module (`account`, `posting`, `transfer`, `saga`, `event`, `book`,
+//! `projection`); shared row mappers and codecs live in `row`, the schema
+//! migrations in `migrate`, and the one SQLite/PostgreSQL divergence behind the
+//! `Dialect` seam in `dialect`.
+
+use sqlx::{Any, Pool};
+
+use kuatia_types::autoid::AutoId;
+
+use crate::dialect::Dialect;
+
+mod account;
+mod book;
+mod dialect;
+mod event;
+mod migrate;
+mod posting;
+mod projection;
+mod row;
+mod saga;
+mod transfer;
+
+/// SQL-backed [`Store`](kuatia_storage::store::Store) implementation.
 pub struct SqlStore {
     pool: Pool<Any>,
-    autoid: kuatia_types::autoid::AutoId,
-    /// Detected backend kind (lazily probed): one of `BACKEND_*`.
-    backend: AtomicU8,
+    autoid: AutoId,
+    /// Which backend this store talks to; resolved once at construction.
+    dialect: Dialect,
 }
 
 impl SqlStore {
-    /// Create a new SQL store wrapping an existing connection pool.
+    /// Create a new SQL store wrapping an existing connection pool. The backend
+    /// dialect is resolved from the pool's connection URL, so no query is issued
+    /// here; call [`migrate`](Self::migrate) next to apply the schema.
     pub fn new(pool: Pool<Any>) -> Self {
+        let dialect = Dialect::from_pool(&pool);
         Self {
             pool,
-            autoid: kuatia_types::autoid::AutoId::new(),
-            backend: AtomicU8::new(BACKEND_UNKNOWN),
-        }
-    }
-
-    /// Whether the backend is PostgreSQL. Probed once and cached: `SELECT
-    /// sqlite_version()` succeeds only on SQLite, so a failure means Postgres.
-    async fn is_postgres(&self) -> Result<bool, StoreError> {
-        match self.backend.load(Ordering::Relaxed) {
-            BACKEND_POSTGRES => return Ok(true),
-            BACKEND_SQLITE => return Ok(false),
-            _ => {}
-        }
-        let is_sqlite = sqlx::query("SELECT sqlite_version()")
-            .fetch_optional(&self.pool)
-            .await
-            .is_ok();
-        self.backend.store(
-            if is_sqlite {
-                BACKEND_SQLITE
-            } else {
-                BACKEND_POSTGRES
-            },
-            Ordering::Relaxed,
-        );
-        Ok(!is_sqlite)
-    }
-
-    /// The row-locking clause for the current backend: [`FOR_UPDATE`] on
-    /// Postgres, empty on SQLite.
-    async fn lock_clause(&self) -> Result<&'static str, StoreError> {
-        Ok(if self.is_postgres().await? {
-            FOR_UPDATE
-        } else {
-            ""
-        })
-    }
-
-    /// Run database migrations. Idempotent: a `_migrations` ledger records what
-    /// has been applied, so re-running is a no-op. Every column is a text type,
-    /// so the store holds no opaque binary and the DDL is identical for both
-    /// backends. Content-addressed ids and opaque saga bytes are stored as hex
-    /// `TEXT`, and JSON payloads as their `TEXT` serialization, keeping every
-    /// row legible for auditing.
-    pub async fn migrate(&self) -> Result<(), StoreError> {
-        sqlx::query("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY)")
-            .execute(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let migrations: &[(&str, &str)] = &[
-            ("001_init", include_str!("migrations/001_init.sql")),
-            (
-                "002_subaccounts",
-                include_str!("migrations/002_subaccounts.sql"),
-            ),
-            (
-                "003_drop_user_data",
-                include_str!("migrations/003_drop_user_data.sql"),
-            ),
-            (
-                "004_index_tables",
-                include_str!("migrations/004_index_tables.sql"),
-            ),
-            (
-                "005_account_head",
-                include_str!("migrations/005_account_head.sql"),
-            ),
-            (
-                "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 {
-            let applied = sqlx::query("SELECT 1 FROM _migrations WHERE name = $1")
-                .bind(*name)
-                .fetch_optional(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            if applied.is_some() {
-                continue;
-            }
-
-            // Apply every statement and record the migration in one transaction,
-            // so a crash mid-migration rolls back cleanly and the migration is
-            // retried as a whole. Migration 004 drops and rebuilds `postings`;
-            // without the transaction a partial apply would leave the schema in a
-            // state the migration cannot be re-run against. Both SQLite and
-            // PostgreSQL support transactional DDL.
-            let mut tx = self
-                .pool
-                .begin()
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            for statement in sql.split(';') {
-                let trimmed = statement.trim();
-                if !trimmed.is_empty() {
-                    sqlx::query(trimmed)
-                        .execute(&mut *tx)
-                        .await
-                        .map_err(|e| StoreError::Internal(e.to_string()))?;
-                }
-            }
-
-            sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
-                .bind(*name)
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            tx.commit()
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-        }
-        Ok(())
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Serialization helpers
-// ---------------------------------------------------------------------------
-
-/// Serialize a value to a JSON string. Payload columns store JSON as `TEXT` so
-/// the database is directly readable for auditing; the ledger never queries
-/// into the JSON, so no binary or indexed representation is needed.
-fn serialize_json<T: serde::Serialize>(val: &T) -> Result<String, StoreError> {
-    serde_json::to_string(val).map_err(|e| StoreError::Internal(format!("json serialization: {e}")))
-}
-
-fn deserialize_json<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, StoreError> {
-    serde_json::from_str(s).map_err(|e| StoreError::Internal(format!("bad json: {e}")))
-}
-
-/// Lower-case hex encoding. Binary identifiers (content-addressed hashes) and
-/// opaque saga bytes are stored as hex `TEXT` so a row is legible in any SQL
-/// client and matches the hex form used in logs and `Debug` output.
-fn to_hex(bytes: &[u8]) -> String {
-    const HEX: &[u8; 16] = b"0123456789abcdef";
-    let mut s = String::with_capacity(bytes.len() * 2);
-    for &b in bytes {
-        s.push(HEX[(b >> 4) as usize] as char);
-        s.push(HEX[(b & 0x0f) as usize] as char);
-    }
-    s
-}
-
-fn from_hex(s: &str) -> Result<Vec<u8>, StoreError> {
-    if s.len() % 2 != 0 {
-        return Err(StoreError::Internal(format!("odd-length hex: {s:?}")));
-    }
-    (0..s.len())
-        .step_by(2)
-        .map(|i| {
-            u8::from_str_radix(&s[i..i + 2], 16)
-                .map_err(|e| StoreError::Internal(format!("bad hex: {e}")))
-        })
-        .collect()
-}
-
-fn envelope_id_to_hex(id: &EnvelopeId) -> String {
-    to_hex(&id.0)
-}
-
-fn envelope_id_from_hex(s: &str) -> Result<EnvelopeId, StoreError> {
-    let bytes = from_hex(s)?;
-    let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
-        StoreError::Internal(format!("expected 32-byte id, got {} bytes", bytes.len()))
-    })?;
-    Ok(EnvelopeId(arr))
-}
-
-fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
-    let id: i64 = row
-        .try_get("id")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let subaccount: i64 = row
-        .try_get("subaccount")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let version: i64 = row
-        .try_get("version")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let flags_bits: i32 = row
-        .try_get("flags")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let book: i64 = row
-        .try_get("book")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let metadata_json: String = row
-        .try_get("metadata")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-    Ok(Account {
-        id: AccountId::with_sub(id, subaccount),
-        version: version as u64,
-        flags: AccountFlags::from_bits_truncate(flags_bits as u32),
-        book: BookId::new(book),
-        metadata: deserialize_json(&metadata_json)?,
-    })
-}
-
-fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
-    let transfer_id: String = row
-        .try_get("transfer_id")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let idx: i16 = row
-        .try_get("idx")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let owner: i64 = row
-        .try_get("owner")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let subaccount: i64 = row
-        .try_get("subaccount")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let asset: i32 = row
-        .try_get("asset")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let value: String = row
-        .try_get("value")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let value = Cent::from_str(&value).map_err(|e| StoreError::Internal(e.to_string()))?;
-
-    Ok(Posting {
-        id: PostingId {
-            transfer: envelope_id_from_hex(&transfer_id)?,
-            index: idx as u16,
-        },
-        owner: AccountId::with_sub(owner, subaccount),
-        asset: AssetId::new(asset as u32),
-        value,
-    })
-}
-
-/// The FROM source for a posting read of the given derived state. Each index
-/// table carries a full row copy, so the live-set reads target the index table
-/// directly with no merge back to the immutable `postings` record. `Live` is a
-/// `UNION ALL` of the two disjoint live sets (the shared 6 data columns), still
-/// with no join to history. Portable across SQLite and PostgreSQL.
-fn filter_source(filter: PostingFilter) -> &'static str {
-    match filter {
-        PostingFilter::Active => "active_postings",
-        PostingFilter::Reserved => "reserved_postings",
-        PostingFilter::All => "postings",
-        PostingFilter::Live => {
-            "(SELECT transfer_id, idx, owner, subaccount, asset, value FROM active_postings \
-             UNION ALL \
-             SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings) AS live"
-        }
-    }
-}
-
-/// Maximum posting ids matched by a single statement. `id_predicate` expands to
-/// an `OR` of `n` equality pairs, so the binding constraint is SQLite's
-/// expression-tree depth limit (`SQLITE_MAX_EXPR_DEPTH`, default 1000), which a
-/// chain of `n` `OR`s reaches at roughly `n` deep. It caps well before the
-/// bind-parameter limits (SQLite 32766, PostgreSQL 65535) that `2 * n (+1)`
-/// parameters would hit. `500` stays comfortably under the expression-depth
-/// limit; callers that pass more ids are chunked, so the id-batch primitives
-/// have no practical ceiling on batch size.
-const MAX_IDS_PER_QUERY: usize = 500;
-
-/// Build a portable predicate matching a set of posting ids:
-/// `(transfer_id = $s AND idx = $s+1) OR (transfer_id = $s+2 AND idx = $s+3) ...`
-/// starting at placeholder `$start`. Row-value `IN ((a, b), ...)` is not
-/// portable across SQLite and PostgreSQL; an `OR` of equality pairs is. The
-/// caller binds each id as `(hex(transfer), idx as i16)` in order, matching the
-/// placeholder sequence. `ids` must be non-empty and no longer than
-/// [`MAX_IDS_PER_QUERY`]; larger sets are split into chunks by the caller.
-fn id_predicate(count: usize, start: u32) -> String {
-    (0..count)
-        .map(|i| {
-            let p = start + (i as u32) * 2;
-            format!("(transfer_id = ${} AND idx = ${})", p, p + 1)
-        })
-        .collect::<Vec<_>>()
-        .join(" OR ")
-}
-
-// ---------------------------------------------------------------------------
-// AccountStore
-// ---------------------------------------------------------------------------
-
-#[async_trait]
-impl AccountStore for SqlStore {
-    async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
-        // The head points at the current version, so this is a single indexed
-        // lookup into the immutable history — no scan of the version chain.
-        let row = sqlx::query(
-            "SELECT a.* FROM accounts a \
-             JOIN account_head h \
-             ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version \
-             WHERE h.id = $1 AND h.subaccount = $2",
-        )
-        .bind(id.id)
-        .bind(id.sub)
-        .fetch_optional(&self.pool)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?
-        .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
-        row_to_account(&row)
-    }
-
-    async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError> {
-        let mut result = Vec::with_capacity(ids.len());
-        for id in ids {
-            result.push(self.get_account(id).await?);
-        }
-        Ok(result)
-    }
-
-    async fn create_account(&self, account: Account) -> Result<(), StoreError> {
-        // Pessimistic locking: inside one transaction, lock the account's head
-        // row with `SELECT ... FOR UPDATE` so a concurrent creator waits. The
-        // head is the single row per account; its `ON CONFLICT (id, subaccount)
-        // DO NOTHING` insert is the portable backstop that decides the winner
-        // (SQLite has no `FOR UPDATE`, and it turns a concurrent double-create
-        // into a clean affected-row count instead of a unique violation).
-        let lock = self.lock_clause().await?;
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let existing = sqlx::query(&format!(
-            "SELECT 1 FROM account_head WHERE id = $1 AND subaccount = $2 LIMIT 1{lock}"
-        ))
-        .bind(account.id.id)
-        .bind(account.id.sub)
-        .fetch_optional(&mut *tx)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if existing.is_some() {
-            return Err(StoreError::AlreadyExists(format!(
-                "account {:?}",
-                account.id
-            )));
-        }
-
-        // Append the immutable first version, then point the head at it.
-        sqlx::query(
-            "INSERT INTO accounts (id, subaccount, version, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id, subaccount, version) DO NOTHING"
-        )
-            .bind(account.id.id)
-            .bind(account.id.sub)
-            .bind(account.version as i64)
-            .bind(account.flags.bits() as i32)
-            .bind(account.book.0)
-            .bind(serialize_json(&account.metadata)?)
-            .execute(&mut *tx)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let res = sqlx::query(
-            "INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3) ON CONFLICT (id, subaccount) DO NOTHING",
-        )
-        .bind(account.id.id)
-        .bind(account.id.sub)
-        .bind(account.version as i64)
-        .execute(&mut *tx)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if res.rows_affected() == 0 {
-            return Err(StoreError::AlreadyExists(format!(
-                "account {:?}",
-                account.id
-            )));
-        }
-
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(())
-    }
-
-    async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
-        // Pessimistic locking: inside one transaction, lock the account's head
-        // row with `SELECT ... FOR UPDATE` so a concurrent appender waits here
-        // until we commit, then check the version, append the new immutable row,
-        // and move the head. `ON CONFLICT` is the portable backstop (SQLite has
-        // no `FOR UPDATE`, and it covers the append phantom-insert a row lock
-        // does not). The head is maintained by delete + insert, never `UPDATE`,
-        // so the write path issues only inserts and deletes.
-        let lock = self.lock_clause().await?;
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let current = sqlx::query(&format!(
-            "SELECT version FROM account_head WHERE id = $1 AND subaccount = $2{lock}"
-        ))
-        .bind(account.id.id)
-        .bind(account.id.sub)
-        .fetch_optional(&mut *tx)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?
-        .ok_or_else(|| StoreError::NotFound(format!("account {:?}", account.id)))?;
-
-        let current_version: i64 = current
-            .try_get("version")
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let expected = current_version
-            .checked_add(1)
-            .ok_or_else(|| StoreError::Internal("account version overflow".to_string()))?;
-
-        if account.version as i64 != expected {
-            return Err(StoreError::VersionConflict {
-                account: account.id,
-                expected: expected as u64,
-                actual: account.version,
-            });
-        }
-
-        let res = sqlx::query(
-            "INSERT INTO accounts (id, subaccount, version, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id, subaccount, version) DO NOTHING"
-        )
-            .bind(account.id.id)
-            .bind(account.id.sub)
-            .bind(account.version as i64)
-            .bind(account.flags.bits() as i32)
-            .bind(account.book.0)
-            .bind(serialize_json(&account.metadata)?)
-            .execute(&mut *tx)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if res.rows_affected() == 0 {
-            return Err(StoreError::VersionConflict {
-                account: account.id,
-                expected: expected as u64,
-                actual: account.version,
-            });
-        }
-
-        // Move the head to the new version (delete + insert, never update).
-        sqlx::query("DELETE FROM account_head WHERE id = $1 AND subaccount = $2")
-            .bind(account.id.id)
-            .bind(account.id.sub)
-            .execute(&mut *tx)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        sqlx::query("INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3)")
-            .bind(account.id.id)
-            .bind(account.id.sub)
-            .bind(account.version as i64)
-            .execute(&mut *tx)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(())
-    }
-
-    async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
-        let rows = sqlx::query(
-            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version ASC",
-        )
-        .bind(id.id)
-        .bind(id.sub)
-        .fetch_all(&self.pool)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if rows.is_empty() {
-            return Err(StoreError::NotFound(format!("account {id:?}")));
-        }
-        rows.iter().map(row_to_account).collect()
-    }
-
-    async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
-        // One row per account via the head; no read-all-versions + dedup.
-        let rows = sqlx::query(
-            "SELECT a.* FROM accounts a \
-             JOIN account_head h \
-             ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version",
-        )
-        .fetch_all(&self.pool)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-        rows.iter().map(row_to_account).collect()
-    }
-}
-
-// ---------------------------------------------------------------------------
-// PostingStore
-// ---------------------------------------------------------------------------
-
-#[async_trait]
-impl PostingStore for SqlStore {
-    async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
-        if ids.is_empty() {
-            return Ok(Vec::new());
-        }
-
-        // Set-based query per chunk instead of one probe per id, reusing the
-        // portable `id_predicate` and binding each id in order as
-        // `(hex(transfer), idx as i16)`. Chunked so a large batch never exceeds
-        // the backend's bind-parameter limit (see `MAX_IDS_PER_QUERY`).
-        let mut found: HashMap<(String, i16), Posting> = HashMap::with_capacity(ids.len());
-        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
-            let sql = format!(
-                "SELECT * FROM postings WHERE {}",
-                id_predicate(chunk.len(), 1)
-            );
-            let mut q = sqlx::query(&sql);
-            for id in chunk {
-                q = q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let rows = q
-                .fetch_all(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            // Index the fetched postings by the same `(hex, idx)` key that was bound.
-            for row in &rows {
-                let posting = row_to_posting(row)?;
-                let key = (
-                    envelope_id_to_hex(&posting.id.transfer),
-                    posting.id.index as i16,
-                );
-                found.insert(key, posting);
-            }
-        }
-
-        // Return in input order, erroring on the first id absent from the batch
-        // (matching the per-id lookup's `NotFound` semantics).
-        let mut result = Vec::with_capacity(ids.len());
-        for id in ids {
-            let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
-            let posting = found
-                .get(&key)
-                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
-            result.push(posting.clone());
-        }
-        Ok(result)
-    }
-
-    async fn get_postings_by_account(
-        &self,
-        id: i64,
-        sub: Option<i64>,
-        asset: Option<&AssetId>,
-        filter: PostingFilter,
-    ) -> Result<Vec<Posting>, StoreError> {
-        // Build the predicate dynamically: `sub == None` spans every subaccount
-        // of `id`, `Some(s)` restricts to one. The subaccount is compared only
-        // for equality, never as a magnitude. The derived-state filter selects
-        // which table (index copy or immutable record) to read from directly.
-        let mut sql = format!("SELECT * FROM {} WHERE owner = $1", filter_source(filter));
-        let mut placeholder = 2u32;
-        if sub.is_some() {
-            sql.push_str(&format!(" AND subaccount = ${placeholder}"));
-            placeholder += 1;
-        }
-        if asset.is_some() {
-            sql.push_str(&format!(" AND asset = ${placeholder}"));
-        }
-        // Deterministic order by the posting primary key, matching
-        // `query_postings`, so callers (and pagination built on top) see a
-        // stable sequence.
-        sql.push_str(" ORDER BY transfer_id, idx");
-
-        let mut q = sqlx::query(&sql).bind(id);
-        if let Some(s) = sub {
-            q = q.bind(s);
-        }
-        if let Some(a) = asset {
-            q = q.bind(a.0 as i32);
-        }
-
-        let rows = q
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        rows.iter().map(row_to_posting).collect()
-    }
-
-    async fn get_posting_states(&self, ids: &[PostingId]) -> Result<Vec<PostingState>, StoreError> {
-        if ids.is_empty() {
-            return Ok(Vec::new());
-        }
-
-        // One set-based query per state table instead of up to three probes per
-        // id, reusing the portable `id_predicate` (an OR of equality pairs;
-        // row-value `IN` is not portable across SQLite and PostgreSQL) and
-        // binding every id in order as `(hex(transfer), idx as i16)`. Chunked so
-        // a large batch never exceeds the bind-parameter limit.
-
-        // Key membership by the same `(hex, idx)` values that were bound, so the
-        // per-id lookup below matches without decoding transfer ids back.
-        let row_key = |row: &sqlx::any::AnyRow| -> Result<(String, i16), StoreError> {
-            let transfer_id: String = row
-                .try_get("transfer_id")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            let idx: i16 = row
-                .try_get("idx")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            Ok((transfer_id, idx))
-        };
-
-        let mut active: HashSet<(String, i16)> = HashSet::new();
-        let mut reserved: HashMap<(String, i16), i64> = HashMap::new();
-        let mut spent: HashSet<(String, i16)> = HashSet::new();
-
-        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
-            let predicate = id_predicate(chunk.len(), 1);
-
-            let active_sql =
-                format!("SELECT transfer_id, idx FROM active_postings WHERE {predicate}");
-            let mut active_q = sqlx::query(&active_sql);
-            for id in chunk {
-                active_q = active_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let active_rows = active_q
-                .fetch_all(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            for row in &active_rows {
-                active.insert(row_key(row)?);
-            }
-
-            let reserved_sql = format!(
-                "SELECT transfer_id, idx, reservation FROM reserved_postings WHERE {predicate}"
-            );
-            let mut reserved_q = sqlx::query(&reserved_sql);
-            for id in chunk {
-                reserved_q = reserved_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let reserved_rows = reserved_q
-                .fetch_all(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            for row in &reserved_rows {
-                let rid: i64 = row
-                    .try_get("reservation")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                reserved.insert(row_key(row)?, rid);
-            }
-
-            let spent_sql = format!("SELECT transfer_id, idx FROM postings WHERE {predicate}");
-            let mut spent_q = sqlx::query(&spent_sql);
-            for id in chunk {
-                spent_q = spent_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let spent_rows = spent_q
-                .fetch_all(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            for row in &spent_rows {
-                spent.insert(row_key(row)?);
-            }
-        }
-
-        // Reconstruct each id's state in input order, preserving the active >
-        // reserved > spent > missing precedence of the original probes.
-        let mut out = Vec::with_capacity(ids.len());
-        for id in ids {
-            let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
-            out.push(if active.contains(&key) {
-                PostingState::Active
-            } else if let Some(rid) = reserved.get(&key) {
-                PostingState::Reserved(ReservationId::new(*rid))
-            } else if spent.contains(&key) {
-                PostingState::Spent
-            } else {
-                PostingState::Missing
-            });
-        }
-        Ok(out)
-    }
-
-    async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
-        let (where_clause, count_clause) = {
-            let source = filter_source(query.filter);
-            let mut w = String::from("WHERE owner = $1");
-            let mut idx = 2u32;
-            if query.sub.is_some() {
-                w.push_str(&format!(" AND subaccount = ${idx}"));
-                idx += 1;
-            }
-            if query.asset.is_some() {
-                w.push_str(&format!(" AND asset = ${idx}"));
-            }
-            let c = format!("SELECT COUNT(*) as cnt FROM {source} {w}");
-            let limit = query.limit.unwrap_or(u32::MAX);
-            let offset = query.offset.unwrap_or(0);
-            // Order by the posting primary key so pagination is deterministic:
-            // without it LIMIT/OFFSET could skip or repeat rows across pages,
-            // especially for `Live`, whose source is a `UNION ALL` with no
-            // inherent order.
-            w.push_str(&format!(
-                " ORDER BY transfer_id, idx LIMIT {limit} OFFSET {offset}"
-            ));
-            (format!("SELECT * FROM {source} {w}"), c)
-        };
-
-        // Build count query
-        let mut count_q = sqlx::query(&count_clause).bind(query.account);
-        if let Some(s) = query.sub {
-            count_q = count_q.bind(s);
-        }
-        if let Some(ref a) = query.asset {
-            count_q = count_q.bind(a.0 as i32);
-        }
-        let count_row = count_q
-            .fetch_one(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let total: i64 = count_row
-            .try_get("cnt")
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        // Build data query
-        let mut data_q = sqlx::query(&where_clause).bind(query.account);
-        if let Some(s) = query.sub {
-            data_q = data_q.bind(s);
-        }
-        if let Some(ref a) = query.asset {
-            data_q = data_q.bind(a.0 as i32);
-        }
-        let rows = data_q
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let items: Vec<Posting> = rows.iter().map(row_to_posting).collect::<Result<_, _>>()?;
-        Ok(Page {
-            items,
-            total: total as u64,
-        })
-    }
-
-    async fn reserve_postings(
-        &self,
-        ids: &[PostingId],
-        reservation: ReservationId,
-    ) -> Result<u64, StoreError> {
-        // Dumb instruction over the whole id set, in two statements: copy the
-        // currently-active rows into the reserved index (sourced from
-        // `active_postings`, so only active ids move), then delete those same
-        // ids from `active_postings`. The DELETE's affected count is the number
-        // claimed, and by active/reserved disjointness it equals the INSERT's
-        // row count. Concurrent reserves serialize on the reserved-index primary
-        // key, so exactly one wins each contended id.
-        if ids.is_empty() {
-            return Ok(0);
-        }
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        // Chunked so a large id set stays under the bind-parameter limit; all
-        // chunks share one transaction so the whole claim is atomic.
-        let mut claimed: u64 = 0;
-        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
-            // Reservation is $1; each id pair follows starting at $2.
-            let insert_sql = format!(
-                "INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) \
-                 SELECT transfer_id, idx, owner, subaccount, asset, value, $1 FROM active_postings WHERE {} \
-                 ON CONFLICT (transfer_id, idx) DO NOTHING",
-                id_predicate(chunk.len(), 2)
-            );
-            let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
-            for id in chunk {
-                insert_q = insert_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            insert_q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            let delete_sql = format!(
-                "DELETE FROM active_postings WHERE {}",
-                id_predicate(chunk.len(), 1)
-            );
-            let mut delete_q = sqlx::query(&delete_sql);
-            for id in chunk {
-                delete_q = delete_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let del = delete_q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            claimed += del.rows_affected();
-        }
-
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(claimed)
-    }
-
-    async fn release_postings(
-        &self,
-        ids: &[PostingId],
-        reservation: ReservationId,
-    ) -> Result<u64, StoreError> {
-        // Dumb instruction over the whole id set: copy the rows reserved by
-        // `reservation` back into the active index, then delete them from the
-        // reserved index. The DELETE's affected count is the number released; an
-        // id already active or reserved by another saga does not match.
-        if ids.is_empty() {
-            return Ok(0);
-        }
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        // Chunked so a large id set stays under the bind-parameter limit; all
-        // chunks share one transaction.
-        let mut released: u64 = 0;
-        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
-            // Reservation is $1; each id pair follows starting at $2.
-            let insert_sql = format!(
-                "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) \
-                 SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings \
-                 WHERE ({}) AND reservation = $1 ON CONFLICT (transfer_id, idx) DO NOTHING",
-                id_predicate(chunk.len(), 2)
-            );
-            let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
-            for id in chunk {
-                insert_q = insert_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            insert_q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            let delete_sql = format!(
-                "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
-                id_predicate(chunk.len(), 2)
-            );
-            let mut delete_q = sqlx::query(&delete_sql).bind(reservation.0);
-            for id in chunk {
-                delete_q = delete_q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let del = delete_q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            released += del.rows_affected();
-        }
-
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(released)
-    }
-
-    async fn deactivate_postings(
-        &self,
-        ids: &[PostingId],
-        reservation: Option<ReservationId>,
-    ) -> Result<u64, StoreError> {
-        // Dumb instruction over the whole id set: a DELETE removes the ids from
-        // an index so they become spent (present only in the immutable table).
-        // `rows_affected` is the count; the caller interprets a shortfall.
-        // Chunked under one transaction so a large id set stays within the
-        // bind-parameter limit while the removal stays atomic.
-        if ids.is_empty() {
-            return Ok(0);
-        }
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut removed: u64 = 0;
-        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
-            let (sql, rid) = match reservation {
-                // Raw path: remove from the active index.
-                None => (
-                    format!(
-                        "DELETE FROM active_postings WHERE {}",
-                        id_predicate(chunk.len(), 1)
-                    ),
-                    None,
-                ),
-                // Saga path: remove only the rows reserved by `rid`.
-                Some(rid) => (
-                    format!(
-                        "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
-                        id_predicate(chunk.len(), 2)
-                    ),
-                    Some(rid),
-                ),
-            };
-            let mut q = sqlx::query(&sql);
-            if let Some(rid) = rid {
-                q = q.bind(rid.0);
-            }
-            for id in chunk {
-                q = q
-                    .bind(envelope_id_to_hex(&id.transfer))
-                    .bind(id.index as i16);
-            }
-            let res = q
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            removed += res.rows_affected();
-        }
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(removed)
-    }
-
-    async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
-        // Dumb instruction: insert each posting into the immutable table and, only
-        // when the row was newly inserted, add its id to the active index. Return
-        // the count of immutable rows inserted. The newness gate stops a replayed
-        // finalize from re-activating a since-spent posting.
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut inserted: u64 = 0;
-        for posting in postings {
-            let hex = envelope_id_to_hex(&posting.id.transfer);
-            let res = sqlx::query(
-                "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
-            )
-                .bind(hex.clone())
-                .bind(posting.id.index as i16)
-                .bind(posting.owner.id)
-                .bind(posting.owner.sub)
-                .bind(posting.asset.0 as i32)
-                .bind(posting.value.to_string())
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            if res.rows_affected() == 1 {
-                // Activate a full copy so spendable reads never merge.
-                sqlx::query(
-                    "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING",
-                )
-                .bind(hex)
-                .bind(posting.id.index as i16)
-                .bind(posting.owner.id)
-                .bind(posting.owner.sub)
-                .bind(posting.asset.0 as i32)
-                .bind(posting.value.to_string())
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-                inserted += 1;
-            }
-        }
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(inserted)
-    }
-}
-
-// ---------------------------------------------------------------------------
-// TransferStore
-// ---------------------------------------------------------------------------
-
-#[async_trait]
-impl TransferStore for SqlStore {
-    async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError> {
-        let row = sqlx::query("SELECT transfer, receipt, created_at FROM transfers WHERE id = $1")
-            .bind(envelope_id_to_hex(id))
-            .fetch_optional(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        match row {
-            None => Ok(None),
-            Some(row) => {
-                let transfer_json: String = row
-                    .try_get("transfer")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                let receipt_json: String = row
-                    .try_get("receipt")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                let created_at: i64 = row
-                    .try_get("created_at")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                Ok(Some(EnvelopeRecord {
-                    envelope: deserialize_json(&transfer_json)?,
-                    receipt: deserialize_json(&receipt_json)?,
-                    created_at,
-                }))
-            }
-        }
-    }
-
-    async fn store_transfer(
-        &self,
-        record: EnvelopeRecord,
-        involved: &[AccountId],
-    ) -> Result<u64, StoreError> {
-        let tid = record.receipt.transfer_id;
-        let tid_hex = envelope_id_to_hex(&tid);
-        let transfer_json = serialize_json(&record.envelope)?;
-        let receipt_json = serialize_json(&record.receipt)?;
-
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let res = sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, book) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING")
-            .bind(&tid_hex)
-            .bind(&transfer_json)
-            .bind(&receipt_json)
-            .bind(record.created_at)
-            .bind(record.envelope.book().0)
-            .execute(&mut *tx)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let inserted = res.rows_affected();
-
-        // Index every involved account (caller supplies the set; storage does no
-        // computation). Idempotent so a replay is harmless.
-        for account in involved {
-            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id, subaccount) VALUES ($1, $2, $3) ON CONFLICT (transfer_id, account_id, subaccount) DO NOTHING")
-                .bind(&tid_hex)
-                .bind(account.id)
-                .bind(account.sub)
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            autoid: AutoId::new(),
+            dialect,
         }
-
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(inserted)
-    }
-
-    async fn get_transfers_for_account(
-        &self,
-        id: i64,
-        sub: Option<i64>,
-    ) -> Result<Vec<EnvelopeRecord>, StoreError> {
-        // `sub == None` spans every subaccount of `id`; `Some(s)` restricts to
-        // one. The subaccount is matched only for equality.
-        let mut sql = String::from(
-            "SELECT t.id, t.transfer, t.receipt, t.created_at FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id WHERE ta.account_id = $1",
-        );
-        if sub.is_some() {
-            sql.push_str(" AND ta.subaccount = $2");
-        }
-        sql.push_str(" ORDER BY t.created_at");
-
-        let mut q = sqlx::query(&sql).bind(id);
-        if let Some(s) = sub {
-            q = q.bind(s);
-        }
-        let rows = q
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let mut result = Vec::with_capacity(rows.len());
-        for row in &rows {
-            let transfer_json: String = row
-                .try_get("transfer")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            let receipt_json: String = row
-                .try_get("receipt")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            let created_at: i64 = row
-                .try_get("created_at")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            result.push(EnvelopeRecord {
-                envelope: deserialize_json(&transfer_json)?,
-                receipt: deserialize_json(&receipt_json)?,
-                created_at,
-            });
-        }
-        Ok(result)
-    }
-
-    async fn query_transfers(
-        &self,
-        query: &TransferQuery,
-    ) -> Result<Page<EnvelopeRecord>, StoreError> {
-        // Load base records, using the account join when available.
-        let base_records = if let Some(account) = query.account {
-            self.get_transfers_for_account(account, query.sub).await?
-        } else {
-            let rows = sqlx::query(
-                "SELECT transfer, receipt, created_at FROM transfers ORDER BY created_at",
-            )
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-            let mut records = Vec::with_capacity(rows.len());
-            for row in &rows {
-                let transfer_json: String = row
-                    .try_get("transfer")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                let receipt_json: String = row
-                    .try_get("receipt")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                let created_at: i64 = row
-                    .try_get("created_at")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                records.push(EnvelopeRecord {
-                    envelope: deserialize_json(&transfer_json)?,
-                    receipt: deserialize_json(&receipt_json)?,
-                    created_at,
-                });
-            }
-            records
-        };
-
-        // The account/subaccount narrowing happened in the load above; the
-        // shared filter covers the time-window and book predicates, then the
-        // shared page cut applies `offset`/`limit`.
-        Ok(paginate(
-            filter_transfers(base_records, query),
-            query.offset,
-            query.limit,
-        ))
-    }
-}
-
-// ---------------------------------------------------------------------------
-// SagaStore
-// ---------------------------------------------------------------------------
-
-#[async_trait]
-impl SagaStore for SqlStore {
-    async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError> {
-        sqlx::query(
-            "INSERT INTO sagas (id, data) VALUES ($1, $2) \
-             ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data",
-        )
-        .bind(*id)
-        .bind(to_hex(&data))
-        .execute(&self.pool)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(())
-    }
-
-    async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError> {
-        let rows = sqlx::query("SELECT id, data FROM sagas")
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut result = Vec::with_capacity(rows.len());
-        for row in &rows {
-            let id: i64 = row
-                .try_get("id")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            let data_hex: String = row
-                .try_get("data")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            result.push((id, from_hex(&data_hex)?));
-        }
-        Ok(result)
-    }
-
-    async fn delete_saga(&self, id: &i64) -> Result<(), StoreError> {
-        sqlx::query("DELETE FROM sagas WHERE id = $1")
-            .bind(*id)
-            .execute(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(())
-    }
-}
-
-// ---------------------------------------------------------------------------
-// EventStore
-// ---------------------------------------------------------------------------
-
-#[async_trait]
-impl EventStore for SqlStore {
-    async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError> {
-        let kind_str =
-            serde_json::to_string(&event.kind).map_err(|e| StoreError::Internal(e.to_string()))?;
-        let data = serialize_json(event)?;
-        let seq = self.autoid.next() as u64;
-
-        // Idempotent on the dedup key: a replayed transfer or lifecycle-transition
-        // event conflicts on `dedup_key` and returns the existing seq instead of a
-        // duplicate row.
-        match kuatia_storage::events::event_dedup_key(&event.kind) {
-            Some(dedup_key) => {
-                let res = sqlx::query("INSERT INTO events (seq, timestamp, kind, data, dedup_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (dedup_key) DO NOTHING")
-                    .bind(seq as i64)
-                    .bind(event.timestamp)
-                    .bind(&kind_str)
-                    .bind(&data)
-                    .bind(&dedup_key)
-                    .execute(&self.pool)
-                    .await
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                if res.rows_affected() == 0 {
-                    let row = sqlx::query("SELECT seq FROM events WHERE dedup_key = $1")
-                        .bind(&dedup_key)
-                        .fetch_one(&self.pool)
-                        .await
-                        .map_err(|e| StoreError::Internal(e.to_string()))?;
-                    let existing: i64 = row
-                        .try_get("seq")
-                        .map_err(|e| StoreError::Internal(e.to_string()))?;
-                    return Ok(existing as u64);
-                }
-                Ok(seq)
-            }
-            None => {
-                sqlx::query(
-                    "INSERT INTO events (seq, timestamp, kind, data) VALUES ($1, $2, $3, $4)",
-                )
-                .bind(seq as i64)
-                .bind(event.timestamp)
-                .bind(&kind_str)
-                .bind(&data)
-                .execute(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-                Ok(seq)
-            }
-        }
-    }
-
-    async fn get_events_since(
-        &self,
-        after_seq: u64,
-        limit: u32,
-    ) -> Result<Vec<LedgerEvent>, StoreError> {
-        let rows = sqlx::query("SELECT seq, data FROM events WHERE seq > $1 ORDER BY seq LIMIT $2")
-            .bind(after_seq as i64)
-            .bind(limit as i32)
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let mut events = Vec::with_capacity(rows.len());
-        for row in &rows {
-            let seq: i64 = row
-                .try_get("seq")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            let data_json: String = row
-                .try_get("data")
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            let mut event: LedgerEvent = deserialize_json(&data_json)?;
-            event.seq = seq as u64;
-            events.push(event);
-        }
-        Ok(events)
-    }
-}
-
-// ---------------------------------------------------------------------------
-// BookStore
-// ---------------------------------------------------------------------------
-
-#[async_trait]
-impl BookStore for SqlStore {
-    async fn create_book(&self, book: Book) -> Result<(), StoreError> {
-        // Pessimistic locking, same shape as create_account: lock any existing
-        // book row with `SELECT ... FOR UPDATE` inside the transaction, then
-        // insert with `ON CONFLICT DO NOTHING` as the portable backstop.
-        let lock = self.lock_clause().await?;
-        let data = serialize_json(&book)?;
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-
-        let existing = sqlx::query(&format!("SELECT 1 FROM books WHERE id = $1 LIMIT 1{lock}"))
-            .bind(book.id.0)
-            .fetch_optional(&mut *tx)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if existing.is_some() {
-            return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
-        }
-
-        let res = sqlx::query(
-            "INSERT INTO books (id, name, data) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
-        )
-        .bind(book.id.0)
-        .bind(&book.name)
-        .bind(&data)
-        .execute(&mut *tx)
-        .await
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-        if res.rows_affected() == 0 {
-            return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
-        }
-
-        tx.commit()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(())
-    }
-
-    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
-        let row = sqlx::query("SELECT data FROM books WHERE id = $1")
-            .bind(id.0)
-            .fetch_optional(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?
-            .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))?;
-        let data: String = row
-            .try_get("data")
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        deserialize_json(&data)
-    }
-
-    async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
-        let rows = sqlx::query("SELECT data FROM books")
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        rows.iter()
-            .map(|row| {
-                let data: String = row
-                    .try_get("data")
-                    .map_err(|e| StoreError::Internal(e.to_string()))?;
-                deserialize_json(&data)
-            })
-            .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,
-        }))
     }
 }

+ 97 - 0
crates/kuatia-storage-sql/src/migrate.rs

@@ -0,0 +1,97 @@
+//! Schema migrations. Idempotent: a `_migrations` ledger records what has been
+//! applied, so re-running is a no-op. The DDL is identical for both backends.
+
+use kuatia_storage::error::StoreError;
+
+use crate::SqlStore;
+
+impl SqlStore {
+    /// Run database migrations. Idempotent: a `_migrations` ledger records what
+    /// has been applied, so re-running is a no-op. Every column is a text type,
+    /// so the store holds no opaque binary and the DDL is identical for both
+    /// backends. Content-addressed ids and opaque saga bytes are stored as hex
+    /// `TEXT`, and JSON payloads as their `TEXT` serialization, keeping every
+    /// row legible for auditing.
+    pub async fn migrate(&self) -> Result<(), StoreError> {
+        sqlx::query("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY)")
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let migrations: &[(&str, &str)] = &[
+            ("001_init", include_str!("migrations/001_init.sql")),
+            (
+                "002_subaccounts",
+                include_str!("migrations/002_subaccounts.sql"),
+            ),
+            (
+                "003_drop_user_data",
+                include_str!("migrations/003_drop_user_data.sql"),
+            ),
+            (
+                "004_index_tables",
+                include_str!("migrations/004_index_tables.sql"),
+            ),
+            (
+                "005_account_head",
+                include_str!("migrations/005_account_head.sql"),
+            ),
+            (
+                "006_drop_policy",
+                include_str!("migrations/006_drop_policy.sql"),
+            ),
+            (
+                "007_balance_projection",
+                include_str!("migrations/007_balance_projection.sql"),
+            ),
+            (
+                "008_live_postings",
+                include_str!("migrations/008_live_postings.sql"),
+            ),
+        ];
+
+        for (name, sql) in migrations {
+            let applied = sqlx::query("SELECT 1 FROM _migrations WHERE name = $1")
+                .bind(*name)
+                .fetch_optional(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            if applied.is_some() {
+                continue;
+            }
+
+            // Apply every statement and record the migration in one transaction,
+            // so a crash mid-migration rolls back cleanly and the migration is
+            // retried as a whole. Migration 004 drops and rebuilds `postings`;
+            // without the transaction a partial apply would leave the schema in a
+            // state the migration cannot be re-run against. Both SQLite and
+            // PostgreSQL support transactional DDL.
+            let mut tx = self
+                .pool
+                .begin()
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+            for statement in sql.split(';') {
+                let trimmed = statement.trim();
+                if !trimmed.is_empty() {
+                    sqlx::query(trimmed)
+                        .execute(&mut *tx)
+                        .await
+                        .map_err(|e| StoreError::Internal(e.to_string()))?;
+                }
+            }
+
+            sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
+                .bind(*name)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+            tx.commit()
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+        Ok(())
+    }
+}

+ 29 - 0
crates/kuatia-storage-sql/src/migrations/008_live_postings.sql

@@ -0,0 +1,29 @@
+-- Merge the two hot index tables (active_postings, reserved_postings) into one
+-- `live_postings` table with a nullable `reservation`: NULL = Active, set =
+-- Reserved by that reservation id. A posting's state is still derived from
+-- membership (present + NULL = Active, present + rid = Reserved, absent but in
+-- `postings` = Spent), but the live set now lives in a single table, so a
+-- spendable/live read is one index scan instead of a UNION ALL of two tables.
+-- Reserve/release become a single UPDATE of `reservation` (an atomic
+-- single-winner claim via `WHERE reservation IS NULL`) instead of moving a row
+-- between two tables. The value table `postings` stays append-only. See ADR-0022.
+CREATE TABLE live_postings (
+    transfer_id TEXT NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    reservation BIGINT,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+INSERT INTO live_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) SELECT transfer_id, idx, owner, subaccount, asset, value, NULL FROM active_postings;
+
+INSERT INTO live_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) SELECT transfer_id, idx, owner, subaccount, asset, value, reservation FROM reserved_postings;
+
+CREATE INDEX IF NOT EXISTS idx_live_owner ON live_postings(owner, subaccount, asset);
+
+DROP TABLE active_postings;
+
+DROP TABLE reserved_postings;

+ 501 - 0
crates/kuatia-storage-sql/src/posting.rs

@@ -0,0 +1,501 @@
+//! [`PostingStore`]: the immutable posting record plus one `live_postings` hot
+//! table whose `reservation` column derives each posting's lifecycle state.
+//!
+//! A posting is in `live_postings` while it is spendable or reserved:
+//! `reservation IS NULL` = Active, `reservation = rid` = Reserved by that saga.
+//! Once consumed the row is deleted, leaving the posting only in the immutable
+//! `postings` table (= Spent). See ADR-0022 (merged hot index) and ADR-0016.
+
+use std::collections::HashMap;
+
+use async_trait::async_trait;
+use sqlx::Row;
+use sqlx::any::AnyRow;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::*;
+use kuatia_types::*;
+
+use crate::SqlStore;
+use crate::row::{envelope_id_to_hex, row_to_posting};
+
+/// The `(source_table, state_predicate)` a posting read of the given filter
+/// targets. Live/Active/Reserved read the single `live_postings` hot table,
+/// narrowed by the `reservation` column; `All` reads the immutable record. No
+/// UNION: the live set is one table.
+fn filter_source(filter: PostingFilter) -> (&'static str, &'static str) {
+    match filter {
+        PostingFilter::Active => ("live_postings", " AND reservation IS NULL"),
+        PostingFilter::Reserved => ("live_postings", " AND reservation IS NOT NULL"),
+        PostingFilter::Live => ("live_postings", ""),
+        PostingFilter::All => ("postings", ""),
+    }
+}
+
+/// Maximum posting ids matched by a single statement. `id_predicate` expands to
+/// an `OR` of `n` equality pairs, so the binding constraint is SQLite's
+/// expression-tree depth limit (`SQLITE_MAX_EXPR_DEPTH`, default 1000), which a
+/// chain of `n` `OR`s reaches at roughly `n` deep. It caps well before the
+/// bind-parameter limits (SQLite 32766, PostgreSQL 65535) that `2 * n (+1)`
+/// parameters would hit. `500` stays comfortably under the expression-depth
+/// limit; callers that pass more ids are chunked, so the id-batch primitives
+/// have no practical ceiling on batch size.
+const MAX_IDS_PER_QUERY: usize = 500;
+
+/// Build a portable predicate matching a set of posting ids:
+/// `(transfer_id = $s AND idx = $s+1) OR (transfer_id = $s+2 AND idx = $s+3) ...`
+/// starting at placeholder `$start`. Row-value `IN ((a, b), ...)` is not
+/// portable across SQLite and PostgreSQL; an `OR` of equality pairs is. The
+/// caller binds each id as `(hex(transfer), idx as i16)` in order, matching the
+/// placeholder sequence. `ids` must be non-empty and no longer than
+/// [`MAX_IDS_PER_QUERY`]; larger sets are split into chunks by the caller.
+fn id_predicate(count: usize, start: u32) -> String {
+    (0..count)
+        .map(|i| {
+            let p = start + (i as u32) * 2;
+            format!("(transfer_id = ${} AND idx = ${})", p, p + 1)
+        })
+        .collect::<Vec<_>>()
+        .join(" OR ")
+}
+
+#[async_trait]
+impl PostingStore for SqlStore {
+    async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
+        if ids.is_empty() {
+            return Ok(Vec::new());
+        }
+
+        // Set-based query per chunk instead of one probe per id, reusing the
+        // portable `id_predicate` and binding each id in order as
+        // `(hex(transfer), idx as i16)`. Chunked so a large batch never exceeds
+        // the backend's bind-parameter limit (see `MAX_IDS_PER_QUERY`).
+        let mut found: HashMap<(String, i16), Posting> = HashMap::with_capacity(ids.len());
+        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
+            let sql = format!(
+                "SELECT * FROM postings WHERE {}",
+                id_predicate(chunk.len(), 1)
+            );
+            let mut q = sqlx::query(&sql);
+            for id in chunk {
+                q = q
+                    .bind(envelope_id_to_hex(&id.transfer))
+                    .bind(id.index as i16);
+            }
+            let rows = q
+                .fetch_all(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+            // Index the fetched postings by the same `(hex, idx)` key that was bound.
+            for row in &rows {
+                let posting = row_to_posting(row)?;
+                let key = (
+                    envelope_id_to_hex(&posting.id.transfer),
+                    posting.id.index as i16,
+                );
+                found.insert(key, posting);
+            }
+        }
+
+        // Return in input order, erroring on the first id absent from the batch
+        // (matching the per-id lookup's `NotFound` semantics).
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
+            let posting = found
+                .get(&key)
+                .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
+            result.push(posting.clone());
+        }
+        Ok(result)
+    }
+
+    async fn get_postings_by_account(
+        &self,
+        id: i64,
+        sub: Option<i64>,
+        asset: Option<&AssetId>,
+        filter: PostingFilter,
+    ) -> Result<Vec<Posting>, StoreError> {
+        // Build the predicate dynamically: `sub == None` spans every subaccount
+        // of `id`, `Some(s)` restricts to one. The subaccount is compared only
+        // for equality, never as a magnitude. The filter picks the source table
+        // and, for the live table, the `reservation` state predicate.
+        let (source, state) = filter_source(filter);
+        let mut sql = format!("SELECT * FROM {source} WHERE owner = $1");
+        let mut placeholder = 2u32;
+        if sub.is_some() {
+            sql.push_str(&format!(" AND subaccount = ${placeholder}"));
+            placeholder += 1;
+        }
+        if asset.is_some() {
+            sql.push_str(&format!(" AND asset = ${placeholder}"));
+        }
+        sql.push_str(state);
+        // Deterministic order by the posting primary key, matching
+        // `query_postings`, so callers (and pagination built on top) see a
+        // stable sequence.
+        sql.push_str(" ORDER BY transfer_id, idx");
+
+        let mut q = sqlx::query(&sql).bind(id);
+        if let Some(s) = sub {
+            q = q.bind(s);
+        }
+        if let Some(a) = asset {
+            q = q.bind(a.0 as i32);
+        }
+
+        let rows = q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        rows.iter().map(row_to_posting).collect()
+    }
+
+    async fn get_posting_states(&self, ids: &[PostingId]) -> Result<Vec<PostingState>, StoreError> {
+        if ids.is_empty() {
+            return Ok(Vec::new());
+        }
+
+        // Two set-based queries per chunk (was three, before the hot tables were
+        // merged): the live table carries both Active (`reservation IS NULL`) and
+        // Reserved (`reservation = rid`) in one row, and `postings` decides Spent
+        // vs Missing for ids absent from the live set. Reuses the portable
+        // `id_predicate` (an OR of equality pairs; row-value `IN` is not portable
+        // across SQLite and PostgreSQL), binding each id as `(hex, idx)`.
+
+        // Key membership by the same `(hex, idx)` values that were bound, so the
+        // per-id lookup below matches without decoding transfer ids back.
+        let row_key = |row: &AnyRow| -> Result<(String, i16), StoreError> {
+            let transfer_id: String = row
+                .try_get("transfer_id")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let idx: i16 = row
+                .try_get("idx")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            Ok((transfer_id, idx))
+        };
+
+        // `live` maps each present id to its reservation: `None` = Active,
+        // `Some(rid)` = Reserved. `present` is every id in the immutable record,
+        // used to tell Spent (present, not live) from Missing (absent).
+        let mut live: HashMap<(String, i16), Option<i64>> = HashMap::new();
+        let mut present: std::collections::HashSet<(String, i16)> =
+            std::collections::HashSet::new();
+
+        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
+            let predicate = id_predicate(chunk.len(), 1);
+
+            let live_sql = format!(
+                "SELECT transfer_id, idx, reservation FROM live_postings WHERE {predicate}"
+            );
+            let mut live_q = sqlx::query(&live_sql);
+            for id in chunk {
+                live_q = live_q
+                    .bind(envelope_id_to_hex(&id.transfer))
+                    .bind(id.index as i16);
+            }
+            let live_rows = live_q
+                .fetch_all(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            for row in &live_rows {
+                // `reservation` is nullable: NULL decodes to `None` = Active.
+                let rid: Option<i64> = row
+                    .try_get("reservation")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                live.insert(row_key(row)?, rid);
+            }
+
+            let present_sql = format!("SELECT transfer_id, idx FROM postings WHERE {predicate}");
+            let mut present_q = sqlx::query(&present_sql);
+            for id in chunk {
+                present_q = present_q
+                    .bind(envelope_id_to_hex(&id.transfer))
+                    .bind(id.index as i16);
+            }
+            let present_rows = present_q
+                .fetch_all(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            for row in &present_rows {
+                present.insert(row_key(row)?);
+            }
+        }
+
+        // Reconstruct each id's state in input order, preserving the active >
+        // reserved > spent > missing precedence of the original probes.
+        let mut out = Vec::with_capacity(ids.len());
+        for id in ids {
+            let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
+            out.push(match live.get(&key) {
+                Some(None) => PostingState::Active,
+                Some(Some(rid)) => PostingState::Reserved(ReservationId::new(*rid)),
+                None if present.contains(&key) => PostingState::Spent,
+                None => PostingState::Missing,
+            });
+        }
+        Ok(out)
+    }
+
+    async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
+        let (where_clause, count_clause) = {
+            let (source, state) = filter_source(query.filter);
+            let mut w = String::from("WHERE owner = $1");
+            let mut idx = 2u32;
+            if query.sub.is_some() {
+                w.push_str(&format!(" AND subaccount = ${idx}"));
+                idx += 1;
+            }
+            if query.asset.is_some() {
+                w.push_str(&format!(" AND asset = ${idx}"));
+            }
+            w.push_str(state);
+            let c = format!("SELECT COUNT(*) as cnt FROM {source} {w}");
+            let limit = query.limit.unwrap_or(u32::MAX);
+            let offset = query.offset.unwrap_or(0);
+            // Order by the posting primary key so pagination is deterministic:
+            // without it LIMIT/OFFSET could skip or repeat rows across pages.
+            w.push_str(&format!(
+                " ORDER BY transfer_id, idx LIMIT {limit} OFFSET {offset}"
+            ));
+            (format!("SELECT * FROM {source} {w}"), c)
+        };
+
+        // Build count query
+        let mut count_q = sqlx::query(&count_clause).bind(query.account);
+        if let Some(s) = query.sub {
+            count_q = count_q.bind(s);
+        }
+        if let Some(ref a) = query.asset {
+            count_q = count_q.bind(a.0 as i32);
+        }
+        let count_row = count_q
+            .fetch_one(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let total: i64 = count_row
+            .try_get("cnt")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // Build data query
+        let mut data_q = sqlx::query(&where_clause).bind(query.account);
+        if let Some(s) = query.sub {
+            data_q = data_q.bind(s);
+        }
+        if let Some(ref a) = query.asset {
+            data_q = data_q.bind(a.0 as i32);
+        }
+        let rows = data_q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let items: Vec<Posting> = rows.iter().map(row_to_posting).collect::<Result<_, _>>()?;
+        Ok(Page {
+            items,
+            total: total as u64,
+        })
+    }
+
+    async fn reserve_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError> {
+        // Dumb instruction over the whole id set: flip each still-Active row's
+        // `reservation` from NULL to this saga's id. `WHERE reservation IS NULL`
+        // is the atomic single-winner claim: concurrent reserves serialize on the
+        // row lock, and the loser's predicate no longer matches, so exactly one
+        // wins each contended id. `rows_affected` is the number claimed; an
+        // already-reserved or spent id does not match and is not counted.
+        if ids.is_empty() {
+            return Ok(0);
+        }
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // Chunked so a large id set stays under the bind-parameter limit; all
+        // chunks share one transaction so the whole claim is atomic.
+        let mut claimed: u64 = 0;
+        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
+            // Reservation is $1; each id pair follows starting at $2.
+            let sql = format!(
+                "UPDATE live_postings SET reservation = $1 WHERE ({}) AND reservation IS NULL",
+                id_predicate(chunk.len(), 2)
+            );
+            let mut q = sqlx::query(&sql).bind(reservation.0);
+            for id in chunk {
+                q = q
+                    .bind(envelope_id_to_hex(&id.transfer))
+                    .bind(id.index as i16);
+            }
+            let res = q
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            claimed += res.rows_affected();
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(claimed)
+    }
+
+    async fn release_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<u64, StoreError> {
+        // Dumb instruction over the whole id set: clear the `reservation` of the
+        // rows this saga holds, returning them to Active. `rows_affected` is the
+        // number released; an id already Active or reserved by another saga does
+        // not match `reservation = rid` and is left untouched.
+        if ids.is_empty() {
+            return Ok(0);
+        }
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let mut released: u64 = 0;
+        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
+            // Reservation is $1; each id pair follows starting at $2.
+            let sql = format!(
+                "UPDATE live_postings SET reservation = NULL WHERE ({}) AND reservation = $1",
+                id_predicate(chunk.len(), 2)
+            );
+            let mut q = sqlx::query(&sql).bind(reservation.0);
+            for id in chunk {
+                q = q
+                    .bind(envelope_id_to_hex(&id.transfer))
+                    .bind(id.index as i16);
+            }
+            let res = q
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            released += res.rows_affected();
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(released)
+    }
+
+    async fn deactivate_postings(
+        &self,
+        ids: &[PostingId],
+        reservation: Option<ReservationId>,
+    ) -> Result<u64, StoreError> {
+        // Dumb instruction over the whole id set: DELETE the ids from the live
+        // table so they become spent (present only in the immutable `postings`).
+        // The raw path removes still-Active rows (`reservation IS NULL`); the saga
+        // path removes only the rows reserved by `rid`. `rows_affected` is the
+        // count; the caller interprets a shortfall. Chunked under one transaction.
+        if ids.is_empty() {
+            return Ok(0);
+        }
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut removed: u64 = 0;
+        for chunk in ids.chunks(MAX_IDS_PER_QUERY) {
+            let (sql, rid) = match reservation {
+                // Raw path: remove still-Active rows.
+                None => (
+                    format!(
+                        "DELETE FROM live_postings WHERE ({}) AND reservation IS NULL",
+                        id_predicate(chunk.len(), 1)
+                    ),
+                    None,
+                ),
+                // Saga path: remove only the rows reserved by `rid`.
+                Some(rid) => (
+                    format!(
+                        "DELETE FROM live_postings WHERE ({}) AND reservation = $1",
+                        id_predicate(chunk.len(), 2)
+                    ),
+                    Some(rid),
+                ),
+            };
+            let mut q = sqlx::query(&sql);
+            if let Some(rid) = rid {
+                q = q.bind(rid.0);
+            }
+            for id in chunk {
+                q = q
+                    .bind(envelope_id_to_hex(&id.transfer))
+                    .bind(id.index as i16);
+            }
+            let res = q
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            removed += res.rows_affected();
+        }
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(removed)
+    }
+
+    async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
+        // Dumb instruction: insert each posting into the immutable table and, only
+        // when the row was newly inserted, add it to the live table as Active
+        // (reservation NULL). Return the count of immutable rows inserted. The
+        // newness gate stops a replayed finalize from re-activating a since-spent
+        // posting.
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut inserted: u64 = 0;
+        for posting in postings {
+            let hex = envelope_id_to_hex(&posting.id.transfer);
+            let res = sqlx::query(
+                "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
+            )
+                .bind(hex.clone())
+                .bind(posting.id.index as i16)
+                .bind(posting.owner.id)
+                .bind(posting.owner.sub)
+                .bind(posting.asset.0 as i32)
+                .bind(posting.value.to_string())
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            if res.rows_affected() == 1 {
+                // Activate a full copy (reservation NULL) so spendable reads never merge.
+                sqlx::query(
+                    "INSERT INTO live_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) VALUES ($1, $2, $3, $4, $5, $6, NULL) ON CONFLICT (transfer_id, idx) DO NOTHING",
+                )
+                .bind(hex)
+                .bind(posting.id.index as i16)
+                .bind(posting.owner.id)
+                .bind(posting.owner.sub)
+                .bind(posting.asset.0 as i32)
+                .bind(posting.value.to_string())
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+                inserted += 1;
+            }
+        }
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(inserted)
+    }
+}

+ 81 - 0
crates/kuatia-storage-sql/src/projection.rs

@@ -0,0 +1,81 @@
+//! [`BalanceProjectionStore`]: append-only balance cache points (ADR-0019).
+
+use std::str::FromStr;
+
+use async_trait::async_trait;
+use sqlx::Row;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::*;
+use kuatia_types::*;
+
+use crate::SqlStore;
+
+#[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,
+        }))
+    }
+}

+ 124 - 0
crates/kuatia-storage-sql/src/row.rs

@@ -0,0 +1,124 @@
+//! Shared row mappers and codecs: how the store turns domain values into the
+//! text columns it stores, and rows back into domain types.
+//!
+//! Every column is a text type, so the database holds no opaque binary and a row
+//! is legible in any SQL client. Content-addressed ids and opaque saga bytes are
+//! stored as hex `TEXT`, JSON payloads as their `TEXT` serialization.
+
+use std::str::FromStr;
+
+use sqlx::Row;
+use sqlx::any::AnyRow;
+
+use kuatia_storage::error::StoreError;
+use kuatia_types::*;
+
+/// Serialize a value to a JSON string. Payload columns store JSON as `TEXT` so
+/// the database is directly readable for auditing; the ledger never queries into
+/// the JSON, so no binary or indexed representation is needed.
+pub(crate) fn serialize_json<T: serde::Serialize>(val: &T) -> Result<String, StoreError> {
+    serde_json::to_string(val).map_err(|e| StoreError::Internal(format!("json serialization: {e}")))
+}
+
+pub(crate) fn deserialize_json<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, StoreError> {
+    serde_json::from_str(s).map_err(|e| StoreError::Internal(format!("bad json: {e}")))
+}
+
+/// Lower-case hex encoding. Binary identifiers (content-addressed hashes) and
+/// opaque saga bytes are stored as hex `TEXT` so a row is legible in any SQL
+/// client and matches the hex form used in logs and `Debug` output.
+pub(crate) fn to_hex(bytes: &[u8]) -> String {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+    let mut s = String::with_capacity(bytes.len() * 2);
+    for &b in bytes {
+        s.push(HEX[(b >> 4) as usize] as char);
+        s.push(HEX[(b & 0x0f) as usize] as char);
+    }
+    s
+}
+
+pub(crate) fn from_hex(s: &str) -> Result<Vec<u8>, StoreError> {
+    if s.len() % 2 != 0 {
+        return Err(StoreError::Internal(format!("odd-length hex: {s:?}")));
+    }
+    (0..s.len())
+        .step_by(2)
+        .map(|i| {
+            u8::from_str_radix(&s[i..i + 2], 16)
+                .map_err(|e| StoreError::Internal(format!("bad hex: {e}")))
+        })
+        .collect()
+}
+
+pub(crate) fn envelope_id_to_hex(id: &EnvelopeId) -> String {
+    to_hex(&id.0)
+}
+
+pub(crate) fn envelope_id_from_hex(s: &str) -> Result<EnvelopeId, StoreError> {
+    let bytes = from_hex(s)?;
+    let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
+        StoreError::Internal(format!("expected 32-byte id, got {} bytes", bytes.len()))
+    })?;
+    Ok(EnvelopeId(arr))
+}
+
+pub(crate) fn row_to_account(row: &AnyRow) -> Result<Account, StoreError> {
+    let id: i64 = row
+        .try_get("id")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let subaccount: i64 = row
+        .try_get("subaccount")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let version: i64 = row
+        .try_get("version")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let flags_bits: i32 = row
+        .try_get("flags")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let book: i64 = row
+        .try_get("book")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let metadata_json: String = row
+        .try_get("metadata")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+    Ok(Account {
+        id: AccountId::with_sub(id, subaccount),
+        version: version as u64,
+        flags: AccountFlags::from_bits_truncate(flags_bits as u32),
+        book: BookId::new(book),
+        metadata: deserialize_json(&metadata_json)?,
+    })
+}
+
+pub(crate) fn row_to_posting(row: &AnyRow) -> Result<Posting, StoreError> {
+    let transfer_id: String = row
+        .try_get("transfer_id")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let idx: i16 = row
+        .try_get("idx")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let owner: i64 = row
+        .try_get("owner")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let subaccount: i64 = row
+        .try_get("subaccount")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let asset: i32 = row
+        .try_get("asset")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let value: String = row
+        .try_get("value")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let value = Cent::from_str(&value).map_err(|e| StoreError::Internal(e.to_string()))?;
+
+    Ok(Posting {
+        id: PostingId {
+            transfer: envelope_id_from_hex(&transfer_id)?,
+            index: idx as u16,
+        },
+        owner: AccountId::with_sub(owner, subaccount),
+        asset: AssetId::new(asset as u32),
+        value,
+    })
+}

+ 70 - 0
crates/kuatia-storage-sql/src/saga.rs

@@ -0,0 +1,70 @@
+//! [`SagaStore`]: opaque write-ahead saga records stored as hex `TEXT`.
+
+use async_trait::async_trait;
+use sqlx::Row;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::*;
+
+use crate::SqlStore;
+use crate::row::{from_hex, to_hex};
+
+#[async_trait]
+impl SagaStore for SqlStore {
+    async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError> {
+        sqlx::query(
+            "INSERT INTO sagas (id, data) VALUES ($1, $2) \
+             ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data",
+        )
+        .bind(*id)
+        .bind(to_hex(&data))
+        .execute(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError> {
+        let rows = sqlx::query("SELECT id, data FROM sagas")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let mut result = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let id: i64 = row
+                .try_get("id")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let data_hex: String = row
+                .try_get("data")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            result.push((id, from_hex(&data_hex)?));
+        }
+        Ok(result)
+    }
+
+    async fn get_saga(&self, id: &i64) -> Result<Option<Vec<u8>>, StoreError> {
+        let row = sqlx::query("SELECT data FROM sagas WHERE id = $1")
+            .bind(*id)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        match row {
+            Some(row) => {
+                let data_hex: String = row
+                    .try_get("data")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                Ok(Some(from_hex(&data_hex)?))
+            }
+            None => Ok(None),
+        }
+    }
+
+    async fn delete_saga(&self, id: &i64) -> Result<(), StoreError> {
+        sqlx::query("DELETE FROM sagas WHERE id = $1")
+            .bind(*id)
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+}

+ 232 - 0
crates/kuatia-storage-sql/src/transfer.rs

@@ -0,0 +1,232 @@
+//! [`TransferStore`]: committed envelope records and their account index.
+
+use async_trait::async_trait;
+use sqlx::Row;
+
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::*;
+use kuatia_types::*;
+
+use crate::SqlStore;
+use crate::row::{deserialize_json, envelope_id_to_hex, serialize_json};
+
+#[async_trait]
+impl TransferStore for SqlStore {
+    async fn get_transfer(&self, id: &EnvelopeId) -> Result<Option<EnvelopeRecord>, StoreError> {
+        let row = sqlx::query("SELECT transfer, receipt, created_at FROM transfers WHERE id = $1")
+            .bind(envelope_id_to_hex(id))
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        match row {
+            None => Ok(None),
+            Some(row) => {
+                let transfer_json: String = row
+                    .try_get("transfer")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                let receipt_json: String = row
+                    .try_get("receipt")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                let created_at: i64 = row
+                    .try_get("created_at")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                Ok(Some(EnvelopeRecord {
+                    envelope: deserialize_json(&transfer_json)?,
+                    receipt: deserialize_json(&receipt_json)?,
+                    created_at,
+                }))
+            }
+        }
+    }
+
+    async fn store_transfer(
+        &self,
+        record: EnvelopeRecord,
+        involved: &[AccountId],
+    ) -> Result<u64, StoreError> {
+        let tid = record.receipt.transfer_id;
+        let tid_hex = envelope_id_to_hex(&tid);
+        let transfer_json = serialize_json(&record.envelope)?;
+        let receipt_json = serialize_json(&record.receipt)?;
+
+        let mut tx = self
+            .pool
+            .begin()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let res = sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, book) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING")
+            .bind(&tid_hex)
+            .bind(&transfer_json)
+            .bind(&receipt_json)
+            .bind(record.created_at)
+            .bind(record.envelope.book().0)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let inserted = res.rows_affected();
+
+        // Index every involved account (caller supplies the set; storage does no
+        // computation). Idempotent so a replay is harmless.
+        for account in involved {
+            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id, subaccount) VALUES ($1, $2, $3) ON CONFLICT (transfer_id, account_id, subaccount) DO NOTHING")
+                .bind(&tid_hex)
+                .bind(account.id)
+                .bind(account.sub)
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+        }
+
+        tx.commit()
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(inserted)
+    }
+
+    async fn get_transfers_for_account(
+        &self,
+        id: i64,
+        sub: Option<i64>,
+    ) -> Result<Vec<EnvelopeRecord>, StoreError> {
+        // `sub == None` spans every subaccount of `id`; `Some(s)` restricts to
+        // one. The subaccount is matched only for equality.
+        let mut sql = String::from(
+            "SELECT t.id, t.transfer, t.receipt, t.created_at FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id WHERE ta.account_id = $1",
+        );
+        if sub.is_some() {
+            sql.push_str(" AND ta.subaccount = $2");
+        }
+        sql.push_str(" ORDER BY t.created_at");
+
+        let mut q = sqlx::query(&sql).bind(id);
+        if let Some(s) = sub {
+            q = q.bind(s);
+        }
+        let rows = q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let mut result = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let transfer_json: String = row
+                .try_get("transfer")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let receipt_json: String = row
+                .try_get("receipt")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let created_at: i64 = row
+                .try_get("created_at")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            result.push(EnvelopeRecord {
+                envelope: deserialize_json(&transfer_json)?,
+                receipt: deserialize_json(&receipt_json)?,
+                created_at,
+            });
+        }
+        Ok(result)
+    }
+
+    async fn query_transfers(
+        &self,
+        query: &TransferQuery,
+    ) -> Result<Page<EnvelopeRecord>, StoreError> {
+        // Push every predicate into SQL so the database returns only the
+        // requested page, not the whole table (or the account's whole history).
+        // This is what bounds the `balance()` tail scan by the watermark
+        // (ADR-0019): `fold_tail` passes `from_ts = Some(watermark + 1)`, and
+        // that lower bound now reaches the DB instead of being applied in Rust
+        // after loading everything. Every bound is an `i64`, so they collect into
+        // one ordered bind list. The account join is only added when an account
+        // is requested (subaccount narrows within it).
+        let from_clause = if query.account.is_some() {
+            "FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id"
+        } else {
+            "FROM transfers t"
+        };
+
+        let mut conds: Vec<String> = Vec::new();
+        let mut binds: Vec<i64> = Vec::new();
+        let mut p = 1u32;
+        if let Some(account) = query.account {
+            conds.push(format!("ta.account_id = ${p}"));
+            binds.push(account);
+            p += 1;
+            if let Some(sub) = query.sub {
+                conds.push(format!("ta.subaccount = ${p}"));
+                binds.push(sub);
+                p += 1;
+            }
+        }
+        if let Some(from) = query.from_ts {
+            conds.push(format!("t.created_at >= ${p}"));
+            binds.push(from);
+            p += 1;
+        }
+        if let Some(to) = query.to_ts {
+            conds.push(format!("t.created_at < ${p}"));
+            binds.push(to);
+            p += 1;
+        }
+        if let Some(book) = query.book {
+            conds.push(format!("t.book = ${p}"));
+            binds.push(book.0);
+        }
+        let where_sql = if conds.is_empty() {
+            String::new()
+        } else {
+            format!(" WHERE {}", conds.join(" AND "))
+        };
+
+        let count_sql = format!("SELECT COUNT(*) as cnt {from_clause}{where_sql}");
+        let mut count_q = sqlx::query(&count_sql);
+        for b in &binds {
+            count_q = count_q.bind(*b);
+        }
+        let total: i64 = count_q
+            .fetch_one(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?
+            .try_get("cnt")
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let limit = query.limit.unwrap_or(u32::MAX);
+        let offset = query.offset.unwrap_or(0);
+        let data_sql = format!(
+            "SELECT t.transfer, t.receipt, t.created_at {from_clause}{where_sql} \
+             ORDER BY t.created_at LIMIT {limit} OFFSET {offset}"
+        );
+        let mut data_q = sqlx::query(&data_sql);
+        for b in &binds {
+            data_q = data_q.bind(*b);
+        }
+        let rows = data_q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let mut items = Vec::with_capacity(rows.len());
+        for row in &rows {
+            let transfer_json: String = row
+                .try_get("transfer")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let receipt_json: String = row
+                .try_get("receipt")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let created_at: i64 = row
+                .try_get("created_at")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            items.push(EnvelopeRecord {
+                envelope: deserialize_json(&transfer_json)?,
+                receipt: deserialize_json(&receipt_json)?,
+                created_at,
+            });
+        }
+        Ok(Page {
+            items,
+            total: total as u64,
+        })
+    }
+}

+ 55 - 0
crates/kuatia-storage-sql/tests/postgres.rs

@@ -0,0 +1,55 @@
+#![allow(missing_docs)]
+#![cfg(feature = "test-postgres")]
+
+//! PostgreSQL conformance run.
+//!
+//! The same `store_tests!` suite the SQLite backend passes, driven against a
+//! real PostgreSQL instance so the Postgres-only code paths (the `FOR UPDATE`
+//! row locks behind `lock_clause`, the `ON CONFLICT` upserts) are actually
+//! exercised. Point `DATABASE_URL` at a Postgres database to run it; the CI
+//! `Test (PostgreSQL)` job supplies one.
+//!
+//! Unlike `sqlite::memory:`, where every pool is its own fresh database, all
+//! tests here share one Postgres database and the conformance tests reuse fixed
+//! ids. Each store therefore gets its own uniquely-named schema, and the pool is
+//! pinned to a single connection so the session `search_path` set below persists
+//! for the store's whole lifetime.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use kuatia_storage_sql::SqlStore;
+use sqlx::{Any, Pool};
+
+static SCHEMA_SEQ: AtomicU64 = AtomicU64::new(0);
+
+async fn new_store() -> SqlStore {
+    sqlx::any::install_default_drivers();
+    let url = std::env::var("DATABASE_URL")
+        .expect("DATABASE_URL must point at a PostgreSQL instance for this suite");
+
+    // One connection per store so the session-level `search_path` set below
+    // survives across every query the store issues.
+    let pool: Pool<Any> = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect(&url)
+        .await
+        .unwrap();
+
+    // Isolate each store in its own schema: the conformance tests reuse fixed
+    // ids, so a shared schema would collide across tests.
+    let n = SCHEMA_SEQ.fetch_add(1, Ordering::Relaxed);
+    let schema = format!("conformance_{n}");
+    for stmt in [
+        format!("DROP SCHEMA IF EXISTS {schema} CASCADE"),
+        format!("CREATE SCHEMA {schema}"),
+        format!("SET search_path TO {schema}"),
+    ] {
+        sqlx::query(&stmt).execute(&pool).await.unwrap();
+    }
+
+    let store = SqlStore::new(pool);
+    store.migrate().await.unwrap();
+    store
+}
+
+kuatia_storage::store_tests!(new_store);

+ 109 - 5
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -209,8 +209,7 @@ async fn migration_004_backfills_index_tables() {
         .await
         .unwrap();
 
-    // A post-003 DB also has the accounts table (empty here); migrate() will run
-    // 005 after 004, which backfills the account head from it.
+    // A post-003 DB also has the accounts table (empty here).
     sqlx::query(
         "CREATE TABLE accounts (id BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL, policy TEXT NOT NULL, flags INTEGER NOT NULL, book BIGINT NOT NULL, metadata TEXT NOT NULL, PRIMARY KEY (id, subaccount, version))",
     )
@@ -218,12 +217,23 @@ async fn migration_004_backfills_index_tables() {
     .await
     .unwrap();
 
-    // Record 001-003 as applied so migrate() only runs 004 and 005.
+    // Record every migration except 004 as applied so migrate() runs only 004,
+    // isolating its backfill. In particular 008 (which merges the index tables
+    // into `live_postings`) is pinned as applied so it does not run and clobber
+    // the active/reserved tables this test inspects.
     sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
         .execute(&pool)
         .await
         .unwrap();
-    for m in ["001_init", "002_subaccounts", "003_drop_user_data"] {
+    for m in [
+        "001_init",
+        "002_subaccounts",
+        "003_drop_user_data",
+        "005_account_head",
+        "006_drop_policy",
+        "007_balance_projection",
+        "008_live_postings",
+    ] {
         sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
             .bind(m)
             .execute(&pool)
@@ -298,7 +308,9 @@ async fn migration_005_backfills_account_head() {
             .unwrap();
     }
 
-    // Record 001-004 as applied so migrate() only runs 005.
+    // Record every migration except 005 as applied so migrate() runs only 005.
+    // 008 is pinned as applied so it does not run against a DB whose 004 index
+    // tables were never created.
     sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
         .execute(&pool)
         .await
@@ -308,6 +320,9 @@ async fn migration_005_backfills_account_head() {
         "002_subaccounts",
         "003_drop_user_data",
         "004_index_tables",
+        "006_drop_policy",
+        "007_balance_projection",
+        "008_live_postings",
     ] {
         sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
             .bind(m)
@@ -335,6 +350,95 @@ async fn migration_005_backfills_account_head() {
     assert_eq!(acct.version, 3);
 }
 
+/// The 008 migration merges the two index tables into one `live_postings`
+/// table: an active row is backfilled with `reservation` NULL, a reserved row
+/// keeps its token, and the old `active_postings`/`reserved_postings` tables are
+/// dropped.
+#[tokio::test]
+async fn migration_008_merges_live_postings() {
+    let pool = new_pool().await;
+
+    // Post-004 index tables with one active and one reserved posting.
+    sqlx::query(
+        "CREATE TABLE active_postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, asset INTEGER NOT NULL, value TEXT NOT NULL, PRIMARY KEY (transfer_id, idx))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query(
+        "CREATE TABLE reserved_postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, asset INTEGER NOT NULL, value TEXT NOT NULL, reservation BIGINT NOT NULL, PRIMARY KEY (transfer_id, idx))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    sqlx::query("INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ('aa', 0, 1, 0, 1, '100')")
+        .execute(&pool)
+        .await
+        .unwrap();
+    sqlx::query("INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) VALUES ('bb', 0, 1, 0, 1, '200', 77)")
+        .execute(&pool)
+        .await
+        .unwrap();
+
+    // Record every migration except 008 as applied so migrate() runs only 008.
+    sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    for m in [
+        "001_init",
+        "002_subaccounts",
+        "003_drop_user_data",
+        "004_index_tables",
+        "005_account_head",
+        "006_drop_policy",
+        "007_balance_projection",
+    ] {
+        sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
+            .bind(m)
+            .execute(&pool)
+            .await
+            .unwrap();
+    }
+
+    let store = SqlStore::new(pool.clone());
+    store.migrate().await.unwrap();
+
+    // The active row lands with reservation NULL, the reserved row keeps its token.
+    let mut rows: Vec<(String, Option<i64>)> =
+        sqlx::query("SELECT transfer_id, reservation FROM live_postings")
+            .fetch_all(&pool)
+            .await
+            .unwrap()
+            .iter()
+            .map(|r| {
+                (
+                    r.try_get("transfer_id").unwrap(),
+                    r.try_get("reservation").unwrap(),
+                )
+            })
+            .collect();
+    rows.sort();
+    assert_eq!(
+        rows,
+        vec![("aa".to_string(), None), ("bb".to_string(), Some(77))]
+    );
+
+    // The old index tables are gone.
+    assert!(
+        sqlx::query("SELECT 1 FROM active_postings")
+            .fetch_all(&pool)
+            .await
+            .is_err()
+    );
+    assert!(
+        sqlx::query("SELECT 1 FROM reserved_postings")
+            .fetch_all(&pool)
+            .await
+            .is_err()
+    );
+}
+
 /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
 #[tokio::test]
 async fn migrate_is_idempotent() {

+ 8 - 27
crates/kuatia-storage/src/error.rs

@@ -1,49 +1,30 @@
 //! Error types for storage implementations.
 
-use kuatia_types::AccountId;
+use std::error::Error;
+use std::fmt;
 
 /// Errors produced by [`Store`](crate::store::Store) implementations.
 ///
 /// The store is a dumb instruction follower: writes report affected-row counts,
 /// not semantic verdicts, so there are no "posting not active"/"reservation
-/// mismatch"/"cas conflict" variants — the saga derives those from counts.
+/// mismatch"/"cas conflict"/"already exists"/"version conflict" variants — every
+/// caller derives those from counts. The only outcomes a write can report are a
+/// count and an I/O fault.
 #[derive(Debug, Clone)]
 pub enum StoreError {
     /// The requested entity was not found.
     NotFound(String),
-    /// The entity already exists (e.g. duplicate account creation).
-    AlreadyExists(String),
-    /// Optimistic version check failed on an account update.
-    VersionConflict {
-        /// Account that had a version mismatch.
-        account: AccountId,
-        /// Version the caller expected.
-        expected: u64,
-        /// Version the store actually had.
-        actual: u64,
-    },
     /// Catch-all for unexpected internal errors.
     Internal(String),
 }
 
-impl std::fmt::Display for StoreError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl fmt::Display for StoreError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::NotFound(msg) => write!(f, "not found: {msg}"),
-            Self::AlreadyExists(msg) => write!(f, "already exists: {msg}"),
-            Self::VersionConflict {
-                account,
-                expected,
-                actual,
-            } => {
-                write!(
-                    f,
-                    "version conflict for {account:?}: expected {expected}, got {actual}"
-                )
-            }
             Self::Internal(msg) => write!(f, "internal error: {msg}"),
         }
     }
 }
 
-impl std::error::Error for StoreError {}
+impl Error for StoreError {}

+ 26 - 21
crates/kuatia-storage/src/mem_store.rs

@@ -4,6 +4,7 @@
 
 use async_trait::async_trait;
 use std::collections::HashMap;
+use std::collections::hash_map::Entry;
 use tokio::sync::RwLock;
 
 use kuatia_types::autoid::AutoId;
@@ -13,7 +14,7 @@ use kuatia_types::{
 };
 
 use crate::error::StoreError;
-use crate::events::{EventStore, LedgerEvent};
+use crate::events::{EventStore, LedgerEvent, event_dedup_key};
 use crate::query::{filter_transfers, paginate};
 use crate::store::{
     AccountStore, BalanceProjection, BalanceProjectionStore, BookStore, EnvelopeRecord, Page,
@@ -148,35 +149,35 @@ impl AccountStore for InMemoryStore {
         Ok(result)
     }
 
-    async fn create_account(&self, account: Account) -> Result<(), StoreError> {
+    async fn create_account(&self, account: Account) -> Result<u64, StoreError> {
         let id = account.id;
         let mut accounts = self.accounts.write().await;
         if accounts.contains_key(&id) {
-            return Err(StoreError::AlreadyExists(format!("account {id:?}")));
+            return Ok(0);
         }
         accounts.insert(id, vec![account]);
-        Ok(())
+        Ok(1)
     }
 
-    async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
+    async fn append_account_version(&self, account: Account) -> Result<u64, StoreError> {
         let id = account.id;
         let mut accounts = self.accounts.write().await;
-        let versions = accounts
-            .get_mut(&id)
-            .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
+        // A guarded write: no such account, or a version that is not exactly one
+        // past the head, matches nothing and reports 0. This is what keeps the
+        // chain gap-free (a stale or skipped version never lands) and makes a
+        // replay of an already-applied version a no-op.
+        let Some(versions) = accounts.get_mut(&id) else {
+            return Ok(0);
+        };
         let current_version = versions.last().map(|a| a.version).unwrap_or(0);
         let expected = current_version
             .checked_add(1)
             .ok_or_else(|| StoreError::Internal("account version overflow".to_string()))?;
         if account.version != expected {
-            return Err(StoreError::VersionConflict {
-                account: account.id,
-                expected,
-                actual: account.version,
-            });
+            return Ok(0);
         }
         versions.push(account);
-        Ok(())
+        Ok(1)
     }
 
     async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
@@ -339,8 +340,7 @@ impl PostingStore for InMemoryStore {
         let mut store = self.postings.write().await;
         let mut inserted: u64 = 0;
         for posting in postings {
-            if let std::collections::hash_map::Entry::Vacant(e) = store.immutable.entry(posting.id)
-            {
+            if let Entry::Vacant(e) = store.immutable.entry(posting.id) {
                 e.insert(posting.clone());
                 // Only newly-inserted postings are activated; a since-spent
                 // posting is not re-activated on a replayed insert. The active
@@ -448,6 +448,11 @@ impl SagaStore for InMemoryStore {
         Ok(sagas.iter().map(|(k, v)| (*k, v.clone())).collect())
     }
 
+    async fn get_saga(&self, id: &i64) -> Result<Option<Vec<u8>>, StoreError> {
+        let sagas = self.sagas.read().await;
+        Ok(sagas.get(id).cloned())
+    }
+
     async fn delete_saga(&self, id: &i64) -> Result<(), StoreError> {
         let mut sagas = self.sagas.write().await;
         sagas.remove(id);
@@ -465,10 +470,10 @@ impl EventStore for InMemoryStore {
         let mut events = self.events.write().await;
         // Idempotent on the dedup key: a replayed transfer or lifecycle-transition
         // event returns the existing seq instead of inserting a duplicate.
-        if let Some(key) = crate::events::event_dedup_key(&event.kind)
+        if let Some(key) = event_dedup_key(&event.kind)
             && let Some(existing) = events
                 .iter()
-                .find(|e| crate::events::event_dedup_key(&e.kind).as_deref() == Some(key.as_str()))
+                .find(|e| event_dedup_key(&e.kind).as_deref() == Some(key.as_str()))
         {
             return Ok(existing.seq);
         }
@@ -498,13 +503,13 @@ impl EventStore for InMemoryStore {
 
 #[async_trait]
 impl BookStore for InMemoryStore {
-    async fn create_book(&self, book: Book) -> Result<(), StoreError> {
+    async fn create_book(&self, book: Book) -> Result<u64, StoreError> {
         let mut books = self.books.write().await;
         if books.contains_key(&book.id) {
-            return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
+            return Ok(0);
         }
         books.insert(book.id, book);
-        Ok(())
+        Ok(1)
     }
 
     async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {

+ 21 - 6
crates/kuatia-storage/src/store.rs

@@ -88,10 +88,19 @@ pub trait AccountStore: Send + Sync {
     async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError>;
     /// Fetch multiple accounts by id.
     async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError>;
-    /// Persist a new account (version 1).
-    async fn create_account(&self, account: Account) -> Result<(), StoreError>;
-    /// Append a new version to an existing account.
-    async fn append_account_version(&self, account: Account) -> Result<(), StoreError>;
+    /// Persist a new account (version 1). A dumb instruction: insert the account
+    /// if absent and return the **number of accounts created** — **1** if this id
+    /// was newly created, **0** if an account with this id already existed. The
+    /// caller decides what `0` means.
+    async fn create_account(&self, account: Account) -> Result<u64, StoreError>;
+    /// Append a new version to an existing account. A dumb instruction with a
+    /// guarded write: the version lands only when it is exactly one past the
+    /// current head (`version == current + 1`) and not already present, which
+    /// keeps the version chain gap-free and single-winner under contention.
+    /// Returns the **number of versions appended** — **1** if it landed, **0**
+    /// otherwise (no such account, a gap/stale append, or an already-applied
+    /// replay). The caller reads state to disambiguate a `0`.
+    async fn append_account_version(&self, account: Account) -> Result<u64, StoreError>;
     /// Return the full version history for an account.
     async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError>;
     /// List all accounts (latest version of each).
@@ -228,6 +237,10 @@ pub trait SagaStore: Send + Sync {
     async fn save_saga(&self, id: &i64, data: Vec<u8>) -> Result<(), StoreError>;
     /// Load all pending (incomplete) saga states.
     async fn list_pending_sagas(&self) -> Result<Vec<(i64, Vec<u8>)>, StoreError>;
+    /// Load one saga state by id, or `None` if no record is stored under `id`.
+    /// A keyed read so a caller checking a single in-flight saga does not scan
+    /// every pending record.
+    async fn get_saga(&self, id: &i64) -> Result<Option<Vec<u8>>, StoreError>;
     /// Delete a completed saga state.
     async fn delete_saga(&self, id: &i64) -> Result<(), StoreError>;
 }
@@ -235,8 +248,10 @@ pub trait SagaStore: Send + Sync {
 /// Book persistence.
 #[async_trait]
 pub trait BookStore: Send + Sync {
-    /// Create a new book.
-    async fn create_book(&self, book: Book) -> Result<(), StoreError>;
+    /// Create a new book. A dumb instruction: insert the book if absent and
+    /// return the **number of books created** — **1** if newly created, **0** if
+    /// a book with this id already existed. The caller decides what `0` means.
+    async fn create_book(&self, book: Book) -> Result<u64, StoreError>;
     /// Fetch a book by id.
     async fn get_book(&self, id: &BookId) -> Result<Book, StoreError>;
     /// List all books.

+ 44 - 25
crates/kuatia-storage/src/store_tests.rs

@@ -173,7 +173,7 @@ async fn commit_envelope(
 /// Create an account and retrieve it.
 pub async fn create_and_get_account(store: &(impl Store + 'static)) {
     let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
-    store.create_account(acc.clone()).await.unwrap();
+    assert_eq!(store.create_account(acc.clone()).await.unwrap(), 1);
     let got = store.get_account(&AccountId::new(1)).await.unwrap();
     assert_eq!(got.id, acc.id);
     assert_eq!(got.version, 1);
@@ -181,12 +181,11 @@ pub async fn create_and_get_account(store: &(impl Store + 'static)) {
     assert_eq!(got.flags, acc.flags);
 }
 
-/// Duplicate account creation fails.
-pub async fn create_duplicate_account_fails(store: &(impl Store + 'static)) {
+/// Duplicate account creation lands nothing and reports 0.
+pub async fn create_duplicate_account_reports_zero(store: &(impl Store + 'static)) {
     let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
-    store.create_account(acc.clone()).await.unwrap();
-    let err = store.create_account(acc).await.unwrap_err();
-    assert!(matches!(err, StoreError::AlreadyExists(_)));
+    assert_eq!(store.create_account(acc.clone()).await.unwrap(), 1);
+    assert_eq!(store.create_account(acc).await.unwrap(), 0);
 }
 
 /// Get non-existent account returns NotFound.
@@ -227,15 +226,20 @@ pub async fn append_account_version(store: &(impl Store + 'static)) {
     assert!(got.is_frozen());
 }
 
-/// Appending with wrong version number fails.
-pub async fn append_version_conflict(store: &(impl Store + 'static)) {
+/// Appending a version that would leave a gap (not `current + 1`) lands nothing
+/// and reports 0, keeping the chain contiguous.
+pub async fn append_version_gap_reports_zero(store: &(impl Store + 'static)) {
     let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
 
     let mut bad = acc.clone();
     bad.version = 5;
-    let err = store.append_account_version(bad).await.unwrap_err();
-    assert!(matches!(err, StoreError::VersionConflict { .. }));
+    assert_eq!(store.append_account_version(bad).await.unwrap(), 0);
+
+    // The head is untouched: still the sole version 1.
+    let history = store.get_account_history(&AccountId::new(1)).await.unwrap();
+    assert_eq!(history.len(), 1);
+    assert_eq!(history[0].version, 1);
 }
 
 /// Re-appending an already-taken version is rejected and leaves the history
@@ -250,13 +254,12 @@ pub async fn append_duplicate_version_rejected(store: &(impl Store + 'static)) {
     v2.flags = AccountFlags::FROZEN;
     store.append_account_version(v2).await.unwrap();
 
-    // A second append that also targets version 2 (now the current max) must be
-    // rejected rather than duplicating or overwriting it.
+    // A second append that also targets version 2 (now the current max) lands
+    // nothing (reports 0) rather than duplicating or overwriting it.
     let mut v2_again = acc.clone();
     v2_again.version = 2;
     v2_again.flags = AccountFlags::CLOSED;
-    let err = store.append_account_version(v2_again).await.unwrap_err();
-    assert!(matches!(err, StoreError::VersionConflict { .. }));
+    assert_eq!(store.append_account_version(v2_again).await.unwrap(), 0);
 
     // Exactly one row at version 2, and it is the first (frozen) write.
     let history = store.get_account_history(&AccountId::new(1)).await.unwrap();
@@ -764,7 +767,7 @@ pub async fn spent_posting_remains_in_immutable_table(store: &(impl Store + 'sta
 }
 
 /// The `Live` filter returns active and reserved postings but excludes spent
-/// ones (the replacement for the old "not Inactive" balance-bearing set).
+/// ones (the balance-bearing set of active plus reserved postings).
 pub async fn get_postings_by_account_live_filter(store: &(impl Store + 'static)) {
     let active = make_posting([0xB2; 32], 0, 1, 1, 100);
     let reserved = make_posting([0xB2; 32], 1, 1, 1, 200);
@@ -890,8 +893,8 @@ pub async fn reserve_twice_second_zero(store: &(impl Store + 'static)) {
     );
 }
 
-/// A posting cannot be deactivated twice: once Inactive, a second raw deactivate
-/// reports zero — the double-spend guard at the storage layer.
+/// A posting cannot be deactivated twice: once spent, a second raw deactivate
+/// reports zero, the double-spend guard at the storage layer.
 pub async fn deactivate_twice_second_zero(store: &(impl Store + 'static)) {
     let consumed = make_posting([7; 32], 0, 1, 1, 100);
     seed_active(store, 200, std::slice::from_ref(&consumed)).await;
@@ -1141,6 +1144,22 @@ pub async fn delete_saga(store: &(impl Store + 'static)) {
     assert!(pending.is_empty());
 }
 
+/// A keyed `get_saga` returns the stored blob, and `None` for an absent or
+/// deleted id.
+pub async fn get_saga_by_id(store: &(impl Store + 'static)) {
+    let id: i64 = 42;
+    let data = vec![7, 8, 9];
+    assert!(store.get_saga(&id).await.unwrap().is_none());
+
+    store.save_saga(&id, data.clone()).await.unwrap();
+    assert_eq!(store.get_saga(&id).await.unwrap(), Some(data));
+    // A different id is still absent while this one exists.
+    assert!(store.get_saga(&99).await.unwrap().is_none());
+
+    store.delete_saga(&id).await.unwrap();
+    assert!(store.get_saga(&id).await.unwrap().is_none());
+}
+
 // ---------------------------------------------------------------------------
 // EventStore tests
 // ---------------------------------------------------------------------------
@@ -1213,12 +1232,11 @@ pub async fn create_and_get_book(store: &(impl Store + 'static)) {
     assert_eq!(got, book);
 }
 
-/// Duplicate book creation fails.
-pub async fn create_duplicate_book_fails(store: &(impl Store + 'static)) {
+/// Duplicate book creation lands nothing and reports 0.
+pub async fn create_duplicate_book_reports_zero(store: &(impl Store + 'static)) {
     let book = make_book(1, "sales");
-    store.create_book(book.clone()).await.unwrap();
-    let err = store.create_book(book).await.unwrap_err();
-    assert!(matches!(err, StoreError::AlreadyExists(_)));
+    assert_eq!(store.create_book(book.clone()).await.unwrap(), 1);
+    assert_eq!(store.create_book(book).await.unwrap(), 0);
 }
 
 /// Get a non-existent book returns NotFound.
@@ -1339,11 +1357,11 @@ macro_rules! store_tests {
         $crate::store_tests!(@tests $factory,
             // AccountStore
             create_and_get_account,
-            create_duplicate_account_fails,
+            create_duplicate_account_reports_zero,
             get_missing_account_fails,
             get_accounts_batch,
             append_account_version,
-            append_version_conflict,
+            append_version_gap_reports_zero,
             append_duplicate_version_rejected,
             get_account_history,
             list_accounts,
@@ -1385,13 +1403,14 @@ macro_rules! store_tests {
             query_transfers_store_wide,
             // SagaStore
             save_and_list_sagas,
+            get_saga_by_id,
             delete_saga,
             // EventStore
             append_and_query_events,
             events_sequence_ordering,
             // BookStore
             create_and_get_book,
-            create_duplicate_book_fails,
+            create_duplicate_book_reports_zero,
             get_missing_book_fails,
             list_books,
             // BalanceProjectionStore

+ 227 - 0
crates/kuatia-types/src/account.rs

@@ -0,0 +1,227 @@
+//! [`Account`] records and their [`AccountFlags`].
+
+use crate::envelope::Metadata;
+use crate::ids::{AccountId, BookId, DEFAULT_BOOK};
+use serde::{Deserialize, Serialize};
+
+bitflags::bitflags! {
+    /// Lifecycle and balance-constraint flags for an [`Account`].
+    ///
+    /// Bits 0–7 are the system range: bits 0–2 carry lifecycle meaning
+    /// (`FROZEN`, `CLOSED`, `INFLIGHT`), bit 3 is the balance constraint
+    /// (`DEBIT_MUST_NOT_EXCEED_CREDIT`), and bits 4–7
+    /// (`RESERVED_4..RESERVED_7`) are held for future system flags. Bits 8–31
+    /// are the user range (`USER_0..USER_23`), meant to be combined with
+    /// [`BookPolicy::allowed_flags`](crate::BookPolicy::allowed_flags) to scope
+    /// which accounts may participate in a book.
+    ///
+    /// Every bit has a named constant so `from_bits_truncate` never discards a
+    /// set bit on the storage read path.
+    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+    pub struct AccountFlags: u32 {
+        /// Account may not be the source or destination of any transfer.
+        const FROZEN = 1 << 0;
+        /// Terminal — no further activity.
+        const CLOSED = 1 << 1;
+        /// Holding account for an inflight (authorize/confirm/void) transaction.
+        /// Parks funds between authorize and settlement; closed once drained.
+        const INFLIGHT = 1 << 2;
+        /// The account's debits may never exceed its credits: its balance may
+        /// not go negative and it may not hold a negative posting. When unset
+        /// (the default), the account may overdraw without bound: a shortfall is
+        /// covered by a negative offset posting, and the ledger records the
+        /// transfer as long as it conserves value per asset.
+        const DEBIT_MUST_NOT_EXCEED_CREDIT = 1 << 3;
+        /// Reserved for a future system flag; not for user assignment.
+        const RESERVED_4 = 1 << 4;
+        /// Reserved for a future system flag; not for user assignment.
+        const RESERVED_5 = 1 << 5;
+        /// Reserved for a future system flag; not for user assignment.
+        const RESERVED_6 = 1 << 6;
+        /// Reserved for a future system flag; not for user assignment.
+        const RESERVED_7 = 1 << 7;
+        /// User-defined flag 0.
+        const USER_0 = 1 << 8;
+        /// User-defined flag 1.
+        const USER_1 = 1 << 9;
+        /// User-defined flag 2.
+        const USER_2 = 1 << 10;
+        /// User-defined flag 3.
+        const USER_3 = 1 << 11;
+        /// User-defined flag 4.
+        const USER_4 = 1 << 12;
+        /// User-defined flag 5.
+        const USER_5 = 1 << 13;
+        /// User-defined flag 6.
+        const USER_6 = 1 << 14;
+        /// User-defined flag 7.
+        const USER_7 = 1 << 15;
+        /// User-defined flag 8.
+        const USER_8 = 1 << 16;
+        /// User-defined flag 9.
+        const USER_9 = 1 << 17;
+        /// User-defined flag 10.
+        const USER_10 = 1 << 18;
+        /// User-defined flag 11.
+        const USER_11 = 1 << 19;
+        /// User-defined flag 12.
+        const USER_12 = 1 << 20;
+        /// User-defined flag 13.
+        const USER_13 = 1 << 21;
+        /// User-defined flag 14.
+        const USER_14 = 1 << 22;
+        /// User-defined flag 15.
+        const USER_15 = 1 << 23;
+        /// User-defined flag 16.
+        const USER_16 = 1 << 24;
+        /// User-defined flag 17.
+        const USER_17 = 1 << 25;
+        /// User-defined flag 18.
+        const USER_18 = 1 << 26;
+        /// User-defined flag 19.
+        const USER_19 = 1 << 27;
+        /// User-defined flag 20.
+        const USER_20 = 1 << 28;
+        /// User-defined flag 21.
+        const USER_21 = 1 << 29;
+        /// User-defined flag 22.
+        const USER_22 = 1 << 30;
+        /// User-defined flag 23.
+        const USER_23 = 1 << 31;
+    }
+}
+
+/// A registered entity that must exist before it can transact.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Account {
+    /// Stable identity for this account (base account plus subaccount).
+    pub id: AccountId,
+    /// Monotonically increasing version, starts at 1 on creation.
+    pub version: u64,
+    /// Lifecycle and balance-constraint flags. The balance constraint lives in
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`].
+    pub flags: AccountFlags,
+    /// Book this entity belongs to.
+    pub book: BookId,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+impl Account {
+    /// Create a version-1 main-subaccount account: no flags, the default book,
+    /// and empty metadata. With no flags the account may overdraw without bound
+    /// (a shortfall becomes a negative offset posting); set
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] to forbid that, or use
+    /// [`Account::debit_must_not_exceed_credit`]. Set the other fields
+    /// explicitly when you need them.
+    pub fn new(id: AccountId) -> Self {
+        Self::new_ref(id)
+    }
+
+    /// Like [`Account::new`] but named for the subaccount-reference case; the
+    /// signature is identical.
+    pub fn new_ref(id: AccountId) -> Self {
+        Self {
+            id,
+            version: 1,
+            flags: AccountFlags::empty(),
+            book: DEFAULT_BOOK,
+            metadata: Metadata::new(),
+        }
+    }
+
+    /// A version-1 account whose debits may never exceed its credits: its
+    /// balance may not go negative and it may not hold a negative posting.
+    /// Equivalent to `Account::new(id)` with
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] set.
+    pub fn debit_must_not_exceed_credit(id: AccountId) -> Self {
+        let mut account = Self::new(id);
+        account.flags |= AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
+        account
+    }
+
+    /// Whether this account forbids overdraft, i.e. carries the
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] flag. When `false` (the
+    /// default) the account may overdraw without bound.
+    pub fn forbids_overdraft(&self) -> bool {
+        self.flags
+            .contains(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)
+    }
+
+    /// Returns `true` if the account has the `FROZEN` flag set.
+    pub fn is_frozen(&self) -> bool {
+        self.flags.contains(AccountFlags::FROZEN)
+    }
+
+    /// Returns `true` if the account has the `CLOSED` flag set.
+    pub fn is_closed(&self) -> bool {
+        self.flags.contains(AccountFlags::CLOSED)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn account_flags_cover_every_bit() {
+        // Every one of the 32 bits has a named constant, so `all()` fills the
+        // whole `u32` and `from_bits_truncate` can never discard a set bit.
+        assert_eq!(AccountFlags::all().bits(), u32::MAX);
+    }
+
+    #[test]
+    fn account_flags_bit_positions() {
+        assert_eq!(AccountFlags::FROZEN.bits(), 1 << 0);
+        assert_eq!(AccountFlags::INFLIGHT.bits(), 1 << 2);
+        assert_eq!(AccountFlags::RESERVED_7.bits(), 1 << 7);
+        assert_eq!(AccountFlags::USER_0.bits(), 1 << 8);
+        assert_eq!(AccountFlags::USER_8.bits(), 1 << 16);
+        assert_eq!(AccountFlags::USER_23.bits(), 1 << 31);
+    }
+
+    #[test]
+    fn account_flags_high_bit_survives_signed_storage_roundtrip() {
+        // The SQL backend persists flags via `bits() as i32` and reloads via
+        // `from_bits_truncate(bits as u32)`. Bit 31 makes the stored i32
+        // negative; this pins that the reinterpret cast is bit-preserving.
+        let flags = AccountFlags::USER_23 | AccountFlags::FROZEN;
+        let stored = flags.bits() as i32;
+        assert!(
+            stored < 0,
+            "USER_23 should set the sign bit when cast to i32"
+        );
+        let loaded = AccountFlags::from_bits_truncate(stored as u32);
+        assert_eq!(loaded, flags);
+    }
+
+    #[test]
+    fn debit_must_not_exceed_credit_sets_the_flag() {
+        let id = AccountId::new(100);
+        let acc = Account::debit_must_not_exceed_credit(id);
+        assert!(acc.forbids_overdraft());
+        assert!(
+            acc.flags
+                .contains(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)
+        );
+        // It differs from the default only by that one flag.
+        let mut expected = Account::new(id);
+        expected.flags |= AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
+        assert_eq!(acc, expected);
+    }
+
+    #[test]
+    fn new_account_allows_overdraft_by_default() {
+        let acc = Account::new(AccountId::new(101));
+        assert!(!acc.forbids_overdraft());
+        assert_eq!(acc.version, 1);
+        assert_eq!(acc.flags, AccountFlags::empty());
+        assert_eq!(acc.book, DEFAULT_BOOK);
+        assert!(acc.metadata.is_empty());
+    }
+
+    #[test]
+    fn debit_must_not_exceed_credit_bit_is_bit_3() {
+        assert_eq!(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT.bits(), 1 << 3);
+    }
+}

+ 6 - 4
crates/kuatia-types/src/account_code.rs

@@ -4,7 +4,7 @@
 //! 18-character base-36 body followed by two ISO 7064 mod-97 check digits, with
 //! no country code. It is produced and consumed through a narrow interface of
 //! three methods on [`AccountId`]: [`Display`](fmt::Display) (machine form),
-//! [`FromStr`](std::str::FromStr) (validating parse), and
+//! [`FromStr`] (validating parse), and
 //! [`to_grouped`](AccountId::to_grouped) (presentation spacing).
 //!
 //! Everything behind that interface is private: bit-packing the `(id, sub)`
@@ -16,7 +16,9 @@
 //! encodable range and to key the deployment's codes.
 
 use crate::AccountId;
+use std::error::Error;
 use std::fmt;
+use std::str::FromStr;
 use std::sync::atomic::{AtomicU64, Ordering};
 
 // ---------------------------------------------------------------------------
@@ -225,7 +227,7 @@ impl fmt::Display for AccountId {
     /// country code. The `(id, sub)` pair is packed into a 93-bit value and run
     /// through a keyed format-preserving permutation (see [`set_id_seed`]) before
     /// encoding, so the body does not reveal the raw ids. Round-trips via
-    /// [`FromStr`](std::str::FromStr); [`to_grouped`](AccountId::to_grouped) adds
+    /// [`FromStr`]; [`to_grouped`](AccountId::to_grouped) adds
     /// the presentation spacing (five groups of four).
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         let body = base36_body(obfuscate(pack(self.id, self.sub), id_seed()));
@@ -262,9 +264,9 @@ impl fmt::Display for ParseAccountIdError {
     }
 }
 
-impl std::error::Error for ParseAccountIdError {}
+impl Error for ParseAccountIdError {}
 
-impl std::str::FromStr for AccountId {
+impl FromStr for AccountId {
     type Err = ParseAccountIdError;
 
     /// Parse an IBAN-style account code back into the two legs. Any spaces

+ 81 - 0
crates/kuatia-types/src/book.rs

@@ -0,0 +1,81 @@
+//! [`Book`] — the transfer-policy scope gating account and asset participation.
+
+use crate::account::AccountFlags;
+use crate::ids::{AccountId, AssetId, BookId};
+use serde::{Deserialize, Serialize};
+
+/// A Book is a transfer policy scope: it gates which accounts and assets may
+/// participate in a transfer. It is **not** the chronological entry log (the
+/// transfer log plays that role), and it does **not** partition balances —
+/// balances are global; a Book only gates participation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Book {
+    /// Stable identity for this book.
+    pub id: BookId,
+    /// Human-readable name.
+    pub name: String,
+    /// Participation rules for this book.
+    pub policy: BookPolicy,
+}
+
+/// The participation rules for a [`Book`]. An empty field means "no restriction".
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BookPolicy {
+    /// If non-empty, only these assets may appear in movements.
+    pub allowed_assets: Vec<AssetId>,
+    /// If non-empty, accounts with ANY of these flags may participate.
+    pub allowed_flags: AccountFlags,
+    /// If non-empty, these specific accounts may participate (in addition to flag matches).
+    pub allowed_accounts: Vec<AccountId>,
+}
+
+/// Builder for constructing [`Book`] values.
+pub struct BookBuilder {
+    book: Book,
+}
+
+impl BookBuilder {
+    /// Create a new book builder with the given name.
+    pub fn new(name: impl Into<String>) -> Self {
+        Self {
+            book: Book {
+                id: BookId::generate(),
+                name: name.into(),
+                policy: BookPolicy {
+                    allowed_assets: Vec::new(),
+                    allowed_flags: AccountFlags::empty(),
+                    allowed_accounts: Vec::new(),
+                },
+            },
+        }
+    }
+
+    /// Set the book id explicitly.
+    pub fn id(mut self, id: BookId) -> Self {
+        self.book.id = id;
+        self
+    }
+
+    /// Add an allowed asset.
+    pub fn allow_asset(mut self, asset: AssetId) -> Self {
+        self.book.policy.allowed_assets.push(asset);
+        self
+    }
+
+    /// Set allowed account flags — accounts with ANY of these flags may participate.
+    pub fn allow_flags(mut self, flags: AccountFlags) -> Self {
+        self.book.policy.allowed_flags = flags;
+        self
+    }
+
+    /// Add a specific allowed account.
+    pub fn allow_account(mut self, account: AccountId) -> Self {
+        self.book.policy.allowed_accounts.push(account);
+        self
+    }
+
+    /// Consume the builder and return the [`Book`].
+    pub fn build(self) -> Book {
+        self.book
+    }
+}

+ 114 - 0
crates/kuatia-types/src/envelope.rs

@@ -0,0 +1,114 @@
+//! The [`Envelope`] — the resolved, atomic unit produced by the saga pipeline.
+
+use crate::ids::{AccountId, AccountSnapshotId, BookId, PostingId};
+use crate::posting::NewPosting;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+/// Free-form key→value metadata.
+pub type Metadata = BTreeMap<String, Vec<u8>>;
+
+/// The unit of atomicity — all of its consumptions and creations apply together
+/// or not at all. This is the resolved, internal form produced by the saga
+/// pipeline from a [`Transfer`](crate::Transfer) intent.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Envelope {
+    /// Posting ids consumed (spent) by this envelope.
+    pub consumes: Vec<PostingId>,
+    /// New postings created by this envelope.
+    pub creates: Vec<NewPosting>,
+    /// Account version pins for optimistic concurrency.
+    pub account_snapshots: Vec<AccountSnapshotId>,
+    /// Book this envelope belongs to.
+    pub book: BookId,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+impl Envelope {
+    /// Posting ids consumed (spent) by this envelope.
+    pub fn consumes(&self) -> &[PostingId] {
+        &self.consumes
+    }
+
+    /// New postings created by this envelope.
+    pub fn creates(&self) -> &[NewPosting] {
+        &self.creates
+    }
+
+    /// Account version pins for optimistic concurrency.
+    pub fn account_snapshots(&self) -> &[AccountSnapshotId] {
+        &self.account_snapshots
+    }
+
+    /// Book this envelope belongs to.
+    pub fn book(&self) -> BookId {
+        self.book
+    }
+
+    /// Free-form key-value metadata.
+    pub fn metadata(&self) -> &Metadata {
+        &self.metadata
+    }
+
+    /// Deduplicated, sorted list of account references in the created postings.
+    pub fn referenced_accounts(&self) -> Vec<AccountId> {
+        let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
+        ids.sort();
+        ids.dedup();
+        ids
+    }
+
+    /// Set account snapshots.
+    pub fn set_account_snapshots(&mut self, snapshots: Vec<AccountSnapshotId>) {
+        self.account_snapshots = snapshots;
+    }
+}
+
+/// Builder for constructing [`Envelope`] values.
+#[derive(Default)]
+pub struct EnvelopeBuilder {
+    envelope: Envelope,
+}
+
+impl EnvelopeBuilder {
+    /// Create an empty builder.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Set the posting ids to consume.
+    pub fn consumes(mut self, ids: Vec<PostingId>) -> Self {
+        self.envelope.consumes = ids;
+        self
+    }
+
+    /// Set the new postings to create.
+    pub fn creates(mut self, postings: Vec<NewPosting>) -> Self {
+        self.envelope.creates = postings;
+        self
+    }
+
+    /// Set the book.
+    pub fn book(mut self, book: BookId) -> Self {
+        self.envelope.book = book;
+        self
+    }
+
+    /// Set the account version pins.
+    pub fn account_snapshots(mut self, snapshots: Vec<AccountSnapshotId>) -> Self {
+        self.envelope.account_snapshots = snapshots;
+        self
+    }
+
+    /// Set the free-form metadata.
+    pub fn metadata(mut self, metadata: Metadata) -> Self {
+        self.envelope.metadata = metadata;
+        self
+    }
+
+    /// Consume the builder and return the [`Envelope`].
+    pub fn build(self) -> Envelope {
+        self.envelope
+    }
+}

+ 233 - 0
crates/kuatia-types/src/ids.rs

@@ -0,0 +1,233 @@
+//! Identifier newtypes for the ledger domain.
+//!
+//! Each id is a thin wrapper over an integer or byte array with its own
+//! `Debug`, constructors, and (where minted) a snowflake-backed `Default`.
+
+use crate::autoid::AutoId;
+use serde::{Deserialize, Serialize};
+use std::fmt;
+
+// ---------------------------------------------------------------------------
+// Identifiers
+// ---------------------------------------------------------------------------
+
+/// Stable account identity. Used in all public APIs.
+///
+/// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
+/// (the default when subaccounts are not used); a non-zero `sub` is a
+/// subaccount of the same base id. Each `(id, sub)` is a full account record
+/// with its own flags and lifecycle. See ADR-0012 and ADR-0015.
+///
+/// Both legs are stored as `i64` (they hash and persist as full `i64`), but the
+/// IBAN-style string form ([`Display`](fmt::Display) / [`FromStr`](std::str::FromStr))
+/// encodes only the low `ID_BITS` of `id` (a 63-bit snowflake never sets the
+/// sign bit) and the low `SUB_BITS` of `sub`. That is what lets the code fit in
+/// a fixed 20 characters. Values outside those ranges still hash, persist, and
+/// compare correctly, but do not round-trip through the string form.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct AccountId {
+    /// Base account id (a 63-bit snowflake; the sign bit is always 0).
+    pub id: i64,
+    /// Subaccount id; `0` is the main account. The string form encodes the low
+    /// [`SUB_BITS`](crate::SUB_BITS) bits, so a subaccount id must fit in that
+    /// range to round-trip.
+    pub sub: i64,
+}
+
+/// Pairs an [`AccountId`] with a snapshot hash — the double-SHA256 of the
+/// account's state at a point in time. Stored on [`Transfer`](crate::Transfer)
+/// to record which account versions a transfer was executed against. Internal
+/// type — the public API uses [`AccountId`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AccountSnapshotId {
+    /// The account (subaccount) this snapshot belongs to.
+    pub account: AccountId,
+    /// Double-SHA256 of the account's state at the time of the snapshot.
+    pub snapshot_id: [u8; 32],
+}
+
+/// Identifies an asset (USD, EUR, BTC, …). Conservation is enforced per asset,
+/// so each asset is an independent conservation boundary.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct AssetId(pub u32);
+
+/// Content-addressed transfer identifier — the double-SHA256 of the canonical
+/// serialization. This makes the id both the idempotency key and the
+/// tamper-evidence artifact.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct EnvelopeId(pub [u8; 32]);
+
+/// Uniquely identifies a posting within the ledger. The `(transfer, index)` pair
+/// ties every posting back to the transfer that created it, which is the basis
+/// of the provenance graph.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct PostingId {
+    /// The transfer that created this posting.
+    pub transfer: EnvelopeId,
+    /// Zero-based position within the transfer's created postings.
+    pub index: u16,
+}
+
+/// Identifies a book — a named scope for transfers.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct BookId(pub i64);
+
+/// Identifies a reservation — the owner token recorded in the reserved index
+/// while a posting is claimed, so only the saga that reserved it may finalize
+/// or release it.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct ReservationId(pub i64);
+
+// ---------------------------------------------------------------------------
+// Debug impls for identifiers
+// ---------------------------------------------------------------------------
+
+impl fmt::Debug for AccountId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if self.sub == 0 {
+            write!(f, "AccountId({})", self.id)
+        } else {
+            write!(f, "AccountId({}.{})", self.id, self.sub)
+        }
+    }
+}
+
+impl fmt::Debug for AssetId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "AssetId({:#010x})", self.0)
+    }
+}
+
+impl fmt::Debug for EnvelopeId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "EnvelopeId({})", hex(&self.0))
+    }
+}
+
+impl fmt::Debug for PostingId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("PostingId")
+            .field("transfer", &self.transfer)
+            .field("index", &self.index)
+            .finish()
+    }
+}
+
+impl fmt::Debug for BookId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "BookId({})", self.0)
+    }
+}
+
+impl fmt::Debug for ReservationId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "ReservationId({})", self.0)
+    }
+}
+
+fn hex(bytes: &[u8]) -> String {
+    bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+// ---------------------------------------------------------------------------
+// Identifier constructors
+// ---------------------------------------------------------------------------
+
+impl Default for AccountId {
+    fn default() -> Self {
+        // Process-global generator: a per-thread one could mint the same id on
+        // two threads within a millisecond, yielding duplicate account ids.
+        static GEN: AutoId = AutoId::new();
+        Self {
+            id: GEN.next(),
+            sub: 0,
+        }
+    }
+}
+
+impl AccountId {
+    /// Create the main account (`sub = 0`) for a base `id`.
+    pub const fn new(id: i64) -> Self {
+        Self { id, sub: 0 }
+    }
+
+    /// Create a specific subaccount of a base `id`.
+    pub const fn with_sub(id: i64, sub: i64) -> Self {
+        Self { id, sub }
+    }
+
+    /// Return the main account of this id (`sub` set to `0`).
+    pub const fn base(&self) -> Self {
+        Self {
+            id: self.id,
+            sub: 0,
+        }
+    }
+
+    /// Whether this is the main account (`sub == 0`).
+    pub const fn is_main(&self) -> bool {
+        self.sub == 0
+    }
+}
+
+impl From<AccountSnapshotId> for AccountId {
+    fn from(snap: AccountSnapshotId) -> Self {
+        snap.account
+    }
+}
+
+impl AssetId {
+    /// Create an `AssetId` from a `u32`.
+    pub const fn new(id: u32) -> Self {
+        Self(id)
+    }
+}
+
+/// The implicit book used when a transfer does not name one. Fixed so that two
+/// otherwise-identical transfers hash to the same [`EnvelopeId`] — a random
+/// default would break content-addressed idempotency.
+pub const DEFAULT_BOOK: BookId = BookId(0);
+
+impl Default for BookId {
+    /// Deterministic: returns [`DEFAULT_BOOK`]. Use [`BookId::generate`] to mint
+    /// a fresh unique id for a real book.
+    fn default() -> Self {
+        DEFAULT_BOOK
+    }
+}
+
+impl BookId {
+    /// Create a `BookId` from an `i64`.
+    pub const fn new(id: i64) -> Self {
+        Self(id)
+    }
+
+    /// Mint a fresh, process-unique book id. Unlike [`Default`], this is not
+    /// stable across calls — use it when creating a new [`Book`](crate::Book),
+    /// never for the implicit book of a transfer.
+    pub fn generate() -> Self {
+        // Process-global so the "process-unique" contract holds across threads;
+        // a per-thread generator can repeat an id on another thread.
+        static GEN: AutoId = AutoId::new();
+        Self(GEN.next())
+    }
+}
+
+impl ReservationId {
+    /// Create a `ReservationId` from an `i64`.
+    pub const fn new(id: i64) -> Self {
+        Self(id)
+    }
+}
+
+impl Default for ReservationId {
+    fn default() -> Self {
+        // One process-global generator, not one per thread: its atomic counter
+        // makes every reservation id unique across threads. A `thread_local`
+        // generator lets two sagas on different threads mint the same id within
+        // a millisecond, which collapses the reservation-ownership check and
+        // allows a double-spend under concurrency.
+        static GEN: AutoId = AutoId::new();
+        Self(GEN.next())
+    }
+}

+ 17 - 890
crates/kuatia-types/src/lib.rs

@@ -7,18 +7,19 @@
 
 pub mod autoid;
 
+mod account;
 mod account_code;
+mod book;
+mod canonical;
+mod envelope;
+mod ids;
+mod posting;
+mod transfer;
 
 pub use account_code::{
     DEFAULT_ID_SEED, ID_BITS, ParseAccountIdError, SUB_BITS, id_seed, set_id_seed,
 };
 
-use serde::{Deserialize, Serialize};
-use std::collections::BTreeMap;
-use std::fmt;
-
-mod canonical;
-
 // The content-addressing contract (trait, version byte, write helpers, and
 // every `impl ToBytes`) lives in `canonical`. Re-exported here so the public
 // surface stays `kuatia_types::{ToBytes, CANONICAL_VERSION, write_*}`.
@@ -26,889 +27,15 @@ pub use canonical::{
     CANONICAL_VERSION, ToBytes, write_i64, write_u16, write_u32, write_u64, write_u128,
 };
 
-// ---------------------------------------------------------------------------
-// Identifiers
-// ---------------------------------------------------------------------------
-
-/// Stable account identity. Used in all public APIs.
-///
-/// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
-/// (the default when subaccounts are not used); a non-zero `sub` is a
-/// subaccount of the same base id. Each `(id, sub)` is a full account record
-/// with its own policy and lifecycle. See ADR-0012 and ADR-0015.
-///
-/// Both legs are stored as `i64` (they hash and persist as full `i64`), but the
-/// IBAN-style string form ([`Display`](fmt::Display) / [`FromStr`](std::str::FromStr))
-/// encodes only the low `ID_BITS` of `id` (a 63-bit snowflake never sets the
-/// sign bit) and the low `SUB_BITS` of `sub`. That is what lets the code fit in
-/// a fixed 20 characters. Values outside those ranges still hash, persist, and
-/// compare correctly, but do not round-trip through the string form.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct AccountId {
-    /// Base account id (a 63-bit snowflake; the sign bit is always 0).
-    pub id: i64,
-    /// Subaccount id; `0` is the main account. The string form encodes the low
-    /// [`SUB_BITS`] bits, so a subaccount id must fit in that range to round-trip.
-    pub sub: i64,
-}
-
-/// Pairs an [`AccountId`] with a snapshot hash — the double-SHA256 of the
-/// account's state at a point in time. Stored on [`Transfer`] to record which
-/// account versions a transfer was executed against. Internal type — the
-/// public API uses [`AccountId`].
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct AccountSnapshotId {
-    /// The account (subaccount) this snapshot belongs to.
-    pub account: AccountId,
-    /// Double-SHA256 of the account's state at the time of the snapshot.
-    pub snapshot_id: [u8; 32],
-}
-
-/// Identifies an asset (USD, EUR, BTC, …). Conservation is enforced per asset,
-/// so each asset is an independent conservation boundary.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct AssetId(pub u32);
-
-/// Content-addressed transfer identifier — the double-SHA256 of the canonical
-/// serialization. This makes the id both the idempotency key and the
-/// tamper-evidence artifact.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct EnvelopeId(pub [u8; 32]);
-
-/// Uniquely identifies a posting within the ledger. The `(transfer, index)` pair
-/// ties every posting back to the transfer that created it, which is the basis
-/// of the provenance graph.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct PostingId {
-    /// The transfer that created this posting.
-    pub transfer: EnvelopeId,
-    /// Zero-based position within the transfer's created postings.
-    pub index: u16,
-}
-
-// ---------------------------------------------------------------------------
-// Cent — re-exported from kuatia-money (swappable integer backing)
-// ---------------------------------------------------------------------------
-
+// Cent — re-exported from kuatia-money (swappable integer backing).
 pub use kuatia_money::{Amount, Cent, OverflowError, ParseAmountError};
 
-// ---------------------------------------------------------------------------
-// Debug / Display impls for identifiers
-// ---------------------------------------------------------------------------
-
-impl fmt::Debug for AccountId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        if self.sub == 0 {
-            write!(f, "AccountId({})", self.id)
-        } else {
-            write!(f, "AccountId({}.{})", self.id, self.sub)
-        }
-    }
-}
-
-impl fmt::Debug for AssetId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "AssetId({:#010x})", self.0)
-    }
-}
-
-impl fmt::Debug for EnvelopeId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "EnvelopeId({})", hex(&self.0))
-    }
-}
-
-impl fmt::Debug for PostingId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.debug_struct("PostingId")
-            .field("transfer", &self.transfer)
-            .field("index", &self.index)
-            .finish()
-    }
-}
-
-fn hex(bytes: &[u8]) -> String {
-    bytes.iter().map(|b| format!("{b:02x}")).collect()
-}
-
-// ---------------------------------------------------------------------------
-// Identifier constructors
-// ---------------------------------------------------------------------------
-
-impl Default for AccountId {
-    fn default() -> Self {
-        // Process-global generator: a per-thread one could mint the same id on
-        // two threads within a millisecond, yielding duplicate account ids.
-        static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
-        Self {
-            id: GEN.next(),
-            sub: 0,
-        }
-    }
-}
-
-impl AccountId {
-    /// Create the main account (`sub = 0`) for a base `id`.
-    pub const fn new(id: i64) -> Self {
-        Self { id, sub: 0 }
-    }
-
-    /// Create a specific subaccount of a base `id`.
-    pub const fn with_sub(id: i64, sub: i64) -> Self {
-        Self { id, sub }
-    }
-
-    /// Return the main account of this id (`sub` set to `0`).
-    pub const fn base(&self) -> Self {
-        Self {
-            id: self.id,
-            sub: 0,
-        }
-    }
-
-    /// Whether this is the main account (`sub == 0`).
-    pub const fn is_main(&self) -> bool {
-        self.sub == 0
-    }
-}
-
-impl From<AccountSnapshotId> for AccountId {
-    fn from(snap: AccountSnapshotId) -> Self {
-        snap.account
-    }
-}
-
-impl AssetId {
-    /// Create an `AssetId` from a `u32`.
-    pub const fn new(id: u32) -> Self {
-        Self(id)
-    }
-}
-
-/// Identifies a book — a named scope for transfers.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct BookId(pub i64);
-
-impl fmt::Debug for BookId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "BookId({})", self.0)
-    }
-}
-
-/// The implicit book used when a transfer does not name one. Fixed so that two
-/// otherwise-identical transfers hash to the same [`EnvelopeId`] — a random
-/// default would break content-addressed idempotency.
-pub const DEFAULT_BOOK: BookId = BookId(0);
-
-impl Default for BookId {
-    /// Deterministic: returns [`DEFAULT_BOOK`]. Use [`BookId::generate`] to mint
-    /// a fresh unique id for a real book.
-    fn default() -> Self {
-        DEFAULT_BOOK
-    }
-}
-
-impl BookId {
-    /// Create a `BookId` from an `i64`.
-    pub const fn new(id: i64) -> Self {
-        Self(id)
-    }
-
-    /// Mint a fresh, process-unique book id. Unlike [`Default`], this is not
-    /// stable across calls — use it when creating a new [`Book`], never for the
-    /// implicit book of a transfer.
-    pub fn generate() -> Self {
-        // Process-global so the "process-unique" contract holds across threads;
-        // a per-thread generator can repeat an id on another thread.
-        static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
-        Self(GEN.next())
-    }
-}
-
-/// Identifies a reservation — the owner token recorded in the reserved index
-/// while a posting is claimed, so only the saga that reserved it may finalize
-/// or release it.
-#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct ReservationId(pub i64);
-
-impl fmt::Debug for ReservationId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "ReservationId({})", self.0)
-    }
-}
-
-impl ReservationId {
-    /// Create a `ReservationId` from an `i64`.
-    pub const fn new(id: i64) -> Self {
-        Self(id)
-    }
-}
-
-impl Default for ReservationId {
-    fn default() -> Self {
-        // One process-global generator, not one per thread: its atomic counter
-        // makes every reservation id unique across threads. A `thread_local`
-        // generator lets two sagas on different threads mint the same id within
-        // a millisecond, which collapses the reservation-ownership check and
-        // allows a double-spend under concurrency.
-        static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
-        Self(GEN.next())
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Book
-// ---------------------------------------------------------------------------
-
-/// A Book is a transfer policy scope: it gates which accounts and assets may
-/// participate in a transfer. It is **not** the chronological entry log (the
-/// transfer log plays that role), and it does **not** partition balances —
-/// balances are global; a Book only gates participation.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Book {
-    /// Stable identity for this book.
-    pub id: BookId,
-    /// Human-readable name.
-    pub name: String,
-    /// Participation rules for this book.
-    pub policy: BookPolicy,
-}
-
-/// The participation rules for a [`Book`]. An empty field means "no restriction".
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct BookPolicy {
-    /// If non-empty, only these assets may appear in movements.
-    pub allowed_assets: Vec<AssetId>,
-    /// If non-empty, accounts with ANY of these flags may participate.
-    pub allowed_flags: AccountFlags,
-    /// If non-empty, these specific accounts may participate (in addition to flag matches).
-    pub allowed_accounts: Vec<AccountId>,
-}
-
-/// Builder for constructing [`Book`] values.
-pub struct BookBuilder {
-    book: Book,
-}
-
-impl BookBuilder {
-    /// Create a new book builder with the given name.
-    pub fn new(name: impl Into<String>) -> Self {
-        Self {
-            book: Book {
-                id: BookId::generate(),
-                name: name.into(),
-                policy: BookPolicy {
-                    allowed_assets: Vec::new(),
-                    allowed_flags: AccountFlags::empty(),
-                    allowed_accounts: Vec::new(),
-                },
-            },
-        }
-    }
-
-    /// Set the book id explicitly.
-    pub fn id(mut self, id: BookId) -> Self {
-        self.book.id = id;
-        self
-    }
-
-    /// Add an allowed asset.
-    pub fn allow_asset(mut self, asset: AssetId) -> Self {
-        self.book.policy.allowed_assets.push(asset);
-        self
-    }
-
-    /// Set allowed account flags — accounts with ANY of these flags may participate.
-    pub fn allow_flags(mut self, flags: AccountFlags) -> Self {
-        self.book.policy.allowed_flags = flags;
-        self
-    }
-
-    /// Add a specific allowed account.
-    pub fn allow_account(mut self, account: AccountId) -> Self {
-        self.book.policy.allowed_accounts.push(account);
-        self
-    }
-
-    /// Consume the builder and return the [`Book`].
-    pub fn build(self) -> Book {
-        self.book
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Posting
-// ---------------------------------------------------------------------------
-
-/// Read filter over the derived lifecycle state of postings.
-///
-/// A posting's state is no longer stored on the posting itself; it is derived
-/// from index-table membership. This filter selects which postings a read
-/// returns:
-///
-/// - `Active` — spendable (present in the active index).
-/// - `Reserved` — claimed by an in-flight saga (present in the reserved index).
-/// - `Live` — `Active ∪ Reserved`; everything that still counts toward balance.
-/// - `All` — every posting in the immutable table, including spent ones.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub enum PostingFilter {
-    /// Spendable postings only.
-    Active,
-    /// Reserved (in-flight) postings only.
-    Reserved,
-    /// Active or reserved — the balance-bearing set (the old "not Inactive").
-    Live,
-    /// Every posting ever created, including spent ones.
-    All,
-}
-
-/// The derived lifecycle state of a single [`Posting`], computed from
-/// index-table membership rather than stored on the posting.
-///
-/// ```text
-/// Active ──reserve──▶ Reserved(rid) ──consume──▶ Spent
-///   ▲  ▲                   │
-///   │  └── release ────────┘  (compensation)
-///   └── (id in active index)
-/// ```
-///
-/// `Reserved` carries the owning [`ReservationId`] so a saga can confirm it
-/// still holds a posting before finalizing or releasing it. `Missing` means the
-/// id is not present in the immutable postings table at all.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub enum PostingState {
-    /// Present in the active index — spendable, counts toward balance.
-    Active,
-    /// Present in the reserved index, claimed by the given reservation.
-    Reserved(ReservationId),
-    /// Present only in the immutable table — consumed by a committed transfer.
-    Spent,
-    /// Not present in the immutable table.
-    Missing,
-}
-
-/// A signed amount of one asset, owned by exactly one account.
-///
-/// A positive posting is value controlled by the account; a negative posting is
-/// an offset position (issuance, external flow, overdraft, or system balancing).
-/// Negative postings are allowed on every policy except `NoOverdraft`.
-///
-/// A `Posting` is an immutable record: once created it is never updated. Its
-/// lifecycle state is not a field here; it is derived from index-table
-/// membership (see [`PostingState`]).
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Posting {
-    /// Unique identifier derived from the creating transfer.
-    pub id: PostingId,
-    /// The account (subaccount) that owns this posting.
-    pub owner: AccountId,
-    /// The asset this posting denominates.
-    pub asset: AssetId,
-    /// Signed: positive = value controlled by the account, negative = offset position.
-    pub value: Cent,
-}
-
-impl Posting {
-    /// Construct a posting record.
-    pub fn new(id: PostingId, owner: AccountId, asset: AssetId, value: Cent) -> Self {
-        Self {
-            id,
-            owner,
-            asset,
-            value,
-        }
-    }
-}
-
-/// A posting to be created — carries no id yet because the [`PostingId`] depends
-/// on the [`EnvelopeId`], which is computed during validation.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct NewPosting {
-    /// The account (subaccount) that will own the created posting.
-    pub owner: AccountId,
-    /// The asset this posting denominates.
-    pub asset: AssetId,
-    /// Signed amount: positive = value controlled by the account, negative = offset position.
-    pub value: Cent,
-    /// Informational provenance — who funded this posting.
-    pub payer: Option<AccountId>,
-}
-
-// ---------------------------------------------------------------------------
-// Transfer
-// ---------------------------------------------------------------------------
-
-/// Free-form key→value metadata.
-pub type Metadata = BTreeMap<String, Vec<u8>>;
-
-/// The unit of atomicity — all of its consumptions and creations apply together
-/// or not at all. This is the resolved, internal form produced by the saga
-/// pipeline from a [`Transfer`] intent.
-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Envelope {
-    /// Posting ids consumed (spent) by this envelope.
-    pub consumes: Vec<PostingId>,
-    /// New postings created by this envelope.
-    pub creates: Vec<NewPosting>,
-    /// Account version pins for optimistic concurrency.
-    pub account_snapshots: Vec<AccountSnapshotId>,
-    /// Book this envelope belongs to.
-    pub book: BookId,
-    /// Free-form key-value metadata.
-    pub metadata: Metadata,
-}
-
-impl Envelope {
-    /// Posting ids consumed (spent) by this envelope.
-    pub fn consumes(&self) -> &[PostingId] {
-        &self.consumes
-    }
-
-    /// New postings created by this envelope.
-    pub fn creates(&self) -> &[NewPosting] {
-        &self.creates
-    }
-
-    /// Account version pins for optimistic concurrency.
-    pub fn account_snapshots(&self) -> &[AccountSnapshotId] {
-        &self.account_snapshots
-    }
-
-    /// Book this envelope belongs to.
-    pub fn book(&self) -> BookId {
-        self.book
-    }
-
-    /// Free-form key-value metadata.
-    pub fn metadata(&self) -> &Metadata {
-        &self.metadata
-    }
-
-    /// Deduplicated, sorted list of account references in the created postings.
-    pub fn referenced_accounts(&self) -> Vec<AccountId> {
-        let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
-        ids.sort();
-        ids.dedup();
-        ids
-    }
-
-    /// Set account snapshots.
-    pub fn set_account_snapshots(&mut self, snapshots: Vec<AccountSnapshotId>) {
-        self.account_snapshots = snapshots;
-    }
-}
-
-// ---------------------------------------------------------------------------
-// EnvelopeBuilder
-// ---------------------------------------------------------------------------
-
-/// Builder for constructing [`Envelope`] values.
-#[derive(Default)]
-pub struct EnvelopeBuilder {
-    envelope: Envelope,
-}
-
-impl EnvelopeBuilder {
-    /// Create an empty builder.
-    pub fn new() -> Self {
-        Self::default()
-    }
-
-    /// Set the posting ids to consume.
-    pub fn consumes(mut self, ids: Vec<PostingId>) -> Self {
-        self.envelope.consumes = ids;
-        self
-    }
-
-    /// Set the new postings to create.
-    pub fn creates(mut self, postings: Vec<NewPosting>) -> Self {
-        self.envelope.creates = postings;
-        self
-    }
-
-    /// Set the book.
-    pub fn book(mut self, book: BookId) -> Self {
-        self.envelope.book = book;
-        self
-    }
-
-    /// Set the account version pins.
-    pub fn account_snapshots(mut self, snapshots: Vec<AccountSnapshotId>) -> Self {
-        self.envelope.account_snapshots = snapshots;
-        self
-    }
-
-    /// Set the free-form metadata.
-    pub fn metadata(mut self, metadata: Metadata) -> Self {
-        self.envelope.metadata = metadata;
-        self
-    }
-
-    /// Consume the builder and return the [`Envelope`].
-    pub fn build(self) -> Envelope {
-        self.envelope
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Account
-// ---------------------------------------------------------------------------
-
-bitflags::bitflags! {
-    /// Lifecycle and balance-constraint flags for an [`Account`].
-    ///
-    /// Bits 0–7 are the system range: bits 0–2 carry lifecycle meaning
-    /// (`FROZEN`, `CLOSED`, `INFLIGHT`), bit 3 is the balance constraint
-    /// (`DEBIT_MUST_NOT_EXCEED_CREDIT`), and bits 4–7
-    /// (`RESERVED_4..RESERVED_7`) are held for future system flags. Bits 8–31
-    /// are the user range (`USER_0..USER_23`), meant to be combined with
-    /// [`BookPolicy::allowed_flags`] to scope which accounts may participate in
-    /// a book.
-    ///
-    /// Every bit has a named constant so `from_bits_truncate` never discards a
-    /// set bit on the storage read path.
-    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-    pub struct AccountFlags: u32 {
-        /// Account may not be the source or destination of any transfer.
-        const FROZEN = 1 << 0;
-        /// Terminal — no further activity.
-        const CLOSED = 1 << 1;
-        /// Holding account for an inflight (authorize/confirm/void) transaction.
-        /// Parks funds between authorize and settlement; closed once drained.
-        const INFLIGHT = 1 << 2;
-        /// The account's debits may never exceed its credits: its balance may
-        /// not go negative and it may not hold a negative posting. When unset
-        /// (the default), the account may overdraw without bound: a shortfall is
-        /// covered by a negative offset posting, and the ledger records the
-        /// transfer as long as it conserves value per asset.
-        const DEBIT_MUST_NOT_EXCEED_CREDIT = 1 << 3;
-        /// Reserved for a future system flag; not for user assignment.
-        const RESERVED_4 = 1 << 4;
-        /// Reserved for a future system flag; not for user assignment.
-        const RESERVED_5 = 1 << 5;
-        /// Reserved for a future system flag; not for user assignment.
-        const RESERVED_6 = 1 << 6;
-        /// Reserved for a future system flag; not for user assignment.
-        const RESERVED_7 = 1 << 7;
-        /// User-defined flag 0.
-        const USER_0 = 1 << 8;
-        /// User-defined flag 1.
-        const USER_1 = 1 << 9;
-        /// User-defined flag 2.
-        const USER_2 = 1 << 10;
-        /// User-defined flag 3.
-        const USER_3 = 1 << 11;
-        /// User-defined flag 4.
-        const USER_4 = 1 << 12;
-        /// User-defined flag 5.
-        const USER_5 = 1 << 13;
-        /// User-defined flag 6.
-        const USER_6 = 1 << 14;
-        /// User-defined flag 7.
-        const USER_7 = 1 << 15;
-        /// User-defined flag 8.
-        const USER_8 = 1 << 16;
-        /// User-defined flag 9.
-        const USER_9 = 1 << 17;
-        /// User-defined flag 10.
-        const USER_10 = 1 << 18;
-        /// User-defined flag 11.
-        const USER_11 = 1 << 19;
-        /// User-defined flag 12.
-        const USER_12 = 1 << 20;
-        /// User-defined flag 13.
-        const USER_13 = 1 << 21;
-        /// User-defined flag 14.
-        const USER_14 = 1 << 22;
-        /// User-defined flag 15.
-        const USER_15 = 1 << 23;
-        /// User-defined flag 16.
-        const USER_16 = 1 << 24;
-        /// User-defined flag 17.
-        const USER_17 = 1 << 25;
-        /// User-defined flag 18.
-        const USER_18 = 1 << 26;
-        /// User-defined flag 19.
-        const USER_19 = 1 << 27;
-        /// User-defined flag 20.
-        const USER_20 = 1 << 28;
-        /// User-defined flag 21.
-        const USER_21 = 1 << 29;
-        /// User-defined flag 22.
-        const USER_22 = 1 << 30;
-        /// User-defined flag 23.
-        const USER_23 = 1 << 31;
-    }
-}
-
-/// A registered entity that must exist before it can transact.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Account {
-    /// Stable identity for this account (base account plus subaccount).
-    pub id: AccountId,
-    /// Monotonically increasing version, starts at 1 on creation.
-    pub version: u64,
-    /// Lifecycle and balance-constraint flags. The balance constraint lives in
-    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`].
-    pub flags: AccountFlags,
-    /// Book this entity belongs to.
-    pub book: BookId,
-    /// Free-form key-value metadata.
-    pub metadata: Metadata,
-}
-
-impl Account {
-    /// Create a version-1 main-subaccount account: no flags, the default book,
-    /// and empty metadata. With no flags the account may overdraw without bound
-    /// (a shortfall becomes a negative offset posting); set
-    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] to forbid that, or use
-    /// [`Account::debit_must_not_exceed_credit`]. Set the other fields
-    /// explicitly when you need them.
-    pub fn new(id: AccountId) -> Self {
-        Self::new_ref(id)
-    }
-
-    /// Like [`Account::new`] but named for the subaccount-reference case; the
-    /// signature is identical.
-    pub fn new_ref(id: AccountId) -> Self {
-        Self {
-            id,
-            version: 1,
-            flags: AccountFlags::empty(),
-            book: DEFAULT_BOOK,
-            metadata: Metadata::new(),
-        }
-    }
-
-    /// A version-1 account whose debits may never exceed its credits: its
-    /// balance may not go negative and it may not hold a negative posting.
-    /// Equivalent to `Account::new(id)` with
-    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] set.
-    pub fn debit_must_not_exceed_credit(id: AccountId) -> Self {
-        let mut account = Self::new(id);
-        account.flags |= AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
-        account
-    }
-
-    /// Whether this account forbids overdraft, i.e. carries the
-    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] flag. When `false` (the
-    /// default) the account may overdraw without bound.
-    pub fn forbids_overdraft(&self) -> bool {
-        self.flags
-            .contains(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)
-    }
-
-    /// Returns `true` if the account has the `FROZEN` flag set.
-    pub fn is_frozen(&self) -> bool {
-        self.flags.contains(AccountFlags::FROZEN)
-    }
-
-    /// Returns `true` if the account has the `CLOSED` flag set.
-    pub fn is_closed(&self) -> bool {
-        self.flags.contains(AccountFlags::CLOSED)
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Receipt
-// ---------------------------------------------------------------------------
-
-/// Confirmation of a committed transfer, carrying its content-addressed id.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Receipt {
-    /// Content-addressed id of the committed transfer.
-    pub transfer_id: EnvelopeId,
-}
-
-// ---------------------------------------------------------------------------
-// Transfer — intent-based API
-// ---------------------------------------------------------------------------
-
-/// A single movement within a transfer: move value from one account to another.
-///
-/// Every operation (pay, deposit, withdraw) is expressed as one or more
-/// movements.  The resolve step aggregates net debits per account and selects
-/// postings only for accounts with a positive net debit.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Movement {
-    /// Account (subaccount) being debited.
-    pub from: AccountId,
-    /// Account (subaccount) being credited.
-    pub to: AccountId,
-    /// Asset to transfer.
-    pub asset: AssetId,
-    /// Amount to transfer (may be negative for offset postings).
-    pub amount: Cent,
-}
-
-/// A transfer intent — one or more movements to execute atomically.
-///
-/// The saga pipeline resolves movements into concrete postings ([`Envelope`])
-/// during execution. Callers express *what* should happen, not *which postings*
-/// to consume.
-#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Transfer {
-    /// Movements to execute atomically.
-    pub movements: Vec<Movement>,
-    /// Book this entity belongs to.
-    pub book: BookId,
-    /// Free-form key-value metadata.
-    pub metadata: Metadata,
-}
-
-/// Builder for constructing [`Transfer`] values.
-#[derive(Default)]
-pub struct TransferBuilder {
-    transfer: Transfer,
-}
-
-impl TransferBuilder {
-    /// Create an empty builder.
-    pub fn new() -> Self {
-        Self::default()
-    }
-
-    /// Add a raw movement between main subaccounts.
-    pub fn movement(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
-        self.movement_ref(from, to, asset, amount)
-    }
-
-    /// Add a raw movement between specific subaccounts.
-    pub fn movement_ref(
-        mut self,
-        from: AccountId,
-        to: AccountId,
-        asset: AssetId,
-        amount: Cent,
-    ) -> Self {
-        self.transfer.movements.push(Movement {
-            from,
-            to,
-            asset,
-            amount,
-        });
-        self
-    }
-
-    /// Add a pay movement between main subaccounts.
-    pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
-        self.movement(from, to, asset, amount)
-    }
-
-    /// Add a pay movement between two specific subaccounts. See
-    /// [`movement_ref`](Self::movement_ref).
-    pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
-        self.movement_ref(from, to, asset, amount)
-    }
-
-    /// Add a deposit: creates an offset posting on the external account and
-    /// credits the target account.  Pushes two movements whose net debit on the
-    /// external account is zero.
-    pub fn deposit(
-        self,
-        to: AccountId,
-        asset: AssetId,
-        amount: Cent,
-        external: AccountId,
-    ) -> Result<Self, OverflowError> {
-        let neg = amount.checked_neg()?;
-        Ok(self
-            .movement(external, external, asset, neg)
-            .movement(external, to, asset, amount))
-    }
-
-    /// Add a withdrawal: move value from an account to an external destination.
-    pub fn withdraw(
-        self,
-        from: AccountId,
-        asset: AssetId,
-        amount: Cent,
-        external: AccountId,
-    ) -> Self {
-        self.movement(from, external, asset, amount)
-    }
-
-    /// Set the book.
-    pub fn book(mut self, book: BookId) -> Self {
-        self.transfer.book = book;
-        self
-    }
-
-    /// Set the free-form metadata.
-    pub fn metadata(mut self, metadata: Metadata) -> Self {
-        self.transfer.metadata = metadata;
-        self
-    }
-
-    /// Consume the builder and return the [`Transfer`].
-    pub fn build(self) -> Transfer {
-        self.transfer
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn account_flags_cover_every_bit() {
-        // Every one of the 32 bits has a named constant, so `all()` fills the
-        // whole `u32` and `from_bits_truncate` can never discard a set bit.
-        assert_eq!(AccountFlags::all().bits(), u32::MAX);
-    }
-
-    #[test]
-    fn account_flags_bit_positions() {
-        assert_eq!(AccountFlags::FROZEN.bits(), 1 << 0);
-        assert_eq!(AccountFlags::INFLIGHT.bits(), 1 << 2);
-        assert_eq!(AccountFlags::RESERVED_7.bits(), 1 << 7);
-        assert_eq!(AccountFlags::USER_0.bits(), 1 << 8);
-        assert_eq!(AccountFlags::USER_8.bits(), 1 << 16);
-        assert_eq!(AccountFlags::USER_23.bits(), 1 << 31);
-    }
-
-    #[test]
-    fn account_flags_high_bit_survives_signed_storage_roundtrip() {
-        // The SQL backend persists flags via `bits() as i32` and reloads via
-        // `from_bits_truncate(bits as u32)`. Bit 31 makes the stored i32
-        // negative; this pins that the reinterpret cast is bit-preserving.
-        let flags = AccountFlags::USER_23 | AccountFlags::FROZEN;
-        let stored = flags.bits() as i32;
-        assert!(
-            stored < 0,
-            "USER_23 should set the sign bit when cast to i32"
-        );
-        let loaded = AccountFlags::from_bits_truncate(stored as u32);
-        assert_eq!(loaded, flags);
-    }
-
-    #[test]
-    fn debit_must_not_exceed_credit_sets_the_flag() {
-        let id = AccountId::new(100);
-        let acc = Account::debit_must_not_exceed_credit(id);
-        assert!(acc.forbids_overdraft());
-        assert!(
-            acc.flags
-                .contains(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)
-        );
-        // It differs from the default only by that one flag.
-        let mut expected = Account::new(id);
-        expected.flags |= AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
-        assert_eq!(acc, expected);
-    }
-
-    #[test]
-    fn new_account_allows_overdraft_by_default() {
-        let acc = Account::new(AccountId::new(101));
-        assert!(!acc.forbids_overdraft());
-        assert_eq!(acc.version, 1);
-        assert_eq!(acc.flags, AccountFlags::empty());
-        assert_eq!(acc.book, DEFAULT_BOOK);
-        assert!(acc.metadata.is_empty());
-    }
-
-    #[test]
-    fn debit_must_not_exceed_credit_bit_is_bit_3() {
-        assert_eq!(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT.bits(), 1 << 3);
-    }
-}
+pub use account::{Account, AccountFlags};
+pub use book::{Book, BookBuilder, BookPolicy};
+pub use envelope::{Envelope, EnvelopeBuilder, Metadata};
+pub use ids::{
+    AccountId, AccountSnapshotId, AssetId, BookId, DEFAULT_BOOK, EnvelopeId, PostingId,
+    ReservationId,
+};
+pub use posting::{NewPosting, Posting, PostingFilter, PostingState};
+pub use transfer::{Movement, Receipt, Transfer, TransferBuilder};

+ 100 - 0
crates/kuatia-types/src/posting.rs

@@ -0,0 +1,100 @@
+//! Posting records and their derived lifecycle state.
+
+use crate::ids::{AccountId, AssetId, PostingId, ReservationId};
+use kuatia_money::Cent;
+use serde::{Deserialize, Serialize};
+
+/// Read filter over the derived lifecycle state of postings.
+///
+/// A posting's state is no longer stored on the posting itself; it is derived
+/// from index-table membership. This filter selects which postings a read
+/// returns:
+///
+/// - `Active` — spendable (present in the active index).
+/// - `Reserved` — claimed by an in-flight saga (present in the reserved index).
+/// - `Live` — `Active ∪ Reserved`; everything that still counts toward balance.
+/// - `All` — every posting in the immutable table, including spent ones.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum PostingFilter {
+    /// Spendable postings only.
+    Active,
+    /// Reserved (in-flight) postings only.
+    Reserved,
+    /// Active or reserved: the balance-bearing set (everything not yet Spent).
+    Live,
+    /// Every posting ever created, including spent ones.
+    All,
+}
+
+/// The derived lifecycle state of a single [`Posting`], computed from
+/// index-table membership rather than stored on the posting.
+///
+/// ```text
+/// Active ──reserve──▶ Reserved(rid) ──consume──▶ Spent
+///   ▲  ▲                   │
+///   │  └── release ────────┘  (compensation)
+///   └── (id in active index)
+/// ```
+///
+/// `Reserved` carries the owning [`ReservationId`] so a saga can confirm it
+/// still holds a posting before finalizing or releasing it. `Missing` means the
+/// id is not present in the immutable postings table at all.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum PostingState {
+    /// Present in the active index — spendable, counts toward balance.
+    Active,
+    /// Present in the reserved index, claimed by the given reservation.
+    Reserved(ReservationId),
+    /// Present only in the immutable table — consumed by a committed transfer.
+    Spent,
+    /// Not present in the immutable table.
+    Missing,
+}
+
+/// A signed amount of one asset, owned by exactly one account.
+///
+/// A positive posting is value controlled by the account; a negative posting is
+/// an offset position (issuance, external flow, overdraft, or system balancing).
+/// Negative postings are allowed on any account except one that forbids
+/// overdraft (carries [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`](crate::AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)).
+///
+/// A `Posting` is an immutable record: once created it is never updated. Its
+/// lifecycle state is not a field here; it is derived from index-table
+/// membership (see [`PostingState`]).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Posting {
+    /// Unique identifier derived from the creating transfer.
+    pub id: PostingId,
+    /// The account (subaccount) that owns this posting.
+    pub owner: AccountId,
+    /// The asset this posting denominates.
+    pub asset: AssetId,
+    /// Signed: positive = value controlled by the account, negative = offset position.
+    pub value: Cent,
+}
+
+impl Posting {
+    /// Construct a posting record.
+    pub fn new(id: PostingId, owner: AccountId, asset: AssetId, value: Cent) -> Self {
+        Self {
+            id,
+            owner,
+            asset,
+            value,
+        }
+    }
+}
+
+/// A posting to be created — carries no id yet because the [`PostingId`] depends
+/// on the [`EnvelopeId`](crate::EnvelopeId), which is computed during validation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NewPosting {
+    /// The account (subaccount) that will own the created posting.
+    pub owner: AccountId,
+    /// The asset this posting denominates.
+    pub asset: AssetId,
+    /// Signed amount: positive = value controlled by the account, negative = offset position.
+    pub value: Cent,
+    /// Informational provenance — who funded this posting.
+    pub payer: Option<AccountId>,
+}

+ 136 - 0
crates/kuatia-types/src/transfer.rs

@@ -0,0 +1,136 @@
+//! The intent-based [`Transfer`] API: [`Movement`], [`TransferBuilder`], and
+//! the [`Receipt`] returned on commit.
+
+use crate::envelope::Metadata;
+use crate::ids::{AccountId, AssetId, BookId, EnvelopeId};
+use kuatia_money::{Cent, OverflowError};
+use serde::{Deserialize, Serialize};
+
+/// Confirmation of a committed transfer, carrying its content-addressed id.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Receipt {
+    /// Content-addressed id of the committed transfer.
+    pub transfer_id: EnvelopeId,
+}
+
+/// A single movement within a transfer: move value from one account to another.
+///
+/// Every operation (pay, deposit, withdraw) is expressed as one or more
+/// movements.  The resolve step aggregates net debits per account and selects
+/// postings only for accounts with a positive net debit.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Movement {
+    /// Account (subaccount) being debited.
+    pub from: AccountId,
+    /// Account (subaccount) being credited.
+    pub to: AccountId,
+    /// Asset to transfer.
+    pub asset: AssetId,
+    /// Amount to transfer (may be negative for offset postings).
+    pub amount: Cent,
+}
+
+/// A transfer intent — one or more movements to execute atomically.
+///
+/// The saga pipeline resolves movements into concrete postings
+/// ([`Envelope`](crate::Envelope)) during execution. Callers express *what*
+/// should happen, not *which postings* to consume.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transfer {
+    /// Movements to execute atomically.
+    pub movements: Vec<Movement>,
+    /// Book this entity belongs to.
+    pub book: BookId,
+    /// Free-form key-value metadata.
+    pub metadata: Metadata,
+}
+
+/// Builder for constructing [`Transfer`] values.
+#[derive(Default)]
+pub struct TransferBuilder {
+    transfer: Transfer,
+}
+
+impl TransferBuilder {
+    /// Create an empty builder.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Add a raw movement between main subaccounts.
+    pub fn movement(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(from, to, asset, amount)
+    }
+
+    /// Add a raw movement between specific subaccounts.
+    pub fn movement_ref(
+        mut self,
+        from: AccountId,
+        to: AccountId,
+        asset: AssetId,
+        amount: Cent,
+    ) -> Self {
+        self.transfer.movements.push(Movement {
+            from,
+            to,
+            asset,
+            amount,
+        });
+        self
+    }
+
+    /// Add a pay movement between main subaccounts.
+    pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement(from, to, asset, amount)
+    }
+
+    /// Add a pay movement between two specific subaccounts. See
+    /// [`movement_ref`](Self::movement_ref).
+    pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(from, to, asset, amount)
+    }
+
+    /// Add a deposit: creates an offset posting on the external account and
+    /// credits the target account.  Pushes two movements whose net debit on the
+    /// external account is zero.
+    pub fn deposit(
+        self,
+        to: AccountId,
+        asset: AssetId,
+        amount: Cent,
+        external: AccountId,
+    ) -> Result<Self, OverflowError> {
+        let neg = amount.checked_neg()?;
+        Ok(self
+            .movement(external, external, asset, neg)
+            .movement(external, to, asset, amount))
+    }
+
+    /// Add a withdrawal: move value from an account to an external destination.
+    pub fn withdraw(
+        self,
+        from: AccountId,
+        asset: AssetId,
+        amount: Cent,
+        external: AccountId,
+    ) -> Self {
+        self.movement(from, external, asset, amount)
+    }
+
+    /// Set the book.
+    pub fn book(mut self, book: BookId) -> Self {
+        self.transfer.book = book;
+        self
+    }
+
+    /// Set the free-form metadata.
+    pub fn metadata(mut self, metadata: Metadata) -> Self {
+        self.transfer.metadata = metadata;
+        self
+    }
+
+    /// Consume the builder and return the [`Transfer`].
+    pub fn build(self) -> Transfer {
+        self.transfer
+    }
+}

+ 29 - 13
crates/kuatia/src/error.rs

@@ -3,9 +3,12 @@
 //! [`LedgerError`] unifies errors from the pure core (validation, selection)
 //! and from storage, so callers get a single error type from every API.
 
+use std::error::Error;
+use std::fmt;
+
 use kuatia_core::{
-    AccountId, AssetId, BookId, EnvelopeId, OverflowError, PostingId, ResolveError, SelectionError,
-    ValidationError,
+    AccountId, AssetId, BookId, EnvelopeId, InsufficientFunds, OverflowError, PostingId,
+    ResolveError, ValidationError,
 };
 use kuatia_storage::error::StoreError;
 
@@ -24,7 +27,7 @@ pub enum LedgerError {
     /// Storage operation failed.
     Store(StoreError),
     /// Posting selection failed (e.g. insufficient funds).
-    Selection(SelectionError),
+    Selection(InsufficientFunds),
     /// The referenced transfer does not exist.
     TransferNotFound(EnvelopeId),
     /// The posting cannot be reversed (e.g. already consumed).
@@ -35,8 +38,21 @@ pub enum LedgerError {
     AccountNotEmpty(AccountId),
     /// The account is already closed.
     AccountAlreadyClosed(AccountId),
+    /// An account with this id already exists.
+    AccountAlreadyExists(AccountId),
+    /// An account-version append did not land: the head was not at
+    /// `expected - 1` when the append was attempted (a concurrent transition
+    /// moved it). Carries the version this attempt targeted.
+    AccountVersionConflict {
+        /// Account whose version append was rejected.
+        account: AccountId,
+        /// Version this attempt targeted (`current + 1` at read time).
+        expected: u64,
+    },
     /// A transfer named a book that does not exist.
     BookNotFound(BookId),
+    /// A book with this id already exists.
+    BookAlreadyExists(BookId),
     /// The referenced inflight transaction does not exist (no authorize record).
     InflightNotFound(EnvelopeId),
     /// The referenced transfer is not an inflight authorize, or its metadata is
@@ -65,8 +81,8 @@ pub enum LedgerError {
     },
 }
 
-impl std::fmt::Display for LedgerError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl fmt::Display for LedgerError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::Validation(e) => write!(f, "validation: {e}"),
             Self::Store(e) => write!(f, "store: {e}"),
@@ -76,7 +92,13 @@ impl std::fmt::Display for LedgerError {
             Self::AccountNotFound(id) => write!(f, "account not found: {id:?}"),
             Self::AccountNotEmpty(id) => write!(f, "account not empty: {id:?}"),
             Self::AccountAlreadyClosed(id) => write!(f, "account already closed: {id:?}"),
+            Self::AccountAlreadyExists(id) => write!(f, "account already exists: {id:?}"),
+            Self::AccountVersionConflict { account, expected } => write!(
+                f,
+                "account version conflict for {account:?}: could not append version {expected}"
+            ),
             Self::BookNotFound(id) => write!(f, "book not found: {id:?}"),
+            Self::BookAlreadyExists(id) => write!(f, "book already exists: {id:?}"),
             Self::InflightNotFound(id) => write!(f, "inflight transaction not found: {id:?}"),
             Self::NotInflightTransaction(id) => {
                 write!(f, "not an inflight authorize transaction: {id:?}")
@@ -103,8 +125,8 @@ impl std::fmt::Display for LedgerError {
     }
 }
 
-impl std::error::Error for LedgerError {
-    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+impl Error for LedgerError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
         match self {
             Self::Validation(e) => Some(e),
             Self::Store(e) => Some(e),
@@ -128,12 +150,6 @@ impl From<StoreError> for LedgerError {
     }
 }
 
-impl From<SelectionError> for LedgerError {
-    fn from(e: SelectionError) -> Self {
-        LedgerError::Selection(e)
-    }
-}
-
 impl From<ResolveError> for LedgerError {
     fn from(e: ResolveError) -> Self {
         match e {

+ 49 - 223
crates/kuatia/src/inflight.rs

@@ -2,9 +2,9 @@
 //! later.
 //!
 //! An inflight transaction is an ordinary trade whose every destination is
-//! rewritten to a per-destination holding subaccount (`NoOverdraft`, flagged
-//! [`AccountFlags::INFLIGHT`], keyed by a subaccount derived from the trade).
-//! Committing that rewritten transfer parks the
+//! rewritten to a per-destination holding subaccount (flagged
+//! [`AccountFlags::INFLIGHT`] and forbidding overdraft, keyed by a subaccount
+//! derived from the trade). Committing that rewritten transfer parks the
 //! funds. Confirm and void are ordinary commits that move a hold's balance to
 //! its destination or back to its funder. Nothing new is stored: the authorize
 //! transfer's metadata carries the leg table, and every artifact is tagged with
@@ -16,18 +16,20 @@ use std::collections::{BTreeMap, BTreeSet};
 use std::sync::Arc;
 
 use kuatia_core::{
-    Account, AccountFlags, AccountId, AssetId, BookId, Cent, EnvelopeId, Metadata, Receipt,
-    SelectionError, Transfer, TransferBuilder, hash::double_sha256,
+    Account, AccountFlags, AccountId, AssetId, BookId, Cent, EnvelopeId, InsufficientFunds,
+    Receipt, SUB_BITS, Transfer, TransferBuilder, hash::double_sha256,
 };
-use kuatia_storage::error::StoreError;
 use kuatia_storage::store::EnvelopeRecord;
 use serde::{Deserialize, Serialize};
 
 use crate::error::LedgerError;
 use crate::ledger::Ledger;
 
-/// Single metadata key holding the CBOR-encoded [`InflightMeta`] payload.
-const K_INFLIGHT: &str = "inflight";
+mod projection;
+use projection::{
+    FunderPayout, InflightMeta, K_INFLIGHT, StatusInput, derive_status, distribute_to_funders,
+    encode_meta, group_holds, meta_map, read_meta,
+};
 
 /// One leg of an inflight transaction: an amount of an asset funded by `funder`,
 /// parked in `hold`, destined for `destination`.
@@ -109,31 +111,6 @@ pub struct InflightStatus {
     pub state: InflightState,
 }
 
-// ---------------------------------------------------------------------------
-// Metadata: one CBOR-encoded tagged payload under the `inflight` key
-// ---------------------------------------------------------------------------
-
-/// The inflight payload carried in a transfer's or holding account's metadata.
-/// Serialized to CBOR (via `ciborium`) and stored under [`K_INFLIGHT`], so the
-/// whole lifecycle is self-describing and read back, not inferred.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-enum InflightMeta {
-    /// Tags the authorize transfer and carries its leg table.
-    Authorize { legs: Vec<InflightLeg> },
-    /// Tags a per-destination holding subaccount.
-    Hold { destination: AccountId },
-    /// Tags a settling transfer that delivers to a destination.
-    Confirm {
-        tx: EnvelopeId,
-        destination: AccountId,
-    },
-    /// Tags a settling transfer that returns to a funder.
-    Void {
-        tx: EnvelopeId,
-        destination: AccountId,
-    },
-}
-
 /// Whether a settle delivers to the destination or returns to a funder.
 #[derive(Clone, Copy)]
 enum SettleRole {
@@ -141,31 +118,6 @@ enum SettleRole {
     Void,
 }
 
-fn malformed(tid: EnvelopeId) -> LedgerError {
-    LedgerError::NotInflightTransaction(tid)
-}
-
-/// Encode an [`InflightMeta`] to CBOR bytes.
-fn encode_meta(meta: &InflightMeta) -> Result<Vec<u8>, LedgerError> {
-    let mut buf = Vec::new();
-    ciborium::into_writer(meta, &mut buf)
-        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-    Ok(buf)
-}
-
-/// Wrap a single [`InflightMeta`] into a fresh [`Metadata`] map.
-fn meta_map(meta: &InflightMeta) -> Result<Metadata, LedgerError> {
-    let mut m = Metadata::new();
-    m.insert(K_INFLIGHT.to_string(), encode_meta(meta)?);
-    Ok(m)
-}
-
-/// Decode the [`InflightMeta`] carried by a metadata map, if any.
-fn read_meta(meta: &Metadata) -> Option<InflightMeta> {
-    let bytes = meta.get(K_INFLIGHT)?;
-    ciborium::from_reader(bytes.as_slice()).ok()
-}
-
 impl Ledger {
     // -----------------------------------------------------------------------
     // Authorize
@@ -214,7 +166,7 @@ impl Ledger {
             acct.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
             match self.create_account(acct).await {
                 Ok(()) => {}
-                Err(LedgerError::Store(StoreError::AlreadyExists(_))) => {
+                Err(LedgerError::AccountAlreadyExists(_)) => {
                     return Err(LedgerError::InflightAlreadyOpen(*hold));
                 }
                 Err(e) => return Err(e),
@@ -294,8 +246,8 @@ impl Ledger {
     ///
     /// Each movement delivers `amount` of `asset` from the matching leg's hold to
     /// its destination. `amount` must not exceed the amount still held; the
-    /// `NoOverdraft` hold makes over-confirmation impossible regardless. A hold is
-    /// closed once fully drained.
+    /// overdraft-forbidding hold makes over-confirmation impossible regardless. A
+    /// hold is closed once fully drained.
     ///
     /// Movements settle in order, each its own commit, so the batch is not atomic:
     /// a later movement failing leaves earlier confirmations applied.
@@ -318,7 +270,7 @@ impl Ledger {
                 })?;
             let held = self.balance(&leg.hold, &m.asset).await?;
             if m.amount > held {
-                return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
+                return Err(LedgerError::Selection(InsufficientFunds {
                     available: held,
                     requested: m.amount,
                 }));
@@ -359,40 +311,25 @@ impl Ledger {
         let mut receipts = Vec::new();
         for group in group_holds(&legs, *inflight)? {
             for asset in &group.assets {
-                let mut remaining = self.balance(&group.hold, asset).await?;
-                // Return to funders in leg order, each up to what it funded. For
-                // the common single-funder-per-(hold, asset) case this returns the
-                // whole remaining balance to that funder.
-                let mut funders: Vec<(AccountId, Cent)> = legs
-                    .iter()
-                    .filter(|l| l.hold == group.hold && l.asset == *asset)
-                    .map(|l| (l.funder, l.amount))
-                    .collect();
-                // Ensure any co-funding rounding leftover lands on the last funder.
-                if let Some(last) = funders.last_mut() {
-                    last.1 = Cent::from(i64::MAX);
-                }
-                for (funder, cap) in funders {
-                    if !remaining.is_positive() {
-                        break;
-                    }
-                    let give = if cap < remaining { cap } else { remaining };
-                    if give.is_positive() {
-                        receipts.push(
-                            self.settle(
-                                book,
-                                *inflight,
-                                group.hold,
-                                funder,
-                                group.destination,
-                                *asset,
-                                give,
-                                SettleRole::Void,
-                            )
-                            .await?,
-                        );
-                        remaining = remaining.checked_sub(give)?;
-                    }
+                // Return each hold's remaining balance to its funders; the pure
+                // split owns the leg-order and rounding-leftover rules.
+                let remaining = self.balance(&group.hold, asset).await?;
+                for FunderPayout { funder, give } in
+                    distribute_to_funders(&legs, group.hold, *asset, remaining)?
+                {
+                    receipts.push(
+                        self.settle(
+                            book,
+                            *inflight,
+                            group.hold,
+                            funder,
+                            group.destination,
+                            *asset,
+                            give,
+                            SettleRole::Void,
+                        )
+                        .await?,
+                    );
                 }
             }
             self.close_if_drained(&group.hold).await?;
@@ -412,65 +349,27 @@ impl Ledger {
         inflight: &EnvelopeId,
     ) -> Result<InflightStatus, LedgerError> {
         let (_record, legs) = self.load_inflight(inflight).await?;
-        let groups = group_holds(&legs, *inflight)?;
-
-        // Authorized per (hold, asset).
-        let mut authorized: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
-        for l in &legs {
-            let e = authorized.entry((l.hold, l.asset)).or_insert(Cent::ZERO);
-            *e = e.checked_add(l.amount)?;
-        }
 
-        // Confirmed / voided per (hold, asset), summed from settle transfers.
-        let mut confirmed: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
-        let mut voided: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
-        for group in &groups {
-            for record in self.history(&group.hold).await? {
-                let bucket = match read_meta(record.envelope.metadata()) {
-                    Some(InflightMeta::Confirm { .. }) => &mut confirmed,
-                    Some(InflightMeta::Void { .. }) => &mut voided,
-                    _ => continue,
-                };
-                for np in record.envelope.creates() {
-                    if np.owner == group.hold {
-                        continue; // change returned to the hold, not settled out
-                    }
-                    let e = bucket.entry((group.hold, np.asset)).or_insert(Cent::ZERO);
-                    *e = e.checked_add(np.value)?;
-                }
-            }
-        }
-
-        let mut lines = Vec::new();
-        for group in &groups {
+        // Load exactly what the projection reads: each hold's settle history and
+        // its live per-asset balance. `group_holds` names the holds to fetch;
+        // `derive_status` re-derives its own grouping to fold the status.
+        let mut hold_history: Vec<(AccountId, Vec<EnvelopeRecord>)> = Vec::new();
+        let mut held: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+        for group in group_holds(&legs, *inflight)? {
+            hold_history.push((group.hold, self.history(&group.hold).await?));
             for asset in &group.assets {
-                let held = self.balance(&group.hold, asset).await?;
-                lines.push(InflightLegStatus {
-                    destination: group.destination,
-                    hold: group.hold,
-                    asset: *asset,
-                    authorized: authorized
-                        .get(&(group.hold, *asset))
-                        .copied()
-                        .unwrap_or(Cent::ZERO),
-                    confirmed: confirmed
-                        .get(&(group.hold, *asset))
-                        .copied()
-                        .unwrap_or(Cent::ZERO),
-                    voided: voided
-                        .get(&(group.hold, *asset))
-                        .copied()
-                        .unwrap_or(Cent::ZERO),
-                    held,
-                });
+                held.insert(
+                    (group.hold, *asset),
+                    self.balance(&group.hold, asset).await?,
+                );
             }
         }
 
-        let state = overall_state(&lines);
-        Ok(InflightStatus {
+        derive_status(StatusInput {
             inflight: *inflight,
-            legs: lines,
-            state,
+            legs: &legs,
+            hold_history: &hold_history,
+            held: &held,
         })
     }
 
@@ -565,83 +464,10 @@ fn inflight_subaccount(transfer: &Transfer) -> i64 {
     first.copy_from_slice(&hash[..8]);
     // Keep only the low SUB_BITS so the hold's subaccount id fits the account
     // code's encodable range (ADR-0015). The result is always positive.
-    let mask = (1u64 << kuatia_types::SUB_BITS) - 1;
+    let mask = (1u64 << SUB_BITS) - 1;
     (u64::from_be_bytes(first) & mask) as i64
 }
 
-/// A holding subaccount of an inflight together with its destination and the
-/// assets it carries. Groups a leg table by hold so the confirm, void, and
-/// status paths share one traversal instead of each re-deriving `holds_of` /
-/// `destination_of` / `assets_of` inline.
-struct HoldGroup {
-    hold: AccountId,
-    destination: AccountId,
-    assets: Vec<AssetId>,
-}
-
-/// Group `legs` by holding subaccount, resolving each hold's destination. This
-/// is the single "walk the holds of an inflight" traversal; it is pure over the
-/// leg table and yields holds in sorted order (each with its assets sorted).
-fn group_holds(legs: &[InflightLeg], inflight: EnvelopeId) -> Result<Vec<HoldGroup>, LedgerError> {
-    holds_of(legs)
-        .into_iter()
-        .map(|hold| {
-            Ok(HoldGroup {
-                hold,
-                destination: destination_of(legs, hold, inflight)?,
-                assets: assets_of(legs, hold).into_iter().collect(),
-            })
-        })
-        .collect()
-}
-
-fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountId> {
-    legs.iter().map(|l| l.hold).collect()
-}
-
-fn assets_of(legs: &[InflightLeg], hold: AccountId) -> BTreeSet<AssetId> {
-    legs.iter()
-        .filter(|l| l.hold == hold)
-        .map(|l| l.asset)
-        .collect()
-}
-
-fn destination_of(
-    legs: &[InflightLeg],
-    hold: AccountId,
-    inflight: EnvelopeId,
-) -> Result<AccountId, LedgerError> {
-    legs.iter()
-        .find(|l| l.hold == hold)
-        .map(|l| l.destination)
-        .ok_or_else(|| malformed(inflight))
-}
-
-fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
-    let mut any_held = false;
-    let mut any_confirmed = false;
-    let mut any_voided = false;
-    for l in lines {
-        if l.held.is_positive() {
-            any_held = true;
-        }
-        if l.confirmed.is_positive() {
-            any_confirmed = true;
-        }
-        if l.voided.is_positive() {
-            any_voided = true;
-        }
-    }
-    match (any_held, any_confirmed, any_voided) {
-        (true, false, false) => InflightState::Held,
-        (true, _, _) => InflightState::PartiallyConfirmed,
-        (false, true, true) => InflightState::Mixed,
-        (false, false, true) => InflightState::Voided,
-        // Fully settled to destinations, or an empty/zero authorization.
-        (false, _, false) => InflightState::Confirmed,
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;

+ 577 - 0
crates/kuatia/src/inflight/projection.rs

@@ -0,0 +1,577 @@
+//! Pure inflight projection: the `InflightMeta` schema and the derivation rules
+//! that turn a leg table, the settling transfers, and held balances into an
+//! [`InflightStatus`].
+//!
+//! This is the single owner of the encode/decode halves so they cannot drift,
+//! and of the void funder-distribution arithmetic. Everything here is pure (no
+//! `self`, no IO, no async); the async [`Ledger`](crate::ledger::Ledger)
+//! methods in the parent module only load raw records and call these functions.
+//! It stays in the `kuatia` crate rather than moving to `kuatia-core` because it
+//! reads [`EnvelopeRecord`], a `kuatia-storage` type the pure core avoids.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use kuatia_core::{AccountId, AssetId, Cent, EnvelopeId, Metadata, OverflowError};
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::EnvelopeRecord;
+use serde::{Deserialize, Serialize};
+
+use super::{InflightLeg, InflightLegStatus, InflightState, InflightStatus};
+use crate::error::LedgerError;
+
+/// Single metadata key holding the CBOR-encoded [`InflightMeta`] payload.
+pub(super) const K_INFLIGHT: &str = "inflight";
+
+// ---------------------------------------------------------------------------
+// Metadata: one CBOR-encoded tagged payload under the `inflight` key
+// ---------------------------------------------------------------------------
+
+/// The inflight payload carried in a transfer's or holding account's metadata.
+/// Serialized to CBOR (via `ciborium`) and stored under [`K_INFLIGHT`], so the
+/// whole lifecycle is self-describing and read back, not inferred.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub(super) enum InflightMeta {
+    /// Tags the authorize transfer and carries its leg table.
+    Authorize { legs: Vec<InflightLeg> },
+    /// Tags a per-destination holding subaccount.
+    Hold { destination: AccountId },
+    /// Tags a settling transfer that delivers to a destination.
+    Confirm {
+        tx: EnvelopeId,
+        destination: AccountId,
+    },
+    /// Tags a settling transfer that returns to a funder.
+    Void {
+        tx: EnvelopeId,
+        destination: AccountId,
+    },
+}
+
+fn malformed(tid: EnvelopeId) -> LedgerError {
+    LedgerError::NotInflightTransaction(tid)
+}
+
+/// Encode an [`InflightMeta`] to CBOR bytes.
+pub(super) fn encode_meta(meta: &InflightMeta) -> Result<Vec<u8>, LedgerError> {
+    let mut buf = Vec::new();
+    ciborium::into_writer(meta, &mut buf)
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+    Ok(buf)
+}
+
+/// Wrap a single [`InflightMeta`] into a fresh [`Metadata`] map.
+pub(super) fn meta_map(meta: &InflightMeta) -> Result<Metadata, LedgerError> {
+    let mut m = Metadata::new();
+    m.insert(K_INFLIGHT.to_string(), encode_meta(meta)?);
+    Ok(m)
+}
+
+/// Decode the [`InflightMeta`] carried by a metadata map, if any. Absent or
+/// malformed metadata yields `None` rather than an error.
+pub(super) fn read_meta(meta: &Metadata) -> Option<InflightMeta> {
+    let bytes = meta.get(K_INFLIGHT)?;
+    ciborium::from_reader(bytes.as_slice()).ok()
+}
+
+// ---------------------------------------------------------------------------
+// Hold grouping: the single "walk the holds of an inflight" traversal
+// ---------------------------------------------------------------------------
+
+/// A holding subaccount of an inflight together with its destination and the
+/// assets it carries. Groups a leg table by hold so the confirm, void, and
+/// status paths share one traversal instead of each re-deriving `holds_of` /
+/// `destination_of` / `assets_of` inline.
+pub(super) struct HoldGroup {
+    pub(super) hold: AccountId,
+    pub(super) destination: AccountId,
+    pub(super) assets: Vec<AssetId>,
+}
+
+/// Group `legs` by holding subaccount, resolving each hold's destination. This
+/// is the single "walk the holds of an inflight" traversal; it is pure over the
+/// leg table and yields holds in sorted order (each with its assets sorted).
+pub(super) fn group_holds(
+    legs: &[InflightLeg],
+    inflight: EnvelopeId,
+) -> Result<Vec<HoldGroup>, LedgerError> {
+    holds_of(legs)
+        .into_iter()
+        .map(|hold| {
+            Ok(HoldGroup {
+                hold,
+                destination: destination_of(legs, hold, inflight)?,
+                assets: assets_of(legs, hold).into_iter().collect(),
+            })
+        })
+        .collect()
+}
+
+fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountId> {
+    legs.iter().map(|l| l.hold).collect()
+}
+
+fn assets_of(legs: &[InflightLeg], hold: AccountId) -> BTreeSet<AssetId> {
+    legs.iter()
+        .filter(|l| l.hold == hold)
+        .map(|l| l.asset)
+        .collect()
+}
+
+fn destination_of(
+    legs: &[InflightLeg],
+    hold: AccountId,
+    inflight: EnvelopeId,
+) -> Result<AccountId, LedgerError> {
+    legs.iter()
+        .find(|l| l.hold == hold)
+        .map(|l| l.destination)
+        .ok_or_else(|| malformed(inflight))
+}
+
+// ---------------------------------------------------------------------------
+// Status derivation
+// ---------------------------------------------------------------------------
+
+/// Pre-loaded state for [`derive_status`], mirroring `PlanInput`: the async
+/// layer fetches the raw records and balances, this struct names exactly what
+/// the projection reads.
+pub(super) struct StatusInput<'a> {
+    /// The inflight handle.
+    pub(super) inflight: EnvelopeId,
+    /// The leg table from the authorize transfer.
+    pub(super) legs: &'a [InflightLeg],
+    /// Settle transfers found in each hold's history, tagged with that hold.
+    pub(super) hold_history: &'a [(AccountId, Vec<EnvelopeRecord>)],
+    /// Live held balance per (hold, asset).
+    pub(super) held: &'a BTreeMap<(AccountId, AssetId), Cent>,
+}
+
+/// Fold the leg table, settling transfers, and held balances into an
+/// [`InflightStatus`]. Pure: the caller supplies all state via [`StatusInput`].
+///
+/// Re-derives its own hold grouping via [`group_holds`] rather than trusting a
+/// caller-supplied grouping, the same way `validate_and_plan` re-derives its
+/// account sets; the loader's grouping is only used to decide what to fetch.
+pub(super) fn derive_status(input: StatusInput<'_>) -> Result<InflightStatus, LedgerError> {
+    let StatusInput {
+        inflight,
+        legs,
+        hold_history,
+        held,
+    } = input;
+    let groups = group_holds(legs, inflight)?;
+
+    // Authorized per (hold, asset).
+    let mut authorized: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+    for l in legs {
+        let e = authorized.entry((l.hold, l.asset)).or_insert(Cent::ZERO);
+        *e = e.checked_add(l.amount)?;
+    }
+
+    // Index history by hold so attribution keys on the fetched-for hold, never
+    // on the records' order relative to `groups`.
+    let history_by_hold: BTreeMap<AccountId, &[EnvelopeRecord]> = hold_history
+        .iter()
+        .map(|(hold, recs)| (*hold, recs.as_slice()))
+        .collect();
+
+    // Confirmed / voided per (hold, asset), summed from settle transfers.
+    let mut confirmed: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+    let mut voided: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+    for group in &groups {
+        let records = history_by_hold.get(&group.hold).copied().unwrap_or(&[][..]);
+        for record in records {
+            let bucket = match read_meta(record.envelope.metadata()) {
+                Some(InflightMeta::Confirm { .. }) => &mut confirmed,
+                Some(InflightMeta::Void { .. }) => &mut voided,
+                _ => continue,
+            };
+            for np in record.envelope.creates() {
+                if np.owner == group.hold {
+                    continue; // change returned to the hold, not settled out
+                }
+                let e = bucket.entry((group.hold, np.asset)).or_insert(Cent::ZERO);
+                *e = e.checked_add(np.value)?;
+            }
+        }
+    }
+
+    let mut lines = Vec::new();
+    for group in &groups {
+        for asset in &group.assets {
+            lines.push(InflightLegStatus {
+                destination: group.destination,
+                hold: group.hold,
+                asset: *asset,
+                authorized: authorized
+                    .get(&(group.hold, *asset))
+                    .copied()
+                    .unwrap_or(Cent::ZERO),
+                confirmed: confirmed
+                    .get(&(group.hold, *asset))
+                    .copied()
+                    .unwrap_or(Cent::ZERO),
+                voided: voided
+                    .get(&(group.hold, *asset))
+                    .copied()
+                    .unwrap_or(Cent::ZERO),
+                held: held
+                    .get(&(group.hold, *asset))
+                    .copied()
+                    .unwrap_or(Cent::ZERO),
+            });
+        }
+    }
+
+    let state = overall_state(&lines);
+    Ok(InflightStatus {
+        inflight,
+        legs: lines,
+        state,
+    })
+}
+
+fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
+    let mut any_held = false;
+    let mut any_confirmed = false;
+    let mut any_voided = false;
+    for l in lines {
+        if l.held.is_positive() {
+            any_held = true;
+        }
+        if l.confirmed.is_positive() {
+            any_confirmed = true;
+        }
+        if l.voided.is_positive() {
+            any_voided = true;
+        }
+    }
+    match (any_held, any_confirmed, any_voided) {
+        (true, false, false) => InflightState::Held,
+        (true, _, _) => InflightState::PartiallyConfirmed,
+        (false, true, true) => InflightState::Mixed,
+        (false, false, true) => InflightState::Voided,
+        // Fully settled to destinations, or an empty/zero authorization.
+        (false, _, false) => InflightState::Confirmed,
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Void funder distribution
+// ---------------------------------------------------------------------------
+
+/// One funder's share of a voided hold balance.
+pub(super) struct FunderPayout {
+    pub(super) funder: AccountId,
+    pub(super) give: Cent,
+}
+
+/// Split `remaining` back to the funders of `(hold, asset)` in leg order, each
+/// capped at what it funded. Any co-funding rounding leftover lands on the last
+/// funder (its cap is lifted). Returns only positive payouts; an empty funder
+/// set yields no payouts. Pure arithmetic.
+pub(super) fn distribute_to_funders(
+    legs: &[InflightLeg],
+    hold: AccountId,
+    asset: AssetId,
+    mut remaining: Cent,
+) -> Result<Vec<FunderPayout>, OverflowError> {
+    let mut funders: Vec<(AccountId, Cent)> = legs
+        .iter()
+        .filter(|l| l.hold == hold && l.asset == asset)
+        .map(|l| (l.funder, l.amount))
+        .collect();
+    // Ensure any co-funding rounding leftover lands on the last funder.
+    if let Some(last) = funders.last_mut() {
+        last.1 = Cent::from(i64::MAX);
+    }
+
+    let mut payouts = Vec::new();
+    for (funder, cap) in funders {
+        if !remaining.is_positive() {
+            break;
+        }
+        let give = if cap < remaining { cap } else { remaining };
+        if give.is_positive() {
+            payouts.push(FunderPayout { funder, give });
+            remaining = remaining.checked_sub(give)?;
+        }
+    }
+    Ok(payouts)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use kuatia_core::{BookId, Envelope, NewPosting, Receipt};
+
+    // -- overall_state golden vectors --------------------------------------
+
+    fn line(authorized: i64, confirmed: i64, voided: i64, held: i64) -> InflightLegStatus {
+        InflightLegStatus {
+            destination: AccountId::new(2),
+            hold: AccountId::with_sub(2, 7),
+            asset: AssetId::new(1),
+            authorized: Cent::from(authorized),
+            confirmed: Cent::from(confirmed),
+            voided: Cent::from(voided),
+            held: Cent::from(held),
+        }
+    }
+
+    #[test]
+    fn overall_state_held() {
+        assert_eq!(overall_state(&[line(100, 0, 0, 100)]), InflightState::Held);
+    }
+
+    #[test]
+    fn overall_state_partially_confirmed() {
+        // Some settled out, some still held.
+        assert_eq!(
+            overall_state(&[line(100, 40, 0, 60)]),
+            InflightState::PartiallyConfirmed
+        );
+    }
+
+    #[test]
+    fn overall_state_confirmed() {
+        assert_eq!(
+            overall_state(&[line(100, 100, 0, 0)]),
+            InflightState::Confirmed
+        );
+    }
+
+    #[test]
+    fn overall_state_voided() {
+        assert_eq!(
+            overall_state(&[line(100, 0, 100, 0)]),
+            InflightState::Voided
+        );
+    }
+
+    #[test]
+    fn overall_state_mixed() {
+        // Nothing held; one leg confirmed, another voided.
+        assert_eq!(
+            overall_state(&[line(100, 100, 0, 0), line(100, 0, 100, 0)]),
+            InflightState::Mixed
+        );
+    }
+
+    #[test]
+    fn overall_state_empty_authorization_is_confirmed() {
+        // No legs at all, and a zero-amount leg, both hit the
+        // `(false, _, false)` catch-all. We keep this as Confirmed by design.
+        assert_eq!(overall_state(&[]), InflightState::Confirmed);
+        assert_eq!(overall_state(&[line(0, 0, 0, 0)]), InflightState::Confirmed);
+    }
+
+    // -- distribute_to_funders golden vectors ------------------------------
+
+    fn leg(funder: i64, hold_sub: i64, asset: u32, amount: i64) -> InflightLeg {
+        InflightLeg {
+            destination: AccountId::new(2),
+            hold: AccountId::with_sub(2, hold_sub),
+            funder: AccountId::new(funder),
+            asset: AssetId::new(asset),
+            amount: Cent::from(amount),
+        }
+    }
+
+    #[test]
+    fn distribute_single_funder_gets_whole_balance() {
+        let legs = [leg(1, 7, 1, 100)];
+        let out = distribute_to_funders(
+            &legs,
+            AccountId::with_sub(2, 7),
+            AssetId::new(1),
+            Cent::from(80),
+        )
+        .unwrap();
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].funder, AccountId::new(1));
+        // Last (only) funder's cap is lifted, so the whole remaining is returned.
+        assert_eq!(out[0].give, Cent::from(80));
+    }
+
+    #[test]
+    fn distribute_co_funders_split_by_cap() {
+        // Two funders of 60 and 40; a remaining 100 splits 60 / 40.
+        let legs = [leg(1, 7, 1, 60), leg(3, 7, 1, 40)];
+        let out = distribute_to_funders(
+            &legs,
+            AccountId::with_sub(2, 7),
+            AssetId::new(1),
+            Cent::from(100),
+        )
+        .unwrap();
+        assert_eq!(out.len(), 2);
+        assert_eq!(
+            (out[0].funder, out[0].give),
+            (AccountId::new(1), Cent::from(60))
+        );
+        assert_eq!(
+            (out[1].funder, out[1].give),
+            (AccountId::new(3), Cent::from(40))
+        );
+    }
+
+    #[test]
+    fn distribute_rounding_leftover_lands_on_last_funder() {
+        // First funder capped at 60; the last funder's cap is lifted so it
+        // absorbs the leftover (100 - 60 = 40, even though it funded only 30).
+        let legs = [leg(1, 7, 1, 60), leg(3, 7, 1, 30)];
+        let out = distribute_to_funders(
+            &legs,
+            AccountId::with_sub(2, 7),
+            AssetId::new(1),
+            Cent::from(100),
+        )
+        .unwrap();
+        assert_eq!(out.len(), 2);
+        assert_eq!(out[0].give, Cent::from(60));
+        assert_eq!(out[1].give, Cent::from(40));
+    }
+
+    #[test]
+    fn distribute_no_funders_yields_no_payouts() {
+        let legs: [InflightLeg; 0] = [];
+        let out = distribute_to_funders(
+            &legs,
+            AccountId::with_sub(2, 7),
+            AssetId::new(1),
+            Cent::from(100),
+        )
+        .unwrap();
+        assert!(out.is_empty());
+    }
+
+    // -- derive_status end-to-end vectors ----------------------------------
+
+    fn settle_record(meta: InflightMeta, creates: Vec<NewPosting>) -> EnvelopeRecord {
+        EnvelopeRecord {
+            envelope: Envelope {
+                consumes: vec![],
+                creates,
+                account_snapshots: vec![],
+                book: BookId(0),
+                metadata: meta_map(&meta).unwrap(),
+            },
+            receipt: Receipt {
+                transfer_id: EnvelopeId([0; 32]),
+            },
+            created_at: 0,
+        }
+    }
+
+    fn np(owner: AccountId, asset: u32, value: i64) -> NewPosting {
+        NewPosting {
+            owner,
+            asset: AssetId::new(asset),
+            value: Cent::from(value),
+            payer: None,
+        }
+    }
+
+    #[test]
+    fn derive_status_confirm_skips_change_to_hold() {
+        // A single leg authorized 100. One confirm settle delivers 60 to the
+        // destination and returns 40 change to the hold. The change posting must
+        // not count toward `confirmed`.
+        let inflight = EnvelopeId([9; 32]);
+        let hold = AccountId::with_sub(2, 7);
+        let dest = AccountId::new(2);
+        let legs = [leg(1, 7, 1, 100)];
+
+        let confirm = settle_record(
+            InflightMeta::Confirm {
+                tx: inflight,
+                destination: dest,
+            },
+            vec![np(dest, 1, 60), np(hold, 1, 40)],
+        );
+        let hold_history = vec![(hold, vec![confirm])];
+        let mut held = BTreeMap::new();
+        held.insert((hold, AssetId::new(1)), Cent::from(40));
+
+        let status = derive_status(StatusInput {
+            inflight,
+            legs: &legs,
+            hold_history: &hold_history,
+            held: &held,
+        })
+        .unwrap();
+
+        assert_eq!(status.legs.len(), 1);
+        let l = status.legs[0];
+        assert_eq!(l.authorized, Cent::from(100));
+        assert_eq!(l.confirmed, Cent::from(60)); // change to hold excluded
+        assert_eq!(l.voided, Cent::ZERO);
+        assert_eq!(l.held, Cent::from(40));
+        assert_eq!(status.state, InflightState::PartiallyConfirmed);
+    }
+
+    #[test]
+    fn derive_status_confirmed_and_voided_bucketing() {
+        // Authorized 100, fully settled: 70 confirmed to the destination, 30
+        // voided back to the funder, nothing held -> Mixed.
+        let inflight = EnvelopeId([9; 32]);
+        let hold = AccountId::with_sub(2, 7);
+        let dest = AccountId::new(2);
+        let funder = AccountId::new(1);
+        let legs = [leg(1, 7, 1, 100)];
+
+        let confirm = settle_record(
+            InflightMeta::Confirm {
+                tx: inflight,
+                destination: dest,
+            },
+            vec![np(dest, 1, 70)],
+        );
+        let void = settle_record(
+            InflightMeta::Void {
+                tx: inflight,
+                destination: dest,
+            },
+            vec![np(funder, 1, 30)],
+        );
+        let hold_history = vec![(hold, vec![confirm, void])];
+        let mut held = BTreeMap::new();
+        held.insert((hold, AssetId::new(1)), Cent::ZERO);
+
+        let status = derive_status(StatusInput {
+            inflight,
+            legs: &legs,
+            hold_history: &hold_history,
+            held: &held,
+        })
+        .unwrap();
+
+        let l = status.legs[0];
+        assert_eq!(l.confirmed, Cent::from(70));
+        assert_eq!(l.voided, Cent::from(30));
+        assert_eq!(l.held, Cent::ZERO);
+        assert_eq!(status.state, InflightState::Mixed);
+    }
+
+    #[test]
+    fn derive_status_all_held_when_no_settles() {
+        let inflight = EnvelopeId([9; 32]);
+        let hold = AccountId::with_sub(2, 7);
+        let legs = [leg(1, 7, 1, 100)];
+        let hold_history = vec![(hold, vec![])];
+        let mut held = BTreeMap::new();
+        held.insert((hold, AssetId::new(1)), Cent::from(100));
+
+        let status = derive_status(StatusInput {
+            inflight,
+            legs: &legs,
+            hold_history: &hold_history,
+            held: &held,
+        })
+        .unwrap();
+
+        assert_eq!(status.legs[0].held, Cent::from(100));
+        assert_eq!(status.state, InflightState::Held);
+    }
+}

+ 1 - 9
crates/kuatia/src/ledger.rs

@@ -17,24 +17,16 @@ use std::sync::Arc;
 
 use kuatia_storage::store::Store;
 
-// Kept in root scope so `envelope_saga`'s `use super::*` resolves the `legend!`
-// macro and the types its expansion names.
 use crate::error::LedgerError;
-use crate::saga::{FinalizeTransferStep, LedgerCtx, ReservePostingsStep};
-use legend::legend;
-
-#[allow(missing_docs)]
-mod envelope_saga;
 
 mod balance;
 mod commit;
 mod lifecycle;
-mod projection;
+mod pending;
 mod query;
 mod transition;
 
 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

+ 246 - 9
crates/kuatia/src/ledger/balance.rs

@@ -1,13 +1,34 @@
-//! Per-subaccount balance queries.
+//! Balances: the authoritative live-posting sum, the everyday cached read, and
+//! the append-only cache points that keep that read fast (ADR-0012, ADR-0019).
 //!
-//! Balances are always computed in Rust from the live (active or reserved)
-//! postings and are never summed across subaccounts (ADR-0012).
+//! One concept, two computation strategies that must agree:
+//!
+//! - [`compute_balance`](Ledger::compute_balance) is **authoritative**: the sum
+//!   of the live (active or reserved) postings for one `(account, asset)`, always
+//!   recomputed from the source of truth. It is what validation reads, what the
+//!   projector folds into snapshots, and what reconciliation checks against.
+//! - [`balance`](Ledger::balance) is the **everyday read**: the closest cache
+//!   point (at or before now) plus the folded tail of transfers committed after
+//!   its watermark.
+//!
+//! The module's core contract: summing every one of an account's transfer deltas
+//! equals its live-posting sum, so **`balance` always equals `compute_balance` at
+//! rest**. Cache points are a pure optimization that shortens the tail;
+//! correctness never depends on them, and reconciliation re-derives the
+//! authoritative value to check against a cache point.
+//!
+//! Balances are always computed in Rust with checked arithmetic and are never
+//! summed across subaccounts (ADR-0012).
+
+use std::collections::HashSet;
+use std::sync::Arc;
 
 use tracing::instrument;
 
-use kuatia_core::{AccountId, AssetId, Cent, PostingFilter};
+use kuatia_core::{AccountId, AssetId, Cent, PostingFilter, PostingId};
+use kuatia_storage::store::{EnvelopeRecord, Store, TransferQuery};
 
-use super::Ledger;
+use super::{Ledger, now_millis};
 use crate::error::LedgerError;
 
 /// A single subaccount's balance for one asset. Balances are always reported
@@ -20,6 +41,138 @@ pub struct SubAccountBalance {
     pub value: Cent,
 }
 
+// ---------------------------------------------------------------------------
+// Cache-point internals (ADR-0019): fold committed-transfer deltas onto a
+// snapshot. Private to this module; the read/reconcile entry points are on
+// `Ledger` below.
+// ---------------------------------------------------------------------------
+
+/// 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: &[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 authoritative balance: the sum of the live (active or reserved)
     /// postings for one `(account, asset)`, computed in Rust. This bypasses the
@@ -45,11 +198,56 @@ impl Ledger {
         Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
     }
 
+    /// 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)
+            }
+        }
+    }
+
     /// Report the per-subaccount balances of a base account for one asset.
     ///
-    /// One entry per non-closed subaccount. `sub == None` spans every
-    /// subaccount of `account`'s base id; `Some(s)` restricts to that one.
-    /// Balances are never summed across subaccounts (ADR-0012).
+    /// One entry per non-closed subaccount, each read through the everyday cached
+    /// [`balance`](Ledger::balance) so every balance read goes through one path.
+    /// `sub == None` spans every subaccount of `account`'s base id; `Some(s)`
+    /// restricts to that one. Balances are never summed across subaccounts
+    /// (ADR-0012).
     #[instrument(skip(self), name = "ledger.balances")]
     pub async fn balances(
         &self,
@@ -64,7 +262,7 @@ impl Ledger {
             {
                 continue;
             }
-            let value = self.compute_balance(&subaccount, asset).await?;
+            let value = self.balance(&subaccount, asset).await?;
             result.push(SubAccountBalance {
                 account: subaccount,
                 value,
@@ -94,4 +292,43 @@ impl Ledger {
         subs.sort();
         Ok(subs)
     }
+
+    /// 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
+    }
 }

+ 152 - 299
crates/kuatia/src/ledger/commit.rs

@@ -1,129 +1,70 @@
-//! The write-ahead saga/commit engine: resolve, reserve, finalize, recover.
+//! The write-ahead commit engine: resolve, reserve, finalize, recover.
 //!
-//! This is the deep core of the ledger. Every commit is the two-step envelope
-//! saga (`reserve → finalize`, validation inside finalize) with automatic retry
-//! and LIFO compensation. A phase-tracked write-ahead record ([`PendingSaga`])
-//! lets [`Ledger::recover`] complete or safely abandon a commit interrupted by a
-//! crash.
-
-use std::collections::{HashMap, HashSet};
+//! This is the deep core of the ledger. Every commit is the linear
+//! `reserve → finalize` path (validation inside finalize), which releases its
+//! reservation to compensate a failure before the point of no return. A
+//! phase-tracked write-ahead record ([`PendingSaga`]) lets [`Ledger::recover`]
+//! complete or safely abandon a commit interrupted by a crash.
+
+use std::collections::HashMap;
+use std::collections::hash_map::Entry;
 use std::sync::Arc;
 
-use legend::ExecutionResult;
 use tracing::instrument;
 
 use kuatia_core::{
-    AccountId, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope, EnvelopeBuilder,
-    EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId, PostingState, Receipt,
-    ResolveInput, Transfer, account_snapshot_id, draft_movements, envelope_id, resolve_envelope,
-    validate_and_plan,
+    Account, AccountId, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
+    EnvelopeBuilder, EnvelopeId, NewPosting, Plan, PlanInput, Posting, PostingFilter, PostingId,
+    PostingState, Receipt, ReservationId, ResolveInput, Transfer, account_snapshot_id,
+    draft_movements, envelope_id, required_state, resolve_envelope, validate_and_plan,
 };
 
 use kuatia_storage::error::StoreError;
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 use kuatia_storage::store::EnvelopeRecord;
 
-use super::envelope_saga::*;
 use super::{Ledger, now_millis};
 use crate::error::LedgerError;
-use crate::saga::{FinalizeInput, LedgerCtx, ReserveInput, apply_and_verify, verify_postings};
-
-/// Phase of an in-flight commit, persisted with the write-ahead record so
-/// recovery knows whether validation has completed.
-#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
-enum SagaPhase {
-    /// Saved before reserve. Validation has not necessarily run, so recovery must
-    /// re-reserve and re-validate before it can commit.
-    Reserving,
-    /// Saved at the start of finalize — after validation passed and just before
-    /// the consumed postings begin being removed from the reserved index (the
-    /// point of no return). Recovery rolls forward without re-validating.
-    Finalizing,
-}
+use crate::saga::{apply_and_verify, consume_reserved, verify_postings};
 
-/// Write-ahead record for an in-flight commit, persisted via `SagaStore` before
-/// the saga mutates anything and removed once it reaches a terminal state. On
-/// startup [`Ledger::recover`] completes any that survive a crash.
-#[derive(serde::Serialize, serde::Deserialize)]
-struct PendingSaga {
-    envelope: Envelope,
-    reservation: kuatia_core::ReservationId,
-    phase: SagaPhase,
-}
-
-/// Write-ahead record for an in-flight account-version transition
-/// (freeze/unfreeze/close). The transition appends a new account version and then
-/// its lifecycle event; a crash between the two leaves a version bump with no
-/// event. Persisting this before either write lets [`Ledger::recover`] roll the
-/// transition forward, re-appending the (idempotent) event.
-#[derive(serde::Serialize, serde::Deserialize)]
-pub(super) struct PendingTransition {
-    /// The next account version to append: version already bumped, flag flipped.
-    pub next: kuatia_core::Account,
-    /// The lifecycle event paired with this version bump. It carries the target
-    /// version, so re-appending it on recovery dedups to the original.
-    pub event: LedgerEventKind,
-}
-
-/// The two kinds of write-ahead record the [`SagaStore`](kuatia_storage::store::SagaStore)
-/// holds, tagged so [`Ledger::recover`] can tell an envelope commit saga from an
-/// account transition and complete each through its own path.
-#[derive(serde::Serialize, serde::Deserialize)]
-enum PendingRecord {
-    /// A two-step envelope commit saga (reserve → finalize).
-    Envelope(PendingSaga),
-    /// A single account-version transition (append version + lifecycle event).
-    Transition(PendingTransition),
-}
+use super::pending::{PendingRecord, PendingSaga, SagaPhase};
 
 /// State loaded in phase 1, passed to the pure validation in phase 2.
-pub struct LoadedState {
+struct LoadedState {
     /// Postings being consumed by the envelope.
-    pub consumed_postings: Vec<Posting>,
+    consumed_postings: Vec<Posting>,
     /// Accounts referenced by the envelope.
-    pub accounts: HashMap<AccountId, kuatia_core::Account>,
+    accounts: HashMap<AccountId, Account>,
     /// Current balances for all referenced (account, asset) pairs.
-    pub balances: HashMap<(AccountId, AssetId), Cent>,
+    balances: HashMap<(AccountId, AssetId), Cent>,
     /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
-    pub book: Option<Book>,
+    book: Option<Book>,
 }
 
 impl Ledger {
     // -----------------------------------------------------------------------
-    // Three-piece API: load -> plan -> apply
+    // Validation phases: load (read state) -> plan (pure validate)
     // -----------------------------------------------------------------------
 
-    /// Phase 1: load all state needed for validation.
+    /// Load all state needed for validation.
     #[instrument(skip(self, envelope), name = "ledger.load")]
-    pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
+    async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
         let consumed_postings = if envelope.consumes().is_empty() {
             vec![]
         } else {
             self.store.get_postings(envelope.consumes()).await?
         };
 
-        let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
-        for p in &consumed_postings {
-            account_ids.push(p.owner);
-        }
-        account_ids.sort();
-        account_ids.dedup();
+        // The pure core names exactly what validation will read; iterate that
+        // key-set so the loader cannot silently under-fetch (a missing balance
+        // key defaults to zero and would flip an overdraft decision).
+        let required = required_state(envelope, &consumed_postings);
 
-        let account_list = self.store.get_accounts(&account_ids).await?;
+        let account_list = self.store.get_accounts(&required.accounts).await?;
         let accounts: HashMap<AccountId, _> = account_list.into_iter().map(|a| (a.id, a)).collect();
 
-        let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
-        for p in &consumed_postings {
-            balance_keys.push((p.owner, p.asset));
-        }
-        for np in envelope.creates() {
-            balance_keys.push((np.owner, np.asset));
-        }
-        balance_keys.sort();
-        balance_keys.dedup();
-
         let mut balances = HashMap::new();
-        for (account_id, asset_id) in &balance_keys {
+        for (account_id, asset_id) in &required.balances {
             let bal = self.compute_balance(account_id, asset_id).await?;
             balances.insert((*account_id, *asset_id), bal);
         }
@@ -146,12 +87,20 @@ impl Ledger {
         })
     }
 
-    /// Phase 2: run pure validation and produce a plan.
-    pub fn plan(
-        &self,
-        envelope: &Envelope,
-        loaded: &LoadedState,
-    ) -> Result<kuatia_core::Plan, LedgerError> {
+    /// Run pure validation over the loaded state and produce a plan.
+    fn plan(&self, envelope: &Envelope, loaded: &LoadedState) -> Result<Plan, LedgerError> {
+        // The loader must have fetched every balance key validation reads.
+        // `compute_balance` never omits a key (an empty account is zero), so a
+        // gap here means `load` under-fetched — a silent write-skew, not a
+        // missing row. Fail loudly instead of validating against zero.
+        debug_assert!(
+            required_state(envelope, &loaded.consumed_postings)
+                .balances
+                .iter()
+                .all(|key| loaded.balances.contains_key(key)),
+            "load under-fetched balances required by validation",
+        );
+
         let input = PlanInput {
             envelope,
             consumed_postings: &loaded.consumed_postings,
@@ -172,18 +121,20 @@ impl Ledger {
     /// The decision is pure ([`kuatia_core::draft_movements`] +
     /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
     /// functions need. Pass 1 aggregates net debits and tells us which postings
-    /// to load and which accounts permit overdraft; pass 2 selects postings,
-    /// computes change, and covers any overdraft shortfall.
+    /// and accounts to load; pass 2 selects postings, computes change, and covers
+    /// any overdraft shortfall (reading each account's own flag, the same one
+    /// validation reads).
     #[instrument(skip(self, transfer), name = "ledger.resolve")]
     pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
         let draft = draft_movements(transfer)?;
 
-        // Load the active postings for each debit, and note which debit accounts
-        // permit overdraft. A deposit nets to zero on the system account, so it
-        // produces no debit and loads nothing here.
+        // Load the active postings for each debit and the debit accounts
+        // themselves. Pass 2 reads the overdraft decision off each account's flag,
+        // so we hand it the accounts rather than a re-derived set. A deposit nets
+        // to zero on the system account, so it produces no debit and loads nothing
+        // here.
         let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
-        let mut overdraft_allowed: HashSet<AccountId> = HashSet::new();
-        let mut checked: HashSet<AccountId> = HashSet::new();
+        let mut accounts: HashMap<AccountId, Account> = HashMap::new();
         for debit in &draft.debits {
             let postings = self
                 .store
@@ -195,14 +146,8 @@ impl Ledger {
                 )
                 .await?;
             available.insert((debit.account, debit.asset), postings);
-            if checked.insert(debit.account)
-                && !self
-                    .store
-                    .get_account(&debit.account)
-                    .await?
-                    .forbids_overdraft()
-            {
-                overdraft_allowed.insert(debit.account);
+            if let Entry::Vacant(e) = accounts.entry(debit.account) {
+                e.insert(self.store.get_account(&debit.account).await?);
             }
         }
 
@@ -210,7 +155,7 @@ impl Ledger {
             transfer,
             draft,
             available: &available,
-            overdraft_allowed: &overdraft_allowed,
+            accounts: &accounts,
         })?;
 
         // Resolve account snapshots for optimistic concurrency
@@ -237,9 +182,9 @@ impl Ledger {
     /// validate -> finalize). This is the single commit path; `commit()` and
     /// `reverse()` both funnel through it.
     ///
-    /// Before running, the saga (envelope + reservation) is persisted as a
+    /// Before running, the commit (envelope + reservation) is persisted as a
     /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
-    /// record is deleted once the saga reaches a terminal state. The commit is
+    /// record is deleted once the commit reaches a terminal state. The commit is
     /// idempotent on the content-addressed transfer id.
     #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
     pub async fn commit_envelope(
@@ -259,138 +204,99 @@ impl Ledger {
             return Ok(record.receipt);
         }
 
-        // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
-        // mutation. The finalize step bumps the phase to Finalizing.
-        let reservation = kuatia_core::ReservationId::default();
-        let saga_id = reservation.0;
-        self.save_pending(&envelope, reservation, SagaPhase::Reserving)
-            .await?;
+        // The write-ahead record owns its own lifecycle (persist at Reserving,
+        // drive the saga, clear only when past-the-point-of-no-return is safe), so
+        // this path and `recover()` share one contract instead of each re-deriving
+        // the phase rules.
+        PendingSaga::new(envelope, ReservationId::default())
+            .run(self)
+            .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
-        // failure that never reached finalize (phase still Reserving → the saga's
-        // compensation released our reservation, nothing of ours was applied). If
-        // finalize started (Finalizing) and failed, keep it so `recover()` rolls
-        // the half-applied commit forward.
-        let safe_to_delete = match &result {
-            Ok(_) => true,
-            Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
+    /// The single-commit core: reserve the consumed postings, then finalize.
+    ///
+    /// This used to be a two-step `legend` saga; for one commit the steps were
+    /// pass-throughs, so the reserve/compensation policy now lives here as a
+    /// linear path next to [`finalize_envelope`](Self::finalize_envelope).
+    /// (`legend` still drives the composed multi-transfer sagas in `saga`.)
+    ///
+    /// On any failure the reservation is released (the LIFO compensation collapsed
+    /// to its one meaningful action). Before the point of no return that returns
+    /// the postings to Active so the caller's `clear_if_safe` can drop the
+    /// write-ahead record; past `Finalizing` the postings are already spent, so
+    /// the release is a no-op and the record is instead kept for roll-forward. The
+    /// typed error (validation / overdraft / frozen) reaches the caller unchanged.
+    pub(super) async fn reserve_and_finalize(
+        &self,
+        envelope: &Envelope,
+        reservation: ReservationId,
+    ) -> Result<Receipt, LedgerError> {
+        let consumes = envelope.consumes();
+
+        let result = match self.reserve_consumed(consumes, reservation).await {
+            Ok(()) => self.finalize_envelope(envelope, reservation).await,
+            Err(err) => Err(err),
         };
-        if safe_to_delete {
-            self.store.delete_saga(&saga_id).await?;
+
+        match result {
+            Ok(receipt) => Ok(receipt),
+            // Compensate by releasing our reservation. If the release itself fails
+            // we surface both errors, matching the old saga's `CompensationFailed`.
+            Err(err) => match self.store.release_postings(consumes, reservation).await {
+                Ok(_) => Err(err),
+                Err(comp) => Err(LedgerError::CompensationFailed {
+                    original: Box::new(err),
+                    compensation: Box::new(LedgerError::Store(comp)),
+                }),
+            },
         }
-        result
     }
 
-    /// Build and run the envelope saga (reserve → finalize) to a terminal
-    /// outcome, returning the resulting receipt.
-    async fn drive_envelope_saga(
-        self: &Arc<Self>,
-        envelope: Envelope,
-        reservation: kuatia_core::ReservationId,
-    ) -> Result<Receipt, LedgerError> {
-        let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
-            reserve: ReserveInput,
-            finalize: FinalizeInput,
-        });
-        let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
-        let execution = saga.build(ctx);
-
-        match execution.start().await {
-            ExecutionResult::Completed(e) => {
-                let ctx = e.into_context();
-                ctx.receipts.last().cloned().ok_or_else(|| {
-                    LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
-                })
-            }
-            // The saga's error type is `LedgerError`, so a validation / overdraft
-            // / frozen failure detected during commit reaches the caller as the
-            // real typed variant instead of a stringified internal fault.
-            ExecutionResult::Failed(_, err) => Err(err),
-            ExecutionResult::CompensationFailed {
-                original_error,
-                compensation_error,
-                ..
-            } => Err(LedgerError::CompensationFailed {
-                original: Box::new(original_error),
-                compensation: Box::new(compensation_error),
-            }),
-            ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
-                "saga paused unexpectedly".into(),
-            ))),
+    /// Reserve every consumed posting into the reserved index under `reservation`
+    /// (a CAS out of the active index), then check the affected-row count against
+    /// the ADR-0003 contract. A short count is accepted only when the shortfall is
+    /// already reserved by us (an idempotent replay, e.g. recovery re-running a
+    /// `Reserving` saga). A deposit consumes nothing, so this is a no-op.
+    async fn reserve_consumed(
+        &self,
+        consumes: &[PostingId],
+        reservation: ReservationId,
+    ) -> Result<(), LedgerError> {
+        if consumes.is_empty() {
+            return Ok(());
         }
+        let reserved = self
+            .store
+            .reserve_postings(consumes, reservation)
+            .await
+            .map_err(LedgerError::Store)?;
+        verify_postings(
+            self.store.as_ref(),
+            consumes,
+            reserved,
+            |s| matches!(s, PostingState::Reserved(r) if *r == reservation),
+            "reserve",
+        )
+        .await
     }
 
-    /// Complete every pending saga left by a crash. Call on startup; returns how
-    /// many were processed.
+    /// Complete every pending write-ahead record left by a crash. Call on
+    /// startup; returns how many were processed.
     ///
-    /// Recovery branches on the persisted phase. A `Reserving` saga had not
-    /// necessarily validated, so it is re-run through the real saga (which
-    /// re-reserves and **re-validates** — aborting cleanly if the postings were
-    /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
-    /// validated and owns its postings, so it is rolled forward through the
-    /// verified `finalize_envelope`. Either way the record is removed only once
-    /// the work is committed or safely abandoned.
+    /// Each record is decoded and driven to a terminal state by
+    /// `PendingRecord::complete` (in the `pending` module), which owns the
+    /// per-kind completion: a transition rolls forward; an envelope commit
+    /// branches on its persisted phase — a `Reserving` saga is re-run and
+    /// re-validated, a `Finalizing` saga is rolled forward through the verified
+    /// `finalize_envelope`.
     #[instrument(skip(self), name = "ledger.recover")]
     pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
         let pending = self.store.list_pending_sagas().await?;
         let count = pending.len();
         for (saga_id, blob) in pending {
-            let record: PendingRecord = serde_json::from_slice(&blob)
-                .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-
-            match record {
-                PendingRecord::Transition(PendingTransition { next, event }) => {
-                    // Roll the account transition forward: append the version if it
-                    // is not yet present, then (re-)append the idempotent event.
-                    // Both steps no-op when already applied, so this is safe to run
-                    // in any crash window.
-                    self.complete_transition(saga_id, next, event).await?;
-                }
-                PendingRecord::Envelope(PendingSaga {
-                    envelope,
-                    reservation,
-                    phase,
-                }) => {
-                    // The transfer record is durable, but a full commit is more
-                    // than the transfer row: it also includes the committed event,
-                    // appended *after* store_transfer. A crash in that window
-                    // leaves the record present yet the event missing, so repair
-                    // the whole end-state (idempotent) before clearing the record.
-                    let tid = envelope_id(&envelope);
-                    if self.store.get_transfer(&tid).await?.is_some() {
-                        self.append_committed_event(tid).await?;
-                        self.store.delete_saga(&saga_id).await?;
-                        continue;
-                    }
-
-                    match phase {
-                        SagaPhase::Finalizing => {
-                            // Validation passed and the postings are ours; roll
-                            // forward. Keep the record if completion fails so a
-                            // later run retries.
-                            if self.finalize_envelope(&envelope, reservation).await.is_ok() {
-                                self.store.delete_saga(&saga_id).await?;
-                            }
-                        }
-                        SagaPhase::Reserving => {
-                            // Re-run the validating saga. On failure, delete only if
-                            // it did not reach finalize (clean abort); otherwise
-                            // keep for next run.
-                            let result = self.drive_envelope_saga(envelope, reservation).await;
-                            let safe_to_delete = result.is_ok()
-                                || self.read_pending_phase(saga_id).await?
-                                    != Some(SagaPhase::Finalizing);
-                            if safe_to_delete {
-                                self.store.delete_saga(&saga_id).await?;
-                            }
-                        }
-                    }
-                }
-            }
+            PendingRecord::decode(&blob)?
+                .complete(self, saga_id)
+                .await?;
         }
         Ok(count)
     }
@@ -408,7 +314,7 @@ impl Ledger {
     pub(crate) async fn finalize_envelope(
         &self,
         envelope: &Envelope,
-        reservation: kuatia_core::ReservationId,
+        reservation: ReservationId,
     ) -> Result<Receipt, LedgerError> {
         let tid = envelope_id(envelope);
         if let Some(record) = self.store.get_transfer(&tid).await? {
@@ -444,26 +350,13 @@ impl Ledger {
         }
 
         // Point of no return: record Finalizing before any posting is consumed.
-        self.save_pending(envelope, reservation, SagaPhase::Finalizing)
+        PendingSaga::finalizing(envelope.clone(), reservation)
+            .persist(self)
             .await?;
 
-        // Consume our reserved postings (remove from the reserved index → spent),
-        // then assert ALL consumed postings are spent. This is the double-spend
-        // guard: `deactivate_postings(Some(rid))` only removes rows we reserved,
-        // so any consumed id still active or reserved by another saga leaves the
-        // "all spent" check failing.
-        let spent = self
-            .store
-            .deactivate_postings(consumes, Some(reservation))
-            .await?;
-        verify_postings(
-            self.store.as_ref(),
-            consumes,
-            spent,
-            |s| *s == PostingState::Spent,
-            "finalize: consume reserved postings",
-        )
-        .await?;
+        // The authoritative double-spend guard (see `consume_reserved`): consume
+        // only rows we reserved, then assert all consumed postings are spent.
+        consume_reserved(self.store.as_ref(), consumes, reservation).await?;
 
         // Created postings, derived deterministically from the envelope.
         let created: Vec<Posting> = envelope
@@ -527,7 +420,7 @@ impl Ledger {
     /// retried finalize both call this to repair the committed end-state.
     /// `append_event` dedups on the transfer id, so calling it more than once for
     /// the same transfer is a no-op.
-    async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
+    pub(super) async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
         self.store
             .append_event(&LedgerEvent {
                 seq: 0,
@@ -538,56 +431,15 @@ impl Ledger {
         Ok(())
     }
 
-    /// Persist the write-ahead pending-saga record (upsert on the reservation id).
-    async fn save_pending(
-        &self,
-        envelope: &Envelope,
-        reservation: kuatia_core::ReservationId,
-        phase: SagaPhase,
-    ) -> Result<(), LedgerError> {
-        let blob = serde_json::to_vec(&PendingRecord::Envelope(PendingSaga {
-            envelope: envelope.clone(),
-            reservation,
-            phase,
-        }))
-        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-        self.store.save_saga(&reservation.0, blob).await?;
-        Ok(())
-    }
-
-    /// Persist the write-ahead record for an account-version transition, keyed by
-    /// a fresh unique id, and return that id so the caller can delete the record
-    /// once the transition is complete. Shares the reservation-id generator so the
-    /// key never collides with an in-flight commit saga's key.
-    pub(super) async fn save_transition(
-        &self,
-        next: &kuatia_core::Account,
-        event: &LedgerEventKind,
-    ) -> Result<i64, LedgerError> {
-        let saga_id = kuatia_core::ReservationId::default().0;
-        let blob = serde_json::to_vec(&PendingRecord::Transition(PendingTransition {
-            next: next.clone(),
-            event: event.clone(),
-        }))
-        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-        self.store.save_saga(&saga_id, blob).await?;
-        Ok(saga_id)
-    }
-
-    /// Read the persisted phase of a pending *envelope* saga, if one exists under
-    /// `saga_id`. A transition record (no phase) reads as `None`.
-    async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
-        for (id, blob) in self.store.list_pending_sagas().await? {
-            if id == saga_id {
-                let record: PendingRecord = serde_json::from_slice(&blob)
-                    .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-                return Ok(match record {
-                    PendingRecord::Envelope(s) => Some(s.phase),
-                    PendingRecord::Transition(_) => None,
-                });
-            }
+    /// The persisted commit phase of the record stored under `saga_id`, via a
+    /// keyed read. `None` when no record exists there or it is a transition
+    /// (which has no phase). Used to decide whether a failed commit reached the
+    /// point of no return.
+    pub(super) async fn saga_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
+        match self.store.get_saga(&saga_id).await? {
+            Some(blob) => Ok(PendingRecord::decode(&blob)?.envelope_phase()),
+            None => Ok(None),
         }
-        Ok(None)
     }
 
     // -----------------------------------------------------------------------
@@ -656,6 +508,7 @@ impl Ledger {
 
 #[cfg(test)]
 mod recovery_tests {
+    use super::super::pending::{PendingSaga, PendingTransition};
     use super::*;
     use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder};
     use kuatia_storage::mem_store::InMemoryStore;

+ 0 - 8
crates/kuatia/src/ledger/envelope_saga.rs

@@ -1,8 +0,0 @@
-use super::*;
-
-legend! {
-    EnvelopeSaga<LedgerCtx, LedgerError> {
-        reserve: ReservePostingsStep,
-        finalize: FinalizeTransferStep,
-    }
-}

+ 5 - 3
crates/kuatia/src/ledger/lifecycle.rs

@@ -11,7 +11,7 @@
 
 use tracing::instrument;
 
-use kuatia_core::{AccountFlags, AccountId, PostingFilter};
+use kuatia_core::{Account, AccountFlags, AccountId, PostingFilter};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 
 use super::{Ledger, now_millis};
@@ -19,9 +19,11 @@ use crate::error::LedgerError;
 
 impl Ledger {
     /// Create a new account and emit an AccountCreated event.
-    pub async fn create_account(&self, account: kuatia_core::Account) -> Result<(), LedgerError> {
+    pub async fn create_account(&self, account: Account) -> Result<(), LedgerError> {
         let id = account.id;
-        self.store.create_account(account).await?;
+        if self.store.create_account(account).await? == 0 {
+            return Err(LedgerError::AccountAlreadyExists(id));
+        }
         self.store
             .append_event(&LedgerEvent {
                 seq: 0,

+ 265 - 0
crates/kuatia/src/ledger/pending.rs

@@ -0,0 +1,265 @@
+//! The write-ahead record awaiting recovery, and how each kind completes.
+//!
+//! A commit (reserve → finalize) and an account-version transition (append
+//! version → append event) are each more than one store write with no shared
+//! transaction, so a crash mid-sequence can leave a half-applied state. Before
+//! either mutates anything it persists a [`PendingRecord`] via `SagaStore`; on
+//! startup [`Ledger::recover`](super::Ledger::recover) loads every surviving
+//! record and drives it to a terminal state through [`PendingRecord::complete`].
+//!
+//! This module owns the whole write-ahead concept behind one seam: what a
+//! pending record *is*, how it is (de)serialized, how it is persisted, and how
+//! each kind completes. The completion primitives it calls (`finalize_envelope`,
+//! `reserve_and_finalize`) stay on [`Ledger`] because the live commit path shares
+//! them; this module sequences them for the recovery path.
+
+use std::sync::Arc;
+
+use kuatia_core::{Account, Envelope, Receipt, ReservationId, envelope_id};
+use kuatia_storage::error::StoreError;
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+
+use super::{Ledger, now_millis};
+use crate::error::LedgerError;
+
+/// Phase of an in-flight commit, persisted with the write-ahead record so
+/// recovery knows whether validation has completed.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
+pub(super) enum SagaPhase {
+    /// Saved before reserve. Validation has not necessarily run, so recovery must
+    /// re-reserve and re-validate before it can commit.
+    Reserving,
+    /// Saved at the start of finalize — after validation passed and just before
+    /// the consumed postings begin being removed from the reserved index (the
+    /// point of no return). Recovery rolls forward without re-validating.
+    Finalizing,
+}
+
+/// Write-ahead record for an in-flight commit (reserve → finalize). Persisted
+/// before the saga mutates anything and removed once it reaches a terminal
+/// state.
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub(super) struct PendingSaga {
+    pub(super) envelope: Envelope,
+    pub(super) reservation: ReservationId,
+    pub(super) phase: SagaPhase,
+}
+
+/// Write-ahead record for an in-flight account-version transition
+/// (freeze/unfreeze/close). The transition appends a new account version and then
+/// its lifecycle event; a crash between the two leaves a version bump with no
+/// event. Persisting this before either write lets recovery roll the transition
+/// forward, re-appending the (idempotent) event.
+#[derive(serde::Serialize, serde::Deserialize)]
+pub(super) struct PendingTransition {
+    /// The next account version to append: version already bumped, flag flipped.
+    pub(super) next: Account,
+    /// The lifecycle event paired with this version bump. It carries the target
+    /// version, so re-appending it on recovery dedups to the original.
+    pub(super) event: LedgerEventKind,
+}
+
+/// The two kinds of write-ahead record the [`SagaStore`] holds, tagged so
+/// recovery can tell an envelope commit saga from an account transition and
+/// complete each through its own path.
+#[derive(serde::Serialize, serde::Deserialize)]
+pub(super) enum PendingRecord {
+    /// A two-step envelope commit saga (reserve → finalize).
+    Envelope(PendingSaga),
+    /// A single account-version transition (append version + lifecycle event).
+    Transition(PendingTransition),
+}
+
+impl PendingRecord {
+    /// An account-transition write-ahead record.
+    pub(super) fn transition(next: Account, event: LedgerEventKind) -> Self {
+        Self::Transition(PendingTransition { next, event })
+    }
+
+    /// Decode a record from its stored bytes. The single decoder for the
+    /// write-ahead format, shared by `recover` and the keyed phase read.
+    pub(super) fn decode(blob: &[u8]) -> Result<Self, LedgerError> {
+        serde_json::from_slice(blob)
+            .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))
+    }
+
+    /// The commit phase of an envelope record; `None` for a transition record,
+    /// which has no phase.
+    pub(super) fn envelope_phase(&self) -> Option<SagaPhase> {
+        match self {
+            Self::Envelope(s) => Some(s.phase),
+            Self::Transition(_) => None,
+        }
+    }
+
+    /// Persist this record under `saga_id` (upsert on the id).
+    pub(super) async fn save(&self, ledger: &Ledger, saga_id: i64) -> Result<(), LedgerError> {
+        let blob = serde_json::to_vec(self)
+            .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+        ledger.store.save_saga(&saga_id, blob).await?;
+        Ok(())
+    }
+
+    /// Drive this record to a terminal state and clear it when safe. Called by
+    /// [`Ledger::recover`](super::Ledger::recover) for every surviving record.
+    ///
+    /// A transition rolls forward (any completion error propagates, so recovery
+    /// retries on the next run). An envelope commit branches on its phase, and
+    /// its drive/finalize failures are absorbed here (the record is kept for a
+    /// later run) rather than aborting recovery of the remaining records.
+    pub(super) async fn complete(
+        self,
+        ledger: &Arc<Ledger>,
+        saga_id: i64,
+    ) -> Result<(), LedgerError> {
+        match self {
+            Self::Transition(PendingTransition { next, event }) => {
+                complete_transition(ledger, saga_id, next, event).await
+            }
+            // The commit lifecycle (phase rules, delete-safety) lives on
+            // `PendingSaga`; recovery just hands the decoded record to it.
+            Self::Envelope(saga) => saga.complete(ledger).await,
+        }
+    }
+}
+
+impl PendingSaga {
+    /// A fresh write-ahead record for a new commit, at the pre-mutation phase.
+    pub(super) fn new(envelope: Envelope, reservation: ReservationId) -> Self {
+        Self {
+            envelope,
+            reservation,
+            phase: SagaPhase::Reserving,
+        }
+    }
+
+    /// The same record advanced to the point of no return, used for the finalize
+    /// bump just before the consumed postings are removed.
+    pub(super) fn finalizing(envelope: Envelope, reservation: ReservationId) -> Self {
+        Self {
+            envelope,
+            reservation,
+            phase: SagaPhase::Finalizing,
+        }
+    }
+
+    /// An envelope saga is always keyed by its reservation id, so the live commit
+    /// path and recovery agree on where the record lives.
+    fn saga_id(&self) -> i64 {
+        self.reservation.0
+    }
+
+    /// Persist this record at its current phase (upsert). The single writer of a
+    /// commit write-ahead record: the Reserving→Finalizing bump is just a persist
+    /// of the [`finalizing`](Self::finalizing) variant.
+    pub(super) async fn persist(&self, ledger: &Ledger) -> Result<(), LedgerError> {
+        PendingRecord::Envelope(self.clone())
+            .save(ledger, self.saga_id())
+            .await
+    }
+
+    /// Run a fresh commit end to end: write-ahead at Reserving, reserve then
+    /// finalize, then clear the record when it is safe. The single home of the commit
+    /// write-ahead lifecycle; [`commit_envelope`](Ledger::commit_envelope) calls
+    /// this and recovery mirrors it.
+    pub(super) async fn run(self, ledger: &Arc<Ledger>) -> Result<Receipt, LedgerError> {
+        self.persist(ledger).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 = ledger
+            .reserve_and_finalize(&self.envelope, self.reservation)
+            .await;
+        self.clear_if_safe(ledger, result.is_ok()).await?;
+        result
+    }
+
+    /// Delete the write-ahead record unless the saga crossed its point of no
+    /// return on a failing run. The ONE place the delete-safety rule lives: safe
+    /// on success, or on a failure that never reached `Finalizing` (compensation
+    /// released our reservation, nothing of ours applied). A failure that reached
+    /// `Finalizing` keeps the record so recovery rolls the commit forward.
+    async fn clear_if_safe(
+        &self,
+        ledger: &Arc<Ledger>,
+        succeeded: bool,
+    ) -> Result<(), LedgerError> {
+        let safe =
+            succeeded || ledger.saga_phase(self.saga_id()).await? != Some(SagaPhase::Finalizing);
+        if safe {
+            ledger.store.delete_saga(&self.saga_id()).await?;
+        }
+        Ok(())
+    }
+
+    /// Complete a crash-interrupted commit from its persisted phase, clearing the
+    /// record when safe. The recovery counterpart of [`run`](Self::run): same
+    /// lifecycle rules, entered from a decoded record instead of a fresh one.
+    async fn complete(self, ledger: &Arc<Ledger>) -> Result<(), LedgerError> {
+        // A full commit is the transfer row plus its committed event (appended
+        // after store_transfer). If the row is present the commit reached the far
+        // side; repair the possibly-missing event (idempotent) and clear.
+        let tid = envelope_id(&self.envelope);
+        if ledger.store.get_transfer(&tid).await?.is_some() {
+            ledger.append_committed_event(tid).await?;
+            ledger.store.delete_saga(&self.saga_id()).await?;
+            return Ok(());
+        }
+
+        match self.phase {
+            // Validation passed and the postings are ours; roll forward through the
+            // verified finalize. Keep the record if it fails so a later run retries.
+            SagaPhase::Finalizing => {
+                if ledger
+                    .finalize_envelope(&self.envelope, self.reservation)
+                    .await
+                    .is_ok()
+                {
+                    ledger.store.delete_saga(&self.saga_id()).await?;
+                }
+                Ok(())
+            }
+            // Not past the point of no return: re-run the validating saga and clear
+            // under the same rule as a live commit. The saga's own failure is
+            // absorbed (record kept for the next run); only infra errors propagate.
+            SagaPhase::Reserving => {
+                let result = ledger
+                    .reserve_and_finalize(&self.envelope, self.reservation)
+                    .await;
+                self.clear_if_safe(ledger, result.is_ok()).await
+            }
+        }
+    }
+}
+
+/// Roll a crash-interrupted transition forward and clear its write-ahead record.
+///
+/// Idempotent in every crash window: the version append runs only into an empty
+/// version slot (`append_account_version` requires `version == current + 1`, so a
+/// blind retry after it applied would fail), and the event carries its target
+/// version so re-appending it dedups to the original. The empty-slot guard also
+/// subsumes the forward path's is_closed check: a close always bumps the version,
+/// so a since-closed account sits at `version >= next.version` and is skipped.
+async fn complete_transition(
+    ledger: &Ledger,
+    saga_id: i64,
+    next: Account,
+    event: LedgerEventKind,
+) -> Result<(), LedgerError> {
+    // The account is guaranteed to exist here (its version was bumped, or is
+    // about to be), so a read failure is transient or a real invariant breach,
+    // not "not found": surface it verbatim so recovery retries.
+    let current = ledger.store.get_account(&next.id).await?;
+    if current.version < next.version {
+        ledger.store.append_account_version(next).await?;
+    }
+    ledger
+        .store
+        .append_event(&LedgerEvent {
+            seq: 0,
+            timestamp: now_millis()?,
+            kind: event,
+        })
+        .await?;
+    ledger.store.delete_saga(&saga_id).await?;
+    Ok(())
+}

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

@@ -1,231 +0,0 @@
-//! 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
-    }
-}

+ 21 - 29
crates/kuatia/src/ledger/query.rs

@@ -5,20 +5,23 @@
 //! underlying store is reachable via [`Ledger::store`] for callers that want the
 //! raw storage error instead.
 
-use kuatia_core::{AccountId, PostingFilter, PostingId, PostingState};
+use kuatia_core::{
+    Account, AccountId, Book, BookId, Posting, PostingFilter, PostingId, PostingState,
+};
 use kuatia_storage::events::LedgerEvent;
 
 use super::Ledger;
 use crate::error::LedgerError;
+use crate::store::{EnvelopeRecord, Page, PostingQuery, TransferQuery};
 
 impl Ledger {
     /// List all accounts (latest version of each).
-    pub async fn list_accounts(&self) -> Result<Vec<kuatia_core::Account>, LedgerError> {
+    pub async fn list_accounts(&self) -> Result<Vec<Account>, LedgerError> {
         Ok(self.store.list_accounts().await?)
     }
 
     /// Fetch a single account by id.
-    pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
+    pub async fn get_account(&self, id: &AccountId) -> Result<Account, LedgerError> {
         self.store
             .get_account(id)
             .await
@@ -26,10 +29,7 @@ impl Ledger {
     }
 
     /// Return all transfers involving the given account (exact subaccount).
-    pub async fn history(
-        &self,
-        account: &AccountId,
-    ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
+    pub async fn history(&self, account: &AccountId) -> Result<Vec<EnvelopeRecord>, LedgerError> {
         Ok(self
             .store
             .get_transfers_for_account(account.id, Some(account.sub))
@@ -39,16 +39,13 @@ impl Ledger {
     /// Query transfers with filtering and pagination.
     pub async fn query_transfers(
         &self,
-        query: &crate::store::TransferQuery,
-    ) -> Result<crate::store::Page<crate::store::EnvelopeRecord>, LedgerError> {
+        query: &TransferQuery,
+    ) -> Result<Page<EnvelopeRecord>, LedgerError> {
         Ok(self.store.query_transfers(query).await?)
     }
 
     /// Return all postings (any state) for the given account.
-    pub async fn postings(
-        &self,
-        account: &AccountId,
-    ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
+    pub async fn postings(&self, account: &AccountId) -> Result<Vec<Posting>, LedgerError> {
         Ok(self
             .store
             .get_postings_by_account(account.id, Some(account.sub), None, PostingFilter::All)
@@ -60,7 +57,7 @@ impl Ledger {
     pub async fn postings_with_state(
         &self,
         account: &AccountId,
-    ) -> Result<Vec<(kuatia_core::Posting, PostingState)>, LedgerError> {
+    ) -> Result<Vec<(Posting, PostingState)>, LedgerError> {
         let postings = self.postings(account).await?;
         let ids: Vec<PostingId> = postings.iter().map(|p| p.id).collect();
         let states = self.store.get_posting_states(&ids).await?;
@@ -68,36 +65,31 @@ impl Ledger {
     }
 
     /// Query postings with filtering and pagination.
-    pub async fn query_postings(
-        &self,
-        query: &crate::store::PostingQuery,
-    ) -> Result<crate::store::Page<kuatia_core::Posting>, LedgerError> {
+    pub async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, LedgerError> {
         Ok(self.store.query_postings(query).await?)
     }
 
     /// Return the full version history for an account.
-    pub async fn account_history(
-        &self,
-        id: &AccountId,
-    ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
+    pub async fn account_history(&self, id: &AccountId) -> Result<Vec<Account>, LedgerError> {
         Ok(self.store.get_account_history(id).await?)
     }
 
     /// Create a new book.
-    pub async fn create_book(&self, book: kuatia_core::Book) -> Result<(), LedgerError> {
-        Ok(self.store.create_book(book).await?)
+    pub async fn create_book(&self, book: Book) -> Result<(), LedgerError> {
+        let id = book.id;
+        if self.store.create_book(book).await? == 0 {
+            return Err(LedgerError::BookAlreadyExists(id));
+        }
+        Ok(())
     }
 
     /// Fetch a book by id.
-    pub async fn get_book(
-        &self,
-        id: &kuatia_core::BookId,
-    ) -> Result<kuatia_core::Book, LedgerError> {
+    pub async fn get_book(&self, id: &BookId) -> Result<Book, LedgerError> {
         Ok(self.store.get_book(id).await?)
     }
 
     /// List all books.
-    pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
+    pub async fn list_books(&self) -> Result<Vec<Book>, LedgerError> {
         Ok(self.store.list_books().await?)
     }
 

+ 18 - 41
crates/kuatia/src/ledger/transition.rs

@@ -9,15 +9,16 @@
 //! The durability point: the version append and the event append are two separate
 //! store writes with no shared transaction. A crash between them would otherwise
 //! leave a version bump with no event and nothing to repair it. Persisting a
-//! write-ahead [`PendingTransition`](super::commit) before either write lets
+//! write-ahead [`PendingTransition`](super::pending) before either write lets
 //! [`Ledger::recover`](Ledger) roll the transition forward. Recovery is
 //! idempotent both ways: the version append is skipped when the version is
 //! already present, and the event carries its target version so a second append
 //! dedups to the original.
 
-use kuatia_core::{Account, AccountFlags, AccountId};
+use kuatia_core::{AccountFlags, AccountId, ReservationId};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 
+use super::pending::PendingRecord;
 use super::{Ledger, now_millis};
 use crate::error::LedgerError;
 
@@ -51,47 +52,23 @@ impl Ledger {
         let event = make_event(*id, next.version);
 
         // Write-ahead before either write. A crash between the version append and
-        // the event append is then repaired by recover(), not left dangling.
-        let saga_id = self.save_transition(&next, &event).await?;
-
-        self.store.append_account_version(next).await?;
-        self.store
-            .append_event(&LedgerEvent {
-                seq: 0,
-                timestamp: now_millis()?,
-                kind: event,
-            })
+        // the event append is then repaired by recover(), not left dangling. The
+        // key shares the reservation-id generator so it never collides with an
+        // in-flight commit saga's key.
+        let saga_id = ReservationId::default().0;
+        PendingRecord::transition(next.clone(), event.clone())
+            .save(self, saga_id)
             .await?;
-        self.store.delete_saga(&saga_id).await?;
-        Ok(())
-    }
 
-    /// Roll a crash-interrupted transition forward and clear its write-ahead
-    /// record. Called by [`recover`](Ledger::recover) for a persisted
-    /// [`PendingTransition`](super::commit).
-    ///
-    /// Idempotent in every crash window: the version append runs only when the
-    /// version is not yet present (`append_account_version` requires
-    /// `version == current + 1`, so a blind retry after it applied would fail),
-    /// and the event carries its target version so re-appending it dedups to the
-    /// original.
-    pub(super) async fn complete_transition(
-        &self,
-        saga_id: i64,
-        next: Account,
-        event: LedgerEventKind,
-    ) -> Result<(), LedgerError> {
-        // The account is guaranteed to exist here (its version was already
-        // bumped, or is about to be), so a read failure is transient or a real
-        // invariant breach, not "not found": surface it verbatim so recovery
-        // retries rather than reporting a misleading domain error.
-        let current = self.store.get_account(&next.id).await?;
-        // Append only into an empty version slot. This also subsumes the
-        // is_closed guard the forward path runs: a close always bumps the
-        // version, so a since-closed account sits at version >= next.version and
-        // this branch is skipped, never appending onto a closed account.
-        if current.version < next.version {
-            self.store.append_account_version(next).await?;
+        let expected = next.version;
+        if self.store.append_account_version(next).await? == 0 {
+            // A concurrent transition moved the head; the guarded append matched
+            // nothing. Surface the conflict; the write-ahead record is repaired
+            // (or cleared) by recover() on the next startup.
+            return Err(LedgerError::AccountVersionConflict {
+                account: *id,
+                expected,
+            });
         }
         self.store
             .append_event(&LedgerEvent {

+ 2 - 2
crates/kuatia/src/lib.rs

@@ -1,8 +1,8 @@
 //! Kuatia — async ledger resource built on top of [`kuatia_core`].
 //!
 //! This crate adds IO to the pure decision logic: the [`Store`](kuatia_storage::store::Store) trait
-//! abstracts storage, and the [`Ledger`](crate::ledger::Ledger) struct composes the three-phase
-//! commit pipeline (load → plan → apply) behind a convenient async API.
+//! abstracts storage, and the [`Ledger`](crate::ledger::Ledger) struct drives the linear
+//! reserve → finalize commit path (validation inside finalize) behind an async API.
 
 pub mod error;
 pub mod inflight;

+ 138 - 208
crates/kuatia/src/saga.rs

@@ -1,38 +1,31 @@
-//! Legend saga step adapters for the ledger.
+//! Ledger commit helpers and high-level saga steps.
 //!
-//! Provides [`Step`] implementations so the ledger can participate
-//! in multi-resource saga workflows, with automatic LIFO compensation across
-//! resource boundaries.
+//! # Count contract
 //!
-//! # Envelope pipeline saga
-//!
-//! A commit is two saga steps over a pre-resolved [`Envelope`] (resolution runs
-//! before the saga, in `Ledger::commit`):
-//!
-//! 1. **ReservePostingsStep** -- `reserve_postings`: move each consumed posting from the active index into the reserved index under the saga's `ReservationId`; interprets the count via `verify_postings`.
-//! 2. **FinalizeTransferStep** -- delegates to `Ledger::finalize_envelope`, which re-validates against current state (the last-step floor / freeze-close guard), marks the saga `Finalizing`, then runs the dumb primitives (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`) verifying every end-state.
-//!
-//! The `EnvelopeSaga` is defined via `legend!` in `ledger.rs` and driven by
-//! `commit_envelope()`. Crash recovery (`Ledger::recover`) re-completes a
-//! persisted saga using its persisted phase: a `Reserving` saga is re-run
-//! (re-validating); a `Finalizing` saga is rolled forward through the same
-//! verified `finalize_envelope`.
+//! `apply_and_verify`, `verify_postings`, and `consume_reserved` encode the
+//! ADR-0003 affected-row-count rule applied after every dumb write primitive in
+//! the commit path. The commit path itself (reserve → finalize) is a linear
+//! method on [`Ledger`] (`ledger::commit`), not a `legend` saga: for a single
+//! commit the two steps were pass-throughs, so collapsing them keeps the
+//! reserve/compensation policy next to the logic it governs (refines ADR-0002).
 //!
 //! # High-level composition
 //!
-//! High-level steps (`PayMovementStep` and `DepositMovementStep`) compose over
-//! the intent-layer API and can be combined into multi-transfer sagas via `legend!`.
+//! [`PayMovementStep`] and [`DepositMovementStep`] wrap the intent-layer
+//! `Ledger::commit` as `legend` [`Step`]s, so several transfers compose into one
+//! multi-transfer saga (an FX trade, a multi-leg settlement) with LIFO
+//! compensation across the whole workflow. This is where `legend` earns its keep.
 
+use std::fmt;
+use std::future::Future;
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use legend::step::{CompensationOutcome, RetryPolicy, Step, StepOutcome};
+use legend::step::{CompensationOutcome, Step, StepOutcome};
 use serde::{Deserialize, Serialize};
-use tracing::Instrument;
 
 use kuatia_core::{
-    AccountId, AssetId, Cent, Envelope, PostingId, PostingState, Receipt, ReservationId,
-    TransferBuilder,
+    AccountId, AssetId, Cent, PostingId, PostingState, Receipt, ReservationId, TransferBuilder,
 };
 
 use crate::error::LedgerError;
@@ -65,7 +58,7 @@ pub(crate) async fn apply_and_verify<F, Fut>(
 ) -> Result<(), LedgerError>
 where
     F: FnOnce() -> Fut,
-    Fut: std::future::Future<Output = Result<bool, LedgerError>>,
+    Fut: Future<Output = Result<bool, LedgerError>>,
 {
     if count == target as u64 {
         return Ok(());
@@ -98,36 +91,58 @@ pub(crate) async fn verify_postings(
     .await
 }
 
+/// The authoritative double-spend / reservation-ownership guard.
+///
+/// Consume the reserved postings, then assert every consumed id is now `Spent`.
+/// `deactivate_postings(_, Some(rid))` removes *only* rows this saga reserved, so
+/// the "all Spent" assertion can only pass when no consumed id was left active or
+/// held by another saga: that is what forbids a double-spend.
+///
+/// This CAS is the real concurrency authority for the consumed-posting lifecycle.
+/// The pure lifecycle check in [`validate_and_plan`](kuatia_core::validate_and_plan)
+/// is a snapshot-in-time, best-effort read (ADR-0003); this is the check that
+/// holds under contention. It runs once the saga is past its point of no return
+/// (phase `Finalizing`). See ADR-0021 for the full commit-safety map.
+pub(crate) async fn consume_reserved(
+    store: &dyn Store,
+    consumes: &[PostingId],
+    reservation: ReservationId,
+) -> Result<(), LedgerError> {
+    let spent = store
+        .deactivate_postings(consumes, Some(reservation))
+        .await
+        .map_err(LedgerError::Store)?;
+    verify_postings(
+        store,
+        consumes,
+        spent,
+        |s| *s == PostingState::Spent,
+        "finalize: consume reserved postings",
+    )
+    .await
+}
+
 // ---------------------------------------------------------------------------
 // Saga context -- carries the ledger handle + state between steps
 // ---------------------------------------------------------------------------
 
-/// Saga context that wraps a ledger and tracks state across steps.
+/// Saga context that wraps a ledger and collects the receipts of the transfers a
+/// multi-transfer saga commits, for LIFO compensation.
 ///
-/// The ledger handle is `#[serde(skip)]` -- after deserializing a paused
-/// execution you must call [`inject_ledger`](LedgerCtx::inject_ledger)
-/// before resuming.
+/// The ledger handle is `#[serde(skip)]`: it is supplied when the context is
+/// constructed and is not part of the serialized form.
 #[derive(Clone, Serialize, Deserialize)]
 pub struct LedgerCtx {
-    /// Receipts collected from completed steps.
+    /// Receipts collected from completed steps, popped in reverse to compensate.
     pub receipts: Vec<Receipt>,
-    /// Posting ids reserved so far (for compensation).
-    pub reserved_postings: Vec<PostingId>,
-    /// Resolved envelope produced by the resolve step.
-    pub envelope: Option<Envelope>,
-    /// Reservation owner token for this saga's reserved postings. Serialized so
-    /// it survives pause/recovery, keeping ownership stable across restarts.
-    pub reservation: ReservationId,
     #[serde(skip)]
     ledger: Option<Arc<Ledger>>,
 }
 
-impl std::fmt::Debug for LedgerCtx {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl fmt::Debug for LedgerCtx {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_struct("LedgerCtx")
             .field("receipts", &self.receipts)
-            .field("reserved_postings", &self.reserved_postings.len())
-            .field("has_envelope", &self.envelope.is_some())
             .field("ledger_present", &self.ledger.is_some())
             .finish()
     }
@@ -138,182 +153,23 @@ impl LedgerCtx {
     pub fn new(ledger: Arc<Ledger>) -> Self {
         Self {
             receipts: Vec::new(),
-            reserved_postings: Vec::new(),
-            envelope: None,
-            reservation: ReservationId::default(),
-            ledger: Some(ledger),
-        }
-    }
-
-    /// Create a context for the envelope pipeline (reserve → finalize; finalize re-validates)
-    /// with a pre-resolved envelope and an explicit reservation.
-    pub fn for_envelope(
-        ledger: Arc<Ledger>,
-        envelope: Envelope,
-        reservation: ReservationId,
-    ) -> Self {
-        Self {
-            receipts: Vec::new(),
-            reserved_postings: Vec::new(),
-            envelope: Some(envelope),
-            reservation,
             ledger: Some(ledger),
         }
     }
 
-    /// Re-inject the ledger handle after deserializing a paused execution.
-    pub fn inject_ledger(&mut self, ledger: Arc<Ledger>) {
-        self.ledger = Some(ledger);
-    }
-
-    /// Borrow the ledger, returning an error if not injected.
+    /// Borrow the ledger, returning an error if the handle is absent.
     pub fn ledger(&self) -> Result<&Ledger, LedgerError> {
-        self.ledger.as_ref().map(|l| l.as_ref()).ok_or_else(|| {
-            internal("ledger not injected -- call inject_ledger() after deserializing")
-        })
+        self.ledger
+            .as_ref()
+            .map(|l| l.as_ref())
+            .ok_or_else(|| internal("ledger handle missing from saga context"))
     }
 
-    /// Clone the ledger `Arc`, returning an error if not injected.
+    /// Clone the ledger `Arc`, returning an error if the handle is absent.
     pub fn ledger_arc(&self) -> Result<Arc<Ledger>, LedgerError> {
-        self.ledger.clone().ok_or_else(|| {
-            internal("ledger not injected -- call inject_ledger() after deserializing")
-        })
-    }
-}
-
-// ===========================================================================
-// Envelope pipeline steps (reserve -> finalize; resolve runs before the saga, validate inside finalize)
-// ===========================================================================
-
-// ---------------------------------------------------------------------------
-// Step 1: ReservePostingsStep
-// ---------------------------------------------------------------------------
-
-/// Input for the reserve step (posting ids come from ctx.envelope).
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ReserveInput;
-
-/// Reserves consumed postings by CAS: move each from the active index to the
-/// reserved index (the delete-returns-one picks a single winner).
-///
-/// Gets the posting ids from the resolved envelope in the context.
-/// Compensation releases all reserved postings back to Active.
-pub struct ReservePostingsStep;
-
-#[async_trait]
-impl Step<LedgerCtx, LedgerError> for ReservePostingsStep {
-    type Input = ReserveInput;
-
-    async fn execute(
-        ctx: &mut LedgerCtx,
-        _input: &ReserveInput,
-    ) -> Result<StepOutcome, LedgerError> {
-        async {
-            let posting_ids: Vec<PostingId> = ctx
-                .envelope
-                .as_ref()
-                .ok_or_else(|| internal("no envelope in context -- resolve step must run first"))?
-                .consumes()
-                .to_vec();
-            let rid = ctx.reservation;
-            let ledger = ctx.ledger_arc()?;
-            let store = ledger.store();
-
-            let reserved = store
-                .reserve_postings(&posting_ids, rid)
-                .await
-                .map_err(LedgerError::Store)?;
-            // Storage reports the count; the saga decides. A short count is fine
-            // only if the shortfall is already reserved by us (idempotent replay).
-            verify_postings(
-                store,
-                &posting_ids,
-                reserved,
-                |s| matches!(s, PostingState::Reserved(r) if *r == rid),
-                "reserve",
-            )
-            .await?;
-            ctx.reserved_postings.extend_from_slice(&posting_ids);
-            Ok(StepOutcome::Continue)
-        }
-        .instrument(tracing::info_span!("saga_step", step = "reserve"))
-        .await
-    }
-
-    async fn compensate(
-        ctx: &mut LedgerCtx,
-        _input: &ReserveInput,
-    ) -> Result<CompensationOutcome, LedgerError> {
-        ctx.ledger()?
-            .store()
-            .release_postings(&ctx.reserved_postings, ctx.reservation)
-            .await
-            .map_err(LedgerError::Store)?;
-        ctx.reserved_postings.clear();
-        Ok(CompensationOutcome::Completed)
-    }
-
-    fn retry_policy() -> RetryPolicy {
-        RetryPolicy::retries(3)
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Step 2: FinalizeTransferStep
-// ---------------------------------------------------------------------------
-
-/// Input for the finalize step (envelope comes from ctx).
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct FinalizeInput;
-
-/// Re-validates against current state (the last-step floor / freeze-close guard),
-/// then drives the verified, idempotent commit via `Ledger::finalize_envelope`.
-///
-/// Compensation reverses the finalized envelope (only relevant once committed).
-pub struct FinalizeTransferStep;
-
-#[async_trait]
-impl Step<LedgerCtx, LedgerError> for FinalizeTransferStep {
-    type Input = FinalizeInput;
-
-    async fn execute(
-        ctx: &mut LedgerCtx,
-        _input: &FinalizeInput,
-    ) -> Result<StepOutcome, LedgerError> {
-        async {
-            let envelope = ctx
-                .envelope
-                .clone()
-                .ok_or_else(|| internal("no envelope in context -- resolve step must run first"))?;
-            let rid = ctx.reservation;
-            let ledger = ctx.ledger_arc()?;
-
-            // All commit work (re-validate, mark Finalizing, deactivate/insert/
-            // store/event with end-state verification) lives in `finalize_envelope`
-            // so recovery uses exactly the same path. Its typed error (validation,
-            // overdraft, frozen) reaches the caller unchanged.
-            let receipt = ledger.finalize_envelope(&envelope, rid).await?;
-
-            ctx.receipts.push(receipt);
-            ctx.reserved_postings.clear();
-            Ok(StepOutcome::Continue)
-        }
-        .instrument(tracing::info_span!("saga_step", step = "finalize"))
-        .await
-    }
-
-    async fn compensate(
-        ctx: &mut LedgerCtx,
-        _input: &FinalizeInput,
-    ) -> Result<CompensationOutcome, LedgerError> {
-        if let Some(receipt) = ctx.receipts.pop() {
-            ctx.ledger_arc()?.reverse(&receipt.transfer_id).await?;
-        }
-        Ok(CompensationOutcome::Completed)
-    }
-
-    fn retry_policy() -> RetryPolicy {
-        RetryPolicy::retries(3)
+        self.ledger
+            .clone()
+            .ok_or_else(|| internal("ledger handle missing from saga context"))
     }
 }
 
@@ -421,8 +277,23 @@ impl Step<LedgerCtx, LedgerError> for DepositMovementStep {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use kuatia_core::{EnvelopeId, Posting};
+    use kuatia_storage::mem_store::InMemoryStore;
+    use kuatia_storage::store::PostingStore;
     use std::cell::Cell;
 
+    fn active_posting(store_seed: u8) -> Posting {
+        Posting::new(
+            PostingId {
+                transfer: EnvelopeId([store_seed; 32]),
+                index: 0,
+            },
+            AccountId::new(1),
+            AssetId::new(1),
+            Cent::from(100),
+        )
+    }
+
     #[tokio::test]
     async fn full_count_is_ok_without_re_reading() {
         let verified = Cell::new(false);
@@ -467,4 +338,63 @@ mod tests {
             Err(LedgerError::Store(StoreError::Internal(_)))
         ));
     }
+
+    /// The guard consumes the postings this saga reserved: they end `Spent`.
+    #[tokio::test]
+    async fn consume_reserved_spends_our_postings() {
+        let store = InMemoryStore::new();
+        let p = active_posting(1);
+        store
+            .insert_postings(std::slice::from_ref(&p))
+            .await
+            .unwrap();
+        let rid = ReservationId::default();
+        store.reserve_postings(&[p.id], rid).await.unwrap();
+
+        consume_reserved(&store, &[p.id], rid).await.unwrap();
+
+        let states = store.get_posting_states(&[p.id]).await.unwrap();
+        assert_eq!(states, vec![PostingState::Spent]);
+    }
+
+    /// An unreserved (still active) posting is refused, and left untouched:
+    /// `deactivate_postings(_, Some(rid))` removes nothing we do not own.
+    #[tokio::test]
+    async fn consume_reserved_refuses_unreserved_posting() {
+        let store = InMemoryStore::new();
+        let p = active_posting(2);
+        store
+            .insert_postings(std::slice::from_ref(&p))
+            .await
+            .unwrap();
+
+        let err = consume_reserved(&store, &[p.id], ReservationId::default())
+            .await
+            .unwrap_err();
+        assert!(matches!(err, LedgerError::Store(StoreError::Internal(_))));
+        let states = store.get_posting_states(&[p.id]).await.unwrap();
+        assert_eq!(states, vec![PostingState::Active]);
+    }
+
+    /// The double-spend guard: a posting reserved by another saga is refused, and
+    /// stays reserved by that saga. Our deactivate removes nothing, so the
+    /// "all Spent" assertion fails.
+    #[tokio::test]
+    async fn consume_reserved_refuses_posting_held_by_another_saga() {
+        let store = InMemoryStore::new();
+        let p = active_posting(3);
+        store
+            .insert_postings(std::slice::from_ref(&p))
+            .await
+            .unwrap();
+        let theirs = ReservationId::default();
+        store.reserve_postings(&[p.id], theirs).await.unwrap();
+
+        let err = consume_reserved(&store, &[p.id], ReservationId::default())
+            .await
+            .unwrap_err();
+        assert!(matches!(err, LedgerError::Store(StoreError::Internal(_))));
+        let states = store.get_posting_states(&[p.id]).await.unwrap();
+        assert_eq!(states, vec![PostingState::Reserved(theirs)]);
+    }
 }

+ 1 - 4
crates/kuatia/tests/saga.rs

@@ -140,10 +140,7 @@ async fn saga_compensation_on_failure() {
             // the overspend surfaces as `Selection(InsufficientFunds)` rather
             // than a stringified `Store(Internal)`.
             assert!(
-                matches!(
-                    err,
-                    LedgerError::Selection(SelectionError::InsufficientFunds { .. })
-                ),
+                matches!(err, LedgerError::Selection(InsufficientFunds { .. })),
                 "expected typed InsufficientFunds, got {err:?}"
             );
             // The deposit should have been compensated (reversed)

+ 86 - 0
doc/adr/0021-commit-safety-map.md

@@ -0,0 +1,86 @@
+# The commit-safety invariant and where each part is enforced
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-28
+* Targeted modules: `kuatia-core` (`validate`), `kuatia` (`ledger::commit`,
+  `saga`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+"What makes a commit safe" had no single place to audit. The invariant was
+split across three modules with no anchor tying them together:
+
+* value / conservation / floor / freeze / snapshot / book-policy checks in
+  `validate_and_plan` (`kuatia-core`),
+* the double-spend / reservation-ownership check inlined inside the
+  ~110-line `finalize_envelope` (`kuatia/ledger/commit.rs`),
+* the affected-row count contract in `apply_and_verify` / `verify_postings`
+  (`kuatia/saga.rs`).
+
+A reader auditing `validate_and_plan` in isolation could reasonably but wrongly
+conclude it prevents double-spends; its lifecycle check even carried a comment
+saying the real enforcement happens "elsewhere" with no pointer to where. The
+load-bearing double-spend guard had no name and was reachable only through an
+end-to-end commit, so its unit-test surface was the easy (pure) half only.
+
+Could the whole decision be concentrated into one pure function "given this
+envelope and current state, may it commit"? No: the double-spend property is not
+decidable against a snapshot. In a concurrent ledger the only way to know a
+posting can be spent is to atomically try to spend it and read the result. The
+CAS *is* the decision (ADR-0003 dumb storage), and it must not be hoisted next
+to `validate_and_plan`, which is pure / sync / no-IO by contract.
+
+## Decision Drivers
+
+* **One audit surface** for the ledger's core safety property, without merging
+  the pure value checks and the stateful CAS into a single function.
+* **Preserve the pure/async boundary** (ADR-0002, ADR-0003): validation stays
+  pure and IO-free; the concurrency authority stays in the saga.
+* **Name and unit-test the double-spend guard** rather than leaving it inline
+  and only end-to-end testable.
+
+## Decision Outcome
+
+Keep the two halves in their correct layers, but make the map explicit and give
+the runtime guard a name. Commit safety is the conjunction of three checks, each
+with a single home:
+
+| Invariant | Home | Kind |
+|---|---|---|
+| Value / conservation / floor / freeze / close / snapshot / book policy | `validate_and_plan` (`kuatia-core::validate`) | Pure, snapshot-in-time, best-effort under concurrency |
+| Double-spend / reservation ownership | `consume_reserved` (`kuatia::saga`), called by `finalize_envelope` | Runtime CAS, authoritative under contention |
+| Affected-row count contract after each dumb write | `apply_and_verify` / `verify_postings` (`kuatia::saga`) | Interpretation of storage counts |
+
+Concretely:
+
+* The double-spend guard was extracted out of `finalize_envelope` into
+  `consume_reserved`, co-located with the count-contract helpers it depends on.
+  It consumes only the rows this saga reserved
+  (`deactivate_postings(_, Some(rid))`) and then asserts every consumed id is
+  `Spent`; that assertion can pass only when no id was left active or held by
+  another saga. It has direct unit tests (spends our reservation; refuses an
+  unreserved posting; refuses one held by another saga).
+* `validate_and_plan`'s lifecycle comment now points at `consume_reserved` and
+  this ADR instead of a vague "elsewhere".
+
+### Positive Consequences
+
+* One documented map of the commit-safety invariant; each part is a named,
+  independently testable seam.
+* The authoritative double-spend guard is unit-testable without an end-to-end
+  commit.
+
+### Negative Consequences
+
+* The invariant is still physically split across `kuatia-core` and `kuatia`.
+  That split is intentional (pure value checks vs. runtime CAS) and this ADR is
+  the anchor that makes it navigable; it is not a single code seam.
+
+## Links
+
+* Builds on [ADR-0003](0003-dumb-storage-saga-recovery.md) (dumb storage, the
+  best-effort floor/freeze note) and [ADR-0002](0002-saga-commit-pipeline.md).
+* Relates to [ADR-0006](0006-reservation-protocol-posting-lifecycle.md)
+  (reservation protocol / posting lifecycle).

+ 151 - 0
doc/adr/0022-merged-live-postings-hot-index.md

@@ -0,0 +1,151 @@
+# Merge the active/reserved hot indexes into one `live_postings` table
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-08-01
+* Targeted modules: `kuatia-storage-sql` (schema, migration 008,
+  `PostingStore`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0016/0017 split a posting's live state across two disposable hot tables:
+`active_postings` (membership = spendable) and `reserved_postings` (membership +
+`reservation` = claimed by a saga). A "live" read (`Active ∪ Reserved`), which
+`compute_balance` and `has_live_postings` need, therefore has no single table to
+hit: the SQL backend expresses it as a `UNION ALL` of the two tables. Reserve and
+release move a row between the two tables (`DELETE` from one, `INSERT` into the
+other), and `get_posting_states` probes three tables.
+
+Only the SQL encoding forces the `UNION ALL`; the two-table split is not required
+by any correctness guarantee (ADR-0006's reservation protocol needs an atomic
+single-winner claim, durable observable ownership, and count-returning
+primitives, none of which depend on the physical table count). Can the live set
+live in one table so the `UNION ALL` disappears, without losing those
+guarantees?
+
+## Decision Drivers
+
+* **Query clarity and read performance.** The live/spendable reads are hot
+  (`compute_balance` runs per commit in the finalize validation loader). A
+  single-table scan is simpler to reason about and to index than a `UNION ALL`.
+* **Preserve every ADR-0006/0016 guarantee.** Unconditional lock-free
+  double-spend safety; durable, observable, recoverable reservation ownership;
+  atomic count-returning dumb-storage primitives (ADR-0003); balance as
+  `Active ∪ Reserved`; append-only value table.
+* **Keep the source of truth append-only (ADR-0017).** `postings` (the immutable
+  record) must stay INSERT-only and rebuildable-from is the audit trail. Whatever
+  changes may only touch a disposable hot table.
+
+## Considered Options
+
+#### Option 1: Keep two hot tables (ADR-0016/0017)
+
+`active_postings` + `reserved_postings`, live reads via `UNION ALL`, reserve as
+`DELETE`-from-active + `INSERT`-into-reserved.
+
+**Pros:**
+
+* Good, because the entire hot-table write path is `INSERT` + `DELETE`, so a
+  grant can withhold `UPDATE` on them too.
+* Good, because it is already implemented and conformance-tested.
+
+**Cons:**
+
+* Bad, because a live read has no single table and is a `UNION ALL`.
+* Bad, because reserve/release are two-statement moves and `get_posting_states`
+  probes three tables.
+
+#### Option 2: One `live_postings` table with a nullable `reservation`
+
+Merge the two hot tables into one: `live_postings (transfer_id, idx, owner,
+subaccount, asset, value, reservation)`, `reservation` NULL = Active, set =
+Reserved by that id. State is still derived from membership + the column: present
+& NULL = Active; present & = rid = Reserved(rid); absent from `live_postings` but
+in `postings` = Spent; absent everywhere = Missing. The primitives become:
+
+* Live read: `SELECT ... FROM live_postings WHERE owner/subaccount/asset` (no
+  `UNION`). Active = `... AND reservation IS NULL`, Reserved = `... AND
+  reservation IS NOT NULL`.
+* Reserve: `UPDATE live_postings SET reservation = $rid WHERE <ids> AND
+  reservation IS NULL`. The `IS NULL` guard is the atomic single-winner claim
+  (concurrent reserves serialize on the row lock; the loser's predicate no longer
+  matches → 0 rows). One statement.
+* Release: `UPDATE live_postings SET reservation = NULL WHERE <ids> AND
+  reservation = $rid`.
+* Consume (finalize) / raw deactivate: `DELETE FROM live_postings WHERE <ids> AND
+  reservation = $rid` (or `IS NULL`); the posting stays in `postings` = Spent.
+* `get_posting_states`: two statements (live table + `postings`) instead of three.
+
+**Pros:**
+
+* Good, because the live set is one table: the `UNION ALL` is gone, reserve and
+  release are single statements, and `get_posting_states` drops a statement.
+* Good, because every ADR-0006 guarantee holds: the `UPDATE ... WHERE reservation
+  IS NULL` gives the same single-winner claim as the `DELETE`-CAS; the
+  `reservation` column is still the durable, observable ownership recovery reads;
+  the primitives still return affected-row counts the saga interprets.
+* Good, because the value table `postings` stays append-only INSERT-only, and
+  `live_postings` is still fully rebuildable from `postings` plus the saga
+  write-ahead records, so a corrupt hot table is drop-and-rebuild (ADR-0017's
+  disposability principle is preserved).
+
+**Cons:**
+
+* Bad, because the hot table now takes an `UPDATE` (the reservation flip), so the
+  "hot tables are `INSERT`/`DELETE` only, withhold `UPDATE` everywhere" grant
+  story of ADR-0017 no longer holds for `live_postings` (it still holds for the
+  value tables).
+* Bad, because the schema change is forward-only (migration 008 merges the two
+  tables and drops them).
+
+#### Option 3: Keep the `UNION ALL` but hide it behind a view
+
+A SQL `VIEW live_postings AS active UNION ALL reserved`.
+
+**Pros:**
+
+* Good, because callers read one name.
+
+**Cons:**
+
+* Bad, because it is the same `UNION ALL` at runtime; nothing is actually saved.
+
+## Decision Outcome
+
+Chosen option: **Option 2, one `live_postings` table with a nullable
+`reservation`.** It removes the `UNION ALL` and collapses reserve/release to a
+single statement while preserving every correctness guarantee of ADR-0006/0016.
+The one concession is that the disposable hot table now takes an `UPDATE` (the
+reservation flip): this is deliberately confined to the rebuildable hot table and
+never touches the append-only `postings` value table, so ADR-0017's core driver
+(the source of truth cannot be corrupted or lost by any write path) is intact.
+
+This supersedes the two-table hot-index *encoding* of ADR-0016 and ADR-0017. Their
+principle (append-only value tables plus disposable hot indexes) stands; only the
+number of hot tables and the reserve verb change.
+
+### Positive Consequences
+
+* A live/spendable read is one index scan on `idx_live_owner`, no `UNION ALL`.
+* Reserve and release are single `UPDATE` statements; `get_posting_states` is two
+  queries instead of three.
+* `reservation IS NULL` vs `= rid` still expresses Active vs Reserved, so state is
+  still derived, and recovery still reads "reserved by this saga" from the column.
+
+### Negative Consequences
+
+* The `live_postings` hot table takes an `UPDATE`; the grant that withholds
+  `UPDATE` now applies to the value tables only, not the hot table.
+* Forward-only migration (008) that drops `active_postings` / `reserved_postings`.
+
+## Links
+
+* Supersedes the two-table hot-index encoding of
+  [ADR-0016](0016-immutable-postings-index-tables.md) and
+  [ADR-0017](0017-correctness-first-append-only-hot-indexes.md); their
+  append-only-value / disposable-hot-index principle is unchanged.
+* Preserves the reservation protocol of
+  [ADR-0006](0006-reservation-protocol-posting-lifecycle.md) and the dumb-storage
+  primitives of [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Reflected in [storage-schema.md](../storage-schema.md).

+ 1 - 0
doc/architecture.md

@@ -83,6 +83,7 @@ classDiagram
     class SagaStore {
         +save_saga(id, data)
         +list_pending_sagas()
+        +get_saga(id)
         +delete_saga(id)
     }
     class EventStore {

+ 15 - 8
doc/glossary.md

@@ -93,14 +93,21 @@ atomically flips `Active → PendingInactive` stamped with a `ReservationId`,
 so two sagas cannot both claim the same posting. This (not a global
 transaction) is what prevents double-spend.
 
-### PendingSaga / recovery
-
-A write-ahead record `{envelope, reservation, phase}` persisted via
-`SagaStore` before a commit mutates anything. The `phase`
-(`Reserving` → `Finalizing`) tells `Ledger::recover()` (startup) how to
-complete a crashed saga: a `Reserving` saga is re-run and **re-validated**;
-a `Finalizing` saga (already validated, owns its postings) is rolled forward
-through the verified `finalize_envelope`. Roll-forward, not rollback.
+### Write-ahead record (PendingRecord) / recovery
+
+A record persisted via `SagaStore` before a multi-write operation mutates
+anything, so a crash mid-sequence can be completed on the next startup. There
+are two kinds (`PendingRecord`): a **commit saga** `{envelope, reservation,
+phase}`, and an **account transition** `{next, event}` (freeze/unfreeze/close).
+The `pending` module owns the concept: what a record is, how it is
+(de)serialized, and how each kind completes.
+
+`Ledger::recover()` (startup) loads each surviving record and drives it to a
+terminal state through `PendingRecord::complete`. A commit saga's `phase`
+(`Reserving` → `Finalizing`) decides how: a `Reserving` saga is re-run and
+**re-validated**; a `Finalizing` saga (already validated, owns its postings) is
+rolled forward through the verified `finalize_envelope`. A transition rolls its
+version + event forward idempotently. Roll-forward, not rollback.
 
 ### Book
 

+ 331 - 0
doc/storage-schema.md

@@ -0,0 +1,331 @@
+# SQL storage schema
+
+Reference for the tables `kuatia-storage-sql` creates. This is the composed
+end-state after replaying every migration in
+`crates/kuatia-storage-sql/src/migrations/` (`001_init` through
+`007_balance_projection`) plus the `_migrations` bookkeeping table created in
+`crates/kuatia-storage-sql/src/migrate.rs`. It is a living reference: update it
+when a migration lands. For *why* the schema looks this way, see the ADRs it
+links to, not this file.
+
+Ground rules that keep the catalog terse:
+
+- **The store is a dumb record-keeper, so no foreign keys are declared.** Every
+  cross-table relationship below is logical, enforced in Rust, not by the
+  database. A posting's identity is `transfer_id + idx`; `account_head` points at
+  the current `accounts` version; the account/transfer link is an explicit index
+  table. See [ADR-0003](adr/0003-dumb-storage-saga-recovery.md).
+- **Every payload, money, and hash column is `TEXT`.** Content-addressed ids and
+  opaque saga bytes are lower-case hex; JSON payloads are their `TEXT`
+  serialization; a `Cent` is its decimal string. The store never does arithmetic
+  or `SUM`/`MAX` on these; balances are computed in Rust.
+- **All ids are Rust-minted `BIGINT`.** No `AUTOINCREMENT` / `SERIAL`; snowflake
+  ids come from `AutoId`. See [ADR-0015](adr/0015-fixed-width-account-code.md).
+- The identical DDL runs on both SQLite and PostgreSQL (`sqlx::Any`).
+
+Design rationale lives in [ADR-0016 (immutable postings + index
+tables)](adr/0016-immutable-postings-index-tables.md), [ADR-0017 (append-only
+hot indexes)](adr/0017-correctness-first-append-only-hot-indexes.md),
+[ADR-0022 (merged live-postings hot index)](adr/0022-merged-live-postings-hot-index.md),
+[ADR-0019 (cached balance projection)](adr/0019-cached-balance-projection.md),
+and [ADR-0008 (conformance-tested storage)](adr/0008-conformance-tested-storage.md).
+
+## Entity relationships
+
+Edges are **logical only** (no `FOREIGN KEY` constraint exists); the labels name
+how the ledger relates the rows in Rust.
+
+```mermaid
+erDiagram
+    accounts {
+        BIGINT id PK
+        BIGINT subaccount PK
+        BIGINT version PK
+        INTEGER flags
+        BIGINT book
+        TEXT metadata
+    }
+    account_head {
+        BIGINT id PK
+        BIGINT subaccount PK
+        BIGINT version
+    }
+    postings {
+        TEXT transfer_id PK
+        SMALLINT idx PK
+        BIGINT owner
+        BIGINT subaccount
+        INTEGER asset
+        TEXT value
+    }
+    live_postings {
+        TEXT transfer_id PK
+        SMALLINT idx PK
+        BIGINT owner
+        BIGINT subaccount
+        INTEGER asset
+        TEXT value
+        BIGINT reservation
+    }
+    transfers {
+        TEXT id PK
+        TEXT transfer
+        TEXT receipt
+        BIGINT created_at
+        BIGINT book
+    }
+    transfer_accounts {
+        TEXT transfer_id PK
+        BIGINT account_id PK
+        BIGINT subaccount PK
+    }
+    books {
+        BIGINT id PK
+        TEXT name
+        TEXT data
+    }
+    balance_projection {
+        BIGINT id PK
+        BIGINT account
+        BIGINT subaccount
+        INTEGER asset
+        TEXT balance
+        BIGINT watermark
+    }
+    sagas {
+        BIGINT id PK
+        TEXT data
+    }
+    events {
+        BIGINT seq PK
+        BIGINT timestamp
+        TEXT kind
+        TEXT data
+        TEXT dedup_key UK
+    }
+    migrations {
+        TEXT name PK
+    }
+
+    accounts        ||--|| account_head       : "head -> current version"
+    accounts        }o--|| books              : "book id"
+    transfers       ||--o{ transfer_accounts  : "id = transfer_id"
+    accounts        ||--o{ transfer_accounts  : "account_id + subaccount"
+    transfers       ||--o{ postings           : "id = transfer_id"
+    postings        ||--o| live_postings      : "live index copy"
+    accounts        ||--o{ balance_projection : "account + subaccount"
+```
+
+(The `_migrations` table is shown as `migrations`; Mermaid entity names cannot
+start with an underscore.)
+
+## Accounts
+
+Append-only, versioned accounts with a head pointer. Owned by `account.rs`
+(`AccountStore`). See [ADR-0012 (subaccounts)](adr/0012-subaccounts.md) and
+[ADR-0020 (account transition recovery)](adr/0020-account-transition-recovery.md).
+
+### `accounts`
+
+Every account version is an immutable row; a new version is appended, never
+updated in place. `metadata` is JSON.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Base account id. |
+| `subaccount` | `BIGINT` | PK | Subaccount code (`0` = base). |
+| `version` | `BIGINT` | PK | Monotonic version; the chain is gap-free. |
+| `flags` | `INTEGER` | | `AccountFlags` bitfield (frozen/closed/inflight, `DEBIT_MUST_NOT_EXCEED_CREDIT`). |
+| `book` | `BIGINT` | | Owning book id. |
+| `metadata` | `TEXT` | | JSON key/value metadata. |
+
+- **Primary key**: `(id, subaccount, version)`.
+
+### `account_head`
+
+One row per account pointing at its current version, so a lookup is a single
+indexed join instead of scanning the version chain. Maintained by delete+insert,
+never `UPDATE`.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Base account id. |
+| `subaccount` | `BIGINT` | PK | Subaccount code. |
+| `version` | `BIGINT` | | The current version in `accounts`. |
+
+- **Primary key**: `(id, subaccount)`.
+
+## Postings
+
+A posting is a signed amount of one asset owned by one (sub)account, identified
+by `(transfer_id, idx)`. The immutable `postings` record is the historical source
+of truth; one `live_postings` hot table carries a full row copy of the live set
+(spendable + reserved), so spendable reads never merge back to history. Lifecycle
+state is *derived* from `live_postings` membership plus its `reservation` column:
+present with `reservation` NULL = Active, present with a reservation = Reserved,
+in `postings` only = Spent, absent = Missing. Owned by `posting.rs`
+(`PostingStore`). See
+[ADR-0016 (immutable postings + index tables)](adr/0016-immutable-postings-index-tables.md),
+[ADR-0017 (full-row hot copies)](adr/0017-correctness-first-append-only-hot-indexes.md),
+[ADR-0022 (merged hot index)](adr/0022-merged-live-postings-hot-index.md),
+[ADR-0006 (reservation protocol)](adr/0006-reservation-protocol-posting-lifecycle.md).
+
+**Why one hot table with full-row copies?** The index originally held only ids in
+two tables ([ADR-0016](adr/0016-immutable-postings-index-tables.md));
+[ADR-0017](adr/0017-correctness-first-append-only-hot-indexes.md) switched to
+full-row copies, and [ADR-0022](adr/0022-merged-live-postings-hot-index.md) merged
+the active and reserved tables into one `live_postings` (a nullable `reservation`
+replaces the two-table split). The hot read is "what can this account spend in
+this asset" (`get_postings_by_account`, `query_postings`, and the balance sum):
+carrying the data columns lets `live_postings` hold `idx_live_owner(owner,
+subaccount, asset)`, so a live read is one index scan on a small table with no
+join back to history and no `UNION` (the index *is* the table). The duplication
+is safe because the copied columns are immutable: `postings` rows never change and
+reserve/release only flip the `reservation` column, so a copy can never drift from
+its value row. And it is bounded and disposable: only the live set is duplicated
+(spent postings live in `postings` alone), and `live_postings` is rebuildable from
+`postings` plus the saga write-ahead records, so a corrupt hot table is a
+drop-and-rebuild, not data loss.
+
+### `postings`
+
+The immutable record. A row here that is absent from `live_postings` is Spent.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `transfer_id` | `TEXT` | PK | Creating transfer's id (hex). |
+| `idx` | `SMALLINT` | PK | Position within that transfer. |
+| `owner` | `BIGINT` | | Owning base account id. |
+| `subaccount` | `BIGINT` | | Owning subaccount code. |
+| `asset` | `INTEGER` | | Asset id. |
+| `value` | `TEXT` | | Signed `Cent` as a decimal string. |
+
+- **Primary key**: `(transfer_id, idx)`.
+- **Index**: `idx_postings_owner (owner, subaccount, asset)`.
+
+### `live_postings`
+
+The live-set hot copy: the six data columns plus a nullable `reservation`. A
+posting is here while it is spendable or reserved; `reservation IS NULL` = Active,
+a set `reservation` = Reserved by that saga. Reserve/release flip the column
+(`UPDATE`), consume deletes the row (→ Spent). Rebuildable from `postings` + the
+saga records.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `transfer_id` | `TEXT` | PK | Posting id (hex). |
+| `idx` | `SMALLINT` | PK | Position within the transfer. |
+| `owner` | `BIGINT` | | Owning base account id. |
+| `subaccount` | `BIGINT` | | Owning subaccount code. |
+| `asset` | `INTEGER` | | Asset id. |
+| `value` | `TEXT` | | Signed `Cent` as a decimal string. |
+| `reservation` | `BIGINT` | | Nullable: NULL = Active, set = the `ReservationId` holding this posting (Reserved). |
+
+- **Primary key**: `(transfer_id, idx)`.
+- **Index**: `idx_live_owner (owner, subaccount, asset)`.
+
+## Transfers
+
+Committed envelope records and the account index that finds them. Owned by
+`transfer.rs` (`TransferStore`).
+
+### `transfers`
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `TEXT` | PK | Content-addressed envelope id (hex). |
+| `transfer` | `TEXT` | | The `Envelope` as JSON. |
+| `receipt` | `TEXT` | | The `Receipt` as JSON. |
+| `created_at` | `BIGINT` | | Unix millis when stored (default `0`). |
+| `book` | `BIGINT` | | Owning book id (default `0`). |
+
+- **Primary key**: `id`.
+- **Indexes**: `idx_transfers_created_at (created_at)`, `idx_transfers_book (book)`.
+
+### `transfer_accounts`
+
+The account -> transfer index; the caller supplies the involved set, the store
+does no computation.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `transfer_id` | `TEXT` | PK | The transfer id (hex). |
+| `account_id` | `BIGINT` | PK | An involved base account id. |
+| `subaccount` | `BIGINT` | PK | The involved subaccount code. |
+
+- **Primary key**: `(transfer_id, account_id, subaccount)`.
+- **Index**: `idx_xfer_acct (account_id, subaccount)`.
+
+## Ledger plumbing
+
+### `sagas`
+
+Write-ahead saga records for crash recovery. Owned by `saga.rs` (`SagaStore`).
+See [ADR-0002 (saga commit pipeline)](adr/0002-saga-commit-pipeline.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Saga id (the reservation id). |
+| `data` | `TEXT` | | The encoded `PendingSaga` record (hex). |
+
+- **Primary key**: `id`.
+
+### `events`
+
+The append-only ledger event log, idempotent on the dedup key. Owned by
+`event.rs` (`EventStore`). See [ADR-0010 (event stream vs transfer
+log)](adr/0010-event-stream-vs-transfer-log.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `seq` | `BIGINT` | PK | Monotonic sequence number. |
+| `timestamp` | `BIGINT` | | Unix millis of the event. |
+| `kind` | `TEXT` | | Event kind tag (JSON). |
+| `data` | `TEXT` | | The full `LedgerEvent` as JSON. |
+| `dedup_key` | `TEXT` | UNIQUE | Stable key; a replayed event returns the existing `seq`. |
+
+- **Primary key**: `seq`. **Unique**: `dedup_key`.
+
+### `books`
+
+Book definitions (asset/account/flag policy) as JSON. Owned by `book.rs`
+(`BookStore`). See [ADR-0013 (journaling model)](adr/0013-journaling-model.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Book id. |
+| `name` | `TEXT` | | Human-readable name. |
+| `data` | `TEXT` | | The `Book` (with its `BookPolicy`) as JSON. |
+
+- **Primary key**: `id`.
+
+### `balance_projection`
+
+Append-only balance cache points: each row snapshots one `(account, subaccount,
+asset)` balance at a commit-time watermark. Rows are only inserted; a read picks
+the highest-id row at or before a watermark. A derived, rebuildable accelerator,
+never authoritative. Owned by `projection.rs` (`BalanceProjectionStore`). See
+[ADR-0019](adr/0019-cached-balance-projection.md).
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `id` | `BIGINT` | PK | Rust-minted monotonic id (tie-breaker for equal watermarks). |
+| `account` | `BIGINT` | | Base account id. |
+| `subaccount` | `BIGINT` | | Subaccount code. |
+| `asset` | `INTEGER` | | Asset id. |
+| `balance` | `TEXT` | | The cached `Cent` as a decimal string. |
+| `watermark` | `BIGINT` | | Commit-time watermark (unix millis) this snapshot covers. |
+
+- **Primary key**: `id`.
+- **Index**: `idx_balance_projection_closest (account, subaccount, asset, watermark, id)`.
+
+### `_migrations`
+
+The applied-migration ledger. Created in `migrate.rs` (not a `.sql` file); a
+migration whose `name` is present is skipped, making `migrate()` idempotent.
+
+| Column | Type | Key | Purpose |
+|---|---|---|---|
+| `name` | `TEXT` | PK | The migration name (e.g. `007_balance_projection`). |
+
+- **Primary key**: `name`.