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(u128) |
Stable account identity |
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(i64) |
Smallest monetary unit (private field). 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, postings_to_create, and cas_guards (Compare-And-Swap guards for concurrency safety).
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, CommitStore) |
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 |
Driven by a TransferSaga defined via legend! — four steps with automatic retry and LIFO compensation:
graph LR
A[resolve] -->|Envelope| B[reserve_postings]
B -->|batch Active→PendingInactive| C[validate_and_plan]
C -->|Plan| D[commit_transfer atomically]
D --> E[Receipt]
style E fill:#e8f5e9
Note: commit requires Arc<Ledger> (takes self: &Arc<Self>).
graph LR
A["load()"] -->|LoadedState| B["plan()"]
B -->|Plan| C["apply()"]
C --> D[Receipt]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#e8f5e9
commit_atomic(transfer) runs all three in one shot. Used by reverse() and available for direct callers.
| Method | Description |
|---|---|
commit(transfer) |
Saga pipeline: resolve → reserve → validate → finalize with retry and compensation (requires Arc<Ledger>) |
commit_atomic(transfer) |
Raw atomic pipeline: load → plan → apply (used by reverse()) |
reverse(transfer_id) |
Creates compensating transfer that undoes the original |
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 seven focused sub-traits:
graph TB
Store --> AccountStore
Store --> PostingStore
Store --> TransferStore
Store --> SagaStore
Store --> EventStore
Store --> BookStore
Store --> CommitStore
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), reserve_postings(ids, reservation), release_postings(ids, reservation), finalize_postingsTransferStore: get_transfer, store_transfer, get_transfers_for_account, query_transfersEventStore: append_event, get_events_sinceSagaStore: save_saga, list_pending_sagas, delete_sagaBookStore: create_book, get_book, list_booksCommitStore: commit_transfer(req) — the single atomic commit boundary. It applies posting deactivations/creations, the transfer record, the both-sided account index, and events in one critical section, enforcing CappedOverdraft CAS guards and reservation ownership. reserve_postings/release_postings/finalize_postings remain as lower-level primitives; commit_transfer is the production commit path.reserve_postings and release_postings operate on batches with atomic semantics:
stateDiagram-v2
[*] --> Active: created by finalize
Active --> PendingInactive: reserve_postings
PendingInactive --> Active: release_postings
PendingInactive --> Inactive: finalize_postings
Active --> Active: release_postings (no-op)
note right of Inactive: void — release_postings fails
| Operation | Active | PendingInactive | Inactive |
|---|---|---|---|
reserve_postings |
→ PendingInactive | fail | fail |
release_postings |
no-op | → Active | fail (void) |
finalize_postings |
→ Inactive | → Inactive | — |
If any posting in the batch fails validation, the entire batch is rejected and no state changes.
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 }
├── Internal(String)
├── PostingNotActive(PostingId) // reserve_postings: posting not Active
├── PostingInactive(PostingId) // release_postings: posting is void
├── Conflict { account, asset } // commit_transfer: CAS guard balance changed (retryable)
└── ReservationMismatch(PostingId) // posting reserved by a different saga
commit)| Step | Execute | Compensate | Retry |
|---|---|---|---|
ResolveStep |
Convert Transfer intent into Envelope | No-op | None |
ReservePostingsStep |
Batch reserve Active → PendingInactive |
Batch release back to Active |
3 |
ValidateTransferStep |
Load state, validate_and_plan() |
No-op | None |
FinalizeTransferStep |
Finalize postings, store transfer, emit event | reverse(transfer_id) |
3 |
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.