Bläddra i källkod

Concentrate policy, commit-verification, and the single-commit driver

Three deepening refactors that pull scattered logic back to where it
belongs, making the commit path and the policy rules easier to follow and
to test.

AccountPolicy was an anemic enum whose overdraft taxonomy was re-matched
in three places across two crates (resolve's shortfall branch and two
arms in validate). Give it predicate methods (covers_shortfall_with_offset,
permits_negative_posting, balance_floor) so the rule lives once, next to
the data, and can be tested directly. Behavior is unchanged.

The dumb-storage count and end-state check was hand-rolled at four sites.
Extract verify_posting_states (always re-reads, shared by the reserve
phase and finalize's double-spend guard) and ensure (flattens the
existence checks), so finalize reads as a flat deactivate/insert/store
sequence and the count contract is centralized in one place.

The single commit ran a fixed two-step legend saga behind a forwarding
step and an unreachable compensation, spread across three files and a
macro. Drive reserve then finalize with a plain function that replicates
the same per-phase retry and release-on-failure semantics, and reserve
legend for multi-transfer composition, the only place its cross-step
compensation is exercised. Delete the two envelope steps and the macro
module, and trim the orphaned saga-context fields. Recorded in ADR-0018,
refining ADR-0002; the two-phase structure, reservation protocol, and
roll-forward recovery are unchanged.
Cesar Rodas 2 veckor sedan
förälder
incheckning
9316cd0be9

+ 1 - 1
CLAUDE.md

@@ -36,7 +36,7 @@ doc/
 ## Architecture
 
 - **Pure core / async layer separation**: kuatia-core has zero IO, fully deterministic, testable with golden vectors. kuatia adds async Store trait and saga pipeline.
-- **Saga commit pipeline**: every commit is the **two-step** envelope saga `reserve → finalize` (validation runs inside the finalize step, as the last thing before the writes), with automatic retry and LIFO compensation via the `legend` crate. `commit(transfer)` = resolve (read-only) then `commit_envelope`; `reverse()` builds a reversal envelope and runs the same path. There is one commit path, not a separate "atomic" one.
+- **Two-phase commit pipeline**: every commit is the **two-step** `reserve → finalize` pipeline (validation runs inside finalize, as the last thing before the writes), with per-phase retry and release-on-failure compensation. The single commit is driven by a plain function (`drive_envelope_saga`), not a `legend` saga; `legend` is reserved for multi-transfer composition (`PayMovementStep`/`DepositMovementStep`, which wrap `commit()`), the only place its cross-step LIFO compensation is exercised (ADR-0018, refining ADR-0002). `commit(transfer)` = resolve (read-only) then `commit_envelope`; `reverse()` builds a reversal envelope and runs the same path. There is one commit path, not a separate "atomic" one.
 - **Count interpretation**: the saga reads each primitive's affected-row count — full = continue; partial = error → compensate; zero = read state and continue only if this same envelope/reservation already applied it (idempotency). `finalize_envelope` additionally verifies every end-state (all consumed postings `Inactive`, created exist, transfer stored).
 - **Durable recovery**: a phase-tracked write-ahead `PendingSaga {envelope, reservation, phase}` is persisted via `SagaStore` before the saga mutates anything (`Reserving`), bumped to `Finalizing` once validation passed and the consumed postings are about to turn `Inactive`. `Ledger::recover()` (call on startup) branches on phase: a `Reserving` saga is **re-run and re-validated** (aborting cleanly if a posting was taken or an account frozen); a `Finalizing` saga is rolled forward through the verified `finalize_envelope`. Roll-forward, not rollback, so there are no orphaned `PendingInactive` postings to reconcile.
 - **Content-addressed transfers**: EnvelopeId = double-SHA-256 of canonical bytes. Provides idempotency and tamper evidence.

+ 1 - 1
crates/kuatia-core/src/posting_resolution.rs

