Ver Fonte

Give the commit-safety invariant a single audit anchor

The property "what makes a commit safe" had no home. Value, conservation,
floor, freeze, snapshot, and book-policy checks lived in validate_and_plan;
the double-spend / reservation-ownership guard was inlined and nameless
inside finalize_envelope; the affected-row count contract lived in the saga.
A reader auditing validate_and_plan in isolation could wrongly conclude it
prevents double-spends, and its lifecycle comment admitted enforcement
happened "elsewhere" without pointing anywhere.

The double-spend property is not decidable against a snapshot: in a
concurrent ledger the only way to know a posting can be spent is to
atomically try to spend it and read the count, so the CAS is the decision
and cannot join the pure validation. Rather than merge the layers, name the
guard and document the map.

Extract the double-spend guard out of finalize_envelope into consume_reserved,
co-located with the count-contract helpers it depends on, and give it direct
unit tests (spends our reservation; refuses an unreserved posting; refuses
one held by another saga). Point the validate_and_plan lifecycle comment at
the guard, and add an ADR that maps every commit-safety invariant to its
home and explains why the pure checks and the runtime CAS stay in separate
layers.
Cesar Rodas há 2 semanas atrás
pai
commit
068e1624c7

+ 5 - 3
crates/kuatia-core/src/validate.rs

@@ -242,9 +242,11 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
     let consumed_by_id: HashMap<PostingId, &Posting> =
         input.consumed_postings.iter().map(|p| (p.id, p)).collect();
 
