0005-intent-api-movements-vs-envelopes.md 4.4 KB

Intent API: movements and transfers over raw envelopes

  • Status: accepted
  • Authors: Cesar Rodas
  • Date: 2026-06-29
  • Targeted modules: kuatia-types (Movement, Transfer, TransferBuilder), kuatia (ledger::resolve)
  • Associated tickets/PRs: N/A

Context and Problem Statement

In a UTXO-style model (ADR-0001) a commit ultimately operates on concrete postings: which exact postings to consume, which to create, and the change. But making callers assemble that (pick inputs, compute change, balance per asset) is error-prone and couples application code to the ledger's internals. What should the public unit of intent be, and where does the translation to concrete postings happen?

Decision Drivers

  • Ergonomics and safety: callers should express what they want ("pay B 40 USD from A"), not hand-select UTXOs.
  • Keep the UTXO model an implementation detail: selection and change-making should not leak into application code.
  • Determinism and idempotency: the same intent must resolve deterministically, and re-submitting must be a no-op.
  • Composability: multi-account, multi-asset events (FX, compound entries) must be expressible as one atomic intent.

Considered Options

Option 1: Callers build Envelopes directly (raw UTXO API)

The public API is the resolved form: callers choose consumed posting ids and construct created postings.

Pros:

  • Good, because it is maximally explicit and gives full control (useful for FX or hand-tuned flows).

Cons:

  • Bad, because every caller re-implements posting selection, change-making, and per-asset balancing, which is easy to get wrong.
  • Bad, because it couples application code to posting ids and the UTXO model.
  • Bad, because there is no natural high-level vocabulary (pay/deposit/withdraw).

Option 2: A two-layer API: intent (Movement/Transfer) to resolved (Envelope)

Callers express intent as Movement { from, to, asset, amount } values grouped into a Transfer (via TransferBuilder::pay/deposit/withdraw/movement). The ledger's resolve() turns intent into a concrete Envelope by selecting postings and computing change; the envelope is what gets validated and committed. The raw envelope path remains available internally (e.g. for reverse() and hand-built multi-asset envelopes).

Pros:

  • Good, because the common cases are one call and the UTXO mechanics stay hidden.
  • Good, because intent is small and serializable, and resolve is deterministic, so the resolved Envelope has a stable content id (idempotency, ADR re: content-addressing).
  • Good, because compound and multi-asset events are just multiple movements committed atomically; deposits and withdrawals are movements against a boundary account.
  • Good, because the escape hatch (build an Envelope directly) still exists for flows the intent vocabulary cannot express.

Cons:

  • Bad, because there are two representations to understand (intent vs. resolved) and the word "posting" is a noun here, not the accounting verb.
  • Bad, because idempotency keys on the resolved envelope id, so resolution must be deterministic for re-submits to dedupe, a property the resolver must hold.

Decision Outcome

Chosen option: Option 2, the two-layer intent API, because it keeps the UTXO model an internal detail, makes the common operations trivial and safe, and yields a deterministic resolved Envelope whose content id gives idempotency, while still allowing a pre-built envelope for advanced flows.

Positive Consequences

  • TransferBuilder offers pay/deposit/withdraw (preferred) over raw movement construction; one Transfer can carry many movements committed atomically.
  • commit(transfer) = resolve (read-only) then commit_envelope; the saga and recovery operate on the resolved envelope (see ADR-0002/0003).
  • Deposits resolve to two movements that cancel to zero net debit on the system account, so no posting selection is needed.

Negative Consequences

  • Two representations to document; the noun/verb "posting" caveat (see accounting-mapping.md).
  • Resolution must stay deterministic so re-submitting the same intent dedupes.

Links