浏览代码

Add inflight holds via per-destination holding subaccounts

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.

A hold reuses the existing account subaccount dimension: it 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.
Balances are read per subaccount and never summed, so a hold's remaining amount
is just its balance.

Everything rides the existing commit and recover path, so idempotency,
conservation, and crash recovery are inherited, with no new store, sub-trait,
or migration. Recorded in ADR-0013 (inflight holds via holding accounts),
building on the subaccount dimension from ADR-0012.
Cesar Rodas 2 天之前
父节点
当前提交
bd2d16ee58

+ 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"

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

@@ -22,9 +22,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",

+ 28 - 28
crates/kuatia-types/src/lib.rs

@@ -83,7 +83,7 @@ pub struct AccountId {
 /// 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],
@@ -609,7 +609,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,
@@ -646,7 +646,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,
@@ -724,7 +724,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();
@@ -831,7 +831,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;
@@ -855,7 +858,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,
@@ -872,10 +875,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,
@@ -920,9 +928,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,
@@ -959,8 +967,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,
@@ -976,28 +989,15 @@ 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 movement between two specific subaccounts. Identical to
-    /// [`movement`](Self::movement) — `from`/`to` already carry a subaccount —
-    /// but names the subaccount intent at the call site (ADR-0012).
-    pub fn movement_ref(
-        self,
-        from: AccountId,
-        to: AccountId,
-        asset: AssetId,
-        amount: Cent,
-    ) -> Self {
-        self.movement(from, to, asset, amount)
-    }
-
     /// Add a pay movement between two specific subaccounts. See
     /// [`movement_ref`](Self::movement_ref).
     pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
-        self.movement(from, to, asset, amount)
+        self.movement_ref(from, to, asset, amount)
     }
 
     /// Add a deposit: creates an offset posting on the external account and

+ 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
 

+ 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,

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

@@ -0,0 +1,641 @@
+//! 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/0013-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) -> i64 {
+    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]);
+    // Mask the sign bit so the subaccount id is always positive.
+    (u64::from_be_bytes(first) & i64::MAX as u64) as i64
+}
+
+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);
+    }
+}

+ 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.account.sub == 0).unwrap();
+    assert_eq!(main.value, Cent::ZERO); // B's own 1 EUR went into the fee hold
+    let hold = all.iter().find(|e| e.account.sub != 0).unwrap();
+    assert_eq!(hold.value, 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].account.sub, 0);
+}

+ 20 - 1
doc/accounts.md

@@ -137,9 +137,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 an `i64` **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/0012-subaccounts.md](adr/0012-subaccounts.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

+ 291 - 0
doc/adr/0013-inflight-holds-via-holding-accounts.md

@@ -0,0 +1,291 @@
+# Inflight holds via per-destination holding accounts
+
+> Revised by [ADR-0012](0012-subaccounts.md): a hold is now a
+> **subaccount** of its destination rather than a standalone account, and the
+> "one open inflight per account" rule is dropped (a destination hosts many
+> concurrent inflights, one per distinct trade). The rest of this ADR still
+> 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).
+
+Each hold is a **subaccount** of its destination, keyed by a value derived from
+the submitted trade. Because the key is trade-specific, a destination hosts
+**many concurrent inflights at once**, one per distinct trade, each isolated in
+its own subaccount. Re-authorizing the identical trade collides with its own
+existing holds and is rejected, while different trades to the same destination
+run side by side.
+
+**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 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. Because each hold is a subaccount
+  of its destination (sharing its base id), `get_transfers_for_account` on the
+  destination's base id surfaces its open holds without a side index.
+* **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 subaccount per destination per request. 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`.

+ 4 - 3
doc/glossary.md

@@ -45,12 +45,13 @@ sub)` names one; `AccountId::base()` returns the main account of an id. A base
 account does not roll up its subaccounts, so several independent balances live
 under one owner, each individually addressable, drained, and closed. Aggregate
 reads take a base `id` plus an optional subaccount filter; exact entity
-operations take the full `AccountId`.
+operations take the full `AccountId`. Subaccounts let one account hold many
+concurrent inflights (a hold is a subaccount of its destination).
 
 `AccountId` also has an IBAN-style string view via `Display` / `FromStr`: two
 mod-97 check digits + a base-36 body of the two legs (no country code), e.g.
-`9200000000000050000000000007` for `{ id: 5, sub: 7 }` (grouped:
-`9200 0000 0000 0050 0000 0000 07`). Parsing validates the checksum, rejecting
+`221RDWNSN4VCQNK2NN42KJFSAOLI` for `{ id: 5, sub: 7 }` (grouped:
+`221R DWNS N4VC QNK2 NN42 KJFS AOLI`). Parsing validates the checksum, rejecting
 mistyped ids. It is a presentation/routing form (the dashboard URLs); storage
 keeps the two `i64` legs.
 

+ 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/0013-inflight-holds-via-holding-accounts.md](adr/0013-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/0012-subaccounts.md](adr/0012-subaccounts.md)). All holds
+of one inflight share that `sub`. Because a different trade derives a different
+`sub`, a destination can host **many concurrent inflights** at once, each isolated
+in its own subaccount and listed by `ledger.list_subaccounts(&destination)`.
+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`.