|
|
@@ -0,0 +1,285 @@
|
|
|
+# Inflight holds via per-destination holding accounts
|
|
|
+
|
|
|
+* Status: proposed
|
|
|
+* 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 `T` 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. An
|
|
|
+inflight transaction id `T` is minted up front and carried in metadata 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, `T`, and its destination; each later confirm or void
|
|
|
+transfer's metadata records its role, `T`, and the leg it settles. The metadata
|
|
|
+is therefore the record of what is held and for whom, and it is content-addressed
|
|
|
+into each transfer's id, so it is tamper-evident. A hold is keyed by destination,
|
|
|
+so `fee.inflight` legitimately holds two assets funded by two different accounts.
|
|
|
+
|
|
|
+The lifecycle operations are ordinary commits, each driven from the leg table in
|
|
|
+the authorize transfer's metadata:
|
|
|
+
|
|
|
+* **Confirm all (no amount)**: for each leg, sweep the holding account's balance
|
|
|
+ to its destination. The net effect equals the original confirmed trade.
|
|
|
+* **Partial confirm**: commit `X.inflight -> X` for a slice. The remainder stays
|
|
|
+ held.
|
|
|
+* **Void**: for each leg, return the holding account's remaining balance to the
|
|
|
+ funder named in the leg table.
|
|
|
+
|
|
|
+A hold closes when its balance reaches zero; the transaction is terminal when all
|
|
|
+its holds are closed. Whether a leg was confirmed or voided is read from the
|
|
|
+transactions table (the leg's settling transfer goes to the destination on
|
|
|
+confirm, to the funder on void).
|
|
|
+
|
|
|
+To keep one hold per destination unambiguous, an account may take part in **at
|
|
|
+most one open inflight at a time**. This is enforced at authorize by recording the
|
|
|
+real destinations in the authorize transfer's involved-account set (the
|
|
|
+`involved` argument to `store_transfer`), so `get_transfers_for_account` surfaces
|
|
|
+an open hold for a destination even before it is settled.
|
|
|
+
|
|
|
+**Pros:**
|
|
|
+
|
|
|
+* Good, because it adds no posting state, no saga phase, no new store, and no
|
|
|
+ migration. Every operation is an existing `commit`, so idempotency, content
|
|
|
+ addressing, and crash recovery are inherited unchanged.
|
|
|
+* Good, because the amount still held is the holding account's balance, a derived
|
|
|
+ value. Nothing mutable is stored or decremented.
|
|
|
+* Good, because partial confirmation is just another transfer, with change handled
|
|
|
+ by the normal resolve step.
|
|
|
+* Good, because over-confirmation is impossible by construction: the holding
|
|
|
+ account is `NoOverdraft`, so a confirmation exceeding its balance fails
|
|
|
+ validation. The sum of confirmations can never exceed the authorized amount.
|
|
|
+* Good, because concurrent confirmations serialize on the shared holding posting
|
|
|
+ via the reservation protocol, so double-spend safety and the over-confirm bound
|
|
|
+ hold under contention with no new locking.
|
|
|
+* Good, because void routing reads the funder from the stored authorize transfer,
|
|
|
+ so it needs no change to `resolve()` and no reliance on posting provenance.
|
|
|
+* Good, because the request's entire history is the holds and the transactions
|
|
|
+ that touch them: the authorize, each confirmation, and the void.
|
|
|
+
|
|
|
+**Cons:**
|
|
|
+
|
|
|
+* Bad, because it creates one holding account per destination per request.
|
|
|
+ Mitigated by closing terminal holds so they leave the working set. Accounts are
|
|
|
+ cheap, snowflake-keyed rows.
|
|
|
+* Bad, because "one open inflight per account" is a real restriction: a second
|
|
|
+ authorize touching a destination with an open hold is rejected until the first
|
|
|
+ is confirmed or voided.
|
|
|
+* Bad, because a single `(hold, asset)` co-funded by two payers cannot cleanly
|
|
|
+ split a partially-confirmed remainder back to each funder on void; out of scope,
|
|
|
+ documented. Each `(destination, asset)` is expected to have a single funder, as
|
|
|
+ in the example.
|
|
|
+* Bad, because voiding returns funds to the original payer, so that account must
|
|
|
+ still be open to receive them.
|
|
|
+
|
|
|
+## Decision Outcome
|
|
|
+
|
|
|
+Chosen option: **Option 3, per-destination holding accounts, backed by existing
|
|
|
+storage**, because it is the only option that expresses a durable,
|
|
|
+partially-confirmable, multi-leg hold purely as existing ledger primitives, with
|
|
|
+no new store. It adds no mutable state, reuses the commit and recovery path
|
|
|
+wholesale, and gets double-spend and over-confirm safety from invariants the
|
|
|
+ledger already enforces. Concretely:
|
|
|
+
|
|
|
+* **Authorize rewrites destinations.** For an inflight transaction, each movement
|
|
|
+ `from -> to` becomes `from -> hold(to)`, where `hold(to)` is a fresh
|
|
|
+ `NoOverdraft` account flagged `INFLIGHT` whose metadata records the inflight id
|
|
|
+ `T` and its destination. The rewritten transfer is committed normally with the
|
|
|
+ leg table and `T` in its metadata.
|
|
|
+* **Metadata is the record.** Confirm and void load the authorize transfer with
|
|
|
+ `get_transfer` / `get_transfers_for_account` and read the leg table and funders
|
|
|
+ straight from its metadata, rather than reconstructing them from movement
|
|
|
+ directions. No side record and no new store. The real destinations are added to
|
|
|
+ the transfer's `involved` set so an open hold is discoverable per destination,
|
|
|
+ which is how "one open inflight per account" is enforced.
|
|
|
+* **Confirm and void are ordinary transfers, tagged in metadata.** Confirm-all
|
|
|
+ commits `hold -> destination` for each leg's balance; partial confirm commits
|
|
|
+ `hold -> destination` for a slice; void commits `hold -> funder` per leg, with
|
|
|
+ the funder taken from the leg table. Each settling transfer carries its role
|
|
|
+ (`confirm` or `void`), `T`, and the leg it settles in metadata. All go through
|
|
|
+ `commit`, so all are idempotent and crash-safe.
|
|
|
+* **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
|
|
|
+
|
|
|
+All keys live under an `inflight.` namespace in the existing `Metadata` map
|
|
|
+(`BTreeMap<String, Vec<u8>>`). Values use the same canonical big-endian encoding
|
|
|
+(`ToBytes`) as the rest of the ledger.
|
|
|
+
|
|
|
+* **Holding account** (`Account.metadata`):
|
|
|
+ * `inflight.role` = `hold`
|
|
|
+ * `inflight.tx` = `T`
|
|
|
+ * `inflight.destination` = the real destination `AccountId`
|
|
|
+* **Authorize transfer** (`Envelope.metadata`):
|
|
|
+ * `inflight.role` = `authorize`
|
|
|
+ * `inflight.tx` = `T`
|
|
|
+ * `inflight.legs` = encoded list of `{ destination, hold, funder, asset,
|
|
|
+ amount }`
|
|
|
+* **Confirm / void transfer** (`Envelope.metadata`):
|
|
|
+ * `inflight.role` = `confirm` | `void`
|
|
|
+ * `inflight.tx` = `T`
|
|
|
+ * `inflight.destination` = the leg being settled
|
|
|
+
|
|
|
+`T` is minted up front (an `AutoId`, or supplied by the caller) so it is known
|
|
|
+before any account or transfer is built and can be stamped identically
|
|
|
+everywhere. It is distinct from the authorize transfer's content-addressed
|
|
|
+`EnvelopeId`. Because metadata is hashed into each transfer id, these tags are
|
|
|
+tamper-evident. The `involved` index and account `INFLIGHT` flag remain the only
|
|
|
+things used for discovery (metadata is carried, not queried); everything semantic
|
|
|
+is read from the tags above.
|
|
|
+
|
|
|
+### Positive Consequences
|
|
|
+
|
|
|
+* The feature is a thin layer over `commit`, `create_account`, `get_transfer`,
|
|
|
+ `balance`, and `close`. Crash recovery, idempotency, and conservation come for
|
|
|
+ free, and no storage schema changes.
|
|
|
+* The over-confirm bound and double-spend safety are structural: they follow from
|
|
|
+ `NoOverdraft` and the reservation protocol, with no request-specific checks.
|
|
|
+* The audit trail is self-describing: a request's holds and the transactions that
|
|
|
+ touch them fully reconstruct what was authorized, confirmed, and returned.
|
|
|
+
|
|
|
+### Negative Consequences
|
|
|
+
|
|
|
+* One holding account per destination per request, and one open inflight per
|
|
|
+ account at a time. Terminal holds are closed to bound the open working set, but
|
|
|
+ the accounts table still grows with history (as postings and transfers already
|
|
|
+ do).
|
|
|
+* A single `(hold, asset)` co-funded by two payers has an ambiguous
|
|
|
+ partially-confirmed remainder on void; out of scope, documented.
|
|
|
+* Voiding depends on the payer account remaining open. A policy for holds that
|
|
|
+ outlive their payer is out of scope here.
|
|
|
+
|
|
|
+## Links
|
|
|
+
|
|
|
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (signed postings)
|
|
|
+ and [ADR-0003](0003-dumb-storage-saga-recovery.md) (dumb storage, saga
|
|
|
+ recovery). Reuses the `commit_envelope` path and `recover()` unchanged, and adds
|
|
|
+ no new store.
|
|
|
+* Background: [architecture.md](../architecture.md) (commit pipeline, posting
|
|
|
+ lifecycle, resolve and change outputs), [accounts.md](../accounts.md) (policies,
|
|
|
+ account lifecycle).
|
|
|
+* Usage and API to be documented in `doc/inflight.md`.
|