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

Add inflight holds with a subaccount account dimension

Callers need to reserve funds for a multi-leg trade without settling it,
then confirm it fully or in parts or void it, and to run several such holds
against one account at the same time. The ledger is append-only with derived
balances, so a hold has to be real committed state, and holds must be
attributable to the account they belong to.

Model an inflight transaction as the ordinary trade with every destination
rewritten to a per-destination holding subaccount (NoOverdraft), committing
that rewritten transfer to park the funds. Confirm and void are ordinary
commits from the holds to their destinations or back to the funders recorded
in the authorize transfer's metadata. Over-confirmation is blocked by the
NoOverdraft hold, and concurrent confirmations serialize on the shared
holding posting. The inflight facts live in a single CBOR-encoded metadata
entry, and confirm accepts a batch of legs built with the existing
TransferBuilder.pay interface.

Give account identity two legs: AccountId { id: i64, sub: u64 } (sub 0 =
main). Each (id, sub) is a full account record with its own policy, and is
the owner of a posting, the endpoint of a movement, and the id of an
account. A hold is a subaccount of its destination, keyed by a value derived
from a hash of the submitted trade, so different trades derive different
subaccounts and a destination hosts many concurrent inflights while the
identical trade collides on its existing hold. Storage is queried by account
or by subaccount: base reads take (id: i64, sub: Option<u64>), and balances
are always segregated per subaccount, never summed.

Everything rides the existing commit and recover path, so idempotency,
conservation, and crash recovery are inherited. The schema evolves by a
002_subaccounts migration that defaults existing rows to subaccount 0 and
widens the accounts and transfer_accounts primary keys. Recorded in ADRs
0004 (inflight holds) and 0005 (subaccount dimension).

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

+ 45 - 0
Cargo.lock