@@ -202,7 +202,7 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
             // shortfall with a negative posting (an offset position); any other
             // policy — or an unknown one — fails.
             match policies.get(&debit.account) {
-                Some(AccountPolicy::CappedOverdraft { .. } | AccountPolicy::UncappedOverdraft) => {
+                Some(policy) if policy.covers_shortfall_with_offset() => {
                     let positives: Vec<PostingId> = avail
                         .iter()
                         .filter(|p| p.value.is_positive())

+ 14 - 37
crates/kuatia-core/src/validate.rs

@@ -370,18 +370,12 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
                 .accounts
                 .get(&np.owner)
                 .ok_or(ValidationError::AccountNotFound(np.owner))?;
-            match account.policy {
-                AccountPolicy::SystemAccount
-                | AccountPolicy::ExternalAccount
-                | AccountPolicy::UncappedOverdraft
-                | AccountPolicy::CappedOverdraft { .. } => {}
-                AccountPolicy::NoOverdraft => {
-                    return Err(ValidationError::NegativePostingOnNonSystemAccount {
-                        account: np.owner,
-                        asset: np.asset,
-                        value: np.value,
-                    });
-                }
+            if !account.policy.permits_negative_posting() {
+                return Err(ValidationError::NegativePostingOnNonSystemAccount {
+                    account: np.owner,
+                    asset: np.asset,
+                    value: np.value,
+                });
             }
         }
     }
@@ -409,31 +403,14 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
         let projected = current_balance.checked_add(*delta)?;
 
         let account = &input.accounts[account_id];
-        match &account.policy {
-            AccountPolicy::NoOverdraft => {
-                if projected.is_negative() {
-                    return Err(ValidationError::OverdraftExceeded {
-                        account: *account_id,
-                        asset: *asset_id,
-                        floor: Cent::ZERO,
-                        projected,
-                    });
-                }
-            }
-            AccountPolicy::CappedOverdraft { floor } => {
-                if projected < *floor {
-                    return Err(ValidationError::OverdraftExceeded {
-                        account: *account_id,
-                        asset: *asset_id,
-                        floor: *floor,
-                        projected,
-                    });
-                }
-            }
-            AccountPolicy::UncappedOverdraft
-            | AccountPolicy::SystemAccount
-            | AccountPolicy::ExternalAccount => {
-                // No floor check
+        if let Some(floor) = account.policy.balance_floor() {
+            if projected < floor {
+                return Err(ValidationError::OverdraftExceeded {
+                    account: *account_id,
+                    asset: *asset_id,
+                    floor,
+                    projected,
+                });
             }
         }
     }

+ 33 - 0
crates/kuatia-types/src/lib.rs

@@ -573,6 +573,39 @@ pub enum AccountPolicy {
     ExternalAccount,
 }
 
+impl AccountPolicy {
+    /// During posting selection, may an account with this policy cover a
+    /// shortfall by creating a negative offset posting?
+    ///
+    /// Only the two overdraft policies grant this. System and external accounts
+    /// *hold* negative postings (the offset side of deposits, created via
+    /// canceling movements) but are never auto-overdrawn by the resolver's
+    /// selection pass, so they are excluded here.
+    pub fn covers_shortfall_with_offset(&self) -> bool {
+        matches!(self, Self::CappedOverdraft { .. } | Self::UncappedOverdraft)
+    }
+
+    /// May an account with this policy own a negative posting at all? Every
+    /// policy except [`NoOverdraft`](Self::NoOverdraft) permits it (the two
+    /// overdraft policies plus system and external accounts).
+    pub fn permits_negative_posting(&self) -> bool {
+        !matches!(self, Self::NoOverdraft)
+    }
+
+    /// The minimum balance an account with this policy may reach, if bounded.
+    ///
+    /// `NoOverdraft` floors at zero and `CappedOverdraft` at its configured
+    /// floor; the unbounded policies (uncapped overdraft, system, external)
+    /// return `None`.
+    pub fn balance_floor(&self) -> Option<Cent> {
+        match self {
+            Self::NoOverdraft => Some(Cent::ZERO),
+            Self::CappedOverdraft { floor } => Some(*floor),
+            Self::UncappedOverdraft | Self::SystemAccount | Self::ExternalAccount => None,
+        }
+    }
+}
+
 bitflags::bitflags! {
     /// Lifecycle and user-defined flags for an [`Account`].
     ///

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

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

+ 131 - 48
crates/kuatia/src/ledger/commit.rs

@@ -9,7 +9,6 @@
 use std::collections::HashMap;
 use std::sync::Arc;
 
-use legend::ExecutionResult;
 use tracing::instrument;
 
 use kuatia_core::{
@@ -23,10 +22,8 @@ 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.
@@ -63,6 +60,29 @@ pub struct LoadedState {
     pub book: Option<Book>,
 }
 
+/// Number of times a commit phase is retried before it fails. Matches the
+/// former per-step `RetryPolicy::retries(3)`: one initial attempt plus three
+/// retries.
+const COMMIT_STEP_RETRIES: u8 = 3;
+
+/// Run an idempotent commit phase, retrying up to [`COMMIT_STEP_RETRIES`] times
+/// on error. No backoff: the reserve CAS and the idempotent finalize either
+/// converge on an immediate retry or fail deterministically.
+async fn retry<T, F, Fut>(mut phase: F) -> Result<T, LedgerError>
+where
+    F: FnMut() -> Fut,
+    Fut: std::future::Future<Output = Result<T, LedgerError>>,
+{
+    let mut attempt = 0u8;
+    loop {
+        match phase().await {
+            Ok(value) => return Ok(value),
+            Err(error) if attempt >= COMMIT_STEP_RETRIES => return Err(error),
+            Err(_) => attempt += 1,
+        }
+    }
+}
+
 impl Ledger {
     // -----------------------------------------------------------------------
     // Three-piece API: load -> plan -> apply
@@ -251,45 +271,71 @@ impl Ledger {
         result
     }
 
-    /// Build and run the envelope saga (reserve → finalize) to a terminal
+    /// Drive the two-phase envelope commit (`reserve → finalize`) to a terminal
     /// outcome, returning the resulting receipt.
+    ///
+    /// This is the single-commit pipeline. It is a plain function, not a `legend`
+    /// saga: the durability engine is the phase-tracked write-ahead record plus
+    /// `recover()` (ADR-0003), and multi-transfer composition happens one level
+    /// up, where `PayMovementStep`/`DepositMovementStep` wrap `commit()`. The two
+    /// phases and their failure handling mirror the former saga exactly:
+    ///
+    /// - **reserve** (retried): CAS the consumed postings into the reserved
+    ///   index and verify the end-state. A failure after retries leaves nothing
+    ///   to roll back here — recovery re-runs the still-`Reserving` saga.
+    /// - **finalize** (retried): re-validate, mark `Finalizing`, then run the
+    ///   dumb primitives. A failure after retries compensates the reserve phase
+    ///   by releasing the reservation (a no-op once the postings are `Spent`,
+    ///   i.e. past the point of no return, where recovery rolls forward). If the
+    ///   release itself fails, the two errors surface as `CompensationFailed`.
     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()))
-                })
+        let consumes = envelope.consumes().to_vec();
+
+        retry(|| self.reserve_and_verify(&consumes, reservation)).await?;
+
+        match retry(|| self.finalize_envelope(&envelope, reservation)).await {
+            Ok(receipt) => Ok(receipt),
+            Err(finalize_error) => {
+                match self.store.release_postings(&consumes, reservation).await {
+                    Ok(_) => Err(finalize_error),
+                    Err(release_error) => Err(LedgerError::CompensationFailed {
+                        original: Box::new(finalize_error),
+                        compensation: Box::new(LedgerError::Store(release_error)),
+                    }),
+                }
             }
-            // The saga's error type is `LedgerError`, so a validation / overdraft
-            // / frozen failure detected during commit reaches the caller as the
-            // real typed variant instead of a stringified internal fault.
-            ExecutionResult::Failed(_, err) => Err(err),
-            ExecutionResult::CompensationFailed {
-                original_error,
-                compensation_error,
-                ..
-            } => Err(LedgerError::CompensationFailed {
-                original: Box::new(original_error),
-                compensation: Box::new(compensation_error),
-            }),
-            ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
-                "saga paused unexpectedly".into(),
-            ))),
         }
     }
 
