ソースを参照

Squashed commit of the following:

commit 32f50f7ac6d96c1591e7b307199cd5d63a76b808
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Sat Jul 18 21:21:23 2026 -0300

    Make account lifecycle transitions crash-safe

    freeze, unfreeze, and close each append a new account version and then
    append the matching lifecycle event as two separate store writes with no
    shared transaction. A crash between them left a durable version bump with
    no event, and nothing repaired it. This is the same window recover()
    already closes for store_transfer followed by append_event on the commit
    path, left open for account transitions.

    Route the transitions through that existing write-ahead / repair path.
    The three methods, previously near-identical copies of the same five-step
    shape, collapse into one transition primitive parameterized by the flag
    mutation and the event. It persists a PendingTransition write-ahead
    record before either write; recover() rolls it forward, appending the
    version only when it is not yet present and re-appending the event.

    Rolling the event forward requires it to be idempotent, which lifecycle
    events were not: event_dedup_key returned None for them, so a second
    append duplicated the row. Give the three transition events a version
    field and key event_dedup_key on (account, version). The key type
    generalizes to a string; the transfer form stays the lowercase hex the
    dedup_key column already holds, so no migration is needed, and the field
    is serde-default so existing event rows still load.

    The write-ahead records now share one tagged PendingRecord enum so
    recover() dispatches an envelope commit saga and an account transition to
    their own completion paths. See ADR-0019.
Cesar Rodas 23 時間 前
コミット
e8859de701

+ 7 - 3
crates/kuatia-dashboard/src/data.rs