@@ -268,6 +268,33 @@ dependencies = [
 ]
 
 [[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
+[[package]]
 name = "clap"
 version = "4.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -393,6 +420,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
 
 [[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
 name = "crypto-common"
 version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -655,6 +688,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
 name = "hashbrown"
 version = "0.15.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -989,6 +1033,7 @@ name = "kuatia"
 version = "0.2.0"
 dependencies = [
  "async-trait",
+ "ciborium",
  "kuatia-core",
  "kuatia-storage",
  "kuatia-storage-sql",

+ 1 - 0
Cargo.toml

@@ -23,6 +23,7 @@ kuatia-storage-sql = { path = "crates/kuatia-storage-sql", version = "0.2.0" }
 # External crates
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+ciborium = "0.2"
 sha2 = { version = "0.10", default-features = false }
 bitflags = { version = "2", features = ["serde"] }
 async-trait = "0.1"

+ 5 - 4
crates/kuatia-core/src/validate.rs

@@ -22,9 +22,9 @@ pub struct PlanInput<'a> {
     pub envelope: &'a Envelope,
     /// Postings referenced by `transfer.consumes`.
     pub consumed_postings: &'a [Posting],
-    /// All accounts referenced by the transfer.
+    /// All accounts (subaccounts) referenced by the transfer.
     pub accounts: &'a HashMap<AccountId, Account>,
-    /// Current balances keyed by (account, asset).
+    /// Current balances keyed by (account reference, asset).
     pub balances: &'a HashMap<(AccountId, AssetId), Cent>,
     /// The book gating this transfer, if one is loaded. `Some` enforces the
     /// book's [`BookPolicy`] (allowed assets/accounts/flags); `None` means the
@@ -321,13 +321,14 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
         if !no_account_restriction {
             for aid in &all_account_ids {
                 let account = &input.accounts[aid];
-                let listed = policy.allowed_accounts.contains(aid);
+                // Book membership is scoped by base account, not subaccount.
+                let listed = policy.allowed_accounts.contains(&aid.base());
                 let flag_match = !policy.allowed_flags.is_empty()
                     && account.flags.intersects(policy.allowed_flags);
                 if !(listed || flag_match) {
                     return Err(ValidationError::BookAccountNotAllowed {
                         book: book.id,
-                        account: *aid,
+                        account: aid.base(),
                     });
                 }
             }

+ 6 - 4
crates/kuatia-dashboard/src/data.rs

@@ -260,7 +260,7 @@ pub async fn overview(state: &AppState) -> Result<OverviewDto, ApiError> {
 /// Every account (sorted by id) with its balances.
 pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
     let mut accounts = state.ledger.list_accounts().await?;
-    accounts.sort_by_key(|a| a.id.0);
+    accounts.sort_by_key(|a| (a.id.id, a.id.sub));
     let mut out = Vec::with_capacity(accounts.len());
     for account in &accounts {
         out.push(account_dto(state, account).await?);
@@ -271,11 +271,13 @@ pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
 /// One account with its postings (largest first) and the transfers it took part
 /// in.
 pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDetailDto, ApiError> {
-    let account = state.ledger.get_account(&id).await?;
+    // The route addresses the main subaccount of the base account.
+    let aref = id;
+    let account = state.ledger.get_account(&aref).await?;
 
     let mut postings: Vec<PostingDto> = state
         .ledger
-        .postings(&id)
+        .postings(&aref)
         .await?
         .iter()
         .map(|p| PostingDto {
@@ -290,7 +292,7 @@ pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDe
 
     let transfers = state
         .ledger
-        .history(&id)
+        .history(&aref)
         .await?
         .iter()
         .map(transfer_dto)

+ 3 - 2
crates/kuatia-dashboard/src/seed.rs

@@ -19,9 +19,10 @@ pub const CAROL: AccountId = AccountId::new(102);
 pub const MERCHANT: AccountId = AccountId::new(103);
 
 /// Human-readable labels for the seeded accounts, surfaced by the API so the
-/// frontend can show names instead of raw ids.
+/// frontend can show names instead of raw ids. Labels are per base account;
+/// a subaccount (an inflight hold) shares its base account's label.
 pub fn account_label(id: AccountId) -> Option<&'static str> {
-    Some(match id {
+    Some(match id.base() {
         TREASURY => "Treasury",
         EXTERNAL => "External",
         ALICE => "Alice",

+ 5 - 5
crates/kuatia-dashboard/src/ui.rs

@@ -265,11 +265,11 @@ fn floor_style(assets: &[AssetMeta]) -> (u8, &str) {
 fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
     let (floor_dec, floor_sym) = floor_style(assets);
     AccountView {
-        id: dto.id.0,
+        id: dto.id.id,
         name: dto
             .label
             .map(String::from)
-            .unwrap_or_else(|| format!("#{}", dto.id.0)),
+            .unwrap_or_else(|| format!("#{}", dto.id.id)),
         version: dto.version,
         policy_kind: dto.policy.kind,
         floor: dto.policy.floor.map(|f| fmt(f, floor_dec, floor_sym)),
@@ -301,11 +301,11 @@ fn transfer_view(dto: &TransferDto, assets: &[AssetMeta]) -> TransferView {
                 to_name: leg
                     .label
                     .map(String::from)
-                    .unwrap_or_else(|| format!("#{}", leg.owner.0)),
+                    .unwrap_or_else(|| format!("#{}", leg.owner.id)),
                 from_name: leg.payer.map(|p| {
                     leg.payer_label
                         .map(String::from)
-                        .unwrap_or_else(|| format!("#{}", p.0))
+                        .unwrap_or_else(|| format!("#{}", p.id))
                 }),
                 is_change: leg.payer.is_none(),
                 money: fmt_asset(leg.value, asset_of(assets, leg.asset)),
@@ -326,7 +326,7 @@ fn event_view(dto: &EventDto) -> EventView {
     EventView {
         seq: dto.seq,
         kind: dto.kind,
-        account: dto.account.map(|a| a.0),
+        account: dto.account.map(|a| a.id),
         transfer_short: dto.transfer.as_deref().map(short_hex),
         time: fmt_millis(dto.timestamp),
     }

+ 123 - 76
crates/kuatia-storage-sql/src/lib.rs

@@ -47,7 +47,13 @@ impl SqlStore {
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
-        let migrations: &[(&str, &str)] = &[("001_init", include_str!("migrations/001_init.sql"))];
+        let migrations: &[(&str, &str)] = &[
+            ("001_init", include_str!("migrations/001_init.sql")),
+            (
+                "002_subaccounts",
+                include_str!("migrations/002_subaccounts.sql"),
+            ),
+        ];
 
         for (name, sql) in migrations {
             let applied = sqlx::query("SELECT 1 FROM _migrations WHERE name = $1")
@@ -160,6 +166,9 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
     let id: i64 = row
         .try_get("id")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let subaccount: i64 = row
+        .try_get("subaccount")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
     let version: i64 = row
         .try_get("version")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -180,7 +189,7 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
         .map_err(|e| StoreError::Internal(e.to_string()))?;
 
     Ok(Account {
-        id: AccountId::new(id),
+        id: AccountId::with_sub(id, subaccount as u64),
         version: version as u64,
         policy: deserialize_policy(&policy_str)?,
         flags: AccountFlags::from_bits_truncate(flags_bits as u32),
@@ -200,6 +209,9 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
     let owner: i64 = row
         .try_get("owner")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
+    let subaccount: i64 = row
+        .try_get("subaccount")
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
     let asset: i32 = row
         .try_get("asset")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -219,7 +231,7 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
             transfer: envelope_id_from_hex(&transfer_id)?,
             index: idx as u16,
         },
-        owner: AccountId::new(owner),
+        owner: AccountId::with_sub(owner, subaccount as u64),
         asset: AssetId::new(asset as u32),
         value,
         status: status_from_i16(status)?,
@@ -234,12 +246,15 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
 #[async_trait]
 impl AccountStore for SqlStore {
     async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
-        let row = sqlx::query("SELECT * FROM accounts WHERE id = $1 ORDER BY version DESC LIMIT 1")
-            .bind(id.0)
-            .fetch_optional(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?
-            .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
+        let row = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1",
+        )
+        .bind(id.id)
+        .bind(id.sub as i64)
+        .fetch_optional(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?
+        .ok_or_else(|| StoreError::NotFound(format!("account {id:?}")))?;
         row_to_account(&row)
     }
 
@@ -252,11 +267,13 @@ impl AccountStore for SqlStore {
     }
 
     async fn create_account(&self, account: Account) -> Result<(), StoreError> {
-        let exists = sqlx::query("SELECT 1 FROM accounts WHERE id = $1 LIMIT 1")
-            .bind(account.id.0)
-            .fetch_optional(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let exists =
+            sqlx::query("SELECT 1 FROM accounts WHERE id = $1 AND subaccount = $2 LIMIT 1")
+                .bind(account.id.id)
+                .bind(account.id.sub as i64)
+                .fetch_optional(&self.pool)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
         if exists.is_some() {
             return Err(StoreError::AlreadyExists(format!(
                 "account {:?}",
@@ -265,9 +282,10 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
         )
-            .bind(account.id.0)
+            .bind(account.id.id)
+            .bind(account.id.sub as i64)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
@@ -281,13 +299,15 @@ impl AccountStore for SqlStore {
     }
 
     async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
-        let current =
-            sqlx::query("SELECT version FROM accounts WHERE id = $1 ORDER BY version DESC LIMIT 1")
-                .bind(account.id.0)
-                .fetch_optional(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?
-                .ok_or_else(|| StoreError::NotFound(format!("account {:?}", account.id)))?;
+        let current = sqlx::query(
+            "SELECT version FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1",
+        )
+        .bind(account.id.id)
+        .bind(account.id.sub as i64)
+        .fetch_optional(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?
+        .ok_or_else(|| StoreError::NotFound(format!("account {:?}", account.id)))?;
 
         let current_version: i64 = current
             .try_get("version")
@@ -305,9 +325,10 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
         )
-            .bind(account.id.0)
+            .bind(account.id.id)
+            .bind(account.id.sub as i64)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
@@ -321,11 +342,14 @@ impl AccountStore for SqlStore {
     }
 
     async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError> {
-        let rows = sqlx::query("SELECT * FROM accounts WHERE id = $1 ORDER BY version ASC")
-            .bind(id.0)
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        let rows = sqlx::query(
+            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version ASC",
+        )
+        .bind(id.id)
+        .bind(id.sub as i64)
+        .fetch_all(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
         if rows.is_empty() {
             return Err(StoreError::NotFound(format!("account {id:?}")));
         }
@@ -333,12 +357,14 @@ impl AccountStore for SqlStore {
     }
 
     async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
-        let rows = sqlx::query("SELECT * FROM accounts ORDER BY id, version DESC")
+        let rows = sqlx::query("SELECT * FROM accounts ORDER BY id, subaccount, version DESC")
             .fetch_all(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
         let mut accounts: Vec<Account> =
             rows.iter().map(row_to_account).collect::<Result<_, _>>()?;
+        // Ordered by (id, subaccount, version DESC), so the first row of each
+        // (id, subaccount) group is the latest version; dedup keeps it.
         accounts.dedup_by_key(|a| a.id);
         Ok(accounts)
     }
@@ -367,43 +393,40 @@ impl PostingStore for SqlStore {
 
     async fn get_postings_by_account(
         &self,
-        account: &AccountId,
+        account: i64,
+        sub: Option<u64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError> {
-        let rows = match (asset, status) {
-            (Some(a), Some(s)) => {
-                sqlx::query(
-                    "SELECT * FROM postings WHERE owner = $1 AND asset = $2 AND status = $3",
-                )
-                .bind(account.0)
-                .bind(a.0 as i32)
-                .bind(status_to_i16(s))
-                .fetch_all(&self.pool)
-                .await
-            }
-            (Some(a), None) => {
-                sqlx::query("SELECT * FROM postings WHERE owner = $1 AND asset = $2")
-                    .bind(account.0)
-                    .bind(a.0 as i32)
-                    .fetch_all(&self.pool)
-                    .await
-            }
-            (None, Some(s)) => {
-                sqlx::query("SELECT * FROM postings WHERE owner = $1 AND status = $2")
-                    .bind(account.0)
-                    .bind(status_to_i16(s))
-                    .fetch_all(&self.pool)
-                    .await
-            }
-            (None, None) => {
-                sqlx::query("SELECT * FROM postings WHERE owner = $1")
-                    .bind(account.0)
-                    .fetch_all(&self.pool)
-                    .await
-            }
+        // Build the predicate dynamically: owner is always bound; subaccount,
+        // asset, and status are each optional filters.
+        let mut sql = String::from("SELECT * FROM postings WHERE owner = $1");
+        let mut idx = 2u32;
+        if sub.is_some() {
+            sql.push_str(&format!(" AND subaccount = ${idx}"));
+            idx += 1;
         }
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if asset.is_some() {
+            sql.push_str(&format!(" AND asset = ${idx}"));
+            idx += 1;
+        }
+        if status.is_some() {
+            sql.push_str(&format!(" AND status = ${idx}"));
+        }
+        let mut q = sqlx::query(&sql).bind(account);
+        if let Some(s) = sub {
+            q = q.bind(s as i64);
+        }
+        if let Some(a) = asset {
+            q = q.bind(a.0 as i32);
+        }
+        if let Some(s) = status {
+            q = q.bind(status_to_i16(s));
+        }
+        let rows = q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         rows.iter().map(row_to_posting).collect()
     }
@@ -412,6 +435,10 @@ impl PostingStore for SqlStore {
         let (where_clause, count_clause) = {
             let mut w = String::from("WHERE owner = $1");
             let mut idx = 2u32;
+            if query.sub.is_some() {
+                w.push_str(&format!(" AND subaccount = ${idx}"));
+                idx += 1;
+            }
             if query.asset.is_some() {
                 w.push_str(&format!(" AND asset = ${idx}"));
                 idx += 1;
@@ -427,7 +454,10 @@ impl PostingStore for SqlStore {
         };
 
         // Build count query
-        let mut count_q = sqlx::query(&count_clause).bind(query.account.0);
+        let mut count_q = sqlx::query(&count_clause).bind(query.account);
+        if let Some(s) = query.sub {
+            count_q = count_q.bind(s as i64);
+        }
         if let Some(ref a) = query.asset {
             count_q = count_q.bind(a.0 as i32);
         }
@@ -443,7 +473,10 @@ impl PostingStore for SqlStore {
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         // Build data query
-        let mut data_q = sqlx::query(&where_clause).bind(query.account.0);
+        let mut data_q = sqlx::query(&where_clause).bind(query.account);
+        if let Some(s) = query.sub {
+            data_q = data_q.bind(s as i64);
+        }
         if let Some(ref a) = query.asset {
             data_q = data_q.bind(a.0 as i32);
         }
@@ -583,11 +616,12 @@ impl PostingStore for SqlStore {
         let mut inserted: u64 = 0;
         for posting in postings {
             let res = sqlx::query(
-                "INSERT INTO postings (transfer_id, idx, owner, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
+                "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (transfer_id, idx) DO NOTHING"
             )
                 .bind(envelope_id_to_hex(&posting.id.transfer))
                 .bind(posting.id.index as i16)
-                .bind(posting.owner.0)
+                .bind(posting.owner.id)
+                .bind(posting.owner.sub as i64)
                 .bind(posting.asset.0 as i32)
                 .bind(posting.value.to_string())
                 .bind(status_to_i16(posting.status))
@@ -667,9 +701,10 @@ impl TransferStore for SqlStore {
         // Index every involved account (caller supplies the set; storage does no
         // computation). Idempotent so a replay is harmless.
         for account in involved {
-            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id) VALUES ($1, $2) ON CONFLICT (transfer_id, account_id) DO NOTHING")
+            sqlx::query("INSERT INTO transfer_accounts (transfer_id, account_id, subaccount) VALUES ($1, $2, $3) ON CONFLICT (transfer_id, account_id, subaccount) DO NOTHING")
                 .bind(&tid_hex)
-                .bind(account.0)
+                .bind(account.id)
+                .bind(account.sub as i64)
                 .execute(&mut *tx)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -683,12 +718,24 @@ impl TransferStore for SqlStore {
 
     async fn get_transfers_for_account(
         &self,
-        account: &AccountId,
+        account: i64,
+        sub: Option<u64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError> {
-        let rows = sqlx::query(
-            "SELECT t.id, t.transfer, t.receipt, t.created_at FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id WHERE ta.account_id = $1 ORDER BY t.created_at"
-        )
-            .bind(account.0)
+        // DISTINCT because a transfer can index the same base account under
+        // several subaccounts; an unfiltered (sub = None) query would otherwise
+        // return the transfer once per matching subaccount row.
+        let mut sql = String::from(
+            "SELECT DISTINCT t.id, t.transfer, t.receipt, t.created_at FROM transfers t INNER JOIN transfer_accounts ta ON t.id = ta.transfer_id WHERE ta.account_id = $1",
+        );
+        if sub.is_some() {
+            sql.push_str(" AND ta.subaccount = $2");
+        }
+        sql.push_str(" ORDER BY t.created_at");
+        let mut q = sqlx::query(&sql).bind(account);
+        if let Some(s) = sub {
+            q = q.bind(s as i64);
+        }
+        let rows = q
             .fetch_all(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -719,7 +766,7 @@ impl TransferStore for SqlStore {
     ) -> Result<Page<EnvelopeRecord>, StoreError> {
         // Load base records, using the account join when available.
         let base_records = if let Some(ref account) = query.account {
-            self.get_transfers_for_account(account).await?
+            self.get_transfers_for_account(*account, query.sub).await?
         } else {
             let rows = sqlx::query(
                 "SELECT transfer, receipt, created_at FROM transfers ORDER BY created_at",

+ 43 - 0
crates/kuatia-storage-sql/src/migrations/002_subaccounts.sql

@@ -0,0 +1,43 @@
+-- Add a subaccount dimension to account identity and posting ownership.
+-- Existing rows become subaccount 0 (the main subaccount). Portable across
+-- SQLite and PostgreSQL. The migration runner executes each statement on its
+-- own, so there are no transaction-control or backend-specific constructs here.
+-- Comments avoid semicolons because the runner splits statements on them.
+
+-- postings: a new column plus a widened owner index. The primary key
+-- (transfer_id, idx) is unaffected, so a plain ADD COLUMN suffices.
+ALTER TABLE postings ADD COLUMN subaccount BIGINT NOT NULL DEFAULT 0;
+DROP INDEX IF EXISTS idx_postings_owner;
+CREATE INDEX IF NOT EXISTS idx_postings_owner ON postings(owner, subaccount, asset, status);
+
+-- accounts: the primary key must widen to (id, subaccount, version). SQLite
+-- cannot alter a primary key, so rebuild the table and copy rows as subaccount 0.
+CREATE TABLE accounts_new (
+    id          BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    version     BIGINT NOT NULL,
+    policy      TEXT NOT NULL,
+    flags       INTEGER NOT NULL,
+    book        BIGINT NOT NULL,
+    user_data   TEXT NOT NULL,
+    metadata    TEXT NOT NULL,
+    PRIMARY KEY (id, subaccount, version)
+);
+INSERT INTO accounts_new (id, subaccount, version, policy, flags, book, user_data, metadata)
+    SELECT id, 0, version, policy, flags, book, user_data, metadata FROM accounts;
+DROP TABLE accounts;
+ALTER TABLE accounts_new RENAME TO accounts;
+
+-- transfer_accounts: the primary key must widen to include the subaccount, and
+-- the involved-account index widens with it. Rebuild and copy as subaccount 0.
+CREATE TABLE transfer_accounts_new (
+    transfer_id TEXT NOT NULL,
+    account_id  BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    PRIMARY KEY (transfer_id, account_id, subaccount)
+);
+INSERT INTO transfer_accounts_new (transfer_id, account_id, subaccount)
+    SELECT transfer_id, account_id, 0 FROM transfer_accounts;
+DROP TABLE transfer_accounts;
+ALTER TABLE transfer_accounts_new RENAME TO transfer_accounts;
+CREATE INDEX IF NOT EXISTS idx_xfer_acct ON transfer_accounts(account_id, subaccount);

+ 1 - 1
crates/kuatia-storage/src/error.rs

@@ -15,7 +15,7 @@ pub enum StoreError {
     AlreadyExists(String),
     /// Optimistic version check failed on an account update.
     VersionConflict {
-        /// Account that had a version mismatch.
+        /// Account (subaccount) that had a version mismatch.
         account: AccountId,
         /// Version the caller expected.
         expected: u64,

+ 16 - 9
crates/kuatia-storage/src/mem_store.rs

@@ -12,6 +12,11 @@ use kuatia_types::{
     ReservationId,
 };
 
+/// Whether an owner reference matches a base account and optional subaccount.
+fn owner_matches(owner: &AccountId, account: i64, sub: Option<u64>) -> bool {
+    owner.id == account && sub.is_none_or(|s| owner.sub == s)
+}
+
 use crate::error::StoreError;
 use crate::events::{EventStore, LedgerEvent};
 use crate::store::{
@@ -147,7 +152,8 @@ impl PostingStore for InMemoryStore {
 
     async fn get_postings_by_account(
         &self,
-        account: &AccountId,
+        account: i64,
+        sub: Option<u64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError> {
@@ -155,7 +161,7 @@ impl PostingStore for InMemoryStore {
         Ok(postings
             .values()
             .filter(|p| {
-                p.owner == *account
+                owner_matches(&p.owner, account, sub)
                     && asset.is_none_or(|a| p.asset == *a)
                     && status.is_none_or(|s| p.status == s)
             })
@@ -275,7 +281,8 @@ impl TransferStore for InMemoryStore {
 
     async fn get_transfers_for_account(
         &self,
-        account: &AccountId,
+        account: i64,
+        sub: Option<u64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError> {
         // Acquire postings → transfers in a consistent order to avoid an AB–BA
         // deadlock with any reader that takes both.
@@ -288,12 +295,12 @@ impl TransferStore for InMemoryStore {
                     .envelope
                     .creates()
                     .iter()
-                    .any(|np| np.owner == *account)
-                    || record
-                        .envelope
-                        .consumes()
-                        .iter()
-                        .any(|pid| postings.get(pid).is_some_and(|p| p.owner == *account))
+                    .any(|np| owner_matches(&np.owner, account, sub))
+                    || record.envelope.consumes().iter().any(|pid| {
+                        postings
+                            .get(pid)
+                            .is_some_and(|p| owner_matches(&p.owner, account, sub))
+                    })
             })
             .cloned()
             .collect();

+ 22 - 13
crates/kuatia-storage/src/store.rs

@@ -34,8 +34,10 @@ pub struct EnvelopeRecord {
 /// Pagination and filtering parameters for posting queries.
 #[derive(Debug, Clone)]
 pub struct PostingQuery {
-    /// Filter to postings owned by this account.
-    pub account: AccountId,
+    /// Filter to postings owned by this base account.
+    pub account: i64,
+    /// Filter to a specific subaccount; `None` spans all subaccounts.
+    pub sub: Option<u64>,
     /// Filter by asset.
     pub asset: Option<AssetId>,
     /// Filter by posting status.
@@ -49,8 +51,10 @@ pub struct PostingQuery {
 /// Pagination and filtering parameters for transfer queries.
 #[derive(Debug, Clone, Default)]
 pub struct TransferQuery {
-    /// Filter to transfers involving this account.
-    pub account: Option<AccountId>,
+    /// Filter to transfers involving this base account.
+    pub account: Option<i64>,
+    /// Restrict the account filter to a specific subaccount; `None` spans all.
+    pub sub: Option<u64>,
     /// Inclusive lower bound (unix millis).
     pub from_ts: Option<i64>,
     /// Exclusive upper bound (unix millis).
@@ -79,15 +83,15 @@ pub struct Page<T> {
 /// Account persistence: create, version, query.
 #[async_trait]
 pub trait AccountStore: Send + Sync {
-    /// Fetch a single account by id.
+    /// Fetch a single account (subaccount) by reference.
     async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError>;
-    /// Fetch multiple accounts by id.
+    /// Fetch multiple accounts (subaccounts) by reference.
     async fn get_accounts(&self, ids: &[AccountId]) -> Result<Vec<Account>, StoreError>;
     /// Persist a new account (version 1).
     async fn create_account(&self, account: Account) -> Result<(), StoreError>;
     /// Append a new version to an existing account.
     async fn append_account_version(&self, account: Account) -> Result<(), StoreError>;
-    /// Return the full version history for an account.
+    /// Return the full version history for an account (subaccount).
     async fn get_account_history(&self, id: &AccountId) -> Result<Vec<Account>, StoreError>;
     /// List all accounts (latest version of each).
     async fn list_accounts(&self) -> Result<Vec<Account>, StoreError>;
@@ -98,10 +102,13 @@ pub trait AccountStore: Send + Sync {
 pub trait PostingStore: Send + Sync {
     /// Fetch postings by their ids.
     async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError>;
-    /// Return postings owned by an account, optionally filtered by asset and/or status.
+    /// Return postings owned by a base account, optionally filtered by subaccount,
+    /// asset, and/or status. `sub == None` spans every subaccount; `Some(s)`
+    /// restricts to that subaccount.
     async fn get_postings_by_account(
         &self,
-        account: &AccountId,
+        account: i64,
+        sub: Option<u64>,
         asset: Option<&AssetId>,
         status: Option<PostingStatus>,
     ) -> Result<Vec<Posting>, StoreError>;
@@ -147,7 +154,7 @@ pub trait PostingStore: Send + Sync {
     /// Query postings with filtering and pagination.
     async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
         let all = self
-            .get_postings_by_account(&query.account, query.asset.as_ref(), query.status)
+            .get_postings_by_account(query.account, query.sub, query.asset.as_ref(), query.status)
             .await?;
         let total = all.len() as u64;
         let offset = query.offset.unwrap_or(0) as usize;
@@ -172,10 +179,12 @@ pub trait TransferStore: Send + Sync {
         record: EnvelopeRecord,
         involved: &[AccountId],
     ) -> Result<u64, StoreError>;
-    /// Return all transfers involving the given account.
+    /// Return all transfers involving the given base account, optionally
+    /// restricted to a single subaccount (`sub == None` spans all subaccounts).
     async fn get_transfers_for_account(
         &self,
-        account: &AccountId,
+        account: i64,
+        sub: Option<u64>,
     ) -> Result<Vec<EnvelopeRecord>, StoreError>;
 
     /// Query transfers with filtering and pagination.
@@ -185,7 +194,7 @@ pub trait TransferStore: Send + Sync {
     ) -> Result<Page<EnvelopeRecord>, StoreError> {
         // Default in-memory implementation
         let all = if let Some(ref account) = query.account {
-            self.get_transfers_for_account(account).await?
+            self.get_transfers_for_account(*account, query.sub).await?
         } else {
             return Err(StoreError::Internal(
                 "query_transfers requires account filter in default implementation".into(),

+ 23 - 14
crates/kuatia-storage/src/store_tests.rs

@@ -275,20 +275,25 @@ pub async fn get_postings_by_account_filters(store: &(impl Store + 'static)) {
     seed_active(store, 200, &[p1, p2, p3]).await;
 
     let all = store
-        .get_postings_by_account(&AccountId::new(1), None, None)
+        .get_postings_by_account(AccountId::new(1).id, None, None, None)
         .await
         .unwrap();
     assert_eq!(all.len(), 2);
 
     let filtered = store
-        .get_postings_by_account(&AccountId::new(1), Some(&AssetId::new(1)), None)
+        .get_postings_by_account(AccountId::new(1).id, None, Some(&AssetId::new(1)), None)
         .await
         .unwrap();
     assert_eq!(filtered.len(), 1);
     assert_eq!(filtered[0].value, Cent::from(100));
 
     let active = store
-        .get_postings_by_account(&AccountId::new(1), None, Some(PostingStatus::Active))
+        .get_postings_by_account(
+            AccountId::new(1).id,
+            None,
+            None,
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
     assert_eq!(active.len(), 2);
@@ -305,7 +310,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Page 1: first 2
     let page1 = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: AccountId::new(1).id,
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -319,7 +325,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Page 2: next 2
     let page2 = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: AccountId::new(1).id,
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -333,7 +340,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // Page 3: last 1
     let page3 = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: AccountId::new(1).id,
+            sub: None,
             asset: None,
             status: None,
             limit: Some(2),
@@ -347,7 +355,8 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     // With asset filter
     let filtered = store
         .query_postings(&PostingQuery {
-            account: AccountId::new(1),
+            account: AccountId::new(1).id,
+            sub: None,
             asset: Some(AssetId::new(1)),
             status: None,
             limit: Some(10),
@@ -604,7 +613,7 @@ pub async fn store_transfer_counts(store: &(impl Store + 'static)) {
     assert!(store.get_transfer(&tid).await.unwrap().is_some());
     assert_eq!(
         store
-            .get_transfers_for_account(&AccountId::new(1))
+            .get_transfers_for_account(AccountId::new(1).id, None)
             .await
             .unwrap()
             .len(),
@@ -703,13 +712,13 @@ pub async fn get_transfers_for_account(store: &(impl Store + 'static)) {
     commit_envelope(store, envelope, tid, 1000).await;
 
     let records = store
-        .get_transfers_for_account(&AccountId::new(1))
+        .get_transfers_for_account(AccountId::new(1).id, None)
         .await
         .unwrap();
     assert_eq!(records.len(), 1);
 
     let empty = store
-        .get_transfers_for_account(&AccountId::new(999))
+        .get_transfers_for_account(AccountId::new(999).id, None)
         .await
         .unwrap();
     assert!(empty.is_empty());
@@ -738,7 +747,7 @@ pub async fn query_transfers_by_date_range(store: &(impl Store + 'static)) {
 
     let page = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(AccountId::new(1).id),
             from_ts: Some(1500),
             ..Default::default()
         })
@@ -761,7 +770,7 @@ pub async fn query_transfers_pagination(store: &(impl Store + 'static)) {
 
     let page = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(AccountId::new(1).id),
             limit: Some(2),
             offset: Some(0),
             ..Default::default()
@@ -773,7 +782,7 @@ pub async fn query_transfers_pagination(store: &(impl Store + 'static)) {
 
     let page2 = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(AccountId::new(1).id),
             limit: Some(2),
             offset: Some(2),
             ..Default::default()
@@ -794,7 +803,7 @@ pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
 
     let page = store
         .query_transfers(&TransferQuery {
-            account: Some(AccountId::new(1)),
+            account: Some(AccountId::new(1).id),
             book: Some(BookId(5)),
             ..Default::default()
         })

+ 78 - 23
crates/kuatia-types/src/lib.rs

@@ -59,9 +59,18 @@ pub fn write_u128(buf: &mut Vec<u8>, v: u128) {
 // Identifiers
 // ---------------------------------------------------------------------------
 
-/// Stable account identity. Used in all public APIs.
+/// Stable account identity: a base id and a subaccount (two legs). `sub = 0` is
+/// the main account; a non-zero `sub` is a subaccount (e.g. an inflight hold),
+/// itself a full independent account record sharing the base `id`.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct AccountId(pub i64);
+pub struct AccountId {
+    /// Base account id.
+    pub id: i64,
+    /// Subaccount within the base account; `0` is the main account. Opaque and
+    /// unordered (an inflight hold's subaccount is derived from a hash), unsigned
+    /// so the full range is usable.
+    pub sub: u64,
+}
 
 /// Pairs an [`AccountId`] with a snapshot hash — the double-SHA256 of the
 /// account's state at a point in time. Stored on [`Transfer`] to record which
@@ -69,7 +78,7 @@ pub struct AccountId(pub i64);
 /// public API uses [`AccountId`].
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct AccountSnapshotId {
-    /// The account this snapshot belongs to.
+    /// The account (subaccount) this snapshot belongs to.
     pub account: AccountId,
     /// Double-SHA256 of the account's state at the time of the snapshot.
     pub snapshot_id: [u8; 32],
@@ -115,7 +124,11 @@ impl ToBytes for Cent {
 
 impl fmt::Debug for AccountId {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "AccountId({})", self.0)
+        if self.sub == 0 {
+            write!(f, "AccountId({})", self.id)
+        } else {
+            write!(f, "AccountId({}.{})", self.id, self.sub)
+        }
     }
 }
 
@@ -153,14 +166,35 @@ impl Default for AccountId {
         // Process-global generator: a per-thread one could mint the same id on
         // two threads within a millisecond, yielding duplicate account ids.
         static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
-        Self(GEN.next())
+        Self {
+            id: GEN.next(),
+            sub: 0,
+        }
     }
 }
 
 impl AccountId {
-    /// Create an `AccountId` from an `i64`.
+    /// The main account (`sub = 0`) with the given base id.
     pub const fn new(id: i64) -> Self {
-        Self(id)
+        Self { id, sub: 0 }
+    }
+
+    /// A specific subaccount of base id `id`.
+    pub const fn with_sub(id: i64, sub: u64) -> Self {
+        Self { id, sub }
+    }
+
+    /// The main account this id belongs to (`sub = 0`).
+    pub const fn base(&self) -> Self {
+        Self {
+            id: self.id,
+            sub: 0,
+        }
+    }
+
+    /// Whether this is the main account (`sub == 0`).
+    pub const fn is_main(&self) -> bool {
+        self.sub == 0
     }
 }
 
@@ -365,7 +399,7 @@ pub enum PostingStatus {
 pub struct Posting {
     /// Unique identifier derived from the creating transfer.
     pub id: PostingId,
-    /// The account that owns this posting.
+    /// The account (subaccount) that owns this posting.
     pub owner: AccountId,
     /// The asset this posting denominates.
     pub asset: AssetId,
@@ -402,7 +436,7 @@ impl Posting {
 /// on the [`EnvelopeId`], which is computed during validation.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct NewPosting {
-    /// The account that will own the created posting.
+    /// The account (subaccount) that will own the created posting.
     pub owner: AccountId,
     /// The asset this posting denominates.
     pub asset: AssetId,
@@ -480,7 +514,7 @@ impl Envelope {
         &self.metadata
     }
 
-    /// Deduplicated, sorted list of accounts referenced in the created postings.
+    /// Deduplicated, sorted list of account references in the created postings.
     pub fn referenced_accounts(&self) -> Vec<AccountId> {
         let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
         ids.sort();
@@ -587,7 +621,10 @@ bitflags::bitflags! {
         const FROZEN = 1 << 0;
         /// Terminal — no further activity.
         const CLOSED = 1 << 1;
-        // Bits 2–7: reserved for future system flags.
+        /// Holding account for an inflight (authorize/confirm/void) transaction.
+        /// Parks funds between authorize and settlement; closed once drained.
+        const INFLIGHT = 1 << 2;
+        // Bits 3–7: reserved for future system flags.
         // Bits 8–31: user-defined.
         /// User-defined flag 0.
         const USER_0 = 1 << 8;
@@ -611,7 +648,7 @@ bitflags::bitflags! {
 /// A registered entity that must exist before it can transact.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Account {
-    /// Stable identity for this account.
+    /// Stable identity for this account (base account plus subaccount).
     pub id: AccountId,
     /// Monotonically increasing version, starts at 1 on creation.
     pub version: u64,
@@ -628,10 +665,15 @@ pub struct Account {
 }
 
 impl Account {
-    /// Create a version-1 account with the given policy: no flags, the default
-    /// book, and empty user data / metadata. Convenience for the common case —
-    /// set the other fields explicitly when you need them.
+    /// Create a version-1 main-subaccount account with the given policy: no flags,
+    /// the default book, and empty user data / metadata. Convenience for the common
+    /// case — set the other fields explicitly when you need them.
     pub fn new(id: AccountId, policy: AccountPolicy) -> Self {
+        Self::new_ref(id, policy)
+    }
+
+    /// Like [`Account::new`] but for a specific subaccount reference.
+    pub fn new_ref(id: AccountId, policy: AccountPolicy) -> Self {
         Self {
             id,
             version: 1,
@@ -676,9 +718,9 @@ pub struct Receipt {
 /// postings only for accounts with a positive net debit.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Movement {
-    /// Account being debited.
+    /// Account (subaccount) being debited.
     pub from: AccountId,
-    /// Account being credited.
+    /// Account (subaccount) being credited.
     pub to: AccountId,
     /// Asset to transfer.
     pub asset: AssetId,
@@ -715,8 +757,13 @@ impl TransferBuilder {
         Self::default()
     }
 
-    /// Add a raw movement.
-    pub fn movement(
+    /// Add a raw movement between main subaccounts.
+    pub fn movement(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(from, to, asset, amount)
+    }
+
+    /// Add a raw movement between specific subaccounts.
+    pub fn movement_ref(
         mut self,
         from: AccountId,
         to: AccountId,
@@ -732,11 +779,16 @@ impl TransferBuilder {
         self
     }
 
-    /// Add a pay movement: transfer value between two accounts.
+    /// Add a pay movement between main subaccounts.
     pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
         self.movement(from, to, asset, amount)
     }
 
+    /// Add a pay movement between specific subaccounts.
+    pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(from, to, asset, amount)
+    }
+
     /// Add a deposit: creates an offset posting on the external account and
     /// credits the target account.  Pushes two movements whose net debit on the
     /// external account is zero.
@@ -794,14 +846,17 @@ impl TransferBuilder {
 
 impl ToBytes for AccountId {
     fn to_bytes(&self) -> Vec<u8> {
-        self.0.to_be_bytes().to_vec()
+        let mut buf = Vec::with_capacity(16);
+        buf.extend_from_slice(&self.id.to_be_bytes());
+        buf.extend_from_slice(&self.sub.to_be_bytes());
+        buf
     }
 }
 
 impl ToBytes for AccountSnapshotId {
     fn to_bytes(&self) -> Vec<u8> {
-        let mut buf = Vec::with_capacity(40);
-        buf.extend_from_slice(&self.account.0.to_be_bytes());
+        let mut buf = Vec::with_capacity(48);
+        buf.extend(self.account.to_bytes());
         buf.extend_from_slice(&self.snapshot_id);
         buf
     }

+ 1 - 0
crates/kuatia/Cargo.toml

@@ -27,6 +27,7 @@ legend.workspace = true
 tokio = { workspace = true, features = ["sync", "rt", "macros"] }
 serde.workspace = true
 serde_json.workspace = true
+ciborium.workspace = true
 async-trait.workspace = true
 tracing.workspace = true
 

+ 1 - 1
crates/kuatia/examples/create_accounts.rs

@@ -47,7 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // Read them back (latest version of each).
     println!("accounts:");
     let mut accounts = ledger.list_accounts().await?;
-    accounts.sort_by_key(|a| a.id.0);
+    accounts.sort_by_key(|a| a.id.id);
     for a in &accounts {
         println!("  {:?}  policy={:?}  v{}", a.id, a.policy, a.version);
     }

+ 33 - 1
crates/kuatia/src/error.rs

@@ -4,7 +4,8 @@
 //! and from storage, so callers get a single error type from every API.
 
 use kuatia_core::{
-    AccountId, BookId, EnvelopeId, OverflowError, PostingId, SelectionError, ValidationError,
+    AccountId, AssetId, BookId, EnvelopeId, OverflowError, PostingId, SelectionError,
+    ValidationError,
 };
 use kuatia_storage::error::StoreError;
 
@@ -29,6 +30,23 @@ pub enum LedgerError {
     AccountAlreadyClosed(AccountId),
     /// A transfer named a book that does not exist.
     BookNotFound(BookId),
+    /// The referenced inflight transaction does not exist (no authorize record).
+    InflightNotFound(EnvelopeId),
+    /// The referenced transfer is not an inflight authorize, or its metadata is
+    /// malformed.
+    NotInflightTransaction(EnvelopeId),
+    /// The destination already has an open inflight hold; only one is allowed at
+    /// a time per account.
+    InflightAlreadyOpen(AccountId),
+    /// The inflight transaction has no leg matching this destination and asset.
+    InflightLegNotFound {
+        /// The destination account with no matching leg.
+        destination: AccountId,
+        /// The asset with no matching leg.
+        asset: AssetId,
+    },
+    /// An inflight movement must move between two distinct accounts.
+    InflightSelfMovement(AccountId),
     /// Monetary arithmetic overflow.
     Overflow,
     /// A saga step failed and its compensation also failed.
@@ -52,6 +70,20 @@ impl std::fmt::Display for LedgerError {
             Self::AccountNotEmpty(id) => write!(f, "account not empty: {id:?}"),
             Self::AccountAlreadyClosed(id) => write!(f, "account already closed: {id:?}"),
             Self::BookNotFound(id) => write!(f, "book not found: {id:?}"),
+            Self::InflightNotFound(id) => write!(f, "inflight transaction not found: {id:?}"),
+            Self::NotInflightTransaction(id) => {
+                write!(f, "not an inflight authorize transaction: {id:?}")
+            }
+            Self::InflightAlreadyOpen(id) => {
+                write!(f, "account already has an open inflight hold: {id:?}")
+            }
+            Self::InflightLegNotFound { destination, asset } => write!(
+                f,
+                "inflight leg not found for destination {destination:?} asset {asset:?}"
+            ),
+            Self::InflightSelfMovement(id) => {
+                write!(f, "inflight movement must have distinct from/to: {id:?}")
+            }
             Self::Overflow => write!(f, "monetary amount overflow"),
             Self::CompensationFailed {
                 original,

+ 640 - 0
crates/kuatia/src/inflight.rs

@@ -0,0 +1,640 @@
+//! Inflight holds: authorize funds now, confirm (fully or partially) or void
+//! later.
+//!
+//! An inflight transaction is an ordinary trade whose every destination is
+//! rewritten to a per-destination holding subaccount (`NoOverdraft`, flagged
+//! [`AccountFlags::INFLIGHT`], keyed by a subaccount derived from the trade).
+//! Committing that rewritten transfer parks the
+//! funds. Confirm and void are ordinary commits that move a hold's balance to
+//! its destination or back to its funder. Nothing new is stored: the authorize
+//! 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`.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::Arc;
+
+use kuatia_core::{
+    Account, AccountFlags, AccountId, AccountPolicy, AssetId, BookId, Cent, EnvelopeId, Metadata,
+    Receipt, SelectionError, Transfer, TransferBuilder, hash::double_sha256,
+};
+use kuatia_storage::error::StoreError;
+use kuatia_storage::store::EnvelopeRecord;
+use kuatia_types::PostingStatus;
+use serde::{Deserialize, Serialize};
+
+use crate::error::LedgerError;
+use crate::ledger::Ledger;
+
+/// Single metadata key holding the CBOR-encoded [`InflightMeta`] payload.
+const K_INFLIGHT: &str = "inflight";
+
+/// One leg of an inflight transaction: an amount of an asset funded by `funder`,
+/// parked in `hold`, destined for `destination`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InflightLeg {
+    /// Account the funds settle to on confirm.
+    pub destination: AccountId,
+    /// Per-destination holding account parking the funds.
+    pub hold: AccountId,
+    /// Account that funded this leg (the funds return here on void).
+    pub funder: AccountId,
+    /// Asset being held.
+    pub asset: AssetId,
+    /// Amount authorized for this leg.
+    pub amount: Cent,
+}
+
+/// Result of [`Ledger::authorize`].
+#[derive(Debug, Clone)]
+pub struct Authorization {
+    /// Handle for the inflight transaction: the authorize transfer's id.
+    pub inflight: EnvelopeId,
+    /// Receipt of the authorize commit.
+    pub receipt: Receipt,
+    /// The legs, one per original movement.
+    pub legs: Vec<InflightLeg>,
+}
+
+/// Lifecycle state of an inflight transaction, derived from balances and the
+/// settling transfers.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InflightState {
+    /// Nothing settled yet; the full authorized amount is still held.
+    Held,
+    /// Some funds settled, some still held.
+    PartiallyConfirmed,
+    /// Fully settled to destinations.
+    Confirmed,
+    /// Fully returned to funders.
+    Voided,
+    /// Fully settled, but a mix of confirmed and voided legs.
+    Mixed,
+}
+
+/// Per-(destination, asset) status line.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct InflightLegStatus {
+    /// Destination account.
+    pub destination: AccountId,
+    /// Holding account.
+    pub hold: AccountId,
+    /// Asset.
+    pub asset: AssetId,
+    /// Amount originally authorized.
+    pub authorized: Cent,
+    /// Amount confirmed to the destination so far.
+    pub confirmed: Cent,
+    /// Amount returned to funders so far.
+    pub voided: Cent,
+    /// Amount still held (`= authorized - confirmed - voided`).
+    pub held: Cent,
+}
+
+/// Derived status of an inflight transaction.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InflightStatus {
+    /// The inflight handle.
+    pub inflight: EnvelopeId,
+    /// One entry per (destination, asset).
+    pub legs: Vec<InflightLegStatus>,
+    /// Overall state.
+    pub state: InflightState,
+}
+
+// ---------------------------------------------------------------------------
+// Metadata: one CBOR-encoded tagged payload under the `inflight` key
+// ---------------------------------------------------------------------------
+
+/// The inflight payload carried in a transfer's or holding account's metadata.
+/// Serialized to CBOR (via `ciborium`) and stored under [`K_INFLIGHT`], so the
+/// whole lifecycle is self-describing and read back, not inferred.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+enum InflightMeta {
+    /// Tags the authorize transfer and carries its leg table.
+    Authorize { legs: Vec<InflightLeg> },
+    /// Tags a per-destination holding subaccount.
+    Hold { destination: AccountId },
+    /// Tags a settling transfer that delivers to a destination.
+    Confirm {
+        tx: EnvelopeId,
+        destination: AccountId,
+    },
+    /// Tags a settling transfer that returns to a funder.
+    Void {
+        tx: EnvelopeId,
+        destination: AccountId,
+    },
+}
+
+/// Whether a settle delivers to the destination or returns to a funder.
+#[derive(Clone, Copy)]
+enum SettleRole {
+    Confirm,
+    Void,
+}
+
+fn malformed(tid: EnvelopeId) -> LedgerError {
+    LedgerError::NotInflightTransaction(tid)
+}
+
+/// Encode an [`InflightMeta`] to CBOR bytes.
+fn encode_meta(meta: &InflightMeta) -> Result<Vec<u8>, LedgerError> {
+    let mut buf = Vec::new();
+    ciborium::into_writer(meta, &mut buf)
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+    Ok(buf)
+}
+
+/// Wrap a single [`InflightMeta`] into a fresh [`Metadata`] map.
+fn meta_map(meta: &InflightMeta) -> Result<Metadata, LedgerError> {
+    let mut m = Metadata::new();
+    m.insert(K_INFLIGHT.to_string(), encode_meta(meta)?);
+    Ok(m)
+}
+
+/// Decode the [`InflightMeta`] carried by a metadata map, if any.
+fn read_meta(meta: &Metadata) -> Option<InflightMeta> {
+    let bytes = meta.get(K_INFLIGHT)?;
+    ciborium::from_reader(bytes.as_slice()).ok()
+}
+
+impl Ledger {
+    // -----------------------------------------------------------------------
+    // Authorize
+    // -----------------------------------------------------------------------
+
+    /// Authorize a trade without settling it. Each movement's destination is
+    /// rewritten to a fresh per-destination holding account, and the rewritten
+    /// transfer is committed, parking the funds. Returns a handle used by
+    /// [`confirm_all`](Self::confirm_all), [`confirm`](Self::confirm), and
+    /// [`void`](Self::void).
+    ///
+    /// Every movement must move between two distinct accounts. All holds share a
+    /// subaccount derived from the trade, so re-authorizing the identical trade is
+    /// rejected (its holds already exist), while different trades to the same
+    /// destination run concurrently under distinct subaccounts.
+    pub async fn authorize(
+        self: &Arc<Self>,
+        transfer: Transfer,
+    ) -> Result<Authorization, LedgerError> {
+        // All holds of this inflight share one subaccount, derived from the
+        // submitted trade so it is stable and known before the holds are created
+        // (the authorize transfer's own id cannot be used: it is a hash of the
+        // envelope that pays into the holds).
+        let sub = inflight_subaccount(&transfer);
+
+        // One holding subaccount per distinct destination: (destination, sub).
+        let mut dest_to_hold: BTreeMap<AccountId, AccountId> = BTreeMap::new();
+        for m in &transfer.movements {
+            if m.from == m.to {
+                return Err(LedgerError::InflightSelfMovement(m.from));
+            }
+            dest_to_hold
+                .entry(m.to)
+                .or_insert_with(|| AccountId::with_sub(m.to.id, sub));
+        }
+
+        // Create the holds. An existing (destination, sub) entity means this exact
+        // trade is already inflight, so different trades (different subs) can hold
+        // against the same destination at once.
+        for (dest, hold) in &dest_to_hold {
+            let mut acct = Account::new_ref(*hold, AccountPolicy::NoOverdraft);
+            acct.flags = AccountFlags::INFLIGHT;
+            acct.book = transfer.book;
+            acct.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
+            match self.create_account(acct).await {
+                Ok(()) => {}
+                Err(LedgerError::Store(StoreError::AlreadyExists(_))) => {
+                    return Err(LedgerError::InflightAlreadyOpen(*hold));
+                }
+                Err(e) => return Err(e),
+            }
+        }
+
+        // Rewrite each movement funder -> hold and record the leg table.
+        let mut legs = Vec::with_capacity(transfer.movements.len());
+        let mut builder = TransferBuilder::new()
+            .book(transfer.book)
+            .user_data(transfer.user_data.clone());
+        for m in &transfer.movements {
+            let hold = dest_to_hold[&m.to];
+            legs.push(InflightLeg {
+                destination: m.to,
+                hold,
+                funder: m.from,
+                asset: m.asset,
+                amount: m.amount,
+            });
+            builder = builder.movement_ref(m.from, hold, m.asset, m.amount);
+        }
+        let mut md = transfer.metadata.clone();
+        md.insert(
+            K_INFLIGHT.to_string(),
+            encode_meta(&InflightMeta::Authorize { legs: legs.clone() })?,
+        );
+        let rewritten = builder.metadata(md).build();
+
+        let receipt = self.commit(rewritten).await?;
+        Ok(Authorization {
+            inflight: receipt.transfer_id,
+            receipt,
+            legs,
+        })
+    }
+
+    // -----------------------------------------------------------------------
+    // Confirm
+    // -----------------------------------------------------------------------
+
+    /// Confirm the entire inflight transaction: sweep every hold's remaining
+    /// balance to its destination and close the drained holds.
+    pub async fn confirm_all(
+        self: &Arc<Self>,
+        inflight: &EnvelopeId,
+    ) -> Result<Vec<Receipt>, LedgerError> {
+        let (record, legs) = self.load_inflight(inflight).await?;
+        let book = record.envelope.book();
+        let mut receipts = Vec::new();
+        for hold in holds_of(&legs) {
+            let dest = destination_of(&legs, hold, *inflight)?;
+            for asset in assets_of(&legs, hold) {
+                let bal = self.balance(&hold, &asset).await?;
+                if bal.is_positive() {
+                    receipts.push(
+                        self.settle(
+                            book,
+                            *inflight,
+                            hold,
+                            dest,
+                            dest,
+                            asset,
+                            bal,
+                            SettleRole::Confirm,
+                        )
+                        .await?,
+                    );
+                }
+            }
+            self.close_if_drained(&hold).await?;
+        }
+        Ok(receipts)
+    }
+
+    /// Confirm one or more legs in a single call. Each movement is expressed with
+    /// the same `(from, to, asset, amount)` shape as [`TransferBuilder::pay`]:
+    /// `from` is the leg's funder, `to` its destination. Build the set with
+    /// `TransferBuilder` and pass the resulting [`Transfer`]; its book, user data,
+    /// and metadata are ignored.
+    ///
+    /// Each movement delivers `amount` of `asset` from the matching leg's hold to
+    /// its destination. `amount` must not exceed the amount still held; the
+    /// `NoOverdraft` hold makes over-confirmation impossible regardless. A hold is
+    /// closed once fully drained.
+    ///
+    /// Movements settle in order, each its own commit, so the batch is not atomic:
+    /// a later movement failing leaves earlier confirmations applied.
+    pub async fn confirm(
+        self: &Arc<Self>,
+        inflight: &EnvelopeId,
+        confirms: Transfer,
+    ) -> Result<Vec<Receipt>, LedgerError> {
+        let (record, legs) = self.load_inflight(inflight).await?;
+        let book = record.envelope.book();
+        let mut receipts = Vec::new();
+        let mut touched: BTreeSet<AccountId> = BTreeSet::new();
+        for m in &confirms.movements {
+            let leg = legs
+                .iter()
+                .find(|l| l.funder == m.from && l.destination == m.to && l.asset == m.asset)
+                .ok_or(LedgerError::InflightLegNotFound {
+                    destination: m.to,
+                    asset: m.asset,
+                })?;
+            let held = self.balance(&leg.hold, &m.asset).await?;
+            if m.amount > held {
+                return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
+                    available: held,
+                    requested: m.amount,
+                }));
+            }
+            receipts.push(
+                self.settle(
+                    book,
+                    *inflight,
+                    leg.hold,
+                    m.to,
+                    m.to,
+                    m.asset,
+                    m.amount,
+                    SettleRole::Confirm,
+                )
+                .await?,
+            );
+            touched.insert(leg.hold);
+        }
+        for hold in touched {
+            self.close_if_drained(&hold).await?;
+        }
+        Ok(receipts)
+    }
+
+    // -----------------------------------------------------------------------
+    // Void
+    // -----------------------------------------------------------------------
+
+    /// Void the entire inflight transaction: return every hold's remaining
+    /// balance to the funders recorded in the leg table and close the holds.
+    pub async fn void(
+        self: &Arc<Self>,
+        inflight: &EnvelopeId,
+    ) -> Result<Vec<Receipt>, LedgerError> {
+        let (record, legs) = self.load_inflight(inflight).await?;
+        let book = record.envelope.book();
+        let mut receipts = Vec::new();
+        for hold in holds_of(&legs) {
+            let dest = destination_of(&legs, hold, *inflight)?;
+            for asset in assets_of(&legs, hold) {
+                let mut remaining = self.balance(&hold, &asset).await?;
+                // Return to funders in leg order, each up to what it funded. For
+                // the common single-funder-per-(hold, asset) case this returns the
+                // whole remaining balance to that funder.
+                let mut funders: Vec<(AccountId, Cent)> = legs
+                    .iter()
+                    .filter(|l| l.hold == hold && l.asset == asset)
+                    .map(|l| (l.funder, l.amount))
+                    .collect();
+                // Ensure any co-funding rounding leftover lands on the last funder.
+                if let Some(last) = funders.last_mut() {
+                    last.1 = Cent::from(i64::MAX);
+                }
+                for (funder, cap) in funders {
+                    if !remaining.is_positive() {
+                        break;
+                    }
+                    let give = if cap < remaining { cap } else { remaining };
+                    if give.is_positive() {
+                        receipts.push(
+                            self.settle(
+                                book,
+                                *inflight,
+                                hold,
+                                funder,
+                                dest,
+                                asset,
+                                give,
+                                SettleRole::Void,
+                            )
+                            .await?,
+                        );
+                        remaining = remaining.checked_sub(give)?;
+                    }
+                }
+            }
+            self.close_if_drained(&hold).await?;
+        }
+        Ok(receipts)
+    }
+
+    // -----------------------------------------------------------------------
+    // Status / queries
+    // -----------------------------------------------------------------------
+
+    /// Derived status of an inflight transaction: per-leg authorized, confirmed,
+    /// voided, and still-held amounts, plus an overall state. All figures come
+    /// from balances and the metadata-tagged settling transfers.
+    pub async fn inflight_status(
+        &self,
+        inflight: &EnvelopeId,
+    ) -> Result<InflightStatus, LedgerError> {
+        let (_record, legs) = self.load_inflight(inflight).await?;
+
+        // Authorized per (hold, asset).
+        let mut authorized: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+        for l in &legs {
+            let e = authorized.entry((l.hold, l.asset)).or_insert(Cent::ZERO);
+            *e = e.checked_add(l.amount)?;
+        }
+
+        // Confirmed / voided per (hold, asset), summed from settle transfers.
+        let mut confirmed: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+        let mut voided: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
+        for hold in holds_of(&legs) {
+            for record in self.history(&hold).await? {
+                let bucket = match read_meta(record.envelope.metadata()) {
+                    Some(InflightMeta::Confirm { .. }) => &mut confirmed,
+                    Some(InflightMeta::Void { .. }) => &mut voided,
+                    _ => continue,
+                };
+                for np in record.envelope.creates() {
+                    if np.owner == hold {
+                        continue; // change returned to the hold, not settled out
+                    }
+                    let e = bucket.entry((hold, np.asset)).or_insert(Cent::ZERO);
+                    *e = e.checked_add(np.value)?;
+                }
+            }
+        }
+
+        let mut lines = Vec::new();
+        for ((hold, asset), auth) in &authorized {
+            let held = self.balance(hold, asset).await?;
+            let dest = destination_of(&legs, *hold, *inflight)?;
+            lines.push(InflightLegStatus {
+                destination: dest,
+                hold: *hold,
+                asset: *asset,
+                authorized: *auth,
+                confirmed: confirmed
+                    .get(&(*hold, *asset))
+                    .copied()
+                    .unwrap_or(Cent::ZERO),
+                voided: voided.get(&(*hold, *asset)).copied().unwrap_or(Cent::ZERO),
+                held,
+            });
+        }
+
+        let state = overall_state(&lines);
+        Ok(InflightStatus {
+            inflight: *inflight,
+            legs: lines,
+            state,
+        })
+    }
+
+    /// List the holding accounts of every currently open inflight (an
+    /// `INFLIGHT`-flagged account that is not closed).
+    pub async fn list_open_inflights(&self) -> Result<Vec<AccountId>, LedgerError> {
+        Ok(self
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| a.flags.contains(AccountFlags::INFLIGHT) && !a.is_closed())
+            .map(|a| a.id)
+            .collect())
+    }
+
+    // -----------------------------------------------------------------------
+    // Internal helpers
+    // -----------------------------------------------------------------------
+
+    /// Load the authorize transfer and decode its leg table.
+    async fn load_inflight(
+        &self,
+        inflight: &EnvelopeId,
+    ) -> Result<(EnvelopeRecord, Vec<InflightLeg>), LedgerError> {
+        let record = self
+            .store()
+            .get_transfer(inflight)
+            .await?
+            .ok_or(LedgerError::InflightNotFound(*inflight))?;
+        let legs = match read_meta(record.envelope.metadata()) {
+            Some(InflightMeta::Authorize { legs }) => legs,
+            _ => return Err(LedgerError::NotInflightTransaction(*inflight)),
+        };
+        Ok((record, legs))
+    }
+
+    /// Commit a `hold -> target` settling transfer tagged with the inflight role.
+    #[allow(clippy::too_many_arguments)]
+    async fn settle(
+        self: &Arc<Self>,
+        book: BookId,
+        inflight: EnvelopeId,
+        hold: AccountId,
+        target: AccountId,
+        destination: AccountId,
+        asset: AssetId,
+        amount: Cent,
+        role: SettleRole,
+    ) -> Result<Receipt, LedgerError> {
+        let meta = match role {
+            SettleRole::Confirm => InflightMeta::Confirm {
+                tx: inflight,
+                destination,
+            },
+            SettleRole::Void => InflightMeta::Void {
+                tx: inflight,
+                destination,
+            },
+        };
+        let tx = TransferBuilder::new()
+            .book(book)
+            .pay_ref(hold, target, asset, amount)
+            .metadata(meta_map(&meta)?)
+            .build();
+        self.commit(tx).await
+    }
+
+    /// Close a holding account once it has no live (Active/PendingInactive)
+    /// postings left. No-op if already closed or still holding funds.
+    async fn close_if_drained(&self, hold: &AccountId) -> Result<(), LedgerError> {
+        let live = self
+            .store()
+            .get_postings_by_account(hold.id, Some(hold.sub), None, None)
+            .await?
+            .into_iter()
+            .any(|p| p.status != PostingStatus::Inactive);
+        if live {
+            return Ok(());
+        }
+        if !self.get_account(hold).await?.is_closed() {
+            self.close(hold).await?;
+        }
+        Ok(())
+    }
+}
+
+/// Derive the shared subaccount id for an inflight from the submitted trade.
+/// Deterministic and known before the holds are created (unlike the authorize
+/// transfer's id). The sign bit is masked so the id is always positive.
+fn inflight_subaccount(transfer: &Transfer) -> u64 {
+    let mut buf = Vec::new();
+    // Serialization of a fixed `Transfer` is deterministic; the encode cannot
+    // fail for these types, but on any error fall back to the empty buffer so
+    // the result stays deterministic rather than panicking.
+    let _ = ciborium::into_writer(transfer, &mut buf);
+    let hash = double_sha256(&buf);
+    let mut first = [0u8; 8];
+    first.copy_from_slice(&hash[..8]);
+    u64::from_be_bytes(first)
+}
+
+fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountId> {
+    legs.iter().map(|l| l.hold).collect()
+}
+
+fn assets_of(legs: &[InflightLeg], hold: AccountId) -> BTreeSet<AssetId> {
+    legs.iter()
+        .filter(|l| l.hold == hold)
+        .map(|l| l.asset)
+        .collect()
+}
+
+fn destination_of(
+    legs: &[InflightLeg],
+    hold: AccountId,
+    inflight: EnvelopeId,
+) -> Result<AccountId, LedgerError> {
+    legs.iter()
+        .find(|l| l.hold == hold)
+        .map(|l| l.destination)
+        .ok_or_else(|| malformed(inflight))
+}
+
+fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
+    let mut any_held = false;
+    let mut any_confirmed = false;
+    let mut any_voided = false;
+    for l in lines {
+        if l.held.is_positive() {
+            any_held = true;
+        }
+        if l.confirmed.is_positive() {
+            any_confirmed = true;
+        }
+        if l.voided.is_positive() {
+            any_voided = true;
+        }
+    }
+    match (any_held, any_confirmed, any_voided) {
+        (true, false, false) => InflightState::Held,
+        (true, _, _) => InflightState::PartiallyConfirmed,
+        (false, true, true) => InflightState::Mixed,
+        (false, false, true) => InflightState::Voided,
+        // Fully settled to destinations, or an empty/zero authorization.
+        (false, _, false) => InflightState::Confirmed,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use kuatia_core::AccountId;
+
+    fn pay_trade(amount: i64) -> Transfer {
+        TransferBuilder::new()
+            .pay(
+                AccountId::new(1),
+                AccountId::new(2),
+                AssetId::new(1),
+                Cent::from(amount),
+            )
+            .build()
+    }
+
+    #[test]
+    fn inflight_subaccount_is_deterministic_and_trade_specific() {
+        let t1 = pay_trade(10);
+        // Same trade -> same subaccount, so re-authorizing collides with itself.
+        assert_eq!(inflight_subaccount(&t1), inflight_subaccount(&t1));
+        // A different trade -> a different subaccount, so it can run concurrently.
+        assert_ne!(
+            inflight_subaccount(&t1),
+            inflight_subaccount(&pay_trade(20))
+        );
+        // Never the main subaccount.
+        assert_ne!(inflight_subaccount(&t1), 0);
+    }
+}

+ 89 - 9
crates/kuatia/src/ledger.rs

@@ -189,7 +189,12 @@ impl Ledger {
             }
             let available = self
                 .store
-                .get_postings_by_account(account, Some(asset), Some(PostingStatus::Active))
+                .get_postings_by_account(
+                    account.id,
+                    Some(account.sub),
+                    Some(asset),
+                    Some(PostingStatus::Active),
+                )
                 .await?;
             let total_positive = Cent::checked_sum(
                 available
@@ -653,7 +658,7 @@ impl Ledger {
     ) -> Result<Cent, LedgerError> {
         let postings = self
             .store
-            .get_postings_by_account(account, Some(asset), None)
+            .get_postings_by_account(account.id, Some(account.sub), Some(asset), None)
             .await?;
         Ok(Cent::checked_sum(
             postings
@@ -741,7 +746,7 @@ impl Ledger {
         // (or none) permit a close.
         let blocking = self
             .store
-            .get_postings_by_account(id, None, None)
+            .get_postings_by_account(id.id, Some(id.sub), None, None)
             .await?
             .into_iter()
             .any(|p| p.status != PostingStatus::Inactive);
@@ -763,12 +768,73 @@ impl Ledger {
         Ok(())
     }
 
-    /// Query the current balance of an account for a given asset.
+    /// Balance of a single subaccount for a given asset. Names one subaccount, so
+    /// it is already segregated; for the account-wide, per-subaccount view use
+    /// [`balances`](Self::balances).
     #[instrument(skip(self), name = "ledger.balance")]
     pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
         self.compute_balance(account, asset).await
     }
 
+    /// Balances of a base account for a given asset, **always segregated by
+    /// subaccount** — one entry per non-closed subaccount, never a summed total.
+    /// `sub == None` spans every subaccount (the main plus every open hold);
+    /// `Some(s)` restricts to that one subaccount. Closed subaccounts are excluded.
+    #[instrument(skip(self), name = "ledger.balances")]
+    pub async fn balances(
+        &self,
+        account: &AccountId,
+        asset: &AssetId,
+        sub: Option<u64>,
+    ) -> Result<Vec<SubAccountBalance>, LedgerError> {
+        // Non-closed subaccounts of this base account (seeded at zero so a
+        // subaccount with no posting in this asset still reports segregated).
+        let mut by_sub: std::collections::BTreeMap<u64, Cent> = self
+            .store
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| {
+                a.id.id == account.id && !a.is_closed() && sub.is_none_or(|want| a.id.sub == want)
+            })
+            .map(|a| (a.id.sub, Cent::ZERO))
+            .collect();
+
+        let postings = self
+            .store
+            .get_postings_by_account(account.id, sub, Some(asset), None)
+            .await?;
+        for p in postings {
+            if p.status == PostingStatus::Inactive {
+                continue;
+            }
+            if let Some(bal) = by_sub.get_mut(&p.owner.sub) {
+                *bal = bal.checked_add(p.value)?;
+            }
+        }
+
+        Ok(by_sub
+            .into_iter()
+            .map(|(sub, balance)| SubAccountBalance { sub, balance })
+            .collect())
+    }
+
+    /// List the non-closed subaccounts of a base account.
+    #[instrument(skip(self), name = "ledger.list_subaccounts")]
+    pub async fn list_subaccounts(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<AccountId>, LedgerError> {
+        Ok(self
+            .store
+            .list_accounts()
+            .await?
+            .into_iter()
+            .filter(|a| a.id.id == account.id && !a.is_closed())
+            .map(|a| a.id)
+            .collect())
+    }
+
     // -----------------------------------------------------------------------
     // Query layer
     // -----------------------------------------------------------------------
@@ -791,7 +857,10 @@ impl Ledger {
         &self,
         account: &AccountId,
     ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
-        Ok(self.store.get_transfers_for_account(account).await?)
+        Ok(self
+            .store
+            .get_transfers_for_account(account.id, Some(account.sub))
+            .await?)
     }
 
     /// Query transfers with filtering and pagination.
@@ -809,7 +878,7 @@ impl Ledger {
     ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
         Ok(self
             .store
-            .get_postings_by_account(account, None, None)
+            .get_postings_by_account(account.id, Some(account.sub), None, None)
             .await?)
     }
 
@@ -871,13 +940,23 @@ impl Ledger {
     }
 }
 
+/// One subaccount's balance for an asset. Balances are always returned
+/// segregated per subaccount, never summed across them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SubAccountBalance {
+    /// The subaccount id (`0` = the main subaccount).
+    pub sub: u64,
+    /// The balance held in that subaccount for the queried asset.
+    pub balance: Cent,
+}
+
 /// State loaded in phase 1, passed to the pure validation in phase 2.
 pub struct LoadedState {
     /// Postings being consumed by the envelope.
     pub consumed_postings: Vec<Posting>,
-    /// Accounts referenced by the envelope.
+    /// Accounts (subaccounts) referenced by the envelope.
     pub accounts: HashMap<AccountId, kuatia_core::Account>,
-    /// Current balances for all referenced (account, asset) pairs.
+    /// Current balances for all referenced (account reference, asset) pairs.
     pub balances: HashMap<(AccountId, AssetId), Cent>,
     /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
     pub book: Option<Book>,
@@ -1155,7 +1234,8 @@ mod recovery_tests {
         let active = ledger
             .store()
             .get_postings_by_account(
-                &AccountId::new(1),
+                AccountId::new(1).id,
+                None,
                 Some(&AssetId::new(1)),
                 Some(PostingStatus::Active),
             )

+ 4 - 0
crates/kuatia/src/lib.rs

@@ -5,6 +5,7 @@
 //! commit pipeline (load → plan → apply) behind a convenient async API.
 
 pub mod error;
+pub mod inflight;
 pub mod ledger;
 pub mod saga;
 
@@ -22,6 +23,9 @@ pub mod prelude {
     pub use kuatia_core::*;
 
     pub use crate::error::LedgerError;
+    pub use crate::inflight::{
+        Authorization, InflightLeg, InflightLegStatus, InflightState, InflightStatus,
+    };
     pub use crate::ledger::Ledger;
     pub use kuatia_storage::mem_store::InMemoryStore;
     pub use kuatia_storage::store::Store;

+ 380 - 0
crates/kuatia/tests/inflight.rs

@@ -0,0 +1,380 @@
+//! Integration tests for inflight holds (authorize / confirm / void).
+//!
+//! The running example is the ADR's confirmed trade between A and B with a fee
+//! account, spanning two assets:
+//!
+//! ```text
+//! A -> B   -> 100 EUR
+//! B -> A   ->  10 BTC
+//! A -> fee ->   1 BTC
+//! B -> fee ->   1 EUR
+//! ```
+//!
+//! Authorized, the funds park in per-destination holding accounts; `fee`'s hold
+//! collects EUR from B and BTC from A.
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use kuatia::prelude::*;
+
+fn eur() -> AssetId {
+    AssetId::new(1)
+}
+fn btc() -> AssetId {
+    AssetId::new(2)
+}
+fn a() -> AccountId {
+    AccountId::new(1)
+}
+fn b() -> AccountId {
+    AccountId::new(2)
+}
+fn fee() -> AccountId {
+    AccountId::new(3)
+}
+fn ext() -> AccountId {
+    AccountId::new(99)
+}
+
+fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    Account {
+        id: AccountId::new(id),
+        version: 1,
+        policy,
+        flags: AccountFlags::empty(),
+        book: BookId(0),
+        user_data: UserData::default(),
+        metadata: BTreeMap::new(),
+    }
+}
+
+async fn deposit(ledger: &Arc<Ledger>, to: AccountId, asset: AssetId, amount: i64) {
+    let t = TransferBuilder::new()
+        .deposit(to, asset, Cent::from(amount), ext())
+        .unwrap()
+        .build();
+    ledger.commit(t).await.unwrap();
+}
+
+/// A ledger with accounts A, B, fee, external; A holds 100 EUR + 1 BTC, B holds
+/// 10 BTC + 1 EUR.
+async fn setup() -> Arc<Ledger> {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    for id in [1, 2, 3] {
+        ledger
+            .store()
+            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .await
+            .unwrap();
+    }
+    ledger
+        .store()
+        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .await
+        .unwrap();
+    deposit(&ledger, a(), eur(), 100).await;
+    deposit(&ledger, a(), btc(), 1).await;
+    deposit(&ledger, b(), btc(), 10).await;
+    deposit(&ledger, b(), eur(), 1).await;
+    ledger
+}
+
+fn trade() -> Transfer {
+    TransferBuilder::new()
+        .pay(a(), b(), eur(), Cent::from(100))
+        .pay(b(), a(), btc(), Cent::from(10))
+        .pay(a(), fee(), btc(), Cent::from(1))
+        .pay(b(), fee(), eur(), Cent::from(1))
+        .build()
+}
+
+async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
+    ledger.balance(&account, &asset).await.unwrap()
+}
+
+/// A one-movement confirm set, built with the same `.pay()` interface as a
+/// transfer: `from` is the leg's funder, `to` its destination.
+fn confirm_one(from: AccountId, to: AccountId, asset: AssetId, amount: i64) -> Transfer {
+    TransferBuilder::new()
+        .pay(from, to, asset, Cent::from(amount))
+        .build()
+}
+
+/// After authorize, funds leave the payers and sit in the holds; the payers'
+/// balances drop to zero and nothing has reached the destinations yet.
+#[tokio::test]
+async fn authorize_parks_funds_in_holds() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // Payers emptied.
+    assert_eq!(bal(&ledger, a(), eur()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, a(), btc()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, b(), btc()).await, Cent::ZERO);
+
+    // Destinations untouched.
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
+
+    // Three holds are open, and status reports everything Held.
+    assert_eq!(ledger.list_open_inflights().await.unwrap().len(), 3);
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::Held);
+    let total_held: Cent = Cent::checked_sum(status.legs.iter().map(|l| l.held)).unwrap();
+    let total_auth: Cent = Cent::checked_sum(status.legs.iter().map(|l| l.authorized)).unwrap();
+    assert_eq!(total_held, total_auth);
+}
+
+/// Confirming the whole transaction settles every leg to its destination and
+/// closes the holds. The net result equals the original trade.
+#[tokio::test]
+async fn confirm_all_settles_to_destinations() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    ledger.confirm_all(&auth.inflight).await.unwrap();
+
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
+    assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(10));
+    assert_eq!(bal(&ledger, fee(), eur()).await, Cent::from(1));
+    assert_eq!(bal(&ledger, fee(), btc()).await, Cent::from(1));
+
+    // Holds drained and closed.
+    assert!(ledger.list_open_inflights().await.unwrap().is_empty());
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::Confirmed);
+}
+
+/// Voiding returns every held posting to the funder recorded in the leg table,
+/// including the multi-asset fee hold funded by two different accounts.
+#[tokio::test]
+async fn void_returns_funds_to_funders() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    ledger.void(&auth.inflight).await.unwrap();
+
+    // Everyone is back where they started.
+    assert_eq!(bal(&ledger, a(), eur()).await, Cent::from(100));
+    assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(1));
+    assert_eq!(bal(&ledger, b(), btc()).await, Cent::from(10));
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(1));
+    assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, fee(), btc()).await, Cent::ZERO);
+
+    assert!(ledger.list_open_inflights().await.unwrap().is_empty());
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::Voided);
+}
+
+/// A partial confirm delivers a slice and leaves the remainder held. Confirming
+/// the rest drains and closes the hold.
+#[tokio::test]
+async fn partial_confirm_then_confirm_remainder() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    ledger
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 40))
+        .await
+        .unwrap();
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(40));
+
+    // The B/EUR leg is partially confirmed.
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    let leg = status
+        .legs
+        .iter()
+        .find(|l| l.destination.id == b().id && l.asset == eur())
+        .unwrap();
+    assert_eq!(leg.authorized, Cent::from(100));
+    assert_eq!(leg.confirmed, Cent::from(40));
+    assert_eq!(leg.held, Cent::from(60));
+    assert_eq!(status.state, InflightState::PartiallyConfirmed);
+
+    // Confirm the rest.
+    ledger
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 60))
+        .await
+        .unwrap();
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
+    // The B hold is now closed (its only asset drained).
+    assert!(
+        !ledger
+            .list_open_inflights()
+            .await
+            .unwrap()
+            .contains(&leg.hold)
+    );
+}
+
+/// A partial confirm followed by a void: the slice reaches the destination and
+/// the remainder returns to the funder.
+#[tokio::test]
+async fn partial_confirm_then_void_remainder() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    ledger
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 40))
+        .await
+        .unwrap();
+    ledger.void(&auth.inflight).await.unwrap();
+
+    // B kept the confirmed 40 EUR from its own hold, and got its 1 EUR fee
+    // contribution back from the (now voided) fee hold: 41 total. A got the
+    // remaining 60 EUR of B's hold back.
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(41));
+    assert_eq!(bal(&ledger, a(), eur()).await, Cent::from(60));
+
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    let leg = status
+        .legs
+        .iter()
+        .find(|l| l.destination.id == b().id && l.asset == eur())
+        .unwrap();
+    assert_eq!(leg.confirmed, Cent::from(40));
+    assert_eq!(leg.voided, Cent::from(60));
+    assert_eq!(leg.held, Cent::ZERO);
+    assert_eq!(status.state, InflightState::Mixed);
+}
+
+/// Confirming more than is held is rejected. The `NoOverdraft` hold makes
+/// over-confirmation impossible.
+#[tokio::test]
+async fn over_confirm_is_rejected() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    let err = ledger
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 101))
+        .await
+        .unwrap_err();
+    assert!(matches!(err, LedgerError::Selection(_)));
+    // Nothing moved.
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
+}
+
+/// A single confirm call settles several legs at once, built with the same
+/// `.pay()` interface as a transfer.
+#[tokio::test]
+async fn batch_confirm_multiple_legs() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // Confirm B's EUR leg and A's BTC leg in one call.
+    let confirms = TransferBuilder::new()
+        .pay(a(), b(), eur(), Cent::from(100))
+        .pay(b(), a(), btc(), Cent::from(10))
+        .build();
+    let receipts = ledger.confirm(&auth.inflight, confirms).await.unwrap();
+    assert_eq!(receipts.len(), 2);
+
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
+    assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(10));
+    // The fee hold is untouched, so it is still open.
+    assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, fee(), btc()).await, Cent::ZERO);
+    assert_eq!(ledger.list_open_inflights().await.unwrap().len(), 1);
+
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::PartiallyConfirmed);
+}
+
+/// Confirming a movement whose `(from, to, asset)` matches no leg is rejected.
+#[tokio::test]
+async fn confirm_unknown_leg_is_rejected() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // fee never funded a BTC leg to B.
+    let err = ledger
+        .confirm(&auth.inflight, confirm_one(fee(), b(), btc(), 1))
+        .await
+        .unwrap_err();
+    assert!(matches!(err, LedgerError::InflightLegNotFound { .. }));
+}
+
+/// A destination can hold several concurrent inflights (one per distinct trade,
+/// each under its own subaccount), but the *same* trade cannot be authorized
+/// twice while open (its holds already exist).
+#[tokio::test]
+async fn concurrent_inflights_per_account() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // Re-authorizing the identical trade collides on the derived hold subaccount.
+    let err = ledger.authorize(trade()).await.unwrap_err();
+    assert!(matches!(err, LedgerError::InflightAlreadyOpen(_)));
+
+    // A different trade to the same destination B opens a second, independent
+    // inflight under a different subaccount.
+    deposit(&ledger, a(), eur(), 10).await;
+    let other = TransferBuilder::new()
+        .pay(a(), b(), eur(), Cent::from(10))
+        .build();
+    let auth2 = ledger.authorize(other).await.unwrap();
+    assert_ne!(auth.inflight, auth2.inflight);
+
+    // Both are open at once: B has two inflight holds under distinct subaccounts.
+    let b_holds = ledger
+        .list_subaccounts(&b())
+        .await
+        .unwrap()
+        .into_iter()
+        .filter(|r| r.sub != 0)
+        .count();
+    assert_eq!(b_holds, 2);
+}
+
+/// After a full confirm closes the holds, a fresh inflight to the same
+/// destinations is allowed again.
+#[tokio::test]
+async fn reauthorize_after_settlement() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+    ledger.confirm_all(&auth.inflight).await.unwrap();
+
+    // B now holds 100 EUR; authorize a new hold of 30 of it to fee.
+    let again = TransferBuilder::new()
+        .pay(b(), fee(), eur(), Cent::from(30))
+        .build();
+    let auth2 = ledger.authorize(again).await.unwrap();
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(70));
+    ledger.confirm_all(&auth2.inflight).await.unwrap();
+    assert_eq!(bal(&ledger, fee(), eur()).await, Cent::from(31));
+}
+
+/// Operating on a non-inflight or unknown transfer id is a clean error.
+#[tokio::test]
+async fn unknown_inflight_is_an_error() {
+    let ledger = setup().await;
+    let bogus = EnvelopeId([7u8; 32]);
+    assert!(matches!(
+        ledger.confirm_all(&bogus).await.unwrap_err(),
+        LedgerError::InflightNotFound(_)
+    ));
+}
+
+/// Balances are always segregated by subaccount: the account query lists the
+/// main subaccount and each open hold separately, never summed.
+#[tokio::test]
+async fn balances_are_segregated_by_subaccount() {
+    let ledger = setup().await;
+    let _auth = ledger.authorize(trade()).await.unwrap();
+
+    // B's EUR across subaccounts: the main (0, now empty) and its inflight hold.
+    let all = ledger.balances(&b(), &eur(), None).await.unwrap();
+    let main = all.iter().find(|e| e.sub == 0).unwrap();
+    assert_eq!(main.balance, Cent::ZERO); // B's own 1 EUR went into the fee hold
+    let hold = all.iter().find(|e| e.sub != 0).unwrap();
+    assert_eq!(hold.balance, Cent::from(100)); // A's 100 EUR parked for B
+    assert_eq!(all.len(), 2);
+
+    // Filtering to the main subaccount returns only it (still segregated form).
+    let only_main = ledger.balances(&b(), &eur(), Some(0)).await.unwrap();
+    assert_eq!(only_main.len(), 1);
+    assert_eq!(only_main[0].sub, 0);
+}

+ 24 - 4
crates/kuatia/tests/integration.rs

@@ -410,12 +410,22 @@ async fn fx_trade_via_market_account() {
     // Build the atomic envelope manually since it spans two assets
     let a1_usd_postings = ledger
         .store()
-        .get_postings_by_account(&account(1), Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(
+            account(1).id,
+            None,
+            Some(&usd()),
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
     let fx_eur_postings = ledger
         .store()
-        .get_postings_by_account(&account(50), Some(&eur()), Some(PostingStatus::Active))
+        .get_postings_by_account(
+            account(50).id,
+            None,
+            Some(&eur()),
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
 
@@ -539,7 +549,12 @@ async fn close_rejects_reserved_postings() {
     // Reserve the account's only posting (a transfer in flight): Active → PendingInactive.
     let postings = ledger
         .store()
-        .get_postings_by_account(&account(1), Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(
+            account(1).id,
+            None,
+            Some(&usd()),
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
     ledger
@@ -778,7 +793,12 @@ async fn capped_overdraft_creates_negative_posting() {
     // A negative posting now backs the overdraft.
     let postings = ledger
         .store()
-        .get_postings_by_account(&account(10), Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(
+            account(10).id,
+            None,
+            Some(&usd()),
+            Some(PostingStatus::Active),
+        )
         .await
         .unwrap();
     assert!(postings.iter().any(|p| p.value == Cent::from(-50)));

+ 21 - 2
doc/accounts.md

@@ -13,7 +13,7 @@ the ledger balance.
 
 | Field | Type | Description |
 |-------|------|-------------|
-| `id` | `AccountId(i64)` | Stable identity, assigned at creation |
+| `id` | `AccountId { id: i64, sub: u64 }` | Stable identity (base id + subaccount); `sub = 0` is the main account |
 | `version` | `u64` | Starts at 1, increments on every mutation |
 | `policy` | `AccountPolicy` | Balance floor rule (see below) |
 | `flags` | `AccountFlags` | Lifecycle flags (`FROZEN`, `CLOSED`) + user-defined (`USER_0` to `USER_7`) |
@@ -98,9 +98,28 @@ preventing TOCTOU races where an account is mutated between load and apply.
 
 The saga `commit()` path auto-populates snapshots when none are provided.
 
+## Subaccounts
+
+An account is identified by a base id plus a `u64` **subaccount**, written
+`AccountId { id, sub }`; `sub = 0` is the main account. Each `(id,
+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)).
+
+Balances are reported **segregated per subaccount**, never summed across them:
+
+- `balance(&AccountId, asset)` reads one subaccount.
+- `balances(&AccountId, asset, sub)` returns one entry per non-closed subaccount
+  (`sub = None` spans all; `Some(s)` filters to one). Closed subaccounts are
+  excluded.
+- `list_subaccounts(&AccountId)` lists the non-closed subaccounts of a base
+  account.
+
 ## Balance Computation
 
-Balance for an (account, asset) pair is computed as:
+Balance for an (account reference, asset) pair is computed as:
 
 ```
 balance(account, asset) = sum(p.value for p in postings

+ 294 - 0
doc/adr/0004-inflight-holds-via-holding-accounts.md

@@ -0,0 +1,294 @@
+# Inflight holds via per-destination holding accounts
+
+> Revised by [ADR-0005](0005-subaccount-dimension.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
+> holds.
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-03
+* Targeted modules: `kuatia` (`ledger`, `saga`), `kuatia-types`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+Callers need to reserve funds for a trade without settling it yet: authorize the
+whole trade now, then later confirm it (in full or in parts) or void it. This is
+the authorization/capture pattern, applied to a multi-leg trade rather than a
+single payment. A confirmed trade like
+
+```
+A -> B   -> 100 EUR
+B -> A   -> 0.1 BTC
+A -> fee -> 0.0001 BTC
+B -> fee -> 1 EUR
+```
+
+should be expressible in an inflight form where the funds leave the payers now
+but are parked until each leg is confirmed or returned.
+
+The tension is with the ledger's core model. Kuatia is append-only and
+UTXO-style: value is signed postings that move between accounts, balances are
+derived (never stored), and there is no mutable state to update in place. The
+only reservation concept today is the transient `PendingInactive` posting status
+stamped with a `ReservationId`. That is a short-lived concurrency primitive owned
+by a single in-flight saga, not a durable, user-facing hold. Recovery
+(`Ledger::recover`) treats any `PendingInactive` posting as a saga to complete or
+release, and a hold can stay open far longer than a saga.
+
+A further constraint: this must use the existing storage. No new `Store`
+sub-trait and no migration. The accounts table and the transactions table have to
+carry everything.
+
+How do we represent a durable, partially-confirmable, multi-leg reservation
+without adding mutable state, a parallel recovery mechanism, a new store, or
+domain logic in the storage layer?
+
+## Decision Drivers
+
+* **Append-only**: a hold must be a durable fact recorded in the ledger, not a
+  lock held in memory or a transient status.
+* **Reuse the commit path**: authorize, confirm, and void should ride the
+  existing content-addressed, idempotent, crash-safe `commit_envelope` saga and
+  its `recover()` roll-forward, not a second bespoke mechanism.
+* **No mutable balance / derive, don't store**: the amount still held must be a
+  derived balance, not a counter decremented on each confirmation.
+* **Existing storage only**: state lives in the accounts and transactions tables.
+  No new store, no migration, no arithmetic pushed into SQL.
+* **Self-describing via metadata**: the inflight facts (the transaction id, the
+  leg table, the role of each transfer, the funder per leg) are carried in the
+  metadata of the holding accounts and the transfers, so the lifecycle is read
+  from recorded fields rather than inferred from movement direction.
+* **Safety by construction**: no double-spend, and no confirming more than was
+  authorized, enforced by existing invariants rather than new guards.
+* **Auditability and simplicity**: the full history of a request should be
+  readable from the ledger, and the feature should add as little surface as
+  possible.
+
+## Considered Options
+
+#### Option 1: Promote the transient `PendingInactive` reservation to a durable hold
+
+Keep the payers' postings in place and hold them as `PendingInactive` for the
+whole authorization window. Available balance already excludes `PendingInactive`,
+so the funds would look reserved.
+
+**Pros:**
+
+* Good, because it reuses the existing reservation stamp and moves no funds.
+* Good, because the available-vs-ledger balance split already models "reserved
+  but not spent."
+
+**Cons:**
+
+* Bad, because it breaks recovery: `recover()` treats every `PendingInactive`
+  posting as an in-flight saga to roll forward or release, so a durable hold would
+  be torn down or double-driven by the first startup recovery pass.
+* Bad, because a reservation is all-or-nothing on a whole posting. Partial
+  confirmation has no representation, and there is nowhere to keep the change from
+  a partial confirmation.
+* Bad, because it conflates a short-lived concurrency primitive with a long-lived
+  business state, and pins postings under a saga-owned lock for an unbounded time.
+
+#### Option 2: Add a new posting state (`Held`) for reserved funds
+
+Introduce a fourth posting status that keeps funds attached to the payer but
+marks them reserved for a specific request, with a new primitive to split a held
+posting on partial confirmation.
+
+**Pros:**
+
+* Good, because the reservation is explicit and the funds visibly stay on the
+  payer.
+
+**Cons:**
+
+* Bad, because a new state touches every layer: the balance rule, validation, the
+  store trait and both backends, recovery, and the whole conformance suite.
+* Bad, because partial confirmation forces a posting-splitting primitive, which is
+  new domain logic pushed back toward the store.
+* Bad, because it grows a special case into a model whose whole point is that
+  value is just postings moving between accounts. Larger surface, higher risk.
+
+#### Option 3: Rewrite each destination to a per-destination holding account (chosen)
+
+Model an inflight transaction as the ordinary trade with every destination
+`to` rewritten to a fresh holding account created for that destination:
+
+```
+A -> B.inflight   -> 100 EUR
+B -> A.inflight   -> 0.1 BTC
+A -> fee.inflight -> 0.0001 BTC
+B -> fee.inflight -> 1 EUR
+```
+
+Committing that rewritten transfer is the authorize step: one atomic,
+conservation-preserving commit moves the funds out of A and B into the holding
+accounts. That commit is stored in the transactions table like any other, and its
+content-addressed `EnvelopeId` is the inflight handle. The metadata carries the
+record across every artifact: the authorize transfer's metadata declares its role
+and the full leg table `[(destination, hold, funder, asset, amount)]`; each
+holding account's metadata records its role and its destination; each later
+confirm or void transfer's metadata records its role, the inflight handle, and the
+leg it settles. The metadata is therefore the record of what is held and for whom,
+and it is content-addressed into each transfer's id, so it is tamper-evident. A
+hold is keyed by destination,
+so `fee.inflight` legitimately holds two assets funded by two different accounts.
+
+The lifecycle operations are ordinary commits, each driven from the leg table in
+the authorize transfer's metadata:
+
+* **Confirm all (no amount)**: for each leg, sweep the holding account's balance
+  to its destination. The net effect equals the original confirmed trade.
+* **Partial confirm**: commit `X.inflight -> X` for a slice. The remainder stays
+  held.
+* **Void**: for each leg, return the holding account's remaining balance to the
+  funder named in the leg table.
+
+A hold closes when its balance reaches zero; the transaction is terminal when all
+its holds are closed. Whether a leg was confirmed or voided is read from the
+transactions table (the leg's settling transfer goes to the destination on
+confirm, to the funder on void).
+
+To keep one hold per destination unambiguous, an account may take part in **at
+most one open inflight at a time**. This is enforced at authorize by recording the
+real destinations in the authorize transfer's involved-account set (the
+`involved` argument to `store_transfer`), so `get_transfers_for_account` surfaces
+an open hold for a destination even before it is settled.
+
+**Pros:**
+
+* Good, because it adds no posting state, no saga phase, no new store, and no
+  migration. Every operation is an existing `commit`, so idempotency, content
+  addressing, and crash recovery are inherited unchanged.
+* Good, because the amount still held is the holding account's balance, a derived
+  value. Nothing mutable is stored or decremented.
+* Good, because partial confirmation is just another transfer, with change handled
+  by the normal resolve step.
+* Good, because over-confirmation is impossible by construction: the holding
+  account is `NoOverdraft`, so a confirmation exceeding its balance fails
+  validation. The sum of confirmations can never exceed the authorized amount.
+* Good, because concurrent confirmations serialize on the shared holding posting
+  via the reservation protocol, so double-spend safety and the over-confirm bound
+  hold under contention with no new locking.
+* Good, because void routing reads the funder from the stored authorize transfer,
+  so it needs no change to `resolve()` and no reliance on posting provenance.
+* Good, because the request's entire history is the holds and the transactions
+  that touch them: the authorize, each confirmation, and the void.
+
+**Cons:**
+
+* Bad, because it creates one holding account per destination per request.
+  Mitigated by closing terminal holds so they leave the working set. Accounts are
+  cheap, snowflake-keyed rows.
+* Bad, because "one open inflight per account" is a real restriction: a second
+  authorize touching a destination with an open hold is rejected until the first
+  is confirmed or voided.
+* Bad, because a single `(hold, asset)` co-funded by two payers cannot cleanly
+  split a partially-confirmed remainder back to each funder on void; out of scope,
+  documented. Each `(destination, asset)` is expected to have a single funder, as
+  in the example.
+* Bad, because voiding returns funds to the original payer, so that account must
+  still be open to receive them.
+
+## Decision Outcome
+
+Chosen option: **Option 3, per-destination holding accounts, backed by existing
+storage**, because it is the only option that expresses a durable,
+partially-confirmable, multi-leg hold purely as existing ledger primitives, with
+no new store. It adds no mutable state, reuses the commit and recovery path
+wholesale, and gets double-spend and over-confirm safety from invariants the
+ledger already enforces. Concretely:
+
+* **Authorize rewrites destinations.** For an inflight transaction, each movement
+  `from -> to` becomes `from -> hold(to)`, where `hold(to)` is a fresh
+  `NoOverdraft` account flagged `INFLIGHT` whose metadata records its destination.
+  The rewritten transfer is committed normally with the leg table in its metadata,
+  and its content-addressed `EnvelopeId` becomes the inflight handle.
+* **Metadata is the record.** Confirm and void load the authorize transfer with
+  `get_transfer` / `get_transfers_for_account` and read the leg table and funders
+  straight from its metadata, rather than reconstructing them from movement
+  directions. No side record and no new store. The real destinations are added to
+  the transfer's `involved` set so an open hold is discoverable per destination,
+  which is how "one open inflight per account" is enforced.
+* **Confirm and void are ordinary transfers, tagged in metadata.** Confirm-all
+  commits `hold -> destination` for each leg's balance; partial confirm commits
+  `hold -> destination` for a slice; void commits `hold -> funder` per leg, with
+  the funder taken from the leg table. Each settling transfer carries its role
+  (`confirm` or `void`), the inflight handle, and the leg it settles in metadata.
+  All go through `commit`, so all are idempotent and crash-safe. Confirm accepts a
+  batch of legs to settle in one call, expressed with the same
+  `(from, to, asset, amount)` shape as `TransferBuilder::pay` (`from` the funder,
+  `to` the destination); the movements settle in order, each its own commit, so a
+  batch is not atomic.
+* **State is derived.** The amount held on a leg is `balance(hold, asset)`. The
+  authorized amount is the leg's amount in the metadata leg table. Confirmed is
+  authorized minus held. Whether a leg was confirmed or voided is read from the
+  role tag on its settling transfer, not inferred from where the funds went.
+* **Termination closes the holds.** When a hold balance reaches zero, close it
+  (legal, since it then has zero active postings). The inflight transaction is done
+  when all its holds are closed.
+
+Because void reads the funder from the leg table in metadata rather than from
+posting provenance, `resolve()` is left unchanged (the change posting keeps its
+current `payer: None`).
+
+### Inflight metadata schema
+
+The payload is a single CBOR-encoded tagged enum stored under one `inflight` key
+in the existing `Metadata` map (`BTreeMap<String, Vec<u8>>`), via `ciborium`.
+This supersedes the earlier per-key big-endian byte layout: one typed value
+instead of hand-packed fields.
+
+```rust
+enum InflightMeta {
+    Authorize { legs: Vec<InflightLeg> }, // on the authorize transfer
+    Hold { destination: AccountId },      // on each holding account
+    Confirm { tx: EnvelopeId, destination: AccountId }, // on a confirm transfer
+    Void { tx: EnvelopeId, destination: AccountId },    // on a void transfer
+}
+```
+
+Each `InflightLeg` is `{ destination, hold, funder, asset, amount }`. The
+inflight handle is the authorize transfer's content-addressed `EnvelopeId`; the
+`tx` field on a settling transfer back-references it.
+
+Open holds are discovered by scanning `INFLIGHT`-flagged, not-closed accounts and
+reading their `Hold` metadata; the flag is the marker (metadata is carried, not
+queried). Everything semantic (leg table, funders, per-transfer role) is read
+from the enum. Because metadata is hashed as opaque bytes into each transfer id,
+the payload is tamper-evident; `ciborium` is deterministic for a fixed value.
+
+### Positive Consequences
+
+* The feature is a thin layer over `commit`, `create_account`, `get_transfer`,
+  `balance`, and `close`. Crash recovery, idempotency, and conservation come for
+  free, and no storage schema changes.
+* The over-confirm bound and double-spend safety are structural: they follow from
+  `NoOverdraft` and the reservation protocol, with no request-specific checks.
+* The audit trail is self-describing: a request's holds and the transactions that
+  touch them fully reconstruct what was authorized, confirmed, and returned.
+
+### Negative Consequences
+
+* One holding account per destination per request, and one open inflight per
+  account at a time. Terminal holds are closed to bound the open working set, but
+  the accounts table still grows with history (as postings and transfers already
+  do).
+* A single `(hold, asset)` co-funded by two payers has an ambiguous
+  partially-confirmed remainder on void; out of scope, documented.
+* Voiding depends on the payer account remaining open. A policy for holds that
+  outlive their payer is out of scope here.
+
+## Links
+
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (signed postings)
+  and [ADR-0003](0003-dumb-storage-saga-recovery.md) (dumb storage, saga
+  recovery). Reuses the `commit_envelope` path and `recover()` unchanged, and adds
+  no new store.
+* Background: [architecture.md](../architecture.md) (commit pipeline, posting
+  lifecycle, resolve and change outputs), [accounts.md](../accounts.md) (policies,
+  account lifecycle).
+* Usage and API to be documented in `doc/inflight.md`.

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

@@ -0,0 +1,155 @@
+# 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).

+ 10 - 0
doc/glossary.md

@@ -31,6 +31,16 @@ the sum of non-inactive postings for a given (account, asset) pair.
 Accounts have a **policy** (balance floor rule), **flags** (lifecycle +
 user-defined), and a **book** assignment.
 
+### Subaccount / AccountId
+
+An account is identified by a base id plus a `u64` subaccount
+(`AccountId { id, sub }`); `sub = 0` is the main account. Each `(account,
+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).
+
 ### Asset
 
 An identifier (`AssetId(u32)`) representing a unit of value: a currency, a

+ 134 - 0
doc/inflight.md

@@ -0,0 +1,134 @@
+# Inflight holds
+
+Inflight holds let you reserve funds now and settle later: authorize a trade,
+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).
+This page is the usage guide.
+
+## Model
+
+An inflight transaction is an ordinary trade whose every destination is
+rewritten to a fresh per-destination **holding account** (`NoOverdraft`, flagged
+`INFLIGHT`). Committing that rewritten transfer parks the funds:
+
+```text
+Confirmed trade            Inflight form
+-----------------          --------------------------------
+A -> B   -> 100 EUR        A -> hold(B)   -> 100 EUR
+B -> A   ->  10 BTC        B -> hold(A)   ->  10 BTC
+A -> fee ->   1 BTC        A -> hold(fee) ->   1 BTC
+B -> fee ->   1 EUR        B -> hold(fee) ->   1 EUR
+```
+
+A hold is keyed by destination, so `hold(fee)` collects EUR from B and BTC from
+A. Each holding posting's funder is recorded in the authorize transfer's leg
+table, so a void returns each posting to the account that paid it.
+
+Nothing new is stored. The authorize transfer is the record: its `EnvelopeId` is
+the inflight handle, and its metadata carries the leg table. Every artifact
+(holding accounts, authorize, confirm, void) is tagged with a CBOR-encoded
+payload under a single `inflight` metadata key, so the lifecycle is read from
+recorded fields, not inferred.
+
+## Lifecycle
+
+```mermaid
+stateDiagram-v2
+    [*] --> Held: authorize
+    Held --> Held: confirm (partial)
+    Held --> Confirmed: confirm_all / drained
+    Held --> Voided: void
+    Confirmed --> [*]
+    Voided --> [*]
+```
+
+Every operation is an ordinary `commit`, so idempotency, conservation, and crash
+recovery are inherited unchanged. A hold closes automatically once drained.
+
+## API
+
+All methods hang off `Ledger`.
+
+```rust
+use kuatia::prelude::*;
+
+// Authorize the trade. Funds leave A and B and park in the holds.
+let trade = TransferBuilder::new()
+    .pay(a, b, eur, Cent::from(100))
+    .pay(b, a, btc, Cent::from(10))
+    .pay(a, fee, btc, Cent::from(1))
+    .pay(b, fee, eur, Cent::from(1))
+    .build();
+let auth = ledger.authorize(trade).await?;
+
+// Confirm one or more legs, built with the same .pay() interface as a transfer
+// (from = funder, to = destination). Deliver 40 EUR of B's hold to B now:
+let some = TransferBuilder::new()
+    .pay(a, b, eur, Cent::from(40))
+    .build();
+ledger.confirm(&auth.inflight, some).await?;
+
+// Confirm everything else and close the holds.
+ledger.confirm_all(&auth.inflight).await?;
+
+// ...or return everything to the funders instead.
+ledger.void(&auth.inflight).await?;
+
+// Derived status: per-leg authorized / confirmed / voided / held, plus state.
+let status = ledger.inflight_status(&auth.inflight).await?;
+
+// The holding accounts of every open inflight.
+let open = ledger.list_open_inflights().await?;
+```
+
+`authorize` returns an `Authorization { inflight, receipt, legs }`. The
+`inflight` field (an `EnvelopeId`) is the handle passed to every other call.
+
+## Guarantees
+
+- **Over-confirmation is impossible.** A hold is `NoOverdraft`, so confirming
+  more than it holds fails validation. The sum of confirmations can never exceed
+  the authorized amount.
+- **No double-spend under concurrency.** Concurrent confirmations serialize on
+  the shared holding posting via the reservation protocol. On contention, one
+  wins and the caller retries the other against the new remaining balance.
+- **State is derived.** The amount still held on a leg is `balance(hold, asset)`.
+  Confirmed and voided amounts are summed from the metadata-tagged settling
+  transfers. Nothing mutable is stored.
+
+## Subaccounts and concurrency
+
+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
+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)`.
+Re-authorizing the *identical* trade collides on the existing hold and is
+rejected. Balances are always segregated per subaccount: `ledger.balances(&base,
+&asset, None)` lists the main account and every open hold separately, never
+summed.
+
+## Constraints and limitations
+
+- **Distinct movements.** Every movement in an authorize must move between two
+  different accounts.
+- **Void needs an open payer.** Voiding returns funds to the original funder, so
+  that account must still be open.
+- **Single funder per `(hold, asset)`.** When two accounts fund the same asset
+  into the same destination hold, a partially-confirmed remainder cannot be
+  split back to each funder exactly; void returns it in leg order.
+- **Books.** A hold is created in the authorize transfer's book. If that book
+  restricts participation by flag or account, it must admit the holds (for
+  example by allowing the `INFLIGHT` flag).
+
+## Where it lives
+
+- `crates/kuatia/src/inflight.rs` — the API and metadata schema.
+- `crates/kuatia/tests/inflight.rs` — authorize, confirm, partial confirm, void,
+  over-confirm rejection, concurrent inflights per account, segregated balances,
+  and status tests.
+- `AccountFlags::INFLIGHT` — `crates/kuatia-types/src/lib.rs`.