+    /// Reserve `consumes` for this saga: CAS each consumed posting from the
+    /// active index into the reserved index under `reservation`, then confirm the
+    /// end-state (all reserved by us) via the dumb-storage count contract. A short
+    /// count is tolerated only when the shortfall is already reserved by us (an
+    /// idempotent replay); anything else is a genuine failure.
+    async fn reserve_and_verify(
+        &self,
+        consumes: &[PostingId],
+        reservation: kuatia_core::ReservationId,
+    ) -> Result<(), LedgerError> {
+        let reserved = self
+            .store
+            .reserve_postings(consumes, reservation)
+            .await
+            .map_err(LedgerError::Store)?;
+        crate::saga::verify_postings(
+            self.store.as_ref(),
+            consumes,
+            reserved,
+            |s| matches!(s, PostingState::Reserved(r) if *r == reservation),
+            "reserve",
+        )
+        .await
+    }
+
     /// Complete every pending saga left by a crash. Call on startup; returns how
     /// many were processed.
     ///
@@ -408,12 +454,13 @@ impl Ledger {
             .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(),
-                )));
-            }
+            crate::saga::verify_posting_states(
+                self.store.as_ref(),
+                consumes,
+                |s| *s == PostingState::Spent,
+                "finalize: consumed postings not all spent (contended or not reserved by this saga)",
+            )
+            .await?;
         }
 
         // Created postings, derived deterministically from the envelope.
