docs(adr): ADR-005 — a partial dispense distributes once, when the outcome is final

Decision 1's partial row said "operator confirms → distribute scaled"
and left the undispensed remainder's fate implicit. Made explicit:
partial_pending holds everything — including the share of the notes
that did dispense — until the operator records how the shortfall was
resolved (remediated → full amount; vouchered or written off → scaled),
then one distribution runs at that amount with the existing scaling
arithmetic. A vouchered remainder waits for redemption or expiry.

The alternative (scaled part now, remainder on resolution) is recorded
as deferred, not rejected: it needs a second additive distribution pass
the repo lacks, and a partial is almost always a terminal fault that
has latched cash-out off, so resolution is hours. Decided 2026-10-10.

Also carries the hold-invoice decision rule that fell out of the same
analysis: dispensed > 0 → settle, dispensed == 0 → cancel. An exit jam
that reports zero cancels cleanly — the customer is charged nothing and
the stuck note is the operator's to recover — so hold invoices remove
owed-cash for full faults and exit jams, not for true partials.
This commit is contained in:
Padreug 2026-10-10 21:06:08 +02:00
commit 1a24bfdda8

View file

@ -88,10 +88,30 @@ stays there until the machine reports.
| Machine reports | Settlement becomes | Then | | Machine reports | Settlement becomes | Then |
| ----------------------------------------- | ------------------ | --------------------------------------- | | ----------------------------------------- | ------------------ | --------------------------------------- |
| `dispense_confirmed: true` | `pending` | claim + distribute → `processed` | | `dispense_confirmed: true` | `pending` | claim + distribute → `processed` |
| partial (some notes out, value short) | `partial_pending` | operator confirms → distribute scaled | | partial (some notes out, value short) | `partial_pending` | nothing moves until the shortfall is resolved (below) |
| `dispense_confirmed: false`, nothing out | `cash_owed` | legs never run; funds stay in wallet | | `dispense_confirmed: false`, nothing out | `cash_owed` | legs never run; funds stay in wallet |
| no report within `DISPENSE_REPORT_TTL` | `dispense_unreported` | worklist; operator investigates | | no report within `DISPENSE_REPORT_TTL` | `dispense_unreported` | worklist; operator investigates |
**A partial dispense distributes once, when the outcome is final.** Some notes reached the
customer and some did not, so the sale's true amount is not yet known: it is the full amount
if the shortfall is remediated (an on-machine `manual_dispense` against the `txid`, or an
off-machine payout recorded with `settle_cash_owed`), and the scaled amount if the shortfall
is vouchered or written off. `partial_pending` therefore holds *everything* — including the
operator's and LPs' share of the notes that did dispense — until the operator records which
of those it was. Then one distribution runs, at that amount, using the existing
`apply_partial_dispense_and_redistribute` arithmetic for the scaled case (linear scale; the
fee split by the ratio locked at landing; operator absorbs rounding). A vouchered remainder
stays undistributed until redemption or expiry, per the voucher rules under *Future
directions*.
The alternative — distribute the scaled part immediately and the remainder on resolution —
pays the LPs the same afternoon but needs a second, additive distribution pass keyed to the
same settlement, which the repo does not have and which the completed-legs guard in the
current tool would fight. It is deferred, not rejected: a partial is almost always a
terminal-class fault that has also latched cash-out off, so the operator is coming to the
machine anyway and resolution is hours, not weeks. Revisit if prompt LP payout ever matters
more than one-distribution-per-settlement. (Decided 2026-10-10.)
This is the card-processing shape — authorize, then capture — and it is the same ordering This is the card-processing shape — authorize, then capture — and it is the same ordering
lamassu-server enforces between `dispense_confirmed` and `updateCassettes`. The cost is that lamassu-server enforces between `dispense_confirmed` and `updateCassettes`. The cost is that
operator and DCA legs land seconds later than they do today, which is the dispense time. operator and DCA legs land seconds later than they do today, which is the dispense time.
@ -302,11 +322,16 @@ cancels) is identical.
Two properties bound what this buys: Two properties bound what this buys:
- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled. A - **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled, so
**full** fault (nothing dispensed) is cleanly cancelled. A **partial** fault — notes out, the machine's rule is **`dispensed > 0 → settle; dispensed == 0 → cancel`**. A fault with
value short — cannot be: cancelling would refund a customer who is holding cash, and nothing presented cancels cleanly — and that includes an exit jam like sintra's 2026-10-09,
settling takes the full amount. Partial therefore still needs the voucher or refund path where the note stopped in the transport and the counter read zero: the customer is charged
below. Hold invoices eliminate owed-cash for the common full-fault case, not for every case. nothing and the note is the operator's to recover, with `countsUncertainSince` covering the
inventory side. A **partial** — notes actually in the customer's hand — cannot cancel without
refunding someone holding cash, so it settles the full amount and from that instant is
identical to a plain-BOLT11 partial: `partial_pending`, one distribution when the shortfall
is resolved (Decision 1). Hold invoices eliminate owed-cash for full faults and exit jams,
not for true partials.
- **The hold window locks the payer's funds and route liquidity**, and some wallets surface - **The hold window locks the payer's funds and route liquidity**, and some wallets surface
a long-pending payment as a failure. The window should equal the dispense window — seconds, a long-pending payment as a failure. The window should equal the dispense window — seconds,
capped at a minute or two — with an automatic `cancel` on timeout, never an open-ended hold. capped at a minute or two — with an automatic `cancel` on timeout, never an open-ended hold.