Kuatia is an append-only, auditable, multi-asset UTXO-style ledger library in Rust. Value is tracked as signed postings — no mutable balance fields. Transfers atomically consume and create postings, enforcing per-asset conservation — the double-entry-style safety invariant (sum(consumed) == sum(created) per asset).
crates/
kuatia-money/ Cent monetary type + CentBacking trait; integer width (i64 default, i128 via feature) is hidden and swappable
kuatia-types/ Domain types: AccountId, Posting, Movement, AutoId, etc.; re-exports Cent/Amount from kuatia-money
kuatia-core/ Pure, sync, no-IO logic: validation, hashing, posting selection
kuatia-storage/ Store trait (7 sub-traits), InMemoryStore, conformance tests
kuatia-storage-sql/ SQL backend: SQLite/PostgreSQL via sqlx
kuatia/ Async layer: Ledger resource, saga pipeline, intent API
doc/
architecture.md Architecture decisions and rationale
crates.md Crate reference: modules, types, APIs
accounts.md Account model, policies, lifecycle
transfers.md Transfer/Movement API, resolve algorithm
glossary.md Terms, book design, exchange & supermarket examples
accounting-mapping.md Classical double-entry ↔ Kuatia term mapping
{ from, to, asset, amount } — the fundamental unit of intent. All operations (pay, deposit, withdraw) are one or more movements.sum(consumed) == sum(created).NoOverdraft forbids negative postings; the other four permit them. An overdraft is a negative posting that covers a shortfall — down to the floor for CappedOverdraft, unbounded for UncappedOverdraft.Store is a thin instruction follower. Write methods apply one update and return the number of affected rows (or an I/O error) — they never interpret counts, decide state, enforce idempotency, or compensate. The saga owns all of that. There is no monolithic commit_transfer; commit is a sequence of dumb primitives (reserve_postings, deactivate_postings, insert_postings, store_transfer, append_event), each idempotent. See doc/adr/0003-dumb-storage-saga-recovery.md.reserve → finalize (validation runs inside the finalize step, as the last thing before the writes), with automatic retry and LIFO compensation via the legend crate. commit(transfer) = resolve (read-only) then commit_envelope; reverse() builds a reversal envelope and runs the same path. There is one commit path, not a separate "atomic" one.finalize_envelope additionally verifies every end-state (all consumed postings Inactive, created exist, transfer stored).PendingSaga {envelope, reservation, phase} is persisted via SagaStore before the saga mutates anything (Reserving), bumped to Finalizing once validation passed and the consumed postings are about to turn Inactive. Ledger::recover() (call on startup) branches on phase: a Reserving saga is re-run and re-validated (aborting cleanly if a posting was taken or an account frozen); a Finalizing saga is rolled forward through the verified finalize_envelope. Roll-forward, not rollback, so there are no orphaned PendingInactive postings to reconcile.Arc<dyn Store>: Ledger is non-generic, enabling concrete saga types.Two-pass:
to and accumulate net debit on from.CappedOverdraft/UncappedOverdraft accounts consume all positives and create a negative posting for the shortfall (floor enforced in validation); other policies fail with InsufficientFunds.Deposit: two movements cancel to zero net debit on the system account — no posting selection needed.
NoOverdraft (allowed on overdraft/system/external)cargo test # runs all tests across all crates
cargo test -p kuatia-core # pure core tests only
cargo test -p kuatia # integration + saga tests
SUM, MAX, MIN, AVG, or any computation on monetary amounts or domain values in queries. COUNT(*) for pagination row totals is allowed (it counts rows, not domain values). Balances are always computed in Rust with checked arithmetic (checked_add, checked_sub, checked_neg) — no silent overflowunwrap()/expect() in production code — all errors bubble up via Result.pay(), .deposit(), .withdraw()) over raw .movement() constructionstore_tests! macro — new trait methods require new tests.deposit() returns Result<Self, OverflowError> — callers must handle the errorNo AUTOINCREMENT / SERIAL in the database — all IDs are generated in Rust. Use snowflake-style i64 IDs with the following bit layout:
[0][ 40 bits: ms timestamp ][ 23 bits: CRC32(data) ]
^sign (always 0 = positive)
KUATIA_EPOCH_MS (2026-01-01T00:00:00Z), not the Unix epoch — 40 bits ≈ 34.8 years going forward (until ~2060)AutoId in kuatia-types/src/autoid.rs, includes inline CRC32 (IEEE)BIGINT — the DB never assigns IDs