@@ -436,11 +483,10 @@ impl Ledger {
         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(),
-                )));
-            }
+            crate::saga::ensure(
+                self.store.get_postings(&ids).await?.len() == created.len(),
+                "finalize: created postings missing after insert",
+            )?;
         }
 
         // Index both created and consumed owners.
@@ -460,11 +506,10 @@ impl Ledger {
                 &involved,
             )
             .await?;
-        if self.store.get_transfer(&tid).await?.is_none() {
-            return Err(LedgerError::Store(StoreError::Internal(
-                "finalize: transfer record missing after store".into(),
-            )));
-        }
+        crate::saga::ensure(
+            self.store.get_transfer(&tid).await?.is_some(),
+            "finalize: transfer record missing after store",
+        )?;
 
         self.append_committed_event(tid).await?;
         Ok(receipt)
@@ -921,3 +966,41 @@ mod recovery_tests {
         );
     }
 }
+
+#[cfg(test)]
+mod retry_tests {
+    use super::*;
+    use std::cell::Cell;
+
+    #[tokio::test]
+    async fn retry_gives_up_after_max_attempts() {
+        let calls = Cell::new(0u8);
+        let result: Result<(), LedgerError> = retry(|| {
+            calls.set(calls.get() + 1);
+            async { Err(LedgerError::Overflow) }
+        })
+        .await;
+        assert!(result.is_err());
+        // One initial attempt plus COMMIT_STEP_RETRIES retries.
+        assert_eq!(calls.get(), COMMIT_STEP_RETRIES + 1);
+    }
+
+    #[tokio::test]
+    async fn retry_returns_the_first_success() {
+        let calls = Cell::new(0u8);
+        let result: Result<u8, LedgerError> = retry(|| {
+            let n = calls.get() + 1;
+            calls.set(n);
+            async move {
+                if n >= 2 {
+                    Ok(n)
+                } else {
+                    Err(LedgerError::Overflow)
+                }
+            }
+        })
+        .await;
+        assert_eq!(result.unwrap(), 2);
+        assert_eq!(calls.get(), 2);
+    }
+}

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

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

+ 58 - 204
crates/kuatia/src/saga.rs

@@ -1,39 +1,30 @@
-//! Legend saga step adapters for the ledger.
+//! Saga helpers for the ledger.
 //!
-//! Provides [`Step`] implementations so the ledger can participate
-//! in multi-resource saga workflows, with automatic LIFO compensation across
-//! resource boundaries.
+//! Two things live here:
 //!
-//! # Envelope pipeline saga
+//! # Dumb-storage count contract
 //!
-//! A commit is two saga steps over a pre-resolved [`Envelope`] (resolution runs
-//! before the saga, in `Ledger::commit`):
-//!
-//! 1. **ReservePostingsStep** -- `reserve_postings`: move each consumed posting from the active index into the reserved index under the saga's `ReservationId`; interprets the count via `verify_postings`.
-//! 2. **FinalizeTransferStep** -- delegates to `Ledger::finalize_envelope`, which re-validates against current state (the last-step floor / freeze-close guard), marks the saga `Finalizing`, then runs the dumb primitives (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`) verifying every end-state.
-//!
-//! The `EnvelopeSaga` is defined via `legend!` in `ledger.rs` and driven by
-//! `commit_envelope()`. Crash recovery (`Ledger::recover`) re-completes a
-//! persisted saga using its persisted phase: a `Reserving` saga is re-run
-//! (re-validating); a `Finalizing` saga is rolled forward through the same
-//! verified `finalize_envelope`.
+//! `verify_postings`, `verify_posting_states`, and `ensure` interpret the
+//! affected-row counts the dumb store returns (ADR-0003): a full count is
+//! success, a short count is tolerated only when the postings already reached
+//! the intended end-state, and any other mismatch is a genuine fault. The
+//! single-commit pipeline (`reserve → finalize`) is a plain function in
+//! [`crate::ledger`], not a `legend` saga, and uses these helpers directly.
 //!
 //! # High-level composition
 //!
