From 1a24bfdda8ccac5b083339af0a9fc80181049693 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:06:08 +0200 Subject: [PATCH] =?UTF-8?q?docs(adr):=20ADR-005=20=E2=80=94=20a=20partial?= =?UTF-8?q?=20dispense=20distributes=20once,=20when=20the=20outcome=20is?= =?UTF-8?q?=20final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/adr/005-cash-out-dispense-outcome.md | 37 +++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index c68a89d..e4820c5 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -88,10 +88,30 @@ stays there until the machine reports. | Machine reports | Settlement becomes | Then | | ----------------------------------------- | ------------------ | --------------------------------------- | | `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 | | 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 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. @@ -302,11 +322,16 @@ cancels) is identical. Two properties bound what this buys: -- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled. A - **full** fault (nothing dispensed) is cleanly cancelled. A **partial** fault — notes out, - value short — cannot be: cancelling would refund a customer who is holding cash, and - settling takes the full amount. Partial therefore still needs the voucher or refund path - below. Hold invoices eliminate owed-cash for the common full-fault case, not for every case. +- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled, so + the machine's rule is **`dispensed > 0 → settle; dispensed == 0 → cancel`**. A fault with + nothing presented cancels cleanly — and that includes an exit jam like sintra's 2026-10-09, + where the note stopped in the transport and the counter read zero: the customer is charged + 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 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.