|
@@ -0,0 +1,211 @@
|
|
|
|
|
+# Extend account identity with a subaccount dimension
|
|
|
|
|
+
|
|
|
|
|
+* Status: accepted
|
|
|
|
|
+* Authors: Cesar Rodas
|
|
|
|
|
+* Date: 2026-07-05
|
|
|
|
|
+* Targeted modules: `kuatia-types`, `kuatia-core`, `kuatia-storage`,
|
|
|
|
|
+ `kuatia-storage-sql`, `kuatia` (`ledger`), `kuatia-dashboard`
|
|
|
|
|
+* Associated tickets/PRs: N/A
|
|
|
|
|
+
|
|
|
|
|
+## Context and Problem Statement
|
|
|
|
|
+
|
|
|
|
|
+An 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.
|
|
|
|
|
+
|
|
|
|
|
+## Decision Drivers
|
|
|
|
|
+
|
|
|
|
|
+* **Partitioning and attribution**: several balances under one owner, each
|
|
|
|
|
+ addressable and discoverable as a subaccount of the base account.
|
|
|
|
|
+* **Per-partition policy**: a subaccount must be able to carry its own policy,
|
|
|
|
|
+ flags, book, and version, independent of the base account.
|
|
|
|
|
+* **Segregated balances**: a base account's subaccounts must never be silently
|
|
|
|
|
+ summed into a single figure.
|
|
|
|
|
+* **Query by account or by subaccount**: reads must span all subaccounts or
|
|
|
|
|
+ restrict to one.
|
|
|
|
|
+* **Least churn and preserved invariants**: the change touches every layer that
|
|
|
|
|
+ keys on an account; conservation, double-spend, and floor checks must be
|
|
|
|
|
+ unchanged.
|
|
|
|
|
+
|
|
|
|
|
+## Considered Options
|
|
|
|
|
+
|
|
|
|
|
+#### Option 1: Fold the subaccount into `AccountId` (a composite `{id, sub}`, chosen)
|
|
|
|
|
+
|
|
|
|
|
+Make the account identity itself two legs: `AccountId { id: i64, sub: u64 }`, with
|
|
|
|
|
+`sub = 0` the main account. Aggregate reads take a base `id: i64` plus an optional
|
|
|
|
|
+subaccount filter.
|
|
|
|
|
+
|
|
|
|
|
+**Pros:**
|
|
|
|
|
+
|
|
|
|
|
+* Good, because there is one identity type: posting owners, movement endpoints,
|
|
|
|
|
+ account records, and balance keys are all `AccountId`, so per-subaccount
|
|
|
|
|
+ balances fall out of the existing keys with no new wrapper type.
|
|
|
|
|
+* Good, because "query by account or by subaccount" is explicit: base reads take
|
|
|
|
|
+ `(id: i64, sub: Option<u64>)` — `None` spans every subaccount, `Some(s)`
|
|
|
|
|
+ restricts to one — while entity ops take the full `&AccountId`.
|
|
|
|
|
+* Good, because each `(id, sub)` is a full account record with its own policy.
|
|
|
|
|
+
|
|
|
|
|
+**Cons:**
|
|
|
|
|
+
|
|
|
|
|
+* Bad, because callers that want a base handle read `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.
|
|
|
|
|
+* Bad, because it is a large, cross-crate change (the identity gains a field, `.0`
|
|
|
|
|
+ accesses become `.id`) plus a schema migration.
|
|
|
|
|
+
|
|
|
|
|
+#### Option 2: A separate `AccountRef { account, sub }` owner/identity type
|
|
|
|
|
+
|
|
|
|
|
+Keep `AccountId` as the i64 base and add a separate `AccountRef` wrapper as the
|
|
|
|
|
+owner/endpoint/entity identity.
|
|
|
|
|
+
|
|
|
|
|
+**Pros:**
|
|
|
|
|
+
|
|
|
|
|
+* Good, because the base `AccountId` stays a bare i64, so aggregate "all
|
|
|
|
|
+ subaccounts" reads keep a natural base handle.
|
|
|
|
|
+
|
|
|
|
|
+**Cons:**
|
|
|
|
|
+
|
|
|
|
|
+* Bad, because it adds a second account-identity type (`AccountId` vs
|
|
|
|
|
+ `AccountRef`) that every layer has to convert between.
|
|
|
|
|
+* Bad, because it is the same cross-crate churn as Option 1 without collapsing to
|
|
|
|
|
+ a single identity.
|
|
|
|
|
+
|
|
|
|
|
+#### Option 3: Subaccounts as balance buckets that inherit the parent policy
|
|
|
|
|
+
|
|
|
|
|
+Track a subaccount only on postings, with the account entity keyed by base id and
|
|
|
|
|
+its policy shared by all subaccounts.
|
|
|
|
|
+
|
|
|
|
|
+**Pros:**
|
|
|
|
|
+
|
|
|
|
|
+* Good, because the `accounts` table does not change.
|
|
|
|
|
+
|
|
|
|
|
+**Cons:**
|
|
|
|
|
+
|
|
|
|
|
+* Bad, because a subaccount cannot carry its own policy. A partition that must
|
|
|
|
|
+ stay `NoOverdraft` under a `SystemAccount`/overdraft base account could not,
|
|
|
|
|
+ so any structural guarantee that depends on the partition's own policy is lost.
|
|
|
|
|
+
|
|
|
|
|
+## Decision Outcome
|
|
|
|
|
+
|
|
|
|
|
+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.
|
|
|
|
|
+
|
|
|
|
|
+### The identity type
|
|
|
|
|
+
|
|
|
|
|
+`AccountId { id: i64, sub: u64 }` (in `kuatia-types`). `sub = 0` is the main
|
|
|
|
|
+account; a non-zero `sub` is a subaccount. `sub` is unsigned because it is an
|
|
|
|
|
+opaque, unordered id, so the whole range is usable. 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).
|
|
|
|
|
+
|
|
|
|
|
+### The entity model
|
|
|
|
|
+
|
|
|
|
|
+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.
|
|
|
|
|
+
|
|
|
|
|
+### Reads: by account or by subaccount
|
|
|
|
|
+
|
|
|
|
|
+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<u64>, asset, status)` and
|
|
|
|
|
+ `get_transfers_for_account(id: i64, sub: Option<u64>)` span every subaccount
|
|
|
|
|
+ when `sub` is `None` and one when `Some(s)`.
|
|
|
|
|
+* `PostingQuery`/`TransferQuery` carry a base `account: i64` and `sub:
|
|
|
|
|
+ Option<u64>`.
|
|
|
|
|
+
|
|
|
|
|
+### Balances are always segregated
|
|
|
|
|
+
|
|
|
|
|
+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<u64>) -> 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.
|
|
|
|
|
+
|
|
|
|
|
+### Validation and books
|
|
|
|
|
+
|
|
|
|
|
+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.
|
|
|
|
|
+
|
|
|
|
|
+### Storage schema and migration
|
|
|
|
|
+
|
|
|
|
|
+* `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.
|
|
|
|
|
+* The `u64` subaccount is stored in a signed `BIGINT` column via an `as i64`
|
|
|
|
|
+ bit-cast on write and `as u64` on read (an opaque id, so the reinterpretation is
|
|
|
|
|
+ lossless and never compared as a number in SQL).
|
|
|
|
|
+* A `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.
|
|
|
|
|
+
|
|
|
|
|
+### Positive Consequences
|
|
|
|
|
+
|
|
|
|
|
+* One account can carry several independent balances, each a full account record
|
|
|
|
|
+ with its own policy, discoverable via `list_subaccounts` and attributable by
|
|
|
|
|
+ shared base id.
|
|
|
|
|
+* Balances are always presented per subaccount, so a main account and its
|
|
|
|
|
+ subaccounts are never accidentally summed into one figure.
|
|
|
|
|
+* Conservation, double-spend, and floor guarantees are unchanged; they simply key
|
|
|
|
|
+ on the full `(id, sub)` owner.
|
|
|
|
|
+
|
|
|
|
|
+### Negative Consequences
|
|
|
|
|
+
|
|
|
|
|
+* Every content hash changes (the subaccount is folded into `AccountId`'s
|
|
|
|
|
+ canonical bytes) and the schema migrates. Existing data upgrades in place to
|
|
|
|
|
+ `subaccount = 0`.
|
|
|
|
|
+* Because accounts are append-only and never deleted, each subaccount that is
|
|
|
|
|
+ created and later closed leaves a permanent record (its versions plus its
|
|
|
|
|
+ inactive postings); the accounts and postings tables grow with the number of
|
|
|
|
|
+ subaccounts ever created, not the number currently open.
|
|
|
|
|
+* `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.
|
|
|
|
|
+* The base-id-vs-full-`AccountId` split (aggregate reads take `i64`, entity ops
|
|
|
|
|
+ take `&AccountId`) has to be kept clear at call sites.
|
|
|
|
|
+
|
|
|
|
|
+## Links
|
|
|
|
|
+
|
|
|
|
|
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (signed postings)
|
|
|
|
|
+ and [ADR-0003](0003-dumb-storage-saga-recovery.md) (dumb storage).
|
|
|
|
|
+* Usage: [doc/accounts.md](../accounts.md), [doc/glossary.md](../glossary.md).
|