Sfoglia il codice sorgente

Split the Ledger god-object into concern-named submodules

The async Ledger had grown to a single ~900-line file carrying about ten
responsibilities behind one Arc<dyn Store> field. The write-ahead
saga/commit engine, the invariant code that a ledger fundamentally is,
read as one method among thirty, physically wedged between account
lifecycle, balance queries, and thin store pass-throughs.

Group the methods into sibling file-modules by concern so the deep engine
stands on its own: commit (resolve/commit/reverse/recover/finalize plus
the write-ahead saga types), lifecycle (create/freeze/unfreeze/close),
balance (per-subaccount balances), and query (the read-only Store
delegations). The struct, its public API, and every method signature are
unchanged; this only moves code and re-exports the two public types the
submodules now own.

Keep the query pass-throughs rather than deleting them: they have live
callers across the dashboard, examples, and tests that depend on the
LedgerError return type, so dropping them would break those callers and
downgrade them to StoreError for no structural gain.
Cesar Rodas 2 giorni fa
parent
commit
be55820f6d

+ 29 - 1213
crates/kuatia/src/ledger.rs

@@ -1,19 +1,38 @@
 //! The async ledger resource -- the primary entry point for callers.
+//!
+//! [`Ledger`] is one type over a single [`Store`]. Its methods are grouped into
+//! sibling modules by concern, so the deep commit engine is not buried among
+//! shallow query re-exports:
+//!
+//! - `commit`: the write-ahead saga/commit engine (resolve, commit, reverse,
+//!   recover, finalize). This is what a ledger fundamentally *is*.
+//! - `lifecycle`: account create, freeze, unfreeze, close.
+//! - `balance`: per-subaccount balance queries.
+//! - `query`: read-only queries and book CRUD (thin `Store` pass-throughs that
+//!   relabel `StoreError` as [`LedgerError`]).
+//!
+//! The inflight-hold API is a further cluster, in [`crate::inflight`].
 
-use std::collections::HashMap;
 use std::sync::Arc;
 
-use legend::{ExecutionResult, legend};
-use tracing::instrument;
-
-use kuatia_core::{
-    AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
-    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId,
-    PostingState, Receipt, ResolveInput, Transfer, account_snapshot_id, draft_movements,
-    envelope_id, resolve_envelope, validate_and_plan,
-};
+use kuatia_storage::store::Store;
 
+// Kept in root scope so `envelope_saga`'s `use super::*` resolves the `legend!`
+// macro and the types its expansion names.
 use crate::error::LedgerError;
+use crate::saga::{FinalizeTransferStep, LedgerCtx, ReservePostingsStep};
+use legend::legend;
+
+#[allow(missing_docs)]
+mod envelope_saga;
+
+mod balance;
+mod commit;
+mod lifecycle;
+mod query;
+
+pub use balance::SubAccountBalance;
+pub use commit::LoadedState;
 
 /// Return the current time as Unix milliseconds.
 pub(crate) fn now_millis() -> Result<i64, LedgerError> {
@@ -22,49 +41,6 @@ pub(crate) fn now_millis() -> Result<i64, LedgerError> {
         .map_err(|_| LedgerError::Overflow)?
         .as_millis() as i64)
 }