-//! High-level steps (`PayMovementStep` and `DepositMovementStep`) compose over
-//! the intent-layer API and can be combined into multi-transfer sagas via `legend!`.
+//! [`PayMovementStep`] and [`DepositMovementStep`] are [`Step`] implementations
+//! over the intent-layer API (each wraps `commit()`), so several transfers can
+//! be combined into one multi-transfer saga via `legend!`, with automatic LIFO
+//! compensation (reversal) across the workflow.
 
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use legend::step::{CompensationOutcome, RetryPolicy, Step, StepOutcome};
+use legend::step::{CompensationOutcome, Step, StepOutcome};
 use serde::{Deserialize, Serialize};
-use tracing::Instrument;
 
-use kuatia_core::{
-    AccountId, AssetId, Cent, Envelope, PostingId, PostingState, Receipt, ReservationId,
-    TransferBuilder,
-};
+use kuatia_core::{AccountId, AssetId, Cent, PostingId, PostingState, Receipt, TransferBuilder};
 
 use crate::error::LedgerError;
 use crate::ledger::Ledger;
@@ -48,13 +39,49 @@ fn internal(message: impl Into<String>) -> LedgerError {
     LedgerError::Store(StoreError::Internal(message.into()))
 }
 
+/// Fail with an internal fault unless `condition` holds. Flattens the recurring
+/// "re-read after a dumb write and assert the end-state, else it's a genuine
+/// storage fault" check into one line at each finalize step.
+pub(crate) fn ensure(condition: bool, what: impl Into<String>) -> Result<(), LedgerError> {
+    if condition {
+        Ok(())
+    } else {
+        Err(internal(what))
+    }
+}
+
+/// Re-read the derived states of `ids` and confirm every one satisfies `ok`
+/// (and that all are present). This is the end-state half of the dumb-storage
+/// count contract: the store applied some rows and returned a count; here the
+/// saga confirms the postings actually reached the state it intended, treating
+/// any mismatch as a genuine fault (contended or concurrently modified).
+pub(crate) async fn verify_posting_states(
+    store: &dyn Store,
+    ids: &[PostingId],
+    ok: impl Fn(&PostingState) -> bool,
+    what: &str,
+) -> Result<(), LedgerError> {
+    let states = store
+        .get_posting_states(ids)
+        .await
+        .map_err(LedgerError::Store)?;
+    ensure(
+        states.len() == ids.len() && states.iter().all(&ok),
+        format!(
+            "{what}: posting end-state not satisfied ({}/{} rows)",
+            states.len(),
+            ids.len()
+        ),
+    )
+}
+
 /// Interpret a dumb primitive's affected-row `count` against the `ids` it
 /// targeted. `count == ids.len()` is success. A short count is acceptable only if
 /// the shortfall is already in the desired end-state — a prior attempt (or this