@@ -183,11 +183,15 @@ fn event_dto(event: &LedgerEvent) -> EventDto {
         LedgerEventKind::AccountCreated { account_id } => {
             ("AccountCreated", Some(*account_id), None)
         }
-        LedgerEventKind::AccountFrozen { account_id } => ("AccountFrozen", Some(*account_id), None),
-        LedgerEventKind::AccountUnfrozen { account_id } => {
+        LedgerEventKind::AccountFrozen { account_id, .. } => {
+            ("AccountFrozen", Some(*account_id), None)
+        }
+        LedgerEventKind::AccountUnfrozen { account_id, .. } => {
             ("AccountUnfrozen", Some(*account_id), None)
         }
-        LedgerEventKind::AccountClosed { account_id } => ("AccountClosed", Some(*account_id), None),
+        LedgerEventKind::AccountClosed { account_id, .. } => {
+            ("AccountClosed", Some(*account_id), None)
+        }
     };
     EventDto {
         seq: event.seq,

+ 6 - 6
crates/kuatia-storage-sql/src/lib.rs

@@ -1258,23 +1258,23 @@ impl EventStore for SqlStore {
         let data = serialize_json(event)?;
         let seq = self.autoid.next() as u64;
 
-        // Idempotent on the dedup key: a replayed transfer event conflicts on
-        // `dedup_key` and returns the existing seq instead of a duplicate row.
+        // Idempotent on the dedup key: a replayed transfer or lifecycle-transition
+        // event conflicts on `dedup_key` and returns the existing seq instead of a
+        // duplicate row.
         match kuatia_storage::events::event_dedup_key(&event.kind) {
-            Some(eid) => {
-                let dedup_hex = envelope_id_to_hex(&eid);
+            Some(dedup_key) => {
                 let res = sqlx::query("INSERT INTO events (seq, timestamp, kind, data, dedup_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (dedup_key) DO NOTHING")
                     .bind(seq as i64)
                     .bind(event.timestamp)
                     .bind(&kind_str)
                     .bind(&data)
-                    .bind(&dedup_hex)
+                    .bind(&dedup_key)
                     .execute(&self.pool)
                     .await
                     .map_err(|e| StoreError::Internal(e.to_string()))?;
                 if res.rows_affected() == 0 {
                     let row = sqlx::query("SELECT seq FROM events WHERE dedup_key = $1")
-                        .bind(&dedup_hex)
+                        .bind(&dedup_key)
                         .fetch_one(&self.pool)
                         .await
                         .map_err(|e| StoreError::Internal(e.to_string()))?;

+ 53 - 9
crates/kuatia-storage/src/events.rs

@@ -24,16 +24,28 @@ pub enum LedgerEventKind {
     AccountFrozen {
         /// The id of the frozen account.
         account_id: AccountId,
+        /// The account version this transition produced. Pins the event to one
+        /// version bump so a recovered transition re-appends idempotently.
+        #[serde(default)]
+        version: u64,
     },
     /// An account was unfrozen.
     AccountUnfrozen {
         /// The id of the unfrozen account.
         account_id: AccountId,
+        /// The account version this transition produced. Pins the event to one
+        /// version bump so a recovered transition re-appends idempotently.
+        #[serde(default)]
+        version: u64,
     },
     /// An account was closed.
     AccountClosed {
         /// The id of the closed account.
         account_id: AccountId,
+        /// The account version this transition produced. Pins the event to one
+        /// version bump so a recovered transition re-appends idempotently.
+        #[serde(default)]
+        version: u64,
     },
 }
 
@@ -49,19 +61,51 @@ pub struct LedgerEvent {
 }
 
 /// The idempotency key for an event, if it has a natural one. Replayable events
-/// (a committed transfer, re-driven by saga recovery) dedup on their transfer
-/// id; events with no natural identity (account lifecycle) return `None` and may
-/// recur.
-pub fn event_dedup_key(kind: &LedgerEventKind) -> Option<EnvelopeId> {
+/// (re-appended by crash recovery) carry a key so a second append collapses to
+/// the existing row instead of duplicating.
+///
+/// - A committed transfer keys on its content-addressed id (hex).
+/// - An account lifecycle *transition* (freeze/unfreeze/close) keys on the
+///   `(account, version)` it records: each version bump happens exactly once, so
+///   the pair is a stable identity that recovery reproduces verbatim.
+/// - `AccountCreated` has no version-transition identity and is not re-driven, so
+///   it returns `None` and may recur.
+///
+/// Keys are strings so both identities share the store's single `dedup_key`
+/// column; the transfer form is the same lowercase hex the column already holds,
+/// so no existing row changes meaning.
+pub fn event_dedup_key(kind: &LedgerEventKind) -> Option<String> {
     match kind {
-        LedgerEventKind::TransferCommitted { transfer_id } => Some(*transfer_id),
-        LedgerEventKind::AccountCreated { .. }
-        | LedgerEventKind::AccountFrozen { .. }
-        | LedgerEventKind::AccountUnfrozen { .. }
-        | LedgerEventKind::AccountClosed { .. } => None,
+        LedgerEventKind::TransferCommitted { transfer_id } => Some(hex(&transfer_id.0)),
+        LedgerEventKind::AccountFrozen {
+            account_id,
+            version,
+        }
+        | LedgerEventKind::AccountUnfrozen {
+            account_id,
+            version,
+        }
+        | LedgerEventKind::AccountClosed {
+            account_id,
+            version,
+        } => Some(format!(
+            "acct:{}:{}:{}",
+            account_id.id, account_id.sub, version
+        )),
+        LedgerEventKind::AccountCreated { .. } => None,
     }
 }
 
+/// Lower-case hex, matching the SQL backend's `dedup_key` encoding for transfer
+/// events so the key a recovered append produces equals the stored one.
+fn hex(bytes: &[u8]) -> String {
+    let mut s = String::with_capacity(bytes.len() * 2);
+    for &b in bytes {
+        s.push_str(&format!("{b:02x}"));
+    }
+    s
+}
+
 /// Persistent event log for ledger events.
 #[async_trait]
 pub trait EventStore: Send + Sync {

+ 3 - 3
crates/kuatia-storage/src/mem_store.rs

@@ -463,12 +463,12 @@ impl SagaStore for InMemoryStore {
 impl EventStore for InMemoryStore {
     async fn append_event(&self, event: &LedgerEvent) -> Result<u64, StoreError> {
         let mut events = self.events.write().await;
-        // Idempotent on the dedup key: a replayed transfer event returns the
-        // existing seq instead of inserting a duplicate.
+        // Idempotent on the dedup key: a replayed transfer or lifecycle-transition
+        // event returns the existing seq instead of inserting a duplicate.
         if let Some(key) = crate::events::event_dedup_key(&event.kind)
             && let Some(existing) = events
                 .iter()
-                .find(|e| crate::events::event_dedup_key(&e.kind) == Some(key))
+                .find(|e| crate::events::event_dedup_key(&e.kind).as_deref() == Some(key.as_str()))
         {
             return Ok(existing.seq);
         }

+ 33 - 0
crates/kuatia-storage/src/store_tests.rs

@@ -928,6 +928,38 @@ pub async fn append_event_idempotent(store: &(impl Store + 'static)) {
     assert_eq!(store.get_events_since(0, 10).await.unwrap().len(), 1);
 }
 
+/// `append_event` is idempotent on a lifecycle transition's `(account, version)`
+/// key: re-appending the same `AccountFrozen` (the crash-recovery replay) returns
+/// the existing seq and does not duplicate the row, while the same account frozen
+/// at a *different* version is a distinct event.
+pub async fn append_event_transition_idempotent(store: &(impl Store + 'static)) {
+    let frozen_v2 = LedgerEvent {
+        seq: 0,
+        timestamp: 1000,
+        kind: LedgerEventKind::AccountFrozen {
+            account_id: AccountId::new(7),
+            version: 2,
+        },
+    };
+    let seq1 = store.append_event(&frozen_v2).await.unwrap();
+    let seq2 = store.append_event(&frozen_v2).await.unwrap();
+    assert_eq!(seq1, seq2, "same (account, version) collapses to one event");
+    assert_eq!(store.get_events_since(0, 10).await.unwrap().len(), 1);
+
+    // A later freeze of the same account (a new version bump) is not deduped away.
+    let frozen_v4 = LedgerEvent {
+        seq: 0,
+        timestamp: 2000,
+        kind: LedgerEventKind::AccountFrozen {
+            account_id: AccountId::new(7),
+            version: 4,
+        },
+    };
+    let seq3 = store.append_event(&frozen_v4).await.unwrap();
+    assert_ne!(seq3, seq1);
+    assert_eq!(store.get_events_since(0, 10).await.unwrap().len(), 2);
+}
+
 // ---------------------------------------------------------------------------
 // TransferStore tests
 // ---------------------------------------------------------------------------
@@ -1340,6 +1372,7 @@ macro_rules! store_tests {
             reserve_twice_second_zero,
             deactivate_twice_second_zero,
             append_event_idempotent,
+            append_event_transition_idempotent,
             // TransferStore
             commit_and_get_transfer,
             get_missing_transfer,

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

@@ -31,6 +31,7 @@ mod commit;
 mod lifecycle;
 mod projection;
 mod query;
+mod transition;
 
 pub use balance::SubAccountBalance;
 pub use commit::LoadedState;

+ 315 - 38
crates/kuatia/src/ledger/commit.rs

@@ -51,6 +51,31 @@ struct PendingSaga {
     phase: SagaPhase,
 }
 
+/// Write-ahead record for an in-flight account-version transition
+/// (freeze/unfreeze/close). The transition appends a new account version and then
+/// its lifecycle event; a crash between the two leaves a version bump with no
+/// event. Persisting this before either write lets [`Ledger::recover`] roll the
+/// transition forward, re-appending the (idempotent) event.
+#[derive(serde::Serialize, serde::Deserialize)]
+pub(super) struct PendingTransition {
+    /// The next account version to append: version already bumped, flag flipped.
+    pub next: kuatia_core::Account,
+    /// The lifecycle event paired with this version bump. It carries the target
+    /// version, so re-appending it on recovery dedups to the original.
+    pub event: LedgerEventKind,
+}
+
+/// The two kinds of write-ahead record the [`SagaStore`](kuatia_storage::store::SagaStore)
+/// holds, tagged so [`Ledger::recover`] can tell an envelope commit saga from an
+/// account transition and complete each through its own path.
+#[derive(serde::Serialize, serde::Deserialize)]
+enum PendingRecord {
+    /// A two-step envelope commit saga (reserve → finalize).
+    Envelope(PendingSaga),
+    /// A single account-version transition (append version + lifecycle event).
+    Transition(PendingTransition),
+}
+
 /// State loaded in phase 1, passed to the pure validation in phase 2.
 pub struct LoadedState {
     /// Postings being consumed by the envelope.
@@ -314,41 +339,55 @@ impl Ledger {
         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)
+            let record: PendingRecord = 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?;
-                    }
+            match record {
+                PendingRecord::Transition(PendingTransition { next, event }) => {
+                    // Roll the account transition forward: append the version if it
+                    // is not yet present, then (re-)append the idempotent event.
+                    // Both steps no-op when already applied, so this is safe to run
+                    // in any crash window.
+                    self.complete_transition(saga_id, next, event).await?;
                 }
-                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 {
+                PendingRecord::Envelope(PendingSaga {
+                    envelope,
+                    reservation,
+                    phase,
+                }) => {
+                    // The transfer record is durable, but a full commit is more
+                    // than the transfer row: it also includes the committed event,
+                    // appended *after* store_transfer. A crash in that window
+                    // leaves the record present yet the event missing, so repair
+                    // the whole end-state (idempotent) before clearing the record.
+                    let tid = envelope_id(&envelope);
+                    if self.store.get_transfer(&tid).await?.is_some() {
+                        self.append_committed_event(tid).await?;
                         self.store.delete_saga(&saga_id).await?;
+                        continue;
+                    }
+
+                    match phase {
+                        SagaPhase::Finalizing => {
+                            // Validation passed and the postings are ours; roll
+                            // forward. Keep the record if completion fails so a
+                            // later run retries.
+                            if self.finalize_envelope(&envelope, reservation).await.is_ok() {
+                                self.store.delete_saga(&saga_id).await?;
+                            }
+                        }
+                        SagaPhase::Reserving => {
+                            // Re-run the validating saga. On failure, delete only if
+                            // it did not reach finalize (clean abort); otherwise
+                            // keep for next run.
+                            let result = self.drive_envelope_saga(envelope, reservation).await;
+                            let safe_to_delete = result.is_ok()
+                                || self.read_pending_phase(saga_id).await?
+                                    != Some(SagaPhase::Finalizing);
+                            if safe_to_delete {
+                                self.store.delete_saga(&saga_id).await?;
+                            }
+                        }
                     }
                 }
             }
@@ -506,23 +545,46 @@ impl Ledger {
         reservation: kuatia_core::ReservationId,
         phase: SagaPhase,
     ) -> Result<(), LedgerError> {
-        let blob = serde_json::to_vec(&PendingSaga {
+        let blob = serde_json::to_vec(&PendingRecord::Envelope(PendingSaga {
             envelope: envelope.clone(),
             reservation,
             phase,
-        })
+        }))
         .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
         self.store.save_saga(&reservation.0, blob).await?;
         Ok(())
     }
 
-    /// Read the persisted phase of a pending saga, if it still exists.
+    /// Persist the write-ahead record for an account-version transition, keyed by
+    /// a fresh unique id, and return that id so the caller can delete the record
+    /// once the transition is complete. Shares the reservation-id generator so the
+    /// key never collides with an in-flight commit saga's key.
+    pub(super) async fn save_transition(
+        &self,
+        next: &kuatia_core::Account,
+        event: &LedgerEventKind,
+    ) -> Result<i64, LedgerError> {
+        let saga_id = kuatia_core::ReservationId::default().0;
+        let blob = serde_json::to_vec(&PendingRecord::Transition(PendingTransition {
+            next: next.clone(),
+            event: event.clone(),
+        }))
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+        self.store.save_saga(&saga_id, blob).await?;
+        Ok(saga_id)
+    }
+
+    /// Read the persisted phase of a pending *envelope* saga, if one exists under
+    /// `saga_id`. A transition record (no phase) reads as `None`.
     async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
         for (id, blob) in self.store.list_pending_sagas().await? {
             if id == saga_id {
-                let pending: PendingSaga = serde_json::from_slice(&blob)
+                let record: PendingRecord = serde_json::from_slice(&blob)
                     .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
-                return Ok(Some(pending.phase));
+                return Ok(match record {
+                    PendingRecord::Envelope(s) => Some(s.phase),
+                    PendingRecord::Transition(_) => None,
+                });
             }
         }
         Ok(None)
@@ -649,11 +711,11 @@ mod recovery_tests {
         rid: ReservationId,
         phase: SagaPhase,
     ) {
-        let blob = serde_json::to_vec(&PendingSaga {
+        let blob = serde_json::to_vec(&PendingRecord::Envelope(PendingSaga {
             envelope: envelope.clone(),
             reservation: rid,
             phase,
-        })
+        }))
         .unwrap();
         ledger.store().save_saga(&rid.0, blob).await.unwrap();
     }
@@ -930,4 +992,219 @@ mod recovery_tests {
                 .is_empty()
         );
     }
+
+    // -----------------------------------------------------------------------
+    // Account-version transition recovery (freeze / unfreeze / close)
+    // -----------------------------------------------------------------------
+
+    /// Persist a transition write-ahead record by hand and return its id, so a
+    /// test can simulate a crash mid-transition.
+    async fn save_transition_record(
+        ledger: &Arc<Ledger>,
+        next: &Account,
+        event: &LedgerEventKind,
+    ) -> Result<i64, LedgerError> {
+        let saga_id = ReservationId::default().0;
+        let blob = serde_json::to_vec(&PendingRecord::Transition(PendingTransition {
+            next: next.clone(),
+            event: event.clone(),
+        }))
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+        ledger.store().save_saga(&saga_id, blob).await?;
+        Ok(saga_id)
+    }
+
+    fn count_frozen(events: &[LedgerEvent], id: AccountId) -> usize {
+        events
+            .iter()
+            .filter(|e| {
+                matches!(
+                    e.kind,
+                    LedgerEventKind::AccountFrozen { account_id, .. } if account_id == id
+                )
+            })
+            .count()
+    }
+
+    fn count_closed(events: &[LedgerEvent], id: AccountId) -> usize {
+        events
+            .iter()
+            .filter(|e| {
+                matches!(
+                    e.kind,
+                    LedgerEventKind::AccountClosed { account_id, .. } if account_id == id
+                )
+            })
+            .count()
+    }
+
+    /// The happy path leaves nothing to recover: a completed freeze deletes its
+    /// write-ahead record and emits exactly one event.
+    #[tokio::test]
+    async fn freeze_leaves_no_pending_record() -> Result<(), LedgerError> {
+        let ledger = funded_ledger().await;
+        ledger.freeze(&AccountId::new(1)).await?;
+
+        assert!(
+            ledger
+                .store()
+                .get_account(&AccountId::new(1))
+                .await?
+                .is_frozen()
+        );
+        let events = ledger.get_events_since(0, 1000).await?;
+        assert_eq!(count_frozen(&events, AccountId::new(1)), 1);
+        assert!(ledger.store().list_pending_sagas().await?.is_empty());
+        Ok(())
+    }
+
+    /// The reported gap: a freeze crashed after the version append but before the
+    /// event append. Recovery appends the missing event (without bumping the
+    /// version again) and clears the record.
+    #[tokio::test]
+    async fn recover_completes_transition_missing_event() -> Result<(), LedgerError> {
+        let ledger = funded_ledger().await;
+        let current = ledger.store().get_account(&AccountId::new(1)).await?;
+        let mut next = current;
+        next.version += 1;
+        next.flags |= AccountFlags::FROZEN;
+        let event = LedgerEventKind::AccountFrozen {
+            account_id: AccountId::new(1),
+            version: next.version,
+        };
+
+        // Replay the transition up to (but not including) the event append.
+        ledger.store().append_account_version(next.clone()).await?;
+        save_transition_record(&ledger, &next, &event).await?;
+
+        // Precondition: version bumped and frozen, but no event yet.
+        assert_eq!(next.version, 2);
+        assert_eq!(
+            count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
+            0
+        );
+
+        assert_eq!(ledger.recover().await?, 1);
+
+        // The event is appended, the version is not bumped a second time, and the
+        // record is cleared.
+        let account = ledger.store().get_account(&AccountId::new(1)).await?;
+        assert!(account.is_frozen());
+        assert_eq!(account.version, 2);
+        assert_eq!(
+            count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
+            1
+        );
+        assert!(ledger.store().list_pending_sagas().await?.is_empty());
+        Ok(())
+    }
+
+    /// A freeze that crashed before either write is rolled fully forward: recovery
+    /// appends the version and the event, then clears the record.
+    #[tokio::test]
+    async fn recover_completes_transition_before_any_write() -> Result<(), LedgerError> {
+        let ledger = funded_ledger().await;
+        let current = ledger.store().get_account(&AccountId::new(1)).await?;
+        let mut next = current;
+        next.version += 1;
+        next.flags |= AccountFlags::FROZEN;
+        let event = LedgerEventKind::AccountFrozen {
+            account_id: AccountId::new(1),
+            version: next.version,
+        };
+        save_transition_record(&ledger, &next, &event).await?;
+
+        // Precondition: nothing applied yet.
+        let before = ledger.store().get_account(&AccountId::new(1)).await?;
+        assert_eq!(before.version, 1);
+        assert!(!before.is_frozen());
+
+        assert_eq!(ledger.recover().await?, 1);
+
+        let account = ledger.store().get_account(&AccountId::new(1)).await?;
+        assert!(account.is_frozen());
+        assert_eq!(account.version, 2);
+        assert_eq!(
+            count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
+            1
+        );
+        assert!(ledger.store().list_pending_sagas().await?.is_empty());
+        Ok(())
+    }
+
+    /// A transition that fully applied but whose record survived (crash before the
+    /// final delete) recovers idempotently: no second version, no duplicate event.
+    #[tokio::test]
+    async fn recover_transition_is_idempotent_when_already_applied() -> Result<(), LedgerError> {
+        let ledger = funded_ledger().await;
+        // A real, completed freeze: version 2, one event, no record.
+        ledger.freeze(&AccountId::new(1)).await?;
+        let next = ledger.store().get_account(&AccountId::new(1)).await?;
+        let event = LedgerEventKind::AccountFrozen {
+            account_id: AccountId::new(1),
+            version: next.version,
+        };
+        // Simulate the record surviving the crash window before delete_saga.
+        save_transition_record(&ledger, &next, &event).await?;
+
+        assert_eq!(ledger.recover().await?, 1);
+
+        let account = ledger.store().get_account(&AccountId::new(1)).await?;
+        assert_eq!(account.version, 2, "no second version bump");
+        assert_eq!(
+            count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
+            1,
+            "event not duplicated"
+        );
+        assert!(ledger.store().list_pending_sagas().await?.is_empty());
+        Ok(())
+    }
+
+    /// A close crashed after the version append but before the event append is
+    /// rolled forward: recovery appends the `AccountClosed` event without a second
+    /// version bump and clears the record. Account 2 is empty, so it may close.
+    #[tokio::test]
+    async fn recover_completes_close_missing_event() -> Result<(), LedgerError> {
+        let ledger = funded_ledger().await;
+        let current = ledger.store().get_account(&AccountId::new(2)).await?;
+        let mut next = current;
+        next.version += 1;
+        next.flags |= AccountFlags::CLOSED;
+        let event = LedgerEventKind::AccountClosed {
+            account_id: AccountId::new(2),
+            version: next.version,
+        };
+
+        // Replay the transition up to (but not including) the event append.
+        ledger.store().append_account_version(next.clone()).await?;
+        save_transition_record(&ledger, &next, &event).await?;
+        assert_eq!(
+            count_closed(&ledger.get_events_since(0, 1000).await?, AccountId::new(2)),
+            0
+        );
+
+        assert_eq!(ledger.recover().await?, 1);
+
+        let account = ledger.store().get_account(&AccountId::new(2)).await?;
+        assert!(account.is_closed());
+        assert_eq!(account.version, 2);
+        assert_eq!(
+            count_closed(&ledger.get_events_since(0, 1000).await?, AccountId::new(2)),
+            1
+        );
+        assert!(ledger.store().list_pending_sagas().await?.is_empty());
+        Ok(())
+    }
+
+    /// A rejected close records nothing: the emptiness guard runs before the
+    /// write-ahead, so a non-empty account leaves no pending record to recover.
+    #[tokio::test]
+    async fn rejected_close_leaves_no_pending_record() -> Result<(), LedgerError> {
+        let ledger = funded_ledger().await;
+        // Account 1 holds the funded posting, so it is not empty.
+        let result = ledger.close(&AccountId::new(1)).await;
+        assert!(matches!(result, Err(LedgerError::AccountNotEmpty(_))));
+        assert!(ledger.store().list_pending_sagas().await?.is_empty());
+        Ok(())
+    }
 }

+ 41 - 65
crates/kuatia/src/ledger/lifecycle.rs

@@ -3,10 +3,15 @@
 //! 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).
+//!
+//! Freeze, unfreeze, and close are the same version-bump-plus-event shape; they
+//! delegate it to [`Ledger::transition`](super::transition), which carries the
+//! write-ahead / crash-repair path. Each method here supplies only the flag
+//! mutation, the lifecycle event, and any transition-specific guard.
 
 use tracing::instrument;
 
-use kuatia_core::{AccountId, PostingFilter};
+use kuatia_core::{AccountFlags, AccountId, PostingFilter};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 
 use super::{Ledger, now_millis};
@@ -30,82 +35,53 @@ impl Ledger {
     /// 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(())
+        self.transition(
+            id,
+            |flags| *flags |= AccountFlags::FROZEN,
+            |account_id, version| LedgerEventKind::AccountFrozen {
+                account_id,
+                version,
+            },
+        )
+        .await
     }
 
     /// 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(())
+        self.transition(
+            id,
+            |flags| flags.remove(AccountFlags::FROZEN),
+            |account_id, version| LedgerEventKind::AccountUnfrozen {
+                account_id,
+                version,
+            },
+        )
+        .await
     }
 
-    /// Close an account. Must have no active postings.
+    /// Close an account. Must have no live 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.
+        // Emptiness is close's own guard, checked before the transition's
+        // write-ahead so a non-empty account records nothing. A closed account
+        // holds no live postings, so this ordering still surfaces
+        // `AccountAlreadyClosed` (from `transition`) for a re-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(())
+        self.transition(
+            id,
+            |flags| {
+                *flags |= AccountFlags::CLOSED;
+                flags.remove(AccountFlags::FROZEN);
+            },
+            |account_id, version| LedgerEventKind::AccountClosed {
+                account_id,
+                version,
+            },
+        )
+        .await
     }
 
     /// Whether `account` (exact base id and subaccount) has any live posting: one

+ 106 - 0
crates/kuatia/src/ledger/transition.rs

@@ -0,0 +1,106 @@
+//! One account-version transition, shared by freeze / unfreeze / close.
+//!
+//! Every lifecycle flag change is the same shape: load the account, reject if it
+//! is already closed, append a new version with the flag flipped, then append the
+//! matching lifecycle event. This module holds that shape once, parameterized by
+//! the flag mutation and the event, and routes it through the same write-ahead /
+//! repair path the commit engine uses.
+//!
+//! The durability point: the version append and the event append are two separate
+//! store writes with no shared transaction. A crash between them would otherwise
+//! leave a version bump with no event and nothing to repair it. Persisting a
+//! write-ahead [`PendingTransition`](super::commit) before either write lets
+//! [`Ledger::recover`](Ledger) roll the transition forward. Recovery is
+//! idempotent both ways: the version append is skipped when the version is
+//! already present, and the event carries its target version so a second append
+//! dedups to the original.
+
+use kuatia_core::{Account, AccountFlags, AccountId};
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+
+use super::{Ledger, now_millis};
+use crate::error::LedgerError;
+
+impl Ledger {
+    /// Append one new account version with `mutate` applied to its flags, then
+    /// emit the lifecycle event produced by `make_event` (given the account id and
+    /// the new version). Rejects a closed account. A write-ahead record persisted
+    /// before the writes lets [`recover`](Ledger::recover) complete a transition
+    /// interrupted between the two appends.
+    ///
+    /// Callers layer any transition-specific guard (e.g. close's emptiness check)
+    /// before calling this.
+    pub(super) async fn transition(
+        &self,
+        id: &AccountId,
+        mutate: impl FnOnce(&mut AccountFlags),
+        make_event: impl FnOnce(AccountId, u64) -> LedgerEventKind,
+    ) -> 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;
+        next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
+        mutate(&mut next.flags);
+        let event = make_event(*id, next.version);
+
+        // Write-ahead before either write. A crash between the version append and
+        // the event append is then repaired by recover(), not left dangling.
+        let saga_id = self.save_transition(&next, &event).await?;
+
+        self.store.append_account_version(next).await?;
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: event,
+            })
+            .await?;
+        self.store.delete_saga(&saga_id).await?;
+        Ok(())
+    }
+
+    /// Roll a crash-interrupted transition forward and clear its write-ahead
+    /// record. Called by [`recover`](Ledger::recover) for a persisted
+    /// [`PendingTransition`](super::commit).
+    ///
+    /// Idempotent in every crash window: the version append runs only when the
+    /// version is not yet present (`append_account_version` requires
+    /// `version == current + 1`, so a blind retry after it applied would fail),
+    /// and the event carries its target version so re-appending it dedups to the
+    /// original.
+    pub(super) async fn complete_transition(
+        &self,
+        saga_id: i64,
+        next: Account,
+        event: LedgerEventKind,
+    ) -> Result<(), LedgerError> {
+        // The account is guaranteed to exist here (its version was already
+        // bumped, or is about to be), so a read failure is transient or a real
+        // invariant breach, not "not found": surface it verbatim so recovery
+        // retries rather than reporting a misleading domain error.
+        let current = self.store.get_account(&next.id).await?;
+        // Append only into an empty version slot. This also subsumes the
+        // is_closed guard the forward path runs: a close always bumps the
+        // version, so a since-closed account sits at version >= next.version and
+        // this branch is skipped, never appending onto a closed account.
+        if current.version < next.version {
+            self.store.append_account_version(next).await?;
+        }
+        self.store
+            .append_event(&LedgerEvent {
+                seq: 0,
+                timestamp: now_millis()?,
+                kind: event,
+            })
+            .await?;
+        self.store.delete_saga(&saga_id).await?;
+        Ok(())
+    }
+}

+ 141 - 0
doc/adr/0020-account-transition-recovery.md

@@ -0,0 +1,141 @@
+# Crash-safe account-version transitions
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-18
+* Targeted modules: `kuatia` (`ledger`), `kuatia-storage`, `kuatia-storage-sql`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0003 made the commit path crash-safe with a phase-tracked write-ahead
+record and a roll-forward `recover()`. Account lifecycle transitions
+(`freeze`, `unfreeze`, `close`) never got the same treatment. Each was an
+independent copy of the same shape: load the account, reject if closed, append
+a new account version with the flag flipped, then append the lifecycle event.
+The version append and the event append are two separate store writes with no
+shared transaction, so a crash between them left a durable version bump with no
+event, and nothing repaired it. That is the exact window `recover()` closes for
+`store_transfer → append_event` on the commit path, left open for account
+transitions.
+
+A second problem sat underneath the first. ADR-0010 made `append_event`
+idempotent on a content key, but only for `TransferCommitted` (keyed on the
+transfer id). Account lifecycle events had no key, so `event_dedup_key` returned
+`None` and a second append duplicated the row. Any repair path that re-appends
+the event therefore needs the event to first gain a stable identity; without it,
+recovery cannot tell "the event was already written" from "write it now."
+
+How should a lifecycle transition be made crash-safe, reusing the existing
+recovery machinery rather than inventing a parallel one?
+
+## Decision Drivers
+
+* **Same repair path, not a second one**: reuse the `SagaStore` write-ahead
+  record + startup `recover()` that the commit engine already has.
+* **Idempotent in every crash window**: recovery must converge whether it runs
+  before either write, between the two writes, or after both.
+* **No duplicate events, no double version bump**: rolling a transition forward
+  twice must be a no-op.
+* **Remove the triplication**: one transition primitive parameterized by the
+  flag mutation, not three near-identical copies.
+* **Storage stays dumb** (ADR-0003): the store gains no transition-specific
+  method; it still just appends versions and events and follows instructions.
+
+## Considered Options
+
+#### Option 1: Wrap the two writes in one store transaction
+
+Add a store method that appends the version and the event atomically.
+
+**Pros:**
+
+* No write-ahead record; the gap cannot open.
+
+**Cons:**
+
+* Reintroduces a monolithic, guard-bearing store method, the exact thing
+  ADR-0003 removed. The store would again bundle domain steps into one
+  transaction.
+* Does not compose with the existing `recover()`; lifecycle durability would
+  diverge from commit durability.
+
+#### Option 2: Write-ahead record + idempotent roll-forward (reuse `recover()`)
+
+Persist a `PendingTransition {next_account, event}` write-ahead record before
+either write, keyed in the same `SagaStore` as commit sagas under a tagged
+`PendingRecord` enum. `recover()` rolls it forward: append the version only when
+it is not yet present (`append_account_version` requires `version == current+1`,
+so a version check is the idempotency test), then re-append the event, then
+delete the record. To make the event re-append idempotent, give the three
+transition events a `version` field and key `event_dedup_key` on
+`(account, version)`.
+
+**Pros:**
+
+* One recovery entry point handles commit sagas and account transitions.
+* Idempotent in every window: the version check guards the append, and the
+  `(account, version)` key collapses a duplicate event.
+* Storage stays dumb; no new transition method.
+* Lets the three lifecycle methods collapse to one `transition` helper.
+
+**Cons:**
+
+* Changes the event schema (a new field) and generalizes the dedup key type
+  from `EnvelopeId` to a string.
+* The write-ahead blob format for in-flight commit sagas changes (now wrapped in
+  `PendingRecord::Envelope`), so a saga persisted by an older binary would not
+  deserialize after upgrade. Acceptable for in-flight, single-lifetime records.
+
+## Decision Outcome
+
+Chosen option: **Option 2**. It closes the gap by reusing ADR-0003's machinery
+instead of contradicting it, keeps the store dumb, and removes the triplication
+as a side effect.
+
+Concretely:
+
+* A single `Ledger::transition(id, mutate, make_event)` holds the shared shape.
+  `freeze`/`unfreeze`/`close` supply only the flag mutation and the event;
+  `close` layers its emptiness guard before the call. Because a closed account
+  holds no live postings, checking emptiness before the shared not-closed guard
+  still surfaces `AccountAlreadyClosed` on a re-close.
+* The write-ahead record is a `PendingTransition {next, event}`, stored in the
+  `SagaStore` under a tagged `PendingRecord::{Envelope, Transition}` so
+  `recover()` dispatches by kind. The transition key is minted from the same
+  generator as reservation ids, so it never collides with a commit saga's key.
+* `recover()` rolls a transition forward through `complete_transition`: append
+  the version if `current.version < next.version`, re-append the (now
+  idempotent) event, delete the record. No phase tracking is needed, unlike the
+  commit saga, because both steps are individually idempotent.
+* `LedgerEventKind::Account{Frozen,Unfrozen,Closed}` gain a `version` field
+  (`#[serde(default)]`, so old event JSON still loads). `event_dedup_key`
+  returns `Option<String>`: a transfer's lowercase-hex id (unchanged from what
+  the SQL `dedup_key` column already holds, so no migration) or an account
+  transition's `acct:{id}:{sub}:{version}`. `AccountCreated` keeps no key: it is
+  not a version transition and is not re-driven.
+
+### Positive Consequences
+
+* A lifecycle transition interrupted at any point is repaired on the next
+  `recover()`, matching the commit path's guarantee.
+* Lifecycle events now record which account version they correspond to, so the
+  event stream (ADR-0010) can be correlated with the account history.
+* Three copies become one primitive.
+
+### Negative Consequences
+
+* The event schema and dedup-key type changed; downstream consumers that
+  exhaustively match the event kinds must account for the new field.
+* An in-flight commit saga's write-ahead blob written before this change will
+  not deserialize afterward. In-flight records have no cross-version durability
+  contract, so this is a one-time, accepted cost.
+* `create_account`'s create-then-`append_event` has the same shape of gap. It is
+  not a version transition and is left for a future change.
+
+## Links
+
+* Refines [ADR-0003](0003-dumb-storage-saga-recovery.md) (extends write-ahead +
+  roll-forward recovery to account transitions).
+* Refines [ADR-0010](0010-event-stream-vs-transfer-log.md) (generalizes the
+  idempotent-event key from transfers to lifecycle transitions).

+ 1 - 0
doc/adr/README.md

@@ -29,6 +29,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [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-debit-must-not-exceed-credit-flag.md) | Collapse account policy into a single debit-must-not-exceed-credit flag | accepted | The `AccountPolicy` enum is removed; the only per-account balance constraint is the `AccountFlags` bit `DEBIT_MUST_NOT_EXCEED_CREDIT`. Overdraft is allowed by default (unbounded); the flag forbids a negative balance and negative postings. Credit-line floors become an application concern. Supersedes 0004. |
 | [0019](0019-cached-balance-projection.md) | Cached balance projection via append-only cache points | accepted | Append-only `balance_projection` cache points (id, watermark, balance); a read takes the closest one at or before `now` and folds the tail of committed movements since its commit-time watermark (grace Δ). Correctness never depends on the cache (equals the live sum at rest). Cache points are appended lazily on read once N credits/debits accrue (configurable); no per-commit hook, no lease, no background loop. |
+| [0020](0020-account-transition-recovery.md) | Crash-safe account-version transitions | accepted | `freeze`/`unfreeze`/`close` collapse into one write-ahead `transition` primitive routed through the commit engine's `recover()`. Lifecycle events gain a `version` and dedup on `(account, version)`, so a transition interrupted between the version append and the event append rolls forward idempotently. Refines 0003 and 0010. |
 
 ## Recommended future ADRs
 

+ 2 - 2
doc/crates.md

@@ -195,8 +195,8 @@ graph TB
 - **`TransferStore`**: `get_transfer`,
   `store_transfer(record, involved) -> u64`, `get_transfers_for_account`,
   `query_transfers`
-- **`EventStore`**: `append_event` (idempotent on a per-transfer dedup key),
-  `get_events_since`
+- **`EventStore`**: `append_event` (idempotent on a dedup key: a transfer's id,
+  or a lifecycle transition's `(account, version)`), `get_events_since`
 - **`SagaStore`**: `save_saga`, `list_pending_sagas`, `delete_saga`: the
   write-ahead store the saga and `recover()` use
 - **`BookStore`**: `create_book`, `get_book`, `list_books`