-use crate::saga::{
-    FinalizeInput, FinalizeTransferStep, LedgerCtx, ReserveInput, ReservePostingsStep,
-};
-use kuatia_storage::error::StoreError;
-use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
-use kuatia_storage::store::{EnvelopeRecord, Store};
-
-#[allow(missing_docs)]
-mod envelope_saga;
-use envelope_saga::*;
-
-/// Phase of an in-flight commit, persisted with the write-ahead record so
-/// recovery knows whether validation has completed.
-#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
-enum SagaPhase {
-    /// Saved before reserve. Validation has not necessarily run, so recovery must
-    /// re-reserve and re-validate before it can commit.
-    Reserving,
-    /// Saved at the start of finalize — after validation passed and just before
-    /// the consumed postings begin being removed from the reserved index (the
-    /// point of no return). Recovery rolls forward without re-validating.
-    Finalizing,
-}
-
-/// Write-ahead record for an in-flight commit, persisted via `SagaStore` before
-/// the saga mutates anything and removed once it reaches a terminal state. On
-/// startup [`Ledger::recover`] completes any that survive a crash.
-#[derive(serde::Serialize, serde::Deserialize)]
-struct PendingSaga {
-    envelope: Envelope,
-    reservation: kuatia_core::ReservationId,
-    phase: SagaPhase,
-}
-
-/// A single subaccount's balance for one asset. Balances are always reported
-/// per subaccount and never summed across them (ADR-0012).
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub struct SubAccountBalance {
-    /// The subaccount this balance belongs to.
-    pub account: AccountId,
-    /// The balance of `account` for the queried asset.
-    pub value: Cent,
-}
 
 /// Async ledger resource composing the commit pipeline.
 pub struct Ledger {
@@ -83,1164 +59,4 @@ impl Ledger {
     pub fn store(&self) -> &dyn Store {
         self.store.as_ref()
     }
-
-    // -----------------------------------------------------------------------
-    // Three-piece API: load -> plan -> apply
-    // -----------------------------------------------------------------------
-
-    /// Phase 1: load all state needed for validation.
-    #[instrument(skip(self, envelope), name = "ledger.load")]
-    pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
-        let consumed_postings = if envelope.consumes().is_empty() {
-            vec![]
-        } else {
-            self.store.get_postings(envelope.consumes()).await?
-        };
-
-        let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
-        for p in &consumed_postings {
-            account_ids.push(p.owner);
-        }
-        account_ids.sort();
-        account_ids.dedup();
-
-        let account_list = self.store.get_accounts(&account_ids).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 {
-            let bal = self.compute_balance(account_id, asset_id).await?;
-            balances.insert((*account_id, *asset_id), bal);
-        }
-
-        // Load the gating book. A missing named (non-default) book is an error;
-        // a missing default book means "unrestricted" (no policy to enforce).
-        let book_id = envelope.book();
-        let book = match self.store.get_book(&book_id).await {
-            Ok(b) => Some(b),
-            Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
-            Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
-            Err(e) => return Err(e.into()),
-        };
-
-        Ok(LoadedState {
-            consumed_postings,
-            accounts,
-            balances,
-            book,
-        })
-    }
-
-    /// Phase 2: run pure validation and produce a plan.
-    pub fn plan(
-        &self,
-        envelope: &Envelope,
-        loaded: &LoadedState,
-    ) -> Result<kuatia_core::Plan, LedgerError> {
-        let input = PlanInput {
-            envelope,
-            consumed_postings: &loaded.consumed_postings,
-            accounts: &loaded.accounts,
-            balances: &loaded.balances,
-            book: loaded.book.as_ref(),
-        };
-        Ok(validate_and_plan(input)?)
-    }
-
-    // -----------------------------------------------------------------------
-    // Resolve: Transfer (intent) -> Envelope (concrete postings)
-    // -----------------------------------------------------------------------
-
-    /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
-    /// postings for each movement and computing change.
-    ///
-    /// The decision is pure ([`kuatia_core::draft_movements`] +
-    /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
-    /// functions need. Pass 1 aggregates net debits and tells us which postings
-    /// and account policies to load; pass 2 selects postings, computes change,
-    /// and covers any overdraft shortfall.
-    #[instrument(skip(self, transfer), name = "ledger.resolve")]
-    pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
-        let draft = draft_movements(transfer)?;
-
-        // Load the active postings and account policy for each debit. A deposit
-        // nets to zero on the system account, so it produces no debit and loads
-        // nothing here.
-        let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
-        let mut policies: HashMap<AccountId, AccountPolicy> = HashMap::new();
-        for debit in &draft.debits {
-            let postings = self
-                .store
-                .get_postings_by_account(
-                    debit.account.id,
-                    Some(debit.account.sub),
-                    Some(&debit.asset),
-                    PostingFilter::Active,
-                )
-                .await?;
-            available.insert((debit.account, debit.asset), postings);
-            if let std::collections::hash_map::Entry::Vacant(slot) = policies.entry(debit.account) {
-                slot.insert(self.store.get_account(&debit.account).await?.policy);
-            }
-        }
-
-        let mut envelope = resolve_envelope(ResolveInput {
-            transfer,
-            draft,
-            available: &available,
-            policies: &policies,
-        })?;
-
-        // Resolve account snapshots for optimistic concurrency
-        let ids = envelope.referenced_accounts();
-        envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
-
-        Ok(envelope)
-    }
-
-    // -----------------------------------------------------------------------
-    // Commit: every commit is the envelope saga (reserve -> finalize; finalize re-validates)
-    // -----------------------------------------------------------------------
-
-    /// Commit a [`Transfer`] intent. Resolves it into a concrete envelope, then
-    /// drives the envelope saga. Resolution is read-only, so a crash before the
-    /// saga's write-ahead record leaves no partial state.
-    #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
-    pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
-        let envelope = self.resolve(&transfer).await?;
-        self.commit_envelope(envelope).await
-    }
-
-    /// Commit a pre-resolved [`Envelope`] through the saga pipeline (reserve ->
-    /// validate -> finalize). This is the single commit path; `commit()` and
-    /// `reverse()` both funnel through it.
-    ///
-    /// Before running, the saga (envelope + reservation) is persisted as a
-    /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
-    /// record is deleted once the saga reaches a terminal state. The commit is
-    /// idempotent on the content-addressed transfer id.
-    #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
-    pub async fn commit_envelope(
-        self: &Arc<Self>,
-        mut envelope: Envelope,
-    ) -> Result<Receipt, LedgerError> {
-        if envelope.account_snapshots().is_empty() {
-            let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
-            ids.sort();
-            ids.dedup();
-            envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
-        }
-
-        // Idempotency: an already-committed transfer returns its receipt.
-        let tid = envelope_id(&envelope);
-        if let Some(record) = self.store.get_transfer(&tid).await? {
-            return Ok(record.receipt);
-        }
-
-        // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
-        // mutation. The finalize step bumps the phase to Finalizing.
-        let reservation = kuatia_core::ReservationId::default();
-        let saga_id = reservation.0;
-        self.save_pending(&envelope, reservation, SagaPhase::Reserving)
-            .await?;
-
-        let result = self.drive_envelope_saga(envelope, reservation).await;
-
-        // Delete the pending record only when it is safe: on success, or on a
-        // failure that never reached finalize (phase still Reserving → the saga's
-        // compensation released our reservation, nothing of ours was applied). If
-        // finalize started (Finalizing) and failed, keep it so `recover()` rolls
-        // the half-applied commit forward.
-        let safe_to_delete = match &result {
-            Ok(_) => true,
-            Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
-        };
-        if safe_to_delete {
-            self.store.delete_saga(&saga_id).await?;
-        }
-        result
-    }
-
-    /// Build and run the envelope saga (reserve → finalize) to a terminal
-    /// outcome, returning the resulting receipt.
-    async fn drive_envelope_saga(
-        self: &Arc<Self>,
-        envelope: Envelope,
-        reservation: kuatia_core::ReservationId,
-    ) -> Result<Receipt, LedgerError> {
-        let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
-            reserve: ReserveInput,
-            finalize: FinalizeInput,
-        });
-        let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
-        let execution = saga.build(ctx);
-
-        match execution.start().await {
-            ExecutionResult::Completed(e) => {
-                let ctx = e.into_context();
-                ctx.receipts.last().cloned().ok_or_else(|| {
-                    LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
-                })
-            }
-            // The saga's error type is `LedgerError`, so a validation / overdraft
-            // / frozen failure detected during commit reaches the caller as the
-            // real typed variant instead of a stringified internal fault.
-            ExecutionResult::Failed(_, err) => Err(err),
-            ExecutionResult::CompensationFailed {
-                original_error,
-                compensation_error,
-                ..
-            } => Err(LedgerError::CompensationFailed {
-                original: Box::new(original_error),
-                compensation: Box::new(compensation_error),
-            }),
-            ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
-                "saga paused unexpectedly".into(),
-            ))),
-        }
-    }
-
-    /// Complete every pending saga left by a crash. Call on startup; returns how
-    /// many were processed.
-    ///
-    /// Recovery branches on the persisted phase. A `Reserving` saga had not
-    /// necessarily validated, so it is re-run through the real saga (which
-    /// re-reserves and **re-validates** — aborting cleanly if the postings were
-    /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
-    /// validated and owns its postings, so it is rolled forward through the
-    /// verified `finalize_envelope`. Either way the record is removed only once
-    /// the work is committed or safely abandoned.
-    #[instrument(skip(self), name = "ledger.recover")]
-    pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
-        let pending = self.store.list_pending_sagas().await?;
-        let count = pending.len();
-        for (saga_id, blob) in pending {
-            let PendingSaga {
-                envelope,
-                reservation,
-                phase,
-            } = serde_json::from_slice(&blob)
-                .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-
-            // The transfer record is durable, but a full commit is more than the
-            // transfer row: it also includes the committed event, appended *after*
-            // store_transfer. A crash in that window leaves the record present yet
-            // the event missing, so repair the whole end-state (idempotent) before
-            // clearing the pending record.
-            let tid = envelope_id(&envelope);
-            if self.store.get_transfer(&tid).await?.is_some() {
-                self.append_committed_event(tid).await?;
-                self.store.delete_saga(&saga_id).await?;
-                continue;
-            }
-
-            match phase {
-                SagaPhase::Finalizing => {
-                    // Validation passed and the postings are ours; roll forward.
-                    // Keep the record if completion fails so a later run retries.
-                    if self.finalize_envelope(&envelope, reservation).await.is_ok() {
-                        self.store.delete_saga(&saga_id).await?;
-                    }
-                }
-                SagaPhase::Reserving => {
-                    // Re-run the validating saga. On failure, delete only if it did
-                    // not reach finalize (clean abort); otherwise keep for next run.
-                    let result = self.drive_envelope_saga(envelope, reservation).await;
-                    let safe_to_delete = result.is_ok()
-                        || self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing);
-                    if safe_to_delete {
-                        self.store.delete_saga(&saga_id).await?;
-                    }
-                }
-            }
-        }
-        Ok(count)
-    }
-
-    /// Idempotently finalize `envelope` to its committed state, **verifying every
-    /// step's end-state**. Used by the saga's finalize step and by recovery.
-    ///
-    /// When the consumed postings are still reserved it re-validates against
-    /// current state (the last-step floor / freeze-close guard) and then marks
-    /// the saga `Finalizing` (the point of no return). Once any consumed posting
-    /// is already spent — a prior attempt or recovery passed that point — it
-    /// rolls forward without re-validating. It never creates or stores anything
-    /// unless **all** consumed postings are confirmed spent, which is the
-    /// double-spend guard.
-    pub(crate) async fn finalize_envelope(
-        &self,
-        envelope: &Envelope,
-        reservation: kuatia_core::ReservationId,
-    ) -> Result<Receipt, LedgerError> {
-        let tid = envelope_id(envelope);
-        if let Some(record) = self.store.get_transfer(&tid).await? {
-            // The transfer record is durable, but a crash (or a retried finalize)
-            // can land between store_transfer and the event append below. The
-            // committed end-state includes the event, so ensure it before
-            // returning — `append_committed_event` is idempotent.
-            self.append_committed_event(tid).await?;
-            return Ok(record.receipt); // already committed
-        }
-        let consumes = envelope.consumes();
-
-        // Read consumed postings (immutable rows, kept for owner indexing) and
-        // their derived states.
-        let consumed = if consumes.is_empty() {
-            Vec::new()
-        } else {
-            self.store.get_postings(consumes).await?
-        };
-        let states = if consumes.is_empty() {
-            Vec::new()
-        } else {
-            self.store.get_posting_states(consumes).await?
-        };
-        let past_no_return = states.contains(&PostingState::Spent);
-
-        // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
-        // against current state, but only while it is still safe (validation
-        // rejects a consumed posting that is no longer live).
-        if !past_no_return {
-            let loaded = self.load(envelope).await?;
-            self.plan(envelope, &loaded)?;
-        }
-
-        // Point of no return: record Finalizing before any posting is consumed.
-        self.save_pending(envelope, reservation, SagaPhase::Finalizing)
-            .await?;
-
-        // Consume our reserved postings (remove from the reserved index → spent),
-        // then assert ALL consumed postings are spent. This is the double-spend
-        // guard: `deactivate_postings(Some(rid))` only removes rows we reserved,
-        // so any consumed id still active or reserved by another saga leaves the
-        // "all spent" check failing.
-        self.store
-            .deactivate_postings(consumes, Some(reservation))
-            .await?;
-        if !consumes.is_empty() {
-            let after = self.store.get_posting_states(consumes).await?;
-            if after.len() != consumes.len() || after.iter().any(|s| *s != PostingState::Spent) {
-                return Err(LedgerError::Store(StoreError::Internal(
-                    "finalize: consumed postings not all spent (contended or not reserved by this saga)".into(),
-                )));
-            }
-        }
-
-        // Created postings, derived deterministically from the envelope.
-        let created: Vec<Posting> = envelope
-            .creates()
-            .iter()
-            .enumerate()
-            .map(|(i, np)| {
-                Posting::new(
-                    PostingId {
-                        transfer: tid,
-                        index: i as u16,
-                    },
-                    np.owner,
-                    np.asset,
-                    np.value,
-                )
-            })
-            .collect();
-        self.store.insert_postings(&created).await?;
-        if !created.is_empty() {
-            let ids: Vec<PostingId> = created.iter().map(|p| p.id).collect();
-            if self.store.get_postings(&ids).await?.len() != created.len() {
-                return Err(LedgerError::Store(StoreError::Internal(
-                    "finalize: created postings missing after insert".into(),
-                )));
-            }
-        }
-
-        // Index both created and consumed owners.
-        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
-        involved.extend(consumed.iter().map(|p| p.owner));
-        involved.sort();
-        involved.dedup();
-
-        let receipt = Receipt { transfer_id: tid };
-        self.store
-            .store_transfer(
-                EnvelopeRecord {
-                    envelope: envelope.clone(),
-                    receipt: receipt.clone(),
-                    created_at: now_millis()?,
-                },
-                &involved,
-            )
-            .await?;
-        if self.store.get_transfer(&tid).await?.is_none() {
-            return Err(LedgerError::Store(StoreError::Internal(
-                "finalize: transfer record missing after store".into(),
-            )));
-        }
-
-        self.append_committed_event(tid).await?;
-        Ok(receipt)
-    }
-
-    /// Idempotently append the `TransferCommitted` event for `tid`.
-    ///
-    /// The event append is the final finalize step, *after* `store_transfer`, so a
-    /// crash in that window leaves a stored transfer with no event. Recovery and a
-    /// retried finalize both call this to repair the committed end-state.
-    /// `append_event` dedups on the transfer id, so calling it more than once for
-    /// the same transfer is a no-op.
-    async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
-        self.store
-            .append_event(&LedgerEvent {
-                seq: 0,
-                timestamp: now_millis()?,
-                kind: LedgerEventKind::TransferCommitted { transfer_id: tid },
-            })
-            .await?;
-        Ok(())
-    }
-
-    /// Persist the write-ahead pending-saga record (upsert on the reservation id).
-    async fn save_pending(
-        &self,
-        envelope: &Envelope,
-        reservation: kuatia_core::ReservationId,
-        phase: SagaPhase,
-    ) -> Result<(), LedgerError> {
-        let blob = serde_json::to_vec(&PendingSaga {
-            envelope: envelope.clone(),
-            reservation,
-            phase,
-        })
-        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-        self.store.save_saga(&reservation.0, blob).await?;
-        Ok(())
-    }
-
-    /// Read the persisted phase of a pending saga, if it still exists.
-    async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
-        for (id, blob) in self.store.list_pending_sagas().await? {
-            if id == saga_id {
-                let pending: PendingSaga = serde_json::from_slice(&blob)
-                    .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-                return Ok(Some(pending.phase));
-            }
-        }
-        Ok(None)
-    }
-
-    // -----------------------------------------------------------------------
-    // Reverse
-    // -----------------------------------------------------------------------
-
-    /// Create and commit a reversal envelope for the given envelope id.
-    #[instrument(skip(self), name = "ledger.reverse")]
-    pub async fn reverse(self: &Arc<Self>, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
-        let record = self
-            .store
-            .get_transfer(id)
-            .await?
-            .ok_or(LedgerError::TransferNotFound(*id))?;
-
-        let original = &record.envelope;
-
-        let created_posting_ids: Vec<PostingId> = original
-            .creates()
-            .iter()
-            .enumerate()
-            .map(|(i, _)| PostingId {
-                transfer: record.receipt.transfer_id,
-                index: i as u16,
-            })
-            .collect();
-
-        let original_consumed = if original.consumes().is_empty() {
-            vec![]
-        } else {
-            self.store.get_postings(original.consumes()).await?
-        };
-
-        let new_postings: Vec<NewPosting> = original_consumed
-            .iter()
-            .map(|p| NewPosting {
-                owner: p.owner,
-                asset: p.asset,
-                value: p.value,
-                payer: None,
-            })
-            .collect();
-
-        let reverse_envelope = EnvelopeBuilder::new()
-            .consumes(created_posting_ids)
-            .creates(new_postings)
-            .book(original.book())
-            .metadata(original.metadata().clone())
-            .build();
-
-        self.commit_envelope(reverse_envelope).await
-    }
-
-    // -----------------------------------------------------------------------
-    // Internal: resolve account snapshots
-    // -----------------------------------------------------------------------
-
-    /// Compute balance from the live (active or reserved) postings for an
-    /// account/asset pair.
-    async fn compute_balance(
-        &self,
-        account: &AccountId,
-        asset: &AssetId,
-    ) -> Result<Cent, LedgerError> {
-        let postings = self
-            .store
-            .get_postings_by_account(
-                account.id,
-                Some(account.sub),
-                Some(asset),
-                PostingFilter::Live,
-            )
-            .await?;
-        Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
-    }
-
-    async fn resolve_snapshots(
-        &self,
-        ids: &[AccountId],
-    ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
-        let accounts = self.store.get_accounts(ids).await?;
-        Ok(accounts.iter().map(account_snapshot_id).collect())
-    }
-
-    // -----------------------------------------------------------------------
-    // Account lifecycle
-    // -----------------------------------------------------------------------
-
-    /// Freeze an account, preventing all transfers.
-    #[instrument(skip(self), name = "ledger.freeze")]
-    pub async fn freeze(&self, id: &AccountId) -> Result<(), LedgerError> {
-        let current = self
-            .store
-            .get_account(id)
-            .await
-            .map_err(|_| LedgerError::AccountNotFound(*id))?;
-        if current.is_closed() {
-            return Err(LedgerError::AccountAlreadyClosed(*id));
-        }
-        let mut next = current.clone();
-        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
-        next.flags |= kuatia_core::AccountFlags::FROZEN;
-        self.store.append_account_version(next).await?;
-        self.store
-            .append_event(&LedgerEvent {
-                seq: 0,
-                timestamp: now_millis()?,
-                kind: LedgerEventKind::AccountFrozen { account_id: *id },
-            })
-            .await?;
-        Ok(())
-    }
-
-    /// Unfreeze a previously frozen account.
-    #[instrument(skip(self), name = "ledger.unfreeze")]
-    pub async fn unfreeze(&self, id: &AccountId) -> Result<(), LedgerError> {
-        let current = self
-            .store
-            .get_account(id)
-            .await
-            .map_err(|_| LedgerError::AccountNotFound(*id))?;
-        if current.is_closed() {
-            return Err(LedgerError::AccountAlreadyClosed(*id));
-        }
-        let mut next = current.clone();
-        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
-        next.flags.remove(kuatia_core::AccountFlags::FROZEN);
-        self.store.append_account_version(next).await?;
-        self.store
-            .append_event(&LedgerEvent {
-                seq: 0,
-                timestamp: now_millis()?,
-                kind: LedgerEventKind::AccountUnfrozen { account_id: *id },
-            })
-            .await?;
-        Ok(())
-    }
-
-    /// Close an account. Must have no active postings.
-    #[instrument(skip(self), name = "ledger.close")]
-    pub async fn close(&self, id: &AccountId) -> Result<(), LedgerError> {
-        let current = self
-            .store
-            .get_account(id)
-            .await
-            .map_err(|_| LedgerError::AccountNotFound(*id))?;
-        if current.is_closed() {
-            return Err(LedgerError::AccountAlreadyClosed(*id));
-        }
-        // Reject if any posting is still live — active or reserved (a transfer
-        // in flight). Only spent postings (or none) permit a close.
-        if self.has_live_postings(id).await? {
-            return Err(LedgerError::AccountNotEmpty(*id));
-        }
-        let mut next = current.clone();
-        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
-        next.flags |= kuatia_core::AccountFlags::CLOSED;
-        next.flags.remove(kuatia_core::AccountFlags::FROZEN);
-        self.store.append_account_version(next).await?;
-        self.store
-            .append_event(&LedgerEvent {
-                seq: 0,
-                timestamp: now_millis()?,
-                kind: LedgerEventKind::AccountClosed { account_id: *id },
-            })
-            .await?;
-        Ok(())
-    }
-
-    /// Whether `account` (exact base id and subaccount) has any live posting: one
-    /// that is active or reserved by an in-flight saga. Spent postings do not
-    /// count. This is the emptiness test [`close`](Self::close) gates on, and the
-    /// inflight layer uses it to decide when a drained hold can be closed.
-    #[instrument(skip(self), name = "ledger.has_live_postings")]
-    pub async fn has_live_postings(&self, account: &AccountId) -> Result<bool, LedgerError> {
-        Ok(!self
-            .store
-            .get_postings_by_account(account.id, Some(account.sub), None, PostingFilter::Live)
-            .await?
-            .is_empty())
-    }
-
-    /// Query the current balance of one subaccount for a given asset. This reads
-    /// exactly the `account` passed (base id and subaccount) and never rolls up
-    /// other subaccounts.
-    #[instrument(skip(self), name = "ledger.balance")]
-    pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
-        self.compute_balance(account, asset).await
-    }
-
-    /// Report the per-subaccount balances of a base account for one asset.
-    ///
-    /// One entry per non-closed subaccount. `sub == None` spans every
-    /// subaccount of `account`'s base id; `Some(s)` restricts to that one.
-    /// Balances are never summed across subaccounts (ADR-0012).
-    #[instrument(skip(self), name = "ledger.balances")]
-    pub async fn balances(
-        &self,
-        account: &AccountId,
-        asset: &AssetId,
-        sub: Option<i64>,
-    ) -> Result<Vec<SubAccountBalance>, LedgerError> {
-        let mut result = Vec::new();
-        for subaccount in self.list_subaccounts(account).await? {
-            if let Some(s) = sub
-                && subaccount.sub != s
-            {
-                continue;
-            }
-            let value = self.compute_balance(&subaccount, asset).await?;
-            result.push(SubAccountBalance {
-                account: subaccount,
-                value,
-            });
-        }
-        Ok(result)
-    }
-
-    /// List the non-closed subaccounts of a base account.
-    ///
-    /// This scans every account row and filters in memory, so it pays for
-    /// subaccounts that were created and later closed (ADR-0012).
-    #[instrument(skip(self), name = "ledger.list_subaccounts")]
-    pub async fn list_subaccounts(
-        &self,
-        account: &AccountId,
-    ) -> Result<Vec<AccountId>, LedgerError> {
-        let base = account.id;
-        let mut subs: Vec<AccountId> = self
-            .store
-            .list_accounts()
-            .await?
-            .into_iter()
-            .filter(|a| a.id.id == base && !a.is_closed())
-            .map(|a| a.id)
-            .collect();
-        subs.sort();
-        Ok(subs)
-    }
-
-    // -----------------------------------------------------------------------
-    // Query layer
-    // -----------------------------------------------------------------------
-
-    /// List all accounts (latest version of each).
-    pub async fn list_accounts(&self) -> Result<Vec<kuatia_core::Account>, LedgerError> {
-        Ok(self.store.list_accounts().await?)
-    }
-
-    /// Fetch a single account by id.
-    pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
-        self.store
-            .get_account(id)
-            .await
-            .map_err(|_| LedgerError::AccountNotFound(*id))
-    }
-
-    /// Return all transfers involving the given account (exact subaccount).
-    pub async fn history(
-        &self,
-        account: &AccountId,
-    ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
-        Ok(self
-            .store
-            .get_transfers_for_account(account.id, Some(account.sub))
-            .await?)
-    }
-
-    /// Query transfers with filtering and pagination.
-    pub async fn query_transfers(
-        &self,
-        query: &crate::store::TransferQuery,
-    ) -> Result<crate::store::Page<crate::store::EnvelopeRecord>, LedgerError> {
-        Ok(self.store.query_transfers(query).await?)
-    }
-
-    /// Return all postings (any state) for the given account.
-    pub async fn postings(
-        &self,
-        account: &AccountId,
-    ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
-        Ok(self
-            .store
-            .get_postings_by_account(account.id, Some(account.sub), None, PostingFilter::All)
-            .await?)
-    }
-
-    /// Return all postings for the given account paired with their derived
-    /// lifecycle state (active, reserved, or spent).
-    pub async fn postings_with_state(
-        &self,
-        account: &AccountId,
-    ) -> Result<Vec<(kuatia_core::Posting, PostingState)>, LedgerError> {
-        let postings = self.postings(account).await?;
-        let ids: Vec<PostingId> = postings.iter().map(|p| p.id).collect();
-        let states = self.store.get_posting_states(&ids).await?;
-        Ok(postings.into_iter().zip(states).collect())
-    }
-
-    /// Query postings with filtering and pagination.
-    pub async fn query_postings(
-        &self,
-        query: &crate::store::PostingQuery,
-    ) -> Result<crate::store::Page<kuatia_core::Posting>, LedgerError> {
-        Ok(self.store.query_postings(query).await?)
-    }
-
-    /// Return the full version history for an account.
-    pub async fn account_history(
-        &self,
-        id: &AccountId,
-    ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
-        Ok(self.store.get_account_history(id).await?)
-    }
-
-    /// Create a new account and emit an AccountCreated event.
-    pub async fn create_account(&self, account: kuatia_core::Account) -> Result<(), LedgerError> {
-        let id = account.id;
-        self.store.create_account(account).await?;
-        self.store
-            .append_event(&LedgerEvent {
-                seq: 0,
-                timestamp: now_millis()?,
-                kind: LedgerEventKind::AccountCreated { account_id: id },
-            })
-            .await?;
-        Ok(())
-    }
-
-    /// Create a new book.
-    pub async fn create_book(&self, book: kuatia_core::Book) -> Result<(), LedgerError> {
-        Ok(self.store.create_book(book).await?)
-    }
-
-    /// Fetch a book by id.
-    pub async fn get_book(
-        &self,
-        id: &kuatia_core::BookId,
-    ) -> Result<kuatia_core::Book, LedgerError> {
-        Ok(self.store.get_book(id).await?)
-    }
-
-    /// List all books.
-    pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
-        Ok(self.store.list_books().await?)
-    }
-
-    /// Query ledger events after a given sequence number.
-    pub async fn get_events_since(
-        &self,
-        after_seq: u64,
-        limit: u32,
-    ) -> Result<Vec<LedgerEvent>, LedgerError> {
-        Ok(self.store.get_events_since(after_seq, limit).await?)
-    }
-}
-
-/// State loaded in phase 1, passed to the pure validation in phase 2.
-pub struct LoadedState {
-    /// Postings being consumed by the envelope.
-    pub consumed_postings: Vec<Posting>,
-    /// Accounts referenced by the envelope.
-    pub accounts: HashMap<AccountId, kuatia_core::Account>,
-    /// Current balances for all referenced (account, asset) pairs.
-    pub balances: HashMap<(AccountId, AssetId), Cent>,
-    /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
-    pub book: Option<Book>,
-}
-
-#[cfg(test)]
-mod recovery_tests {
-    use super::*;
-    use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder};
-    use kuatia_storage::mem_store::InMemoryStore;
-    use std::collections::BTreeMap;
-
-    fn acct(id: i64, policy: AccountPolicy) -> Account {
-        Account {
-            id: AccountId::new(id),
-            version: 1,
-            policy,
-            flags: AccountFlags::empty(),
-            book: kuatia_core::BookId(0),
-            metadata: BTreeMap::new(),
-        }
-    }
-
-    async fn funded_ledger() -> Arc<Ledger> {
-        let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
-        for (id, p) in [
-            (1, AccountPolicy::NoOverdraft),
-            (2, AccountPolicy::NoOverdraft),
-            (3, AccountPolicy::NoOverdraft),
-            (99, AccountPolicy::ExternalAccount),
-        ] {
-            ledger.store().create_account(acct(id, p)).await.unwrap();
-        }
-        let deposit = TransferBuilder::new()
-            .deposit(
-                AccountId::new(1),
-                AssetId::new(1),
-                Cent::from(100),
-                AccountId::new(99),
-            )
-            .unwrap()
-            .build();
-        ledger.commit(deposit).await.unwrap();
-        ledger
-    }
-
-    fn pay_transfer() -> Transfer {
-        TransferBuilder::new()
-            .pay(
-                AccountId::new(1),
-                AccountId::new(2),
-                AssetId::new(1),
-                Cent::from(40),
-            )
-            .build()
-    }
-
-    async fn save_pending(
-        ledger: &Arc<Ledger>,
-        envelope: &Envelope,
-        rid: ReservationId,
-        phase: SagaPhase,
-    ) {
-        let blob = serde_json::to_vec(&PendingSaga {
-            envelope: envelope.clone(),
-            reservation: rid,
-            phase,
-        })
-        .unwrap();
-        ledger.store().save_saga(&rid.0, blob).await.unwrap();
-    }
-
-    /// A commit interrupted right after its write-ahead record (phase Reserving,
-    /// before any step) is re-run and completed by `recover()`.
-    #[tokio::test]
-    async fn recover_redrives_reserving_saga() {
-        let ledger = funded_ledger().await;
-        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
-        let rid = ReservationId::default();
-        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
-
-        assert_eq!(ledger.recover().await.unwrap(), 1);
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(40)
-        );
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(60)
-        );
-        assert!(
-            ledger
-                .store()
-                .list_pending_sagas()
-                .await
-                .unwrap()
-                .is_empty()
-        );
-    }
-
-    /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
-    /// is already spent) is rolled forward by `recover()`.
-    #[tokio::test]
-    async fn recover_completes_partial_finalize() {
-        let ledger = funded_ledger().await;
-        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
-        let rid = ReservationId::default();
-        // Run the commit halfway: reserve + deactivate the consumed posting.
-        let consumes = envelope.consumes().to_vec();
-        ledger
-            .store()
-            .reserve_postings(&consumes, rid)
-            .await
-            .unwrap();
-        assert_eq!(
-            ledger
-                .store()
-                .deactivate_postings(&consumes, Some(rid))
-                .await
-                .unwrap(),
-            1
-        );
-        save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
-
-        assert_eq!(ledger.recover().await.unwrap(), 1);
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(40)
-        );
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(60)
-        );
-        assert!(
-            ledger
-                .store()
-                .list_pending_sagas()
-                .await
-                .unwrap()
-                .is_empty()
-        );
-    }
-
-    /// A commit that crashed *after* `store_transfer` but *before* the committed
-    /// event was appended (phase Finalizing, transfer row present, event missing)
-    /// is repaired by `recover()`: the full end-state includes the event, so
-    /// recovery appends it (idempotently) instead of treating the transfer row as
-    /// proof of a complete commit.
-    #[tokio::test]
-    async fn recover_appends_missing_committed_event() {
-        let ledger = funded_ledger().await;
-        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
-        let tid = envelope_id(&envelope);
-        let rid = ReservationId::default();
-
-        // Replay finalize by hand up to and including store_transfer, stopping
-        // short of the event append — exactly the crash window.
-        let consumes = envelope.consumes().to_vec();
-        ledger
-            .store()
-            .reserve_postings(&consumes, rid)
-            .await
-            .unwrap();
-        ledger
-            .store()
-            .deactivate_postings(&consumes, Some(rid))
-            .await
-            .unwrap();
-        let created: Vec<Posting> = envelope
-            .creates()
-            .iter()
-            .enumerate()
-            .map(|(i, np)| {
-                Posting::new(
-                    PostingId {
-                        transfer: tid,
-                        index: i as u16,
-                    },
-                    np.owner,
-                    np.asset,
-                    np.value,
-                )
-            })
-            .collect();
-        ledger.store().insert_postings(&created).await.unwrap();
-        let consumed = ledger.store().get_postings(&consumes).await.unwrap();
-        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
-        involved.extend(consumed.iter().map(|p| p.owner));
-        involved.sort();
-        involved.dedup();
-        ledger
-            .store()
-            .store_transfer(
-                EnvelopeRecord {
-                    envelope: envelope.clone(),
-                    receipt: Receipt { transfer_id: tid },
-                    created_at: 0,
-                },
-                &involved,
-            )
-            .await
-            .unwrap();
-        save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
-
-        // Precondition: the transfer is stored, but no committed event exists yet.
-        let committed = |evs: &[LedgerEvent]| {
-            evs.iter().any(|e| {
-                matches!(
-                    e.kind,
-                    LedgerEventKind::TransferCommitted { transfer_id } if transfer_id == tid
-                )
-            })
-        };
-        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_some());
-        assert!(!committed(&ledger.get_events_since(0, 1000).await.unwrap()));
-
-        assert_eq!(ledger.recover().await.unwrap(), 1);
-
-        // The missing event is repaired and the pending record cleared.
-        assert!(committed(&ledger.get_events_since(0, 1000).await.unwrap()));
-        assert!(
-            ledger
-                .store()
-                .list_pending_sagas()
-                .await
-                .unwrap()
-                .is_empty()
-        );
-    }
-
-    /// Recovery of a `Reserving` saga re-validates against current state: if an
-    /// account was frozen after the write-ahead record, the commit is abandoned —
-    /// no postings move, the reservation is released, and the record is cleared.
-    #[tokio::test]
-    async fn recover_revalidates_and_aborts_when_account_frozen() {
-        let ledger = funded_ledger().await;
-        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
-        let tid = envelope_id(&envelope);
-        let rid = ReservationId::default();
-        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
-
-        // A freeze lands before recovery runs.
-        ledger.freeze(&AccountId::new(1)).await.unwrap();
-
-        assert_eq!(ledger.recover().await.unwrap(), 1);
-        // Nothing committed; balances unchanged; reservation released.
-        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(100)
-        );
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::ZERO
-        );
-        let active = ledger
-            .store()
-            .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::Active)
-            .await
-            .unwrap();
-        assert_eq!(active.len(), 1); // back to Active
-        assert!(
-            ledger
-                .store()
-                .list_pending_sagas()
-                .await
-                .unwrap()
-                .is_empty()
-        );
-    }
-
-    /// Recovery cannot double-spend: if the consumed posting was taken by another
-    /// transfer while the saga was pending, recovery aborts without creating or
-    /// storing anything.
-    #[tokio::test]
-    async fn recover_does_not_double_spend_a_taken_posting() {
-        let ledger = funded_ledger().await;
-        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
-        let tid = envelope_id(&envelope);
-        let rid = ReservationId::default();
-        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
-
-        // Another transfer consumes account 1's posting and commits.
-        let steal = TransferBuilder::new()
-            .pay(
-                AccountId::new(1),
-                AccountId::new(3),
-                AssetId::new(1),
-                Cent::from(50),
-            )
-            .build();
-        ledger.commit(steal).await.unwrap();
-
-        assert_eq!(ledger.recover().await.unwrap(), 1);
-        // Our envelope never committed; only the stealing transfer applied.
-        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(1), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(50)
-        );
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(3), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::from(50)
-        );
-        assert_eq!(
-            ledger
-                .balance(&AccountId::new(2), &AssetId::new(1))
-                .await
-                .unwrap(),
-            Cent::ZERO
-        );
-        assert!(
-            ledger
-                .store()
-                .list_pending_sagas()
-                .await
-                .unwrap()
-                .is_empty()
-        );
-    }
 }

