Pure, sans-IO (Input/Output) decision logic. No async runtime, near-zero
dependencies (sha2, serde, bitflags).
| Module | Purpose |
|---|---|
types |
Domain model: all core types, binary serialization, and AutoId generator |
validate |
validate_and_plan(): single entry point for invariant enforcement |
hash |
Double-SHA-256 (Secure Hash Algorithm), canonical encoding helpers, transfer/account hashing |
posting_selection |
Greedy largest-first posting selection for the intent layer |
| Type | Description |
|---|---|
AccountId(i64) |
Stable account identity (snowflake-style, generated in Rust) |
AssetId(u32) |
Asset identifier (USD, BTC, etc.). Conservation boundary |
EnvelopeId([u8; 32]) |
Content-addressed double-SHA-256 of transfer bytes |
PostingId { transfer, index } |
Identifies a posting by its creating transfer + position |
AccountSnapshotId { account, snapshot_id } |
Account state hash for version pinning |
Cent |
Smallest monetary unit (private field, backing integer hidden). Backing is i64 by default, i128 under the i128 feature. Checked arithmetic via checked_add, checked_sub, checked_neg, checked_sum returning Result<Cent, OverflowError> |
OverflowError |
Returned when a Cent operation would overflow or underflow |
PostingStatus |
Posting lifecycle: Active, PendingInactive, Inactive |
Amount |
Parser/formatter for decimal strings. Not stored; use at API boundaries only |
Posting |
Signed amount of one asset owned by one account. Has status: PostingStatus and reservation: Option<ReservationId> (owner token while PendingInactive) |
ReservationId |
Owner token stamped on a reserved posting so only the reserving saga may finalize/release it |
NewPosting |
Posting to be created (no id yet, assigned during validation) |
Transfer |
Atomic unit: consumes postings + creates postings + metadata |
EnvelopeBuilder |
Fluent builder for Transfer construction |
Account |
Versioned entity with policy, flags, book, user_data, metadata |
AccountPolicy |
Balance floor rule: NoOverdraft, CappedOverdraft, UncappedOverdraft, SystemAccount, ExternalAccount |
AccountFlags |
Bitflags: FROZEN, CLOSED |
UserData |
Fixed 28 bytes (u128 + u64 + u32) for correlation IDs, external refs |
Metadata |
BTreeMap<String, Vec<u8>> for free-form key-value data |
Receipt |
Confirmation of a committed transfer (contains transfer_id) |
AutoId |
Snowflake-inspired i64 ID generator: [0][40-bit ms][23-bit CRC32 or counter]. The ms field counts from KUATIA_EPOCH_MS (2026-01-01T00:00:00Z), giving ~34.8 years forward. Lives in kuatia-types::autoid |
validate_and_plan(input: PlanInput) -> Result<Plan, ValidationError> checks,
in order:
graph TD
A[1. Non-empty] --> B[2. No duplicate consumes]
B --> C[3. Posting existence]
C --> D[4. Posting active or reserved]
D --> E[5. Account existence & lifecycle]
E --> F[6. Snapshot pinning]
F --> BP[7. Book policy]
BP --> G[8. Per-asset conservation]
G --> H[9. Negative posting restriction]
H --> J[10. Policy enforcement]
J --> I[Plan]
style I fill:#e8f5e9
Active or
PendingInactive (prevents double-spend)sum(consumed) == sum(created) for each assetNoOverdraft (allowed on overdraft/system/external)Output is a Plan containing transfer_id, postings_to_deactivate, and
postings_to_create.
Async resource layer. Depends on kuatia-core, tokio, async-trait,
serde, legend.
| Module | Purpose |
|---|---|
kuatia |
Ledger: primary API (non-generic, uses Arc<dyn Store>), saga commit pipeline, intent layer |
store |
Store composite trait + sub-traits (AccountStore, PostingStore, TransferStore, SagaStore, EventStore, BookStore) |
error |
StoreError, LedgerError: unified error hierarchy |
mem_store |
InMemoryStore: in-memory Store implementation for tests |
saga |
Pipeline steps (reserve, validate, finalize) + high-level legend step adapters |
commit(transfer) resolves the intent into an envelope (read-only) then runs
the EnvelopeSaga (defined via legend!): two steps with automatic retry and
LIFO compensation. The finalize step re-validates as its last action before the
writes, then calls the dumb primitives, interpreting/verifying each count:
graph LR
A[resolve] -->|Envelope| W[save PendingSaga: Reserving]
W --> B[reserve_postings]
B -->|Active→PendingInactive| F[finalize]
F --> V[validate_and_plan re-check]
V --> M[mark Finalizing]
M --> D[deactivate → insert → store_transfer → append_event]
D --> E[Receipt + delete PendingSaga]
style E fill:#e8f5e9
Note: commit/commit_envelope/reverse/recover require Arc<Ledger>.
recover() re-completes any PendingSaga left by a crash, pushing the
envelope through the idempotent primitives (roll-forward). Call it on startup.
| Method | Description |
|---|---|
commit(transfer) |
Resolve intent → commit_envelope (requires Arc<Ledger>) |
commit_envelope(envelope) |
The one commit path: write-ahead → reserve → finalize (finalize re-validates, then writes); for pre-built/FX envelopes |
reverse(transfer_id) |
Builds a compensating envelope and runs commit_envelope |
recover() |
Force-completes pending sagas after a crash (call on startup) |
Transfers are built via TransferBuilder and committed with
ledger.commit(transfer):
| Builder method | Description |
|---|---|
.pay(from, to, asset, amount) |
Single movement between accounts |
.deposit(to, asset, amount, external) |
Two movements: offset on external + credit on target |
.withdraw(from, asset, amount, external) |
Single movement from account to external |
.movement(from, to, asset, amount) |
Raw movement for custom operations |
| Method | Description |
|---|---|
create_account(account) |
Create account and emit AccountCreated event |
freeze(id) |
Set FROZEN flag, increment version, emit AccountFrozen event |
unfreeze(id) |
Clear FROZEN flag, increment version, emit AccountUnfrozen event |
close(id) |
Set CLOSED flag (requires zero active postings), emit AccountClosed event |
| Method | Description |
|---|---|
balance(account, asset) |
Sum of non-Inactive postings (computed by Ledger) |
list_accounts() |
All current account snapshots |
get_account(id) |
Latest account snapshot |
query_transfers(query) |
Paginated, filtered transfer history (by date range, book) |
history(account) |
All transfers involving an account |
postings(account) |
All postings (any status) |
query_postings(query) |
Paginated, filtered postings (by asset, status) |
account_history(id) |
All version snapshots |
get_events_since(seq, limit) |
Query ledger event log after a sequence number |
The Store trait is a composite of focused sub-traits. Every write method is a
dumb instruction returning the number of affected rows (u64); the saga
interprets the count.
graph TB
Store --> AccountStore
Store --> PostingStore
Store --> TransferStore
Store --> SagaStore
Store --> EventStore
Store --> BookStore
AccountStore: get_account, get_accounts, create_account,
append_account_version, get_account_history, list_accountsPostingStore: get_postings,
get_postings_by_account(account, asset?, status?), query_postings(query),
and the dumb write primitives reserve_postings(ids, reservation) -> u64,
release_postings(ids, reservation) -> u64,
deactivate_postings(ids, reservation?) -> u64,
insert_postings(postings) -> u64TransferStore: get_transfer,
store_transfer(record, involved) -> u64, get_transfers_for_account,
query_transfersEventStore: append_event (idempotent on a per-transfer dedup key),
get_events_sinceSagaStore: save_saga, list_pending_sagas, delete_saga: the
write-ahead store the saga and recover() useBookStore: create_book, get_book, list_booksThere is no CommitStore/commit_transfer: a commit is the saga calling these
primitives in sequence, each idempotent, with crash-safety from write-ahead
recovery rather than a single transaction.
reserve_postings/release_postings/deactivate_postings apply each id's
conditional update and return how many rows changed (the saga decides what a
short count means):
stateDiagram-v2
[*] --> Active: insert_postings
Active --> PendingInactive: reserve_postings
PendingInactive --> Active: release_postings
PendingInactive --> Inactive: deactivate_postings(reservation)
Active --> Inactive: deactivate_postings(None)
Each cell is the count a primitive returns (1 = flipped, 0 = no-op / not applicable). The saga interprets a 0:
| Operation | Active | PendingInactive (this rid) | Inactive |
|---|---|---|---|
reserve_postings(rid) |
→ PendingInactive (1) | 0 | 0 |
release_postings(rid) |
0 | → Active (1) | 0 |
deactivate_postings(Some rid) |
0 | → Inactive (1) | 0 |
deactivate_postings(None) |
→ Inactive (1) | 0 | 0 |
There is no all-or-nothing batch rejection: a posting whose condition does not hold is skipped (counted as 0, not an error), so a call can apply to some ids and not others. Each id's update is atomic on its own row; the batch as a whole is not. The saga reads the count and decides what to do.
Balance computation lives in the Ledger (compute_balance), not the Store.
LedgerError
├── Validation(ValidationError) // from kuatia-core (includes Overflow)
├── Store(StoreError) // storage failures
├── Selection(SelectionError) // insufficient funds (includes Overflow)
├── TransferNotFound
├── PostingNotReversible
├── AccountNotFound
├── AccountNotEmpty // can't close with active postings
├── AccountAlreadyClosed
├── BookNotFound // transfer named a book that does not exist
├── Overflow // monetary arithmetic overflow
└── CompensationFailed // saga compensation failed (original + compensation errors)
StoreError
├── NotFound(String)
├── AlreadyExists(String)
├── VersionConflict { account, expected, actual } // append_account_version: stale version
└── Internal(String)
The store has no semantic write-outcome errors (no "posting not active", "reservation mismatch", "cas conflict"). Writes return affected-row counts and the saga derives meaning from them.
commit_envelope; resolution runs before the saga)| Step | Execute | Compensate | Retry |
|---|---|---|---|
ReservePostingsStep |
reserve_postings Active → PendingInactive, interpret count |
Release back to Active |
3 |
FinalizeTransferStep |
Ledger::finalize_envelope: re-validate (last-step floor/freeze guard) → mark Finalizing → deactivate → insert → store_transfer → append_event, verifying every end-state |
reverse(transfer_id) |
3 |
Validation lives inside the finalize step so it runs immediately before the
writes. Recovery (recover()) re-uses finalize_envelope for Finalizing
sagas and re-runs the whole saga for Reserving ones.
legend!)| Step | Execute | Compensate |
|---|---|---|
PayMovementStep |
Build pay transfer, ledger.commit(...) |
ledger.reverse(receipt.transfer_id) |
DepositMovementStep |
Build deposit transfer, ledger.commit(...) |
ledger.reverse(receipt.transfer_id) |
WithdrawMovementStep |
Build withdraw transfer, ledger.commit(...) |
ledger.reverse(receipt.transfer_id) |
Compose steps into sagas using legend!. The saga executor drives steps in
order with automatic retry and LIFO compensation. LedgerCtx is serializable
for crash recovery:
legend! {
MyFlow<LedgerCtx, SagaError> {
deposit: DepositMovementStep,
pay: PayMovementStep,
}
}
let ctx = LedgerCtx::new(ledger_arc.clone());
let result = MyFlow::new(inputs).build(ctx).start().await;
LedgerCtx is concrete (not generic) because Ledger uses Arc<dyn Store>
internally.