# 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/0014-inflight-holds-via-holding-accounts.md](adr/0014-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 Held --> Expired: deadline passes Expired --> Voided: reaper voids Confirmed --> [*] Voided --> [*] ``` Every operation is an ordinary `commit`, so idempotency, conservation, and crash recovery are inherited unchanged. A hold closes automatically once drained. `Expired` is a derived, transient state: a hold whose deadline has passed but whose funds are still held. The reaper resolves it to `Voided` by returning the funds to their funders. See [Expiry](#expiry) below. ## 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, legs, expires_at }`. The `inflight` field (an `EnvelopeId`) is the handle passed to every other call. ## Expiry A hold can carry a deadline. Funds still held past it are returned to their funders automatically, so an abandoned authorization does not park a payer's balance forever. ```rust // Authorize with an auto-void deadline (Unix milliseconds). let deadline = now_millis + 30 * 60 * 1000; // 30 minutes from now let auth = ledger.authorize_with_expiry(trade, deadline).await?; // Run once per process: a background task that voids holds as they expire. // Keep the handle alive for as long as the ledger should auto-void; dropping it // stops the task. let reaper = ledger.spawn_expiry_reaper(); // Or drive expiry manually (e.g. from your own scheduler): void everything due // at or before `now`. let reaped = ledger.expire_due(now_millis).await; ``` Plain `authorize` records no deadline: the hold never expires and must be settled explicitly. How it works (see [adr/0016-hold-expiry-and-reaper.md](adr/0016-hold-expiry-and-reaper.md)): - The deadline is stored in the authorize transfer's metadata, next to the leg table. Nothing mutable is added, and the deadline is content-addressed into the handle like the rest of the payload. - The ledger keeps an in-memory `BTreeMap` that drives the reaper: it sleeps until the earliest deadline rather than polling. The map is a cache, rebuilt from the durable metadata by `Ledger::recover()` on startup, so a restart loses no deadline. - Auto-void is the ordinary `void`, so it is crash-safe, idempotent, and appears in the audit trail as a normal void (the funds went back to the funder either way). `inflight_status` reports `InflightState::Expired` for a past-deadline hold that the reaper has not yet reached. Run at most one reaper per store. Two racing reapers are safe (the loser voids nothing) but redundant. While the ledger is down, deadlines do not fire; they are swept on the next startup after `recover()` rebuilds the index. ## 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/src/expiry.rs` — the deadline index and the reaper task. - `crates/kuatia/tests/inflight.rs` — authorize, confirm, partial confirm, void, over-confirm rejection, concurrent inflights per account, segregated balances, and status tests. - `crates/kuatia/tests/expiry.rs` — deadline surfacing, the `Expired` state, `expire_due`, the reaper, and index rebuild. - `AccountFlags::INFLIGHT` — `crates/kuatia-types/src/lib.rs`.