+ 100 - 0
crates/kuatia/src/ledger/balance.rs

@@ -0,0 +1,100 @@
+//! Per-subaccount balance queries.
+//!
+//! Balances are always computed in Rust from the live (active or reserved)
+//! postings and are never summed across subaccounts (ADR-0012).
+
+use tracing::instrument;
+
+use kuatia_core::{AccountId, AssetId, Cent, PostingFilter};
+
+use super::Ledger;
+use crate::error::LedgerError;
+
+/// A single subaccount's balance for one asset. Balances are always reported
+/// per subaccount and never summed across them (ADR-0012).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub struct SubAccountBalance {
+    /// The subaccount this balance belongs to.
+    pub account: AccountId,
+    /// The balance of `account` for the queried asset.
+    pub value: Cent,
+}
+
+impl Ledger {
+    /// Compute balance from the live (active or reserved) postings for an
+    /// account/asset pair.
+    pub(crate) async fn compute_balance(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+    ) -> Result<Cent, LedgerError> {
+        let postings = self
+            .store
+            .get_postings_by_account(
+                account.id,
+                Some(account.sub),
+                Some(asset),
+                PostingFilter::Live,
+            )
+            .await?;
+        Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
+    }
+
+    /// Query the current balance of one subaccount for a given asset. This reads
+    /// exactly the `account` passed (base id and subaccount) and never rolls up
+    /// other subaccounts.
+    #[instrument(skip(self), name = "ledger.balance")]
+    pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
+        self.compute_balance(account, asset).await
+    }
+
+    /// Report the per-subaccount balances of a base account for one asset.
+    ///
+    /// One entry per non-closed subaccount. `sub == None` spans every
+    /// subaccount of `account`'s base id; `Some(s)` restricts to that one.
+    /// Balances are never summed across subaccounts (ADR-0012).
+    #[instrument(skip(self), name = "ledger.balances")]
+    pub async fn balances(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        sub: Option<i64>,
+    ) -> Result<Vec<SubAccountBalance>, LedgerError> {
+        let mut result = Vec::new();
+        for subaccount in self.list_subaccounts(account).await? {
+            if let Some(s) = sub
+                && subaccount.sub != s
+            {
+                continue;
+            }
+            let value = self.compute_balance(&subaccount, asset).await?;
+            result.push(SubAccountBalance {
+                account: subaccount,
+                value,
+            });
+        }
+        Ok(result)
+    }
+
+    /// List the non-closed subaccounts of a base account.
+    ///
+    /// This scans every account row and filters in memory, so it pays for
+    /// subaccounts that were created and later closed (ADR-0012).
+    #[instrument(skip(self), name = "ledger.list_subaccounts")]
+    pub async fn list_subaccounts(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<AccountId>, LedgerError> {
+        let base = account.id;
+        let mut subs: Vec<AccountId> = self
+            .store
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| a.id.id == base && !a.is_closed())
+            .map(|a| a.id)
+            .collect();
+        subs.sort();
+        Ok(subs)
+    }
+}

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