-    // 3. Every consumed posting exists (its lifecycle state is enforced by the
-    // reserve CAS and the finalize "all spent" guard, not here — a `Posting`
-    // carries no state).
+    // 3. Every consumed posting exists. Its lifecycle state (not already
+    // spent / owned by this saga) is not decidable here: a `Posting` carries no
+    // state and this check is a snapshot-in-time read. The authoritative
+    // double-spend guard is the reserve CAS plus `consume_reserved` in the
+    // finalize step (ADR-0021 maps the full commit-safety invariant).
     for pid in envelope.consumes() {
         consumed_by_id
             .get(pid)

+ 6 - 18
crates/kuatia/src/ledger/commit.rs

@@ -27,7 +27,9 @@ use kuatia_storage::store::EnvelopeRecord;
 use super::envelope_saga::*;
 use super::{Ledger, now_millis};
 use crate::error::LedgerError;
-use crate::saga::{FinalizeInput, LedgerCtx, ReserveInput, apply_and_verify, verify_postings};
+use crate::saga::{
+    FinalizeInput, LedgerCtx, ReserveInput, apply_and_verify, consume_reserved, verify_postings,
+};
 
 /// Phase of an in-flight commit, persisted with the write-ahead record so
 /// recovery knows whether validation has completed.
@@ -440,23 +442,9 @@ impl Ledger {
         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.
-        let spent = self
-            .store
-            .deactivate_postings(consumes, Some(reservation))
-            .await?;
-        verify_postings(
-            self.store.as_ref(),
-            consumes,
-            spent,
-            |s| *s == PostingState::Spent,
-            "finalize: consume reserved postings",
-        )
-        .await?;
+        // The authoritative double-spend guard (see `consume_reserved`): consume
+        // only rows we reserved, then assert all consumed postings are spent.
+        consume_reserved(self.store.as_ref(), consumes, reservation).await?;
 
         // Created postings, derived deterministically from the envelope.
         let created: Vec<Posting> = envelope

+ 105 - 0
crates/kuatia/src/saga.rs

@@ -100,6 +100,37 @@ pub(crate) async fn verify_postings(
     .await
 }
 
+/// The authoritative double-spend / reservation-ownership guard.
+///
+/// Consume the reserved postings, then assert every consumed id is now `Spent`.
+/// `deactivate_postings(_, Some(rid))` removes *only* rows this saga reserved, so
+/// the "all Spent" assertion can only pass when no consumed id was left active or
+/// held by another saga: that is what forbids a double-spend.
+///
+/// This CAS is the real concurrency authority for the consumed-posting lifecycle.
+/// The pure lifecycle check in [`validate_and_plan`](kuatia_core::validate_and_plan)
+/// is a snapshot-in-time, best-effort read (ADR-0003); this is the check that
+/// holds under contention. It runs once the saga is past its point of no return
+/// (phase `Finalizing`). See ADR-0021 for the full commit-safety map.
+pub(crate) async fn consume_reserved(
+    store: &dyn Store,
+    consumes: &[PostingId],
+    reservation: ReservationId,
+) -> Result<(), LedgerError> {
+    let spent = store
+        .deactivate_postings(consumes, Some(reservation))
+        .await
+        .map_err(LedgerError::Store)?;
+    verify_postings(
+        store,
+        consumes,
+        spent,
+        |s| *s == PostingState::Spent,
+        "finalize: consume reserved postings",
+    )
+    .await
+}
+
 // ---------------------------------------------------------------------------
 // Saga context -- carries the ledger handle + state between steps
 // ---------------------------------------------------------------------------
@@ -418,8 +449,23 @@ impl Step<LedgerCtx, LedgerError> for DepositMovementStep {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use kuatia_core::{EnvelopeId, Posting};
+    use kuatia_storage::mem_store::InMemoryStore;
+    use kuatia_storage::store::PostingStore;
     use std::cell::Cell;
 
+    fn active_posting(store_seed: u8) -> Posting {
+        Posting::new(
+            PostingId {
+                transfer: EnvelopeId([store_seed; 32]),
+                index: 0,
+            },
+            AccountId::new(1),
+            AssetId::new(1),
+            Cent::from(100),
+        )
+    }
+
     #[tokio::test]
     async fn full_count_is_ok_without_re_reading() {
         let verified = Cell::new(false);
@@ -464,4 +510,63 @@ mod tests {
             Err(LedgerError::Store(StoreError::Internal(_)))
         ));
     }
+
+    /// The guard consumes the postings this saga reserved: they end `Spent`.
+    #[tokio::test]
+    async fn consume_reserved_spends_our_postings() {
+        let store = InMemoryStore::new();
+        let p = active_posting(1);
+        store
+            .insert_postings(std::slice::from_ref(&p))
+            .await
+            .unwrap();
+        let rid = ReservationId::default();
+        store.reserve_postings(&[p.id], rid).await.unwrap();
+
+        consume_reserved(&store, &[p.id], rid).await.unwrap();
+
+        let states = store.get_posting_states(&[p.id]).await.unwrap();
+        assert_eq!(states, vec![PostingState::Spent]);
+    }
+
+    /// An unreserved (still active) posting is refused, and left untouched:
+    /// `deactivate_postings(_, Some(rid))` removes nothing we do not own.
+    #[tokio::test]
+    async fn consume_reserved_refuses_unreserved_posting() {
+        let store = InMemoryStore::new();
+        let p = active_posting(2);
+        store
+            .insert_postings(std::slice::from_ref(&p))
+            .await
+            .unwrap();
+
+        let err = consume_reserved(&store, &[p.id], ReservationId::default())
+            .await
+            .unwrap_err();
+        assert!(matches!(err, LedgerError::Store(StoreError::Internal(_))));
+        let states = store.get_posting_states(&[p.id]).await.unwrap();
+        assert_eq!(states, vec![PostingState::Active]);
+    }
+
+    /// The double-spend guard: a posting reserved by another saga is refused, and
+    /// stays reserved by that saga. Our deactivate removes nothing, so the
+    /// "all Spent" assertion fails.
+    #[tokio::test]
+    async fn consume_reserved_refuses_posting_held_by_another_saga() {
+        let store = InMemoryStore::new();
+        let p = active_posting(3);
+        store
+            .insert_postings(std::slice::from_ref(&p))
+            .await
+            .unwrap();
+        let theirs = ReservationId::default();
+        store.reserve_postings(&[p.id], theirs).await.unwrap();
+
+        let err = consume_reserved(&store, &[p.id], ReservationId::default())
+            .await
+            .unwrap_err();
+        assert!(matches!(err, LedgerError::Store(StoreError::Internal(_))));
+        let states = store.get_posting_states(&[p.id]).await.unwrap();
+        assert_eq!(states, vec![PostingState::Reserved(theirs)]);
+    }
 }

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

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