kuatia-types, kuatia-core, kuatia-storage,
kuatia-storage-sql, kuatia (ledger), kuatia-dashboardAn account was identified by a single i64 (AccountId). Some workloads need to
partition one account's holdings into several distinct balances under the same
owner: sub-ledgers, per-purpose buckets, earmarks, or reservations that are
individually addressable and drained or closed independently, without minting
unrelated top-level accounts. Classical accounting calls the general shape a
control account with a subsidiary ledger; payment and banking systems call it a
sub-ledger or a set of virtual accounts under a master account.
We want that structure as a first-class part of account identity: an account is a
base id plus a subaccount, and each partition is a full account record with
its own policy, so no special-case code is needed to give a partition its own
overdraft rule or lifecycle. The default subaccount (0) is the account's main
account, so existing behaviour is unchanged when subaccounts are not used.
AccountId (a composite {id, sub}, chosen)Make the account identity itself two legs: AccountId { id: i64, sub: i64 }, with
sub = 0 the main account. Aggregate reads take a base id: i64 plus an optional
subaccount filter.
Pros:
AccountId, so per-subaccount
balances fall out of the existing keys with no new wrapper type.(id: i64, sub: Option<i64>) — None spans every subaccount, Some(s)
restricts to one — while entity ops take the full &AccountId.(id, sub) is a full account record with its own policy.Cons:
account.id rather than
passing a distinct base type; the split between base reads (i64) and exact
entity ops (&AccountId) has to be kept clear..0
accesses become .id) plus a schema migration.AccountRef { account, sub } owner/identity typeKeep AccountId as the i64 base and add a separate AccountRef wrapper as the
owner/endpoint/entity identity.
Pros:
AccountId stays a bare i64, so aggregate "all
subaccounts" reads keep a natural base handle.Cons:
AccountId vs
AccountRef) that every layer has to convert between.Track a subaccount only on postings, with the account entity keyed by base id and its policy shared by all subaccounts.
Pros:
accounts table does not change.Cons:
NoOverdraft under a SystemAccount/overdraft base account could not,
so any structural guarantee that depends on the partition's own policy is lost.Chosen option: Option 1, fold the subaccount into AccountId, because a
single two-leg identity keeps balances naturally segregated, lets every
subaccount carry its own policy, and avoids carrying two account-identity types.
AccountId { id: i64, sub: i64 } (in kuatia-types). sub = 0 is the main
account; a non-zero sub is a subaccount. sub is an i64, the same type as
the base id, so it stores directly in a BIGINT column with no cast.
Constructors and helpers:
AccountId::new(id) — the main account { id, sub: 0 }.AccountId::with_sub(id, sub) — a specific subaccount.base() — the main account of an id (sub set to 0).is_main() — whether sub == 0.AccountId derives Copy/Eq/Hash/Ord and its canonical ToBytes is the
base id followed by the subaccount (both big-endian), so the subaccount is folded
into every content hash (envelope ids, posting ids, account snapshots).
AccountId has an IBAN-style string form, so an identifier carries a checksum
and a mistyped one is rejected before it reaches the store. The machine format
is two ISO 7064 mod-97 check digits followed by a 26-character base-36 body,
with no country code. to_grouped() adds a space every four characters for
display.
The body does not encode the raw legs directly. The (id, sub) pair is first
run through a keyed 128-bit Feistel permutation, then each 64-bit half is
base-36 encoded (13 characters each). Without this, small sequential ids would
render as near-zero codes that leak their value and order, and a base account
and its subaccount would share a visible prefix. After the permutation the codes
look random and unrelated. Under the default seed, AccountId { id: 5, sub: 7 }
renders 221RDWNSN4VCQNK2NN42KJFSAOLI (grouped 221R DWNS N4VC QNK2 NN42 KJFS
AOLI).
FromStr ignores spaces and dashes, upper-cases the input, checks the
structure, and validates the mod-97 checksum (returning ParseAccountIdError
on failure), then inverts the permutation to recover the two legs. Each half is
read as a u64 bit pattern and reinterpreted as i64, so any value
round-trips.
The permutation key is a process-global seed with a built-in default, settable
once at startup with set_id_seed (the dashboard exposes it as --id-seed /
KUATIA_ID_SEED). Changing the seed changes every code, so it must be stable
across a deployment. This is obfuscation, not security: anyone with the seed can
decode a code, so it is not a substitute for authorization.
This is a presentation and edge form only. Storage and low-level usages keep the
two i64 legs: the SQL schema, the Store trait signatures and query types,
in-memory keys, ToBytes, and serde ({id, sub}) are unchanged, so there is no
migration and no content-hash impact. The dashboard exposes the string as a
code field and routes account pages by the machine form (/accounts/<code>),
parsing and checksum-validating it at the route boundary. Debug keeps the short
id / id.sub form for logs.
Each (id, sub) is its own full account record with its own policy,
flags, book, version, user_data, and metadata. The main account is
(id, 0). A subaccount is created, versioned, frozen, and closed exactly like any
other account (closing still requires zero live postings), and its policy is
enforced independently — a subaccount can be NoOverdraft while its base account
is not, or vice versa.
AccountId is the owner of a posting (Posting.owner, NewPosting.owner,
NewPosting.payer), the endpoint of a movement (Movement.from/to), the id of
an Account, and the subject of an AccountSnapshotId. TransferBuilder::pay and
movement move between main accounts; pay_ref and movement_ref move between
specific subaccounts.
Entity operations take the full &AccountId (exact): get_account,
get_accounts, append_account_version, get_account_history. Aggregate reads
take a base id: i64 plus an optional subaccount:
get_postings_by_account(id: i64, sub: Option<i64>, asset, status) and
get_transfers_for_account(id: i64, sub: Option<i64>) span every subaccount
when sub is None and one when Some(s).PostingQuery/TransferQuery carry a base account: i64 and sub:
Option<i64>.Balances are reported per subaccount and never summed across them:
Ledger::balance(&AccountId, &AssetId) -> Cent reads exactly one subaccount.Ledger::balances(&AccountId, &AssetId, sub: Option<i64>) -> Vec<SubAccountBalance>
returns one entry per non-closed subaccount (sub = None spans all, Some(s)
filters to one). There is deliberately no API that sums across subaccounts.Ledger::list_subaccounts(&AccountId) -> Vec<AccountId> lists the non-closed
subaccounts of a base account.Closed subaccounts are excluded from the aggregate reads. This inverts the classical control-account expectation (where a parent's balance is the sum of its subsidiaries) on purpose: a base account does not roll up its subaccounts.
Per-asset conservation and the balance-floor / negative-posting checks operate on
the full AccountId owner, so they are per subaccount and use each subaccount's
own policy. Book membership is scoped by base account: a book that lists a
base account (or matches its flags) admits all of that account's subaccounts.
accounts primary key becomes (id, subaccount, version). postings gain a
subaccount column and idx_postings_owner widens to (owner, subaccount,
asset, status). transfer_accounts gains subaccount in its key and index.i64, so it stores directly in a BIGINT column with no
cast (an opaque id, compared only for equality in SQL, never as a magnitude).002_subaccounts migration adds the column (existing rows default to
subaccount = 0, the main account) and rebuilds accounts /
transfer_accounts for the widened primary keys, since SQLite cannot alter a
primary key. 001_init.sql is left intact. The in-memory store keys accounts by
the composite AccountId directly.list_subaccounts and attributable by
shared base id.(id, sub) owner.AccountId's
canonical bytes) and the schema migrates. Existing data upgrades in place to
subaccount = 0.list_subaccounts and any "open subaccounts" scan currently read all account
rows and filter in memory, so they pay for closed subaccounts; a store with many
historical subaccounts would want an index on the not-closed set.AccountId split (aggregate reads take i64, entity ops
take &AccountId) has to be kept clear at call sites.