Sfoglia il codice sorgente

Record design for inflight holds before implementing

Callers need to reserve funds for a multi-leg trade without settling it, then
confirm it in full or in parts or void it. Kuatia is append-only with no
mutable state, so a hold has to be a durable fact, not a lock. Capture the
decision now so the model is agreed before any code lands.

The chosen model rewrites each destination of an ordinary trade to a fresh
per-destination NoOverdraft holding account flagged INFLIGHT, then commits that
rewritten transfer to park the funds. Confirm and void are ordinary commits
from the holds to their destinations or back to the funders. State is derived
from balances, and the inflight facts (id, leg table, funder, per-transfer
role) live in an inflight. metadata namespace on the holding accounts and
transfers, so nothing new is stored. It reuses commit and recover unchanged and
adds no store or migration.
Cesar Rodas 15 ore fa
parent
commit
1d0b16ade4
1 ha cambiato i file con 285 aggiunte e 0 eliminazioni
  1. 285 0
      doc/adr/0004-inflight-holds-via-holding-accounts.md

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

@@ -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`.