-/// saga, replayed by recovery) already applied it — verified by reading the
-/// postings and checking `ok`. Otherwise it is a genuine failure (contended or
-/// concurrently modified) and the saga compensates.
-async fn verify_postings(
+/// saga, replayed by recovery) already applied it — verified by
+/// [`verify_posting_states`]. Otherwise it is a genuine failure (contended or
+/// concurrently modified) and the caller compensates.
+pub(crate) async fn verify_postings(
     store: &dyn Store,
     ids: &[PostingId],
     count: u64,
@@ -64,24 +91,15 @@ async fn verify_postings(
     if count == ids.len() as u64 {
         return Ok(());
     }
-    let states = store
-        .get_posting_states(ids)
-        .await
-        .map_err(LedgerError::Store)?;
-    if states.len() == ids.len() && states.iter().all(&ok) {
-        return Ok(());
-    }
-    Err(internal(format!(
-        "{what}: storage applied {count}/{} rows and the end-state is not satisfied",
-        ids.len()
-    )))
+    verify_posting_states(store, ids, ok, what).await
 }
 
 // ---------------------------------------------------------------------------
 // Saga context -- carries the ledger handle + state between steps
 // ---------------------------------------------------------------------------
 
-/// Saga context that wraps a ledger and tracks state across steps.
+/// Saga context that wraps a ledger and collects the receipts a multi-transfer
+/// saga produces across its steps.
 ///
 /// The ledger handle is `#[serde(skip)]` -- after deserializing a paused
 /// execution you must call [`inject_ledger`](LedgerCtx::inject_ledger)
@@ -90,13 +108,6 @@ async fn verify_postings(
 pub struct LedgerCtx {
     /// Receipts collected from completed steps.
     pub receipts: Vec<Receipt>,
-    /// Posting ids reserved so far (for compensation).
-    pub reserved_postings: Vec<PostingId>,
-    /// Resolved envelope produced by the resolve step.
-    pub envelope: Option<Envelope>,
-    /// Reservation owner token for this saga's reserved postings. Serialized so
-    /// it survives pause/recovery, keeping ownership stable across restarts.
-    pub reservation: ReservationId,
     #[serde(skip)]
     ledger: Option<Arc<Ledger>>,
 }
@@ -105,8 +116,6 @@ impl std::fmt::Debug for LedgerCtx {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("LedgerCtx")
             .field("receipts", &self.receipts)
-            .field("reserved_postings", &self.reserved_postings.len())
-            .field("has_envelope", &self.envelope.is_some())
             .field("ledger_present", &self.ledger.is_some())
             .finish()
     }
@@ -117,25 +126,6 @@ impl LedgerCtx {
     pub fn new(ledger: Arc<Ledger>) -> Self {
         Self {
             receipts: Vec::new(),
-            reserved_postings: Vec::new(),
-            envelope: None,
-            reservation: ReservationId::default(),
-            ledger: Some(ledger),
-        }
-    }
-
-    /// Create a context for the envelope pipeline (reserve → finalize; finalize re-validates)
-    /// with a pre-resolved envelope and an explicit reservation.
-    pub fn for_envelope(
-        ledger: Arc<Ledger>,
-        envelope: Envelope,
-        reservation: ReservationId,
-    ) -> Self {
-        Self {
-            receipts: Vec::new(),
-            reserved_postings: Vec::new(),
-            envelope: Some(envelope),
-            reservation,
             ledger: Some(ledger),
         }
     }
@@ -161,142 +151,6 @@ impl LedgerCtx {
 }
 
 // ===========================================================================
-// Envelope pipeline steps (reserve -> finalize; resolve runs before the saga, validate inside finalize)
-// ===========================================================================
-
-// ---------------------------------------------------------------------------
-// Step 1: ReservePostingsStep
-// ---------------------------------------------------------------------------
-
-/// Input for the reserve step (posting ids come from ctx.envelope).
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ReserveInput;
-
-/// Reserves consumed postings by CAS: move each from the active index to the
-/// reserved index (the delete-returns-one picks a single winner).
-///
-/// Gets the posting ids from the resolved envelope in the context.
-/// Compensation releases all reserved postings back to Active.
-pub struct ReservePostingsStep;
-
-#[async_trait]
-impl Step<LedgerCtx, LedgerError> for ReservePostingsStep {
-    type Input = ReserveInput;
-
-    async fn execute(
-        ctx: &mut LedgerCtx,
-        _input: &ReserveInput,
-    ) -> Result<StepOutcome, LedgerError> {
-        async {
-            let posting_ids: Vec<PostingId> = ctx
-                .envelope
-                .as_ref()
-                .ok_or_else(|| internal("no envelope in context -- resolve step must run first"))?
-                .consumes()
-                .to_vec();
-            let rid = ctx.reservation;
-            let ledger = ctx.ledger_arc()?;
-            let store = ledger.store();
-
-            let reserved = store
-                .reserve_postings(&posting_ids, rid)
-                .await
-                .map_err(LedgerError::Store)?;
-            // Storage reports the count; the saga decides. A short count is fine
-            // only if the shortfall is already reserved by us (idempotent replay).
-            verify_postings(
-                store,
-                &posting_ids,
-                reserved,
-                |s| matches!(s, PostingState::Reserved(r) if *r == rid),
-                "reserve",
-            )
-            .await?;
-            ctx.reserved_postings.extend_from_slice(&posting_ids);
-            Ok(StepOutcome::Continue)
-        }
-        .instrument(tracing::info_span!("saga_step", step = "reserve"))
-        .await
-    }
-
-    async fn compensate(
-        ctx: &mut LedgerCtx,
-        _input: &ReserveInput,
-    ) -> Result<CompensationOutcome, LedgerError> {
-        ctx.ledger()?
-            .store()
-            .release_postings(&ctx.reserved_postings, ctx.reservation)
-            .await
-            .map_err(LedgerError::Store)?;
-        ctx.reserved_postings.clear();
-        Ok(CompensationOutcome::Completed)
-    }
-
-    fn retry_policy() -> RetryPolicy {
-        RetryPolicy::retries(3)
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Step 2: FinalizeTransferStep
-// ---------------------------------------------------------------------------
-
-/// Input for the finalize step (envelope comes from ctx).
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct FinalizeInput;
-
-/// Re-validates against current state (the last-step floor / freeze-close guard),
-/// then drives the verified, idempotent commit via `Ledger::finalize_envelope`.
-///
-/// Compensation reverses the finalized envelope (only relevant once committed).
-pub struct FinalizeTransferStep;
-
-#[async_trait]
-impl Step<LedgerCtx, LedgerError> for FinalizeTransferStep {
-    type Input = FinalizeInput;
-
-    async fn execute(
-        ctx: &mut LedgerCtx,
-        _input: &FinalizeInput,
-    ) -> Result<StepOutcome, LedgerError> {
-        async {
-            let envelope = ctx
-                .envelope
-                .clone()
-                .ok_or_else(|| internal("no envelope in context -- resolve step must run first"))?;
-            let rid = ctx.reservation;
-            let ledger = ctx.ledger_arc()?;
-
-            // All commit work (re-validate, mark Finalizing, deactivate/insert/
-            // store/event with end-state verification) lives in `finalize_envelope`
-            // so recovery uses exactly the same path. Its typed error (validation,
-            // overdraft, frozen) reaches the caller unchanged.
-            let receipt = ledger.finalize_envelope(&envelope, rid).await?;
-
-            ctx.receipts.push(receipt);
-            ctx.reserved_postings.clear();
-            Ok(StepOutcome::Continue)
-        }
-        .instrument(tracing::info_span!("saga_step", step = "finalize"))
-        .await
-    }
-
-    async fn compensate(
-        ctx: &mut LedgerCtx,
-        _input: &FinalizeInput,
-    ) -> Result<CompensationOutcome, LedgerError> {
-        if let Some(receipt) = ctx.receipts.pop() {
-            ctx.ledger_arc()?.reverse(&receipt.transfer_id).await?;
-        }
-        Ok(CompensationOutcome::Completed)
-    }
-
-    fn retry_policy() -> RetryPolicy {
-        RetryPolicy::retries(3)
-    }
-}
-
-// ===========================================================================
 // High-level steps (pay / deposit movement steps)
 // ===========================================================================
 

+ 113 - 0
doc/adr/0018-single-commit-pipeline-as-plain-function.md

@@ -0,0 +1,113 @@
+# Single-commit pipeline as a plain function, legend for multi-transfer only
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-15
+* Targeted modules: `kuatia` (`ledger::commit`, `saga`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0002 chose a saga commit pipeline (`reserve -> finalize`) driven by the
+`legend` crate, and listed as a positive consequence that "the same steps drive
+both a single commit and multi-transfer workflows." In the code this did not
+hold literally: multi-transfer workflows compose `PayMovementStep` and
+`DepositMovementStep`, which each wrap `commit()`, while `ReservePostingsStep`
+and `FinalizeTransferStep` were used only by the single-commit `EnvelopeSaga`.
+
+Driving one fixed two-step sequence through `legend` cost a four-hop call path
+(`commit_envelope` -> `drive_envelope_saga` -> `FinalizeTransferStep::execute`
+-> `Ledger::finalize_envelope`) plus a `legend!` macro in a separate file. The
+finalize step was a trampoline that only forwarded to `finalize_envelope`, and
+its compensation was dead code: `legend` compensates completed steps in LIFO
+order, so a failing terminal step never runs its own compensation. Should the
+single commit keep going through `legend`, or is it clearer as a plain function?
+
+## Decision Drivers
+
+* **Navigability**: reading "how a commit finalizes" should not require
+  bouncing across three files and a macro expansion.
+* **Honesty of the abstraction**: the durability engine is the phase-tracked
+  write-ahead record plus `recover()` (ADR-0003), not `legend`. The saga
+  framework contributed only per-step retry and LIFO compensation to the single
+  commit, and half of that (the terminal step's compensation) was unreachable.
+* **Preserve behavior exactly**: retry counts, the release-on-failure
+  compensation, the point-of-no-return semantics, and the recovery model must
+  not change.
+* **Keep real composition**: multi-transfer workflows must still compose with
+  workflow-wide compensation.
+
+## Considered Options
+
+#### Option 1: Keep `legend` driving the single commit (status quo)
+
+**Pros:**
+
+* Good, because it matches ADR-0002's wording literally.
+* Good, because retry and compensation are expressed through one framework.
+
+**Cons:**
+
+* Bad, because the single commit is a fixed two-step line, so the saga
+  machinery buys little: one trampoline step and one unreachable compensation.
+* Bad, because the control flow spans `commit_envelope`, `saga.rs`,
+  `envelope_saga.rs`, and the `legend!` macro for one straight-line concept.
+
+#### Option 2: Drive the single commit with a plain function; keep `legend` for multi-transfer
+
+Replace `EnvelopeSaga` with a `drive_envelope_saga` function that runs
+`reserve -> finalize` directly: retry each phase, and on a finalize failure
+release the reservation (a no-op past the point of no return, where recovery
+rolls forward). Delete `ReservePostingsStep`, `FinalizeTransferStep`, and
+`envelope_saga.rs`. Keep `PayMovementStep`/`DepositMovementStep` and the
+`legend!`-composed multi-transfer sagas unchanged.
+
+**Pros:**
+
+* Good, because the commit reads top to bottom in one place, next to
+  `finalize_envelope` and `recover`.
+* Good, because it removes the trampoline and the unreachable compensation.
+* Good, because it names the truth: `legend` earns its keep across transfers
+  (real cross-step reversal), not within one commit.
+
+**Cons:**
+
+* Bad, because the retry-and-compensate logic is now hand-written rather than
+  provided by the framework, so it must be kept correct by tests.
+* Bad, because it diverges from ADR-0002's "the same steps drive both."
+
+## Decision Outcome
+
+Chosen option: **Option 2**. The single commit becomes a plain function; the
+`legend` saga is reserved for multi-transfer composition, which is the only
+place its cross-step retry and LIFO compensation are actually exercised. This
+refines ADR-0002: the two-phase `reserve -> finalize` structure, the reservation
+protocol, and the write-ahead recovery model are all unchanged. Only the driver
+of the single commit changes, from a `legend` execution to a function that
+replicates the same retry and compensation semantics.
+
+### Positive Consequences
+
+* One reading path for a commit: `commit_envelope` -> `drive_envelope_saga`
+  (reserve, then finalize) -> `finalize_envelope`.
+* The unreachable terminal-step compensation and the forwarding step are gone.
+* The dumb-storage count contract stays centralized in `saga.rs`
+  (`verify_postings`, `verify_posting_states`, `ensure`) and is shared by the
+  reserve phase and by `finalize_envelope`.
+
+### Negative Consequences
+
+* Per-phase retry and the release-on-failure compensation are now explicit code
+  in `drive_envelope_saga`, covered by the recovery tests (which exercise this
+  function directly): a `Reserving` saga that fails re-validation releases the
+  reservation and aborts; a taken posting makes the reserve phase fail without
+  release.
+* ADR-0002's "the same steps drive both" no longer applies to the reserve and
+  finalize primitives; composition happens at the `commit()` level instead.
+
+## Links
+
+* Refines [ADR-0002](0002-saga-commit-pipeline.md): keeps the two-phase saga and
+  its drivers for multi-transfer workflows, replaces the single-commit driver.
+* Depends on [ADR-0003](0003-dumb-storage-saga-recovery.md): the write-ahead
+  record, phase tracking, and roll-forward recovery the function relies on.

+ 1 - 0
doc/adr/README.md

@@ -27,6 +27,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0015](0015-fixed-width-account-code.md) | Fixed-width 20-character account code | accepted | The IBAN-style code becomes a fixed 20 chars (18-char body + 2 trailing check digits, five groups of four) by packing id (63 bits) and subaccount (30 bits) into one permuted value. Presentation-only; caps the subaccount at `SUB_BITS`. Supersedes the code section of 0012. |
 | [0016](0016-immutable-postings-index-tables.md) | Immutable postings with active/reserved index tables | accepted (hot-table representation refined by 0017) | Postings become an insert-only immutable table; lifecycle state moves to two index tables (`active_postings`, `reserved_postings`). Append-only integrity + least privilege (no `UPDATE`) + hot working set by segregation. Supersedes the storage representation of 0006. |
 | [0017](0017-correctness-first-append-only-hot-indexes.md) | Correctness-first storage: append-only value tables, disposable hot indexes | accepted | The guiding principle: value/audit tables (`postings`, `accounts`) are strictly append-only source of truth; disposable hot tables (`active_postings`, `reserved_postings`, `account_head`) index the live subset by `INSERT`/`DELETE` only and are rebuildable. Correctness first; no `UPDATE` anywhere, enforceable by DB grants. Generalizes 0016 (hot tables now hold full row copies). |
+| [0018](0018-single-commit-pipeline-as-plain-function.md) | Single-commit pipeline as a plain function | accepted | The single commit (`reserve → finalize`) is a plain function replicating the same retry + release-on-failure semantics; `legend` is reserved for multi-transfer composition, the only place its cross-step compensation is exercised. Refines 0002. |
 
 ## Recommended future ADRs