@@ -0,0 +1,923 @@
+//! The write-ahead saga/commit engine: resolve, reserve, finalize, recover.
+//!
+//! This is the deep core of the ledger. Every commit is the two-step envelope
+//! saga (`reserve → finalize`, validation inside finalize) with automatic retry
+//! and LIFO compensation. A phase-tracked write-ahead record ([`PendingSaga`])
+//! lets [`Ledger::recover`] complete or safely abandon a commit interrupted by a
+//! crash.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use legend::ExecutionResult;
+use tracing::instrument;
+
+use kuatia_core::{
+    AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
+    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId,
+    PostingState, Receipt, ResolveInput, Transfer, account_snapshot_id, draft_movements,
+    envelope_id, resolve_envelope, validate_and_plan,
+};
+
+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};
+
+/// Phase of an in-flight commit, persisted with the write-ahead record so
+/// recovery knows whether validation has completed.
+#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
+enum SagaPhase {
+    /// Saved before reserve. Validation has not necessarily run, so recovery must
+    /// re-reserve and re-validate before it can commit.
+    Reserving,
+    /// Saved at the start of finalize — after validation passed and just before
+    /// the consumed postings begin being removed from the reserved index (the
+    /// point of no return). Recovery rolls forward without re-validating.
+    Finalizing,
+}
+
+/// Write-ahead record for an in-flight commit, persisted via `SagaStore` before
+/// the saga mutates anything and removed once it reaches a terminal state. On
+/// startup [`Ledger::recover`] completes any that survive a crash.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct PendingSaga {
+    envelope: Envelope,
+    reservation: kuatia_core::ReservationId,
+    phase: SagaPhase,
+}
+
+/// State loaded in phase 1, passed to the pure validation in phase 2.
+pub struct LoadedState {
+    /// Postings being consumed by the envelope.
+    pub consumed_postings: Vec<Posting>,
+    /// Accounts referenced by the envelope.
+    pub accounts: HashMap<AccountId, kuatia_core::Account>,
+    /// Current balances for all referenced (account, asset) pairs.
+    pub balances: HashMap<(AccountId, AssetId), Cent>,
+    /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
+    pub book: Option<Book>,
+}
+
+impl Ledger {
+    // -----------------------------------------------------------------------
+    // Three-piece API: load -> plan -> apply
+    // -----------------------------------------------------------------------
+
+    /// Phase 1: load all state needed for validation.
+    #[instrument(skip(self, envelope), name = "ledger.load")]
+    pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
+        let consumed_postings = if envelope.consumes().is_empty() {
+            vec![]
+        } else {
+            self.store.get_postings(envelope.consumes()).await?
+        };
+
+        let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+        for p in &consumed_postings {
+            account_ids.push(p.owner);
+        }
+        account_ids.sort();
+        account_ids.dedup();
+
+        let account_list = self.store.get_accounts(&account_ids).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 {
+            let bal = self.compute_balance(account_id, asset_id).await?;
+            balances.insert((*account_id, *asset_id), bal);
+        }
+
+        // Load the gating book. A missing named (non-default) book is an error;
+        // a missing default book means "unrestricted" (no policy to enforce).
+        let book_id = envelope.book();
+        let book = match self.store.get_book(&book_id).await {
+            Ok(b) => Some(b),
+            Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
+            Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
+            Err(e) => return Err(e.into()),
+        };
+
+        Ok(LoadedState {
+            consumed_postings,
+            accounts,
+            balances,
+            book,
+        })
+    }
+
+    /// Phase 2: run pure validation and produce a plan.
+    pub fn plan(
+        &self,
+        envelope: &Envelope,
+        loaded: &LoadedState,
+    ) -> Result<kuatia_core::Plan, LedgerError> {
+        let input = PlanInput {
+            envelope,
+            consumed_postings: &loaded.consumed_postings,
+            accounts: &loaded.accounts,
+            balances: &loaded.balances,
+            book: loaded.book.as_ref(),
+        };
+        Ok(validate_and_plan(input)?)
+    }
+
+    // -----------------------------------------------------------------------
+    // Resolve: Transfer (intent) -> Envelope (concrete postings)
+    // -----------------------------------------------------------------------
+
+    /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
+    /// postings for each movement and computing change.
+    ///
+    /// The decision is pure ([`kuatia_core::draft_movements`] +
+    /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
+    /// functions need. Pass 1 aggregates net debits and tells us which postings
+    /// and account policies to load; pass 2 selects postings, computes change,
+    /// and covers any overdraft shortfall.
+    #[instrument(skip(self, transfer), name = "ledger.resolve")]
+    pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
+        let draft = draft_movements(transfer)?;
+
+        // Load the active postings and account policy for each debit. A deposit
+        // nets to zero on the system account, so it produces no debit and loads
+        // nothing here.
+        let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
+        let mut policies: HashMap<AccountId, AccountPolicy> = HashMap::new();
+        for debit in &draft.debits {
+            let postings = self
+                .store
+                .get_postings_by_account(
+                    debit.account.id,
+                    Some(debit.account.sub),
+                    Some(&debit.asset),
+                    PostingFilter::Active,
+                )
+                .await?;
+            available.insert((debit.account, debit.asset), postings);
+            if let std::collections::hash_map::Entry::Vacant(slot) = policies.entry(debit.account) {
+                slot.insert(self.store.get_account(&debit.account).await?.policy);
+            }
+        }
+
+        let mut envelope = resolve_envelope(ResolveInput {
+            transfer,
+            draft,
+            available: &available,
+            policies: &policies,
+        })?;
+
+        // Resolve account snapshots for optimistic concurrency
+        let ids = envelope.referenced_accounts();
+        envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
+
+        Ok(envelope)
+    }
+
+    // -----------------------------------------------------------------------
+    // Commit: every commit is the envelope saga (reserve -> finalize; finalize re-validates)
+    // -----------------------------------------------------------------------
+
+    /// Commit a [`Transfer`] intent. Resolves it into a concrete envelope, then
+    /// drives the envelope saga. Resolution is read-only, so a crash before the
+    /// saga's write-ahead record leaves no partial state.
+    #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
+    pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
+        let envelope = self.resolve(&transfer).await?;
+        self.commit_envelope(envelope).await
+    }
+
+    /// Commit a pre-resolved [`Envelope`] through the saga pipeline (reserve ->
+    /// validate -> finalize). This is the single commit path; `commit()` and
+    /// `reverse()` both funnel through it.
+    ///
+    /// Before running, the saga (envelope + reservation) is persisted as a
+    /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
+    /// record is deleted once the saga reaches a terminal state. The commit is
+    /// idempotent on the content-addressed transfer id.
+    #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
+    pub async fn commit_envelope(
+        self: &Arc<Self>,
+        mut envelope: Envelope,
+    ) -> Result<Receipt, LedgerError> {
+        if envelope.account_snapshots().is_empty() {
+            let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
+            ids.sort();
+            ids.dedup();
+            envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
+        }
+
+        // Idempotency: an already-committed transfer returns its receipt.
+        let tid = envelope_id(&envelope);
+        if let Some(record) = self.store.get_transfer(&tid).await? {
+            return Ok(record.receipt);
+        }
+
+        // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
+        // mutation. The finalize step bumps the phase to Finalizing.
+        let reservation = kuatia_core::ReservationId::default();
+        let saga_id = reservation.0;
+        self.save_pending(&envelope, reservation, SagaPhase::Reserving)
+            .await?;
+
+        let result = self.drive_envelope_saga(envelope, reservation).await;
+
+        // Delete the pending record only when it is safe: on success, or on a
+        // failure that never reached finalize (phase still Reserving → the saga's
+        // compensation released our reservation, nothing of ours was applied). If
+        // finalize started (Finalizing) and failed, keep it so `recover()` rolls
+        // the half-applied commit forward.
+        let safe_to_delete = match &result {
+            Ok(_) => true,
+            Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
+        };
+        if safe_to_delete {
+            self.store.delete_saga(&saga_id).await?;
+        }
+        result
+    }
+
+    /// Build and run the envelope saga (reserve → finalize) to a terminal
+    /// outcome, returning the resulting receipt.
+    async fn drive_envelope_saga(
+        self: &Arc<Self>,
+        envelope: Envelope,
+        reservation: kuatia_core::ReservationId,
+    ) -> Result<Receipt, LedgerError> {
+        let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
+            reserve: ReserveInput,
+            finalize: FinalizeInput,
+        });
+        let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
+        let execution = saga.build(ctx);
+
+        match execution.start().await {
+            ExecutionResult::Completed(e) => {
+                let ctx = e.into_context();
+                ctx.receipts.last().cloned().ok_or_else(|| {
+                    LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
+                })
+            }
+            // The saga's error type is `LedgerError`, so a validation / overdraft
+            // / frozen failure detected during commit reaches the caller as the
+            // real typed variant instead of a stringified internal fault.
+            ExecutionResult::Failed(_, err) => Err(err),
+            ExecutionResult::CompensationFailed {
+                original_error,
+                compensation_error,
+                ..
+            } => Err(LedgerError::CompensationFailed {
+                original: Box::new(original_error),
+                compensation: Box::new(compensation_error),
+            }),
+            ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
+                "saga paused unexpectedly".into(),
+            ))),
+        }
+    }
+
+    /// Complete every pending saga left by a crash. Call on startup; returns how
+    /// many were processed.
+    ///
+    /// Recovery branches on the persisted phase. A `Reserving` saga had not
+    /// necessarily validated, so it is re-run through the real saga (which
+    /// re-reserves and **re-validates** — aborting cleanly if the postings were
+    /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
+    /// validated and owns its postings, so it is rolled forward through the
+    /// verified `finalize_envelope`. Either way the record is removed only once
+    /// the work is committed or safely abandoned.
+    #[instrument(skip(self), name = "ledger.recover")]
+    pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
+        let pending = self.store.list_pending_sagas().await?;
+        let count = pending.len();
+        for (saga_id, blob) in pending {
+            let PendingSaga {
+                envelope,
+                reservation,
+                phase,
+            } = serde_json::from_slice(&blob)
+                .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+
+            // The transfer record is durable, but a full commit is more than the
+            // transfer row: it also includes the committed event, appended *after*
+            // store_transfer. A crash in that window leaves the record present yet
+            // the event missing, so repair the whole end-state (idempotent) before
+            // clearing the pending record.
+            let tid = envelope_id(&envelope);
+            if self.store.get_transfer(&tid).await?.is_some() {
+                self.append_committed_event(tid).await?;
+                self.store.delete_saga(&saga_id).await?;
+                continue;
+            }
+
+            match phase {
+                SagaPhase::Finalizing => {
+                    // Validation passed and the postings are ours; roll forward.
+                    // Keep the record if completion fails so a later run retries.
+                    if self.finalize_envelope(&envelope, reservation).await.is_ok() {
+                        self.store.delete_saga(&saga_id).await?;
+                    }
+                }
+                SagaPhase::Reserving => {
+                    // Re-run the validating saga. On failure, delete only if it did
+                    // not reach finalize (clean abort); otherwise keep for next run.
+                    let result = self.drive_envelope_saga(envelope, reservation).await;
+                    let safe_to_delete = result.is_ok()
+                        || self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing);
+                    if safe_to_delete {
+                        self.store.delete_saga(&saga_id).await?;
+                    }
+                }
+            }
+        }
+        Ok(count)
+    }
+
+    /// Idempotently finalize `envelope` to its committed state, **verifying every
+    /// step's end-state**. Used by the saga's finalize step and by recovery.
+    ///
+    /// When the consumed postings are still reserved it re-validates against
+    /// current state (the last-step floor / freeze-close guard) and then marks
+    /// the saga `Finalizing` (the point of no return). Once any consumed posting
+    /// is already spent — a prior attempt or recovery passed that point — it
+    /// rolls forward without re-validating. It never creates or stores anything
+    /// unless **all** consumed postings are confirmed spent, which is the
+    /// double-spend guard.
+    pub(crate) async fn finalize_envelope(
+        &self,
+        envelope: &Envelope,
+        reservation: kuatia_core::ReservationId,
+    ) -> Result<Receipt, LedgerError> {
+        let tid = envelope_id(envelope);
+        if let Some(record) = self.store.get_transfer(&tid).await? {
+            // The transfer record is durable, but a crash (or a retried finalize)
+            // can land between store_transfer and the event append below. The
+            // committed end-state includes the event, so ensure it before
+            // returning — `append_committed_event` is idempotent.
+            self.append_committed_event(tid).await?;
+            return Ok(record.receipt); // already committed
+        }
+        let consumes = envelope.consumes();
+
+        // Read consumed postings (immutable rows, kept for owner indexing) and
+        // their derived states.
+        let consumed = if consumes.is_empty() {
+            Vec::new()
+        } else {
+            self.store.get_postings(consumes).await?
+        };
+        let states = if consumes.is_empty() {
+            Vec::new()
+        } else {
+            self.store.get_posting_states(consumes).await?
+        };
+        let past_no_return = states.contains(&PostingState::Spent);
+
+        // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
+        // against current state, but only while it is still safe (validation
+        // rejects a consumed posting that is no longer live).
+        if !past_no_return {
+            let loaded = self.load(envelope).await?;
+            self.plan(envelope, &loaded)?;
+        }
+
+        // Point of no return: record Finalizing before any posting is consumed.
+        self.save_pending(envelope, reservation, SagaPhase::Finalizing)
+            .await?;
+
+        // Consume our reserved postings (remove from the reserved index → spent),
+        // then assert ALL consumed postings are spent. This is the double-spend
+        // guard: `deactivate_postings(Some(rid))` only removes rows we reserved,
+        // so any consumed id still active or reserved by another saga leaves the
+        // "all spent" check failing.
+        self.store
+            .deactivate_postings(consumes, Some(reservation))
+            .await?;
+        if !consumes.is_empty() {
+            let after = self.store.get_posting_states(consumes).await?;
+            if after.len() != consumes.len() || after.iter().any(|s| *s != PostingState::Spent) {
+                return Err(LedgerError::Store(StoreError::Internal(
+                    "finalize: consumed postings not all spent (contended or not reserved by this saga)".into(),
+                )));
+            }
+        }
+
+        // Created postings, derived deterministically from the envelope.
+        let created: Vec<Posting> = envelope
+            .creates()
+            .iter()
+            .enumerate()
+            .map(|(i, np)| {
+                Posting::new(
+                    PostingId {
+                        transfer: tid,
+                        index: i as u16,
+                    },
+                    np.owner,
+                    np.asset,
+                    np.value,
+                )
+            })
+            .collect();
+        self.store.insert_postings(&created).await?;
+        if !created.is_empty() {
+            let ids: Vec<PostingId> = created.iter().map(|p| p.id).collect();
+            if self.store.get_postings(&ids).await?.len() != created.len() {
+                return Err(LedgerError::Store(StoreError::Internal(
+                    "finalize: created postings missing after insert".into(),
+                )));
+            }
+        }
+
+        // Index both created and consumed owners.
+        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
+        involved.extend(consumed.iter().map(|p| p.owner));
+        involved.sort();
+        involved.dedup();
+
+        let receipt = Receipt { transfer_id: tid };
+        self.store
+            .store_transfer(
+                EnvelopeRecord {
+                    envelope: envelope.clone(),
+                    receipt: receipt.clone(),
+                    created_at: now_millis()?,
+                },
+                &involved,
+            )
+            .await?;
+        if self.store.get_transfer(&tid).await?.is_none() {
+            return Err(LedgerError::Store(StoreError::Internal(
+                "finalize: transfer record missing after store".into(),
+            )));
+        }
+
+        self.append_committed_event(tid).await?;
+        Ok(receipt)
+    }
+
+    /// Idempotently append the `TransferCommitted` event for `tid`.
+    ///
+    /// The event append is the final finalize step, *after* `store_transfer`, so a
+    /// crash in that window leaves a stored transfer with no event. Recovery and a
+    /// retried finalize both call this to repair the committed end-state.
+    /// `append_event` dedups on the transfer id, so calling it more than once for
+    /// the same transfer is a no-op.
+    async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::TransferCommitted { transfer_id: tid },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Persist the write-ahead pending-saga record (upsert on the reservation id).
+    async fn save_pending(
+        &self,
+        envelope: &Envelope,
+        reservation: kuatia_core::ReservationId,
+        phase: SagaPhase,
+    ) -> Result<(), LedgerError> {
+        let blob = serde_json::to_vec(&PendingSaga {
+            envelope: envelope.clone(),
+            reservation,
+            phase,
+        })
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+        self.store.save_saga(&reservation.0, blob).await?;
+        Ok(())
+    }
+
+    /// Read the persisted phase of a pending saga, if it still exists.
+    async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
+        for (id, blob) in self.store.list_pending_sagas().await? {
+            if id == saga_id {
+                let pending: PendingSaga = serde_json::from_slice(&blob)
+                    .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+                return Ok(Some(pending.phase));
+            }
+        }
+        Ok(None)
+    }
+
+    // -----------------------------------------------------------------------
+    // Reverse
+    // -----------------------------------------------------------------------
+
+    /// Create and commit a reversal envelope for the given envelope id.
+    #[instrument(skip(self), name = "ledger.reverse")]
+    pub async fn reverse(self: &Arc<Self>, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
+        let record = self
+            .store
+            .get_transfer(id)
+            .await?
+            .ok_or(LedgerError::TransferNotFound(*id))?;
+
+        let original = &record.envelope;
+
+        let created_posting_ids: Vec<PostingId> = original
+            .creates()
+            .iter()
+            .enumerate()
+            .map(|(i, _)| PostingId {
+                transfer: record.receipt.transfer_id,
+                index: i as u16,
+            })
+            .collect();
+
+        let original_consumed = if original.consumes().is_empty() {
+            vec![]
+        } else {
+            self.store.get_postings(original.consumes()).await?
+        };
+
+        let new_postings: Vec<NewPosting> = original_consumed
+            .iter()
+            .map(|p| NewPosting {
+                owner: p.owner,
+                asset: p.asset,
+                value: p.value,
+                payer: None,
+            })
+            .collect();
+
+        let reverse_envelope = EnvelopeBuilder::new()
+            .consumes(created_posting_ids)
+            .creates(new_postings)
+            .book(original.book())
+            .metadata(original.metadata().clone())
+            .build();
+
+        self.commit_envelope(reverse_envelope).await
+    }
+
+    // -----------------------------------------------------------------------
+    // Internal: resolve account snapshots
+    // -----------------------------------------------------------------------
+
+    async fn resolve_snapshots(
+        &self,
+        ids: &[AccountId],
+    ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
+        let accounts = self.store.get_accounts(ids).await?;
+        Ok(accounts.iter().map(account_snapshot_id).collect())
+    }
+}
+
+#[cfg(test)]
+mod recovery_tests {
+    use super::*;
+    use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder};
+    use kuatia_storage::mem_store::InMemoryStore;
+    use std::collections::BTreeMap;
+
+    fn acct(id: i64, policy: AccountPolicy) -> Account {
+        Account {
+            id: AccountId::new(id),
+            version: 1,
+            policy,
+            flags: AccountFlags::empty(),
+            book: kuatia_core::BookId(0),
+            metadata: BTreeMap::new(),
+        }
+    }
+
+    async fn funded_ledger() -> Arc<Ledger> {
+        let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+        for (id, p) in [
+            (1, AccountPolicy::NoOverdraft),
+            (2, AccountPolicy::NoOverdraft),
+            (3, AccountPolicy::NoOverdraft),
+            (99, AccountPolicy::ExternalAccount),
+        ] {
+            ledger.store().create_account(acct(id, p)).await.unwrap();
+        }
+        let deposit = TransferBuilder::new()
+            .deposit(
+                AccountId::new(1),
+                AssetId::new(1),
+                Cent::from(100),
+                AccountId::new(99),
+            )
+            .unwrap()
+            .build();
+        ledger.commit(deposit).await.unwrap();
+        ledger
+    }
+
+    fn pay_transfer() -> Transfer {
+        TransferBuilder::new()
+            .pay(
+                AccountId::new(1),
+                AccountId::new(2),
+                AssetId::new(1),
+                Cent::from(40),
+            )
+            .build()
+    }
+
+    async fn save_pending(
+        ledger: &Arc<Ledger>,
+        envelope: &Envelope,
+        rid: ReservationId,
+        phase: SagaPhase,
+    ) {
+        let blob = serde_json::to_vec(&PendingSaga {
+            envelope: envelope.clone(),
+            reservation: rid,
+            phase,
+        })
+        .unwrap();
+        ledger.store().save_saga(&rid.0, blob).await.unwrap();
+    }
+
+    /// A commit interrupted right after its write-ahead record (phase Reserving,
+    /// before any step) is re-run and completed by `recover()`.
+    #[tokio::test]
+    async fn recover_redrives_reserving_saga() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let rid = ReservationId::default();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(40)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(60)
+        );
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
+    /// is already spent) is rolled forward by `recover()`.
+    #[tokio::test]
+    async fn recover_completes_partial_finalize() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let rid = ReservationId::default();
+        // Run the commit halfway: reserve + deactivate the consumed posting.
+        let consumes = envelope.consumes().to_vec();
+        ledger
+            .store()
+            .reserve_postings(&consumes, rid)
+            .await
+            .unwrap();
+        assert_eq!(
+            ledger
+                .store()
+                .deactivate_postings(&consumes, Some(rid))
+                .await
+                .unwrap(),
+            1
+        );
+        save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(40)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(60)
+        );
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// A commit that crashed *after* `store_transfer` but *before* the committed
+    /// event was appended (phase Finalizing, transfer row present, event missing)
+    /// is repaired by `recover()`: the full end-state includes the event, so
+    /// recovery appends it (idempotently) instead of treating the transfer row as
+    /// proof of a complete commit.
+    #[tokio::test]
+    async fn recover_appends_missing_committed_event() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let tid = envelope_id(&envelope);
+        let rid = ReservationId::default();
+
+        // Replay finalize by hand up to and including store_transfer, stopping
+        // short of the event append — exactly the crash window.
+        let consumes = envelope.consumes().to_vec();
+        ledger
+            .store()
+            .reserve_postings(&consumes, rid)
+            .await
+            .unwrap();
+        ledger
+            .store()
+            .deactivate_postings(&consumes, Some(rid))
+            .await
+            .unwrap();
+        let created: Vec<Posting> = envelope
+            .creates()
+            .iter()
+            .enumerate()
+            .map(|(i, np)| {
+                Posting::new(
+                    PostingId {
+                        transfer: tid,
+                        index: i as u16,
+                    },
+                    np.owner,
+                    np.asset,
+                    np.value,
+                )
+            })
+            .collect();
+        ledger.store().insert_postings(&created).await.unwrap();
+        let consumed = ledger.store().get_postings(&consumes).await.unwrap();
+        let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
+        involved.extend(consumed.iter().map(|p| p.owner));
+        involved.sort();
+        involved.dedup();
+        ledger
+            .store()
+            .store_transfer(
+                EnvelopeRecord {
+                    envelope: envelope.clone(),
+                    receipt: Receipt { transfer_id: tid },
+                    created_at: 0,
+                },
+                &involved,
+            )
+            .await
+            .unwrap();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
+
+        // Precondition: the transfer is stored, but no committed event exists yet.
+        let committed = |evs: &[LedgerEvent]| {
+            evs.iter().any(|e| {
+                matches!(
+                    e.kind,
+                    LedgerEventKind::TransferCommitted { transfer_id } if transfer_id == tid
+                )
+            })
+        };
+        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_some());
+        assert!(!committed(&ledger.get_events_since(0, 1000).await.unwrap()));
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+
+        // The missing event is repaired and the pending record cleared.
+        assert!(committed(&ledger.get_events_since(0, 1000).await.unwrap()));
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// Recovery of a `Reserving` saga re-validates against current state: if an
+    /// account was frozen after the write-ahead record, the commit is abandoned —
+    /// no postings move, the reservation is released, and the record is cleared.
+    #[tokio::test]
+    async fn recover_revalidates_and_aborts_when_account_frozen() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let tid = envelope_id(&envelope);
+        let rid = ReservationId::default();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
+
+        // A freeze lands before recovery runs.
+        ledger.freeze(&AccountId::new(1)).await.unwrap();
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        // Nothing committed; balances unchanged; reservation released.
+        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(100)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::ZERO
+        );
+        let active = ledger
+            .store()
+            .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::Active)
+            .await
+            .unwrap();
+        assert_eq!(active.len(), 1); // back to Active
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+
+    /// Recovery cannot double-spend: if the consumed posting was taken by another
+    /// transfer while the saga was pending, recovery aborts without creating or
+    /// storing anything.
+    #[tokio::test]
+    async fn recover_does_not_double_spend_a_taken_posting() {
+        let ledger = funded_ledger().await;
+        let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
+        let tid = envelope_id(&envelope);
+        let rid = ReservationId::default();
+        save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
+
+        // Another transfer consumes account 1's posting and commits.
+        let steal = TransferBuilder::new()
+            .pay(
+                AccountId::new(1),
+                AccountId::new(3),
+                AssetId::new(1),
+                Cent::from(50),
+            )
+            .build();
+        ledger.commit(steal).await.unwrap();
+
+        assert_eq!(ledger.recover().await.unwrap(), 1);
+        // Our envelope never committed; only the stealing transfer applied.
+        assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(1), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(50)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(3), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::from(50)
+        );
+        assert_eq!(
+            ledger
+                .balance(&AccountId::new(2), &AssetId::new(1))
+                .await
+                .unwrap(),
+            Cent::ZERO
+        );
+        assert!(
+            ledger
+                .store()
+                .list_pending_sagas()
+                .await
+                .unwrap()
+                .is_empty()
+        );
+    }
+}

