kuatia (ledger), kuatia-storage, kuatia-storage-sqlADR-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?
SagaStore write-ahead
record + startup recover() that the commit engine already has.Add a store method that appends the version and the event atomically.
Pros:
Cons:
recover(); lifecycle durability would
diverge from commit durability.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:
(account, version) key collapses a duplicate event.transition helper.Cons:
EnvelopeId to a string.PendingRecord::Envelope), so a saga persisted by an older binary would not
deserialize after upgrade. Acceptable for in-flight, single-lifetime records.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:
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.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.recover(), matching the commit path's guarantee.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.