Revised by ADR-0012: 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.
kuatia (ledger, saga), kuatia-typesCallers 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?
commit_envelope saga and
its recover() roll-forward, not a second bespoke mechanism.PendingInactive reservation to a durable holdKeep 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:
Cons:
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.Held) for reserved fundsIntroduce 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:
Cons:
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:
X.inflight -> X for a slice. The remainder stays
held.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:
commit, so idempotency, content
addressing, and crash recovery are inherited unchanged.NoOverdraft, so a confirmation exceeding its balance fails
validation. The sum of confirmations can never exceed the authorized amount.resolve() and no reliance on posting provenance.Cons:
(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.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:
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.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.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.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.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).
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.
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.
commit, create_account, get_transfer,
balance, and close. Crash recovery, idempotency, and conservation come for
free, and no storage schema changes.NoOverdraft and the reservation protocol, with no request-specific checks.(hold, asset) co-funded by two payers has an ambiguous
partially-confirmed remainder on void; out of scope, documented.commit_envelope path and recover() unchanged, and adds
no new store.doc/inflight.md.