+ 123 - 0
crates/kuatia/src/ledger/lifecycle.rs

@@ -0,0 +1,123 @@
+//! Account lifecycle: create, freeze, unfreeze, close.
+//!
+//! Accounts are append-only and versioned: each mutation appends a new version
+//! rather than editing in place. Freeze/close guards are validate-time and
+//! best-effort under concurrency (see the dumb-storage ADR).
+
+use tracing::instrument;
+
+use kuatia_core::{AccountId, PostingFilter};
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+
+use super::{Ledger, now_millis};
+use crate::error::LedgerError;
+
+impl Ledger {
+    /// Create a new account and emit an AccountCreated event.
+    pub async fn create_account(&self, account: kuatia_core::Account) -> Result<(), LedgerError> {
+        let id = account.id;
+        self.store.create_account(account).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountCreated { account_id: id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Freeze an account, preventing all transfers.
+    #[instrument(skip(self), name = "ledger.freeze")]
+    pub async fn freeze(&self, id: &AccountId) -> Result<(), LedgerError> {
+        let current = self
+            .store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))?;
+        if current.is_closed() {
+            return Err(LedgerError::AccountAlreadyClosed(*id));
+        }
+        let mut next = current.clone();
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        next.flags |= kuatia_core::AccountFlags::FROZEN;
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountFrozen { account_id: *id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Unfreeze a previously frozen account.
+    #[instrument(skip(self), name = "ledger.unfreeze")]
+    pub async fn unfreeze(&self, id: &AccountId) -> Result<(), LedgerError> {
+        let current = self
+            .store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))?;
+        if current.is_closed() {
+            return Err(LedgerError::AccountAlreadyClosed(*id));
+        }
+        let mut next = current.clone();
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        next.flags.remove(kuatia_core::AccountFlags::FROZEN);
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountUnfrozen { account_id: *id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Close an account. Must have no active postings.
+    #[instrument(skip(self), name = "ledger.close")]
+    pub async fn close(&self, id: &AccountId) -> Result<(), LedgerError> {
+        let current = self
+            .store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))?;
+        if current.is_closed() {
+            return Err(LedgerError::AccountAlreadyClosed(*id));
+        }
+        // Reject if any posting is still live — active or reserved (a transfer
+        // in flight). Only spent postings (or none) permit a close.
+        if self.has_live_postings(id).await? {
+            return Err(LedgerError::AccountNotEmpty(*id));
+        }
+        let mut next = current.clone();
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        next.flags |= kuatia_core::AccountFlags::CLOSED;
+        next.flags.remove(kuatia_core::AccountFlags::FROZEN);
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: LedgerEventKind::AccountClosed { account_id: *id },
+            })
+            .await?;
+        Ok(())
+    }
+
+    /// Whether `account` (exact base id and subaccount) has any live posting: one
+    /// that is active or reserved by an in-flight saga. Spent postings do not
+    /// count. This is the emptiness test [`close`](Self::close) gates on, and the
+    /// inflight layer uses it to decide when a drained hold can be closed.
+    #[instrument(skip(self), name = "ledger.has_live_postings")]
+    pub async fn has_live_postings(&self, account: &AccountId) -> Result<bool, LedgerError> {
+        Ok(!self
+            .store
+            .get_postings_by_account(account.id, Some(account.sub), None, PostingFilter::Live)
+            .await?
+            .is_empty())
+    }
+}

