0005-subaccount-dimension.md 7.4 KB

A subaccount dimension on account identity

  • Status: accepted
  • Authors: Cesar Rodas
  • Date: 2026-07-05
  • Targeted modules: kuatia-types, kuatia-core, kuatia-storage, kuatia-storage-sql, kuatia (ledger, inflight), kuatia-dashboard
  • Associated tickets/PRs: N/A

Context and Problem Statement

ADR-0004 modeled an inflight hold as a fresh standalone holding account per destination and allowed only one open inflight per destination at a time (open_inflight_hold_for rejected a second). Two limits followed: a destination could not host several concurrent inflights, and a hold account was a random id unrelated to the destination, so "which holds belong to account X" was a metadata scan rather than a property of identity.

We want many concurrent inflights per account, with holds that are attributable to their destination. The natural model is a subaccount: an account is identified by a base id plus an i64-range subaccount, default 0 (the main account), and a hold is a subaccount of its destination.

Decision Drivers

  • Concurrency: a destination must host several open inflights at once.
  • Attribution: a hold should be discoverable as a subaccount of its destination, not a floating account.
  • Preserve invariants: over-confirmation stays structurally impossible, so a hold must keep its own NoOverdraft policy regardless of the destination's.
  • Least API churn: the change touches every layer that keys on an account, so the representation should minimize signature churn.
  • Segregated balances: balances must never be silently summed across subaccounts.

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, so inflight holds stay NoOverdraft no matter the destination.

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 (a rename to AccountRef in every owner position) 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 hold under a SystemAccount/overdraft destination would inherit that policy and could be over-confirmed. The structural over-confirm guard is lost for those destinations.

Decision Outcome

Chosen option: Option 1, fold the subaccount into AccountId, because a single two-leg identity keeps balances naturally segregated, keeps inflight holds NoOverdraft by giving every subaccount its own record, and avoids carrying two account-identity types. Concretely:

  • AccountId { id: i64, sub: u64 } is the owner of a posting, the endpoint of a movement, the id of an Account, and the subject of a snapshot. sub = 0 is the main account. sub is unsigned because it is an opaque, unordered id (an inflight hold's subaccount is the low 64 bits of a hash of the submitted trade), so the full range is usable. AccountId::new(id) builds a main account; with_sub(id, sub) a subaccount; base() drops back to the main account.
  • Each (id, sub) is its own account record with its own policy, flags, book, and version. The accounts primary key becomes (id, subaccount, version) and transfer_accounts carries the subaccount.
  • Reads are by account or by subaccount. The base reads get_postings_by_account(id: i64, sub: Option<u64>, ..) and get_transfers_for_account(id: i64, sub: Option<u64>) span all subaccounts when sub is None and one when Some(s). Ledger::balances(base, asset, sub) returns one SubAccountBalance per non-closed subaccount and never a summed total; balance(&AccountId, asset) reads exactly one subaccount. Closed subaccounts are excluded from the aggregate reads.
  • Inflight holds become subaccounts. For an inflight, one subaccount sub is derived from a hash of the submitted trade (deterministic and known before the holds are created, unlike the authorize transfer's own id). Each destination's hold is (destination, sub). Re-authorizing the identical trade collides on the existing hold (rejected); a different trade derives a different sub, so a destination hosts many concurrent inflights. open_inflight_hold_for and the "one open inflight per account" rule are removed.
  • Storage evolves by migration. A 002_subaccounts migration adds the subaccount column (existing rows default to 0, the main account) and rebuilds accounts / transfer_accounts for the widened keys. 001_init.sql is left intact.

Positive Consequences

  • A destination can hold arbitrarily many concurrent inflights, each isolated in its own subaccount and attributable via list_subaccounts.
  • Balances are always presented per subaccount; nothing sums a main account and its holds into one figure by accident.
  • The over-confirm and double-spend guarantees are unchanged: holds are NoOverdraft records, and the reservation protocol still serializes captures.

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.
  • The accounts table grows one row per hold, as before, now keyed under the destination rather than a random id.
  • Two co-funders of the same (hold, asset) still cannot split a partially-confirmed remainder exactly on void; unchanged from ADR-0004.

Links