Просмотр исходного кода

Fix ADR numbering: add subaccounts as 0012, renumber inflight to 0013

The inflight and subaccount ADRs added earlier in this branch collided with
the repository's existing ADR numbers (0004 account policies, 0005 intent
API). Give each a free number and split the subaccount decision into its own
record.

Add the subaccount dimension as ADR 0012 (account identity gains a
subaccount, framed purely as an accounts-model change). Renumber the inflight
ADR to 0013 and point its reference at 0012. Delete the redundant
0005-subaccount-dimension.md, whose decision now lives in 0012. Update the
cross-references in doc/accounts.md, doc/glossary.md, doc/inflight.md, and the
inflight module doc.

Claude-Session: https://claude.ai/code/session_01SJFJen8Ethv9Q6Ysb1xmz4
Cesar Rodas 11 часов назад
Родитель
Сommit
0d6c7a8335

+ 1 - 1
crates/kuatia/src/inflight.rs

@@ -10,7 +10,7 @@
 //! transfer's metadata carries the leg table, and every artifact is tagged with
 //! a CBOR-encoded `InflightMeta` entry so the lifecycle is read, not inferred.
 //!
-//! See `doc/adr/0004-inflight-holds-via-holding-accounts.md`.
+//! See `doc/adr/0013-inflight-holds-via-holding-accounts.md`.
 
 use std::collections::{BTreeMap, BTreeSet};
 use std::sync::Arc;

+ 1 - 1
doc/accounts.md

@@ -106,7 +106,7 @@ sub)` is its own record with its own policy, flags, book, and version, so a
 subaccount is a full account that happens to share a base id. Subaccounts are how
 one account holds many concurrent inflights: an inflight hold is a subaccount of
 its destination, keyed by a value derived from the trade (see
-[adr/0005-subaccount-dimension.md](adr/0005-subaccount-dimension.md)).
+[adr/0012-subaccounts.md](adr/0012-subaccounts.md)).
 
 Balances are reported **segregated per subaccount**, never summed across them:
 

+ 0 - 155
doc/adr/0005-subaccount-dimension.md

@@ -1,155 +0,0 @@
-# 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
-
-* Builds on and revises [ADR-0004](0004-inflight-holds-via-holding-accounts.md)
-  (inflight holds), which now uses subaccounts instead of standalone hold
-  accounts and drops the one-open-per-account rule.
-* 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/inflight.md](../inflight.md).

+ 211 - 0
doc/adr/0012-subaccounts.md

@@ -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).

+ 1 - 1
doc/adr/0004-inflight-holds-via-holding-accounts.md → doc/adr/0013-inflight-holds-via-holding-accounts.md

@@ -1,6 +1,6 @@
 # Inflight holds via per-destination holding accounts
 
-> Revised by [ADR-0005](0005-subaccount-dimension.md): a hold is now a
+> Revised by [ADR-0012](0012-subaccounts.md): a hold is now a
 > **subaccount** of its destination rather than a standalone account, and the
 > "one open inflight per account" rule is dropped (a destination hosts many
 > concurrent inflights, one per distinct trade). The rest of this ADR still

+ 1 - 1
doc/glossary.md

@@ -39,7 +39,7 @@ sub)` is a full, independent account record. Subaccounts let one account hold
 many concurrent inflights (a hold is a subaccount of its destination). Balances
 are always reported segregated per subaccount, never summed. See
 [accounts.md](accounts.md) and
-[adr/0005-subaccount-dimension.md](adr/0005-subaccount-dimension.md).
+[adr/0012-subaccounts.md](adr/0012-subaccounts.md).
 
 ### Asset
 

+ 2 - 2
doc/inflight.md

@@ -5,7 +5,7 @@ then confirm it (in full or in parts) or void it. This is the
 authorization/capture pattern, applied to a multi-leg trade.
 
 The design and its rationale are in
-[adr/0004-inflight-holds-via-holding-accounts.md](adr/0004-inflight-holds-via-holding-accounts.md).
+[adr/0013-inflight-holds-via-holding-accounts.md](adr/0013-inflight-holds-via-holding-accounts.md).
 This page is the usage guide.
 
 ## Model
@@ -103,7 +103,7 @@ let open = ledger.list_open_inflights().await?;
 
 A hold is a **subaccount** of its destination: `(destination, sub)`, where `sub`
 is derived from a hash of the submitted trade (see
-[adr/0005-subaccount-dimension.md](adr/0005-subaccount-dimension.md)). All holds
+[adr/0012-subaccounts.md](adr/0012-subaccounts.md)). All holds
 of one inflight share that `sub`. Because a different trade derives a different
 `sub`, a destination can host **many concurrent inflights** at once, each isolated
 in its own subaccount and listed by `ledger.list_subaccounts(&destination)`.