+ 112 - 0
crates/kuatia/src/ledger/query.rs

@@ -0,0 +1,112 @@
+//! Read-only queries and book CRUD.
+//!
+//! These methods delegate to the [`Store`] and relabel `StoreError` as
+//! [`LedgerError`] so callers get one error type across the whole API. The
+//! underlying store is reachable via [`Ledger::store`] for callers that want the
+//! raw storage error instead.
+
+use kuatia_core::{AccountId, PostingFilter, PostingId, PostingState};
+use kuatia_storage::events::LedgerEvent;
+
+use super::Ledger;
+use crate::error::LedgerError;
+
+impl Ledger {
+    /// List all accounts (latest version of each).
+    pub async fn list_accounts(&self) -> Result<Vec<kuatia_core::Account>, LedgerError> {
+        Ok(self.store.list_accounts().await?)
+    }
+
+    /// Fetch a single account by id.
+    pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
+        self.store
+            .get_account(id)
+            .await
+            .map_err(|_| LedgerError::AccountNotFound(*id))
+    }
+
+    /// Return all transfers involving the given account (exact subaccount).
+    pub async fn history(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
+        Ok(self
+            .store
+            .get_transfers_for_account(account.id, Some(account.sub))
+            .await?)
+    }
+
+    /// Query transfers with filtering and pagination.
+    pub async fn query_transfers(
+        &self,
+        query: &crate::store::TransferQuery,
+    ) -> Result<crate::store::Page<crate::store::EnvelopeRecord>, LedgerError> {
+        Ok(self.store.query_transfers(query).await?)
+    }
+
+    /// Return all postings (any state) for the given account.
+    pub async fn postings(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
+        Ok(self
+            .store
+            .get_postings_by_account(account.id, Some(account.sub), None, PostingFilter::All)
+            .await?)
+    }
+
+    /// Return all postings for the given account paired with their derived
+    /// lifecycle state (active, reserved, or spent).
+    pub async fn postings_with_state(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<(kuatia_core::Posting, PostingState)>, LedgerError> {
+        let postings = self.postings(account).await?;
+        let ids: Vec<PostingId> = postings.iter().map(|p| p.id).collect();
+        let states = self.store.get_posting_states(&ids).await?;
+        Ok(postings.into_iter().zip(states).collect())
+    }
+
+    /// Query postings with filtering and pagination.
+    pub async fn query_postings(
+        &self,
+        query: &crate::store::PostingQuery,
+    ) -> Result<crate::store::Page<kuatia_core::Posting>, LedgerError> {
+        Ok(self.store.query_postings(query).await?)
+    }
+
+    /// Return the full version history for an account.
+    pub async fn account_history(
+        &self,
+        id: &AccountId,
+    ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
+        Ok(self.store.get_account_history(id).await?)
+    }
+
+    /// Create a new book.
+    pub async fn create_book(&self, book: kuatia_core::Book) -> Result<(), LedgerError> {
+        Ok(self.store.create_book(book).await?)
+    }
+
+    /// Fetch a book by id.
+    pub async fn get_book(
+        &self,
+        id: &kuatia_core::BookId,
+    ) -> Result<kuatia_core::Book, LedgerError> {
+        Ok(self.store.get_book(id).await?)
+    }
+
+    /// List all books.
+    pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
+        Ok(self.store.list_books().await?)
+    }
+
+    /// Query ledger events after a given sequence number.
+    pub async fn get_events_since(
+        &self,
+        after_seq: u64,
+        limit: u32,
+    ) -> Result<Vec<LedgerEvent>, LedgerError> {
+        Ok(self.store.get_events_since(after_seq, limit).await?)
+    }
+}