ソースを参照

Break the kuatia-types and storage-sql god-modules into files

A cleanup pass that moves risky decisions into the pure core and gives every
namespace its own file, with no behavior change.

types: split the kuatia-types lib.rs into account, book, envelope, ids,
posting and transfer file modules, so each namespace shows up in the
directory tree instead of hiding in one file.

storage-sql: split the kuatia-storage-sql lib.rs into per-concern file
modules (account, book, dialect, event, migrate, posting, projection, row,
saga, transfer) and share row decoding through a single row helper.

validate: derive the state key-set the loader must fetch (required_state) in
kuatia-core, so Ledger::load iterates one authoritative key-set instead of
re-deriving its own. A missing balance key silently defaults to zero and
would flip an overdraft decision; the loader can no longer under-fetch, and
plan asserts balance coverage.

inflight: extract a pure projection module owning the InflightMeta schema,
the hold traversal, status derivation (derive_status), and the void
funder-distribution arithmetic (distribute_to_funders). The encode and decode
halves can no longer drift, and the state classifier and settlement split are
testable through a pure seam instead of only through async commits.
Cesar Rodas 1 週間 前
コミット
c37a4bacf1

+ 3 - 1
crates/kuatia-core/src/lib.rs

@@ -18,4 +18,6 @@ pub use posting_resolution::{
     Debit, InsufficientFunds, MovementDraft, ResolveError, ResolveInput, draft_movements,
     resolve_envelope,
 };
-pub use validate::{Plan, PlanInput, ValidationError, validate_and_plan};
+pub use validate::{
+    Plan, PlanInput, RequiredState, ValidationError, required_state, validate_and_plan,
+};

+ 241 - 0
crates/kuatia-core/src/validate.rs

@@ -48,6 +48,65 @@ pub struct Plan {
 }
 
 // ---------------------------------------------------------------------------
+// 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
 // ---------------------------------------------------------------------------
 
@@ -1128,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)]
+        );
+    }
 }

+ 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)
+    }
+}

+ 26 - 1459
crates/kuatia-storage-sql/src/lib.rs

@@ -9,1481 +9,48 @@
 //! let store = SqlStore::new(pool);
 //! store.migrate().await?;
 //! ```
+//!
+//! 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 std::collections::{HashMap, HashSet};
-use std::str::FromStr;
-use std::sync::atomic::{AtomicU8, Ordering};
-
-use async_trait::async_trait;
-use sqlx::any::AnyRow;
-use sqlx::{Any, Pool, Row};
+use sqlx::{Any, Pool};
 
-use kuatia_storage::error::StoreError;
-use kuatia_storage::events::{EventStore, LedgerEvent, event_dedup_key};
-use kuatia_storage::query::{filter_transfers, paginate};
-use kuatia_storage::store::*;
 use kuatia_types::autoid::AutoId;
-use kuatia_types::*;
 
-// Cached backend kind for `SqlStore::backend`.
-const BACKEND_UNKNOWN: u8 = 0;
-const BACKEND_POSTGRES: u8 = 1;
-const BACKEND_SQLITE: u8 = 2;
+use crate::dialect::Dialect;
 
-/// 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";
+mod account;
+mod book;
+mod dialect;
+mod event;
+mod migrate;
+mod posting;
+mod projection;
+mod row;
+mod saga;
+mod transfer;
 
-/// SQL-backed [`Store`] implementation.
+/// SQL-backed [`Store`](kuatia_storage::store::Store) implementation.
 pub struct SqlStore {
     pool: Pool<Any>,
     autoid: AutoId,
-    /// Detected backend kind (lazily probed): one of `BACKEND_*`.
-    backend: AtomicU8,
+    /// 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: 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: &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: &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<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.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 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.lock_clause().await?;
-        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()
-    }
-}
-
-// ---------------------------------------------------------------------------
-// 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: &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()))?;
-        }
-
-        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 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(())
-    }
-}
-
-// ---------------------------------------------------------------------------
-// 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 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)
-            }
+            dialect,
         }
     }
-
-    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<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.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 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()
-    }
-}
-
-// ---------------------------------------------------------------------------
-// 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,
-        }))
-    }
 }

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

@@ -0,0 +1,93 @@
+//! 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"),
+            ),
+        ];
+
+        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(())
+    }
+}

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

@@ -0,0 +1,560 @@
+//! [`PostingStore`]: the immutable posting record plus two hot index tables
+//! (`active_postings`, `reserved_postings`) whose membership derives each
+//! posting's lifecycle state.
+//!
+//! A posting is in `active_postings` while spendable, moves to
+//! `reserved_postings` (carrying its reservation) while claimed by a saga, and
+//! once consumed is deleted from both, leaving it only in the immutable
+//! `postings` table (= Spent). See ADR-0016.
+
+use std::collections::{HashMap, HashSet};
+
+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 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 ")
+}
+
+// ---------------------------------------------------------------------------
+// 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: &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)
+    }
+}

+ 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,
+        })
+    }
+}

+ 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);
+    }
+}

+ 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 - 892
crates/kuatia-types/src/lib.rs

@@ -7,19 +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 crate::autoid::AutoId;
-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_*}`.
@@ -27,890 +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 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`] 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: 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: 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: 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 (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`]).
-///
-/// 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
+    }
+}

+ 40 - 214
crates/kuatia/src/inflight.rs

@@ -17,17 +17,19 @@ use std::sync::Arc;
 
 use kuatia_core::{
     Account, AccountFlags, AccountId, AssetId, BookId, Cent, EnvelopeId, InsufficientFunds,
-    Metadata, Receipt, SUB_BITS, Transfer, TransferBuilder, hash::double_sha256,
+    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
@@ -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,
         })
     }
 
@@ -569,79 +468,6 @@ fn inflight_subaccount(transfer: &Transfer) -> i64 {
     (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);
+    }
+}

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

@@ -17,14 +17,7 @@ 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;

+ 21 - 21
crates/kuatia/src/ledger/commit.rs

@@ -17,18 +17,18 @@ use kuatia_core::{
     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, resolve_envelope, validate_and_plan,
+    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, consume_reserved, verify_postings,
+    EnvelopeSaga, EnvelopeSagaInputs, FinalizeInput, LedgerCtx, ReserveInput, apply_and_verify,
+    consume_reserved, verify_postings,
 };
 
 use super::pending::{PendingRecord, SagaPhase};
@@ -59,28 +59,16 @@ impl Ledger {
             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);
         }
@@ -105,6 +93,18 @@ impl Ledger {
 
     /// 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,

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

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

+ 14 - 1
crates/kuatia/src/saga.rs

@@ -12,7 +12,7 @@
 //! 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
+//! The `EnvelopeSaga` is defined via `legend!` below 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
@@ -23,11 +23,17 @@
 //! High-level steps (`PayMovementStep` and `DepositMovementStep`) compose over
 //! the intent-layer API and can be combined into multi-transfer sagas via `legend!`.
 
+// The `legend!` expansion for `EnvelopeSaga` below emits public fields it does
+// not document. An outer `#[allow]` on the macro call is ignored by the
+// compiler, so the allow is scoped to the whole module.
+#![allow(missing_docs)]
+
 use std::fmt;
 use std::future::Future;
 use std::sync::Arc;
 
 use async_trait::async_trait;
+use legend::legend;
 use legend::step::{CompensationOutcome, RetryPolicy, Step, StepOutcome};
 use serde::{Deserialize, Serialize};
 use tracing::Instrument;
@@ -345,6 +351,13 @@ impl Step<LedgerCtx, LedgerError> for FinalizeTransferStep {
     }
 }
 
+legend! {
+    EnvelopeSaga<LedgerCtx, LedgerError> {
+        reserve: ReservePostingsStep,
+        finalize: FinalizeTransferStep,
+    }
+}
+
 // ===========================================================================
 // High-level steps (pay / deposit movement steps)
 // ===========================================================================