Design: paid-commit credit-note relief (ADR-078 phase 2)
- Decided: a paid prepaid-commit invoice can be refunded with a credit note that retires its unused credits and refunds their cash at the purchase rate, not at face value. One cumulative rounding anchor makes any series of partial refunds add up exactly.
- Shipped: v0.2.0, in #391 (migration
0138_commit_cn_relief). Design written 2026-07-06.- Current docs: no guide covers it yet. The current rules are ADR-080 and the
commit_reliefblock ofPOST /v1/credit-notesin API endpoints. Where this page and the code disagree, the code wins.
Where the body is out of date (checked against code 2026-10-18):
- The route is
POST /v1/credit-notes, not/v1/credit_notes.- To retire all remaining credits, send
retire_all: true.retire_centstakes a number only, not"remaining".refund_amount_centsandout_of_band_amount_centssit directly incommit_relief. There is noallocationobject.- Validation failures return
422 validation_error, not 400. That includes a slice that rounds to zero cash, which the body answers with 409.
Status: panel-settled 2026-07-06 (a 3-lens divergent panel — independent designs argued from different lenses, then adversarially judged — workflow wf_bf1f8e30-0cf). This doc is the build spec; ADR-080 records the decisions. Implement exactly this — the cap formula was mechanically verified against internal/platform/money/round.go.
The worked example (settles the cap)
Section titled “The worked example (settles the cap)”Max refundable CASH = 5400 cents. Why: the customer bought 10000 credits (a prepaid commit — credits bought up front and drawn down by usage) for 9000 cash, a purchase rate of 0.9 cash/credit. The 4000 consumed credits were consumed at that discounted rate, so they account for RoundHalfToEven(90004000,10000)=3600 of the cash (RoundHalfToEven is banker’s rounding — ties round to even); the remaining 6000 credits are worth f(6000)=RoundHalfToEven(90006000,10000)=5400 (verified mechanically against internal/platform/money/round.go). Accounting identity: 9000 in = 3600 consumed-value + 5400 refunded; credit liability after = 0. The two wrong answers that requirement (b) forbids: 6000 (the face value of the remaining credits — refunding discounted credits at face lets the customer net 4000 face credits for 3000 cash, better than the discount they bought = the draw-at-discount/refund-at-face arbitrage; over-refund by 600) and 5000 = 9000−4000 (charges consumption at FACE instead of the discounted rate purchased — under-refund by 400). All three designs’ arithmetic agrees on 5400; they differ in how partials/rounding/state make that number stay exact over time.
Winner + synthesis
Section titled “Winner + synthesis”Design 1 (telescoping-anchor, retire-denominated commit-relief CN) wins: the relief credit note (CN) is denominated in retired credits — credits permanently removed from the grant so they can no longer be drawn — and its cash is a difference of one cumulative rounding anchor (“telescoping”), so partial refunds always sum exactly. Grafted from Design 3: the grant-row (the credit-ledger row holding the commit’s purchased credits) cumulative counter cn_retired_cents (K), bumped in the same UPDATE as consumed_cents — replaces D1’s SUM-over-prior-CN-rows derivation of K (one lock covers all formula inputs, no cross-table induction), plus D3’s explicit invariant set (I1–I6) and its crisp mode semantics. Grafted from Design 2: (a) stack the two EXISTING CN caps under the commit cap — especially refund-channel ≤ AmountPaidCents − prior non-voided CN refunds (verified real at creditnote/service.go:577-593; guards against out-of-band Stripe-dashboard refunds having drained the PI (the Stripe PaymentIntent — the object the payment was collected through) — a hole in D1/D3); (b) the ratio-integrity concern (no writer may grow amount_cents or shrink consumed_cents on a commit grant) — shipped as an invariant test + comment, NOT D2’s proposed gate, because code inspection shows no such writer exists (positive AdjustAtomic inserts a NEW block, never inflates a grant; all drains are monotone CAS — compare-and-swap-style guarded UPDATEs that apply only while their precondition holds); (c) the wrong-PRICE runbook: ratio corrections are full-relief + reissue-corrected-invoice, never partial relief. Rejected from Design 2: cash-denominated input with ceilDiv retire (mechanically verified it never over-refunds but penny-shaves the customer on every partial and degenerates at deep discounts where 1 cent buys many credits) and the draft→issue two-step (retains the stale-draft window, the drafts-don’t-reserve-cap race, and adjacency to Issue()’s zero-allocation credit-balance re-derive default at service.go:866-871 — the exact requirement-(f) hazard; single-tx create-and-issue deletes all three). Rejected from Design 3: net-basis P + separate gross-up — anchoring on the net (pre-tax) amount and adding tax back separately (two rounding sites; D3’s own risks call it the top review target — D1’s gross basis GrossPaid=inv.TotalAmountCents matches the verified CN gross-line convention and rides the existing proportional tax block with its exhaust true-up) and its invoice-row FOR UPDATE (paid is terminal: invoice/service.go:794 blocks void-of-paid; a plain in-tx re-read avoids a new lock edge). Kept D1’s single retire primitive: generalize RetireCommitGrantForInvoiceTx (credit/postgres.go:812) into one locked slice-retire core with the void path as a thin wrapper — its two callers (invoice.Void, relief) are the complete commit-retirement writer set.
Cap formula (normative)
Section titled “Cap formula (normative)”All quantities int64 cents. Scope: PAID, single-commit-line funding invoice. GrossPaid = inv.TotalAmountCents (tax-inclusive cash collected — CN line amounts are gross by existing convention, service.go:247-251; full-settle holds because Hardcount has no partial payments and commit invoices cannot be credit-paid). G = grant amount_cents; under pg_advisory_xact_lock(hashtextextended(tenantID+“:”+customerID,0)) + grant row FOR UPDATE read: C = consumed_cents, K = cn_retired_cents (NEW column on customer_credit_ledger, BIGINT NOT NULL DEFAULT 0 = cumulative credits retired by prior relief CNs; bumped only by relief, never by void-retire), R = G − C. Anchor: f(k) = money.RoundHalfToEven(GrossPaid * k, G) — the cash value of the first k credits at the purchase rate, rounded once. MAX REFUNDABLE GROSS NOW = f(K+R) − f(K). A relief retiring r credits (retire_cents=“remaining” → r=R; explicit int → 1 ≤ r ≤ R, else typed ErrCommitInsufficientRemaining carrying live R and max cash) refunds gross X = f(K+r) − f(K); if X == 0 → 409 nothing-refundable (no zero-total CN). Grant write: UPDATE … SET consumed_cents = consumed_cents + r, cn_retired_cents = cn_retired_cents + r WHERE consumed_cents + r <= amount_cents (0 rows → abort; structural exactly-once backstop). Telescoping guarantees Σ X over ANY partial sequence = f(K_final) ≤ f(G) = GrossPaid exactly — per-slice independent rounding is forbidden (verified: GrossPaid=2, G=3, three 1-credit slices — naive per-slice refunds 3 on 2 paid; telescoping refunds exactly 2; partition-invariance verified across sampled (P,G) grids). X is additionally clamped by the existing CN caps inherited from the per-invoice-lock path: Σ non-voided CN totals ≤ inv.TotalAmountCents, and refund-channel allocation ≤ AmountPaidCents − Σ prior non-voided CN RefundAmountCents (excess routes to out_of_band — recorded as refunded outside Stripe — or 400s). Tax: X is gross; reuse buildCreditNote’s proportional block verbatim (floor ratio + exhaust true-up at service.go:512-522) — do not re-derive. Overflow: ADR-078 caps commits at $1M so GrossPaidk ≤ 1e16 < 2^63; keep an assert guard. Worked example: f(6000) = RoundHalfToEven(90006000, 10000) = 5400; partial r=2000 → X=1800.
Transaction composition (normative)
Section titled “Transaction composition (normative)”API shape (D1’s): POST /v1/credit_notes grows an optional commit_relief block {retire_cents: <int>|“remaining”, allocation?: {refund_amount_cents?, out_of_band_amount_cents?}} — REQUIRED (and lines FORBIDDEN, server builds the single line) when the invoice has a commit line; rejected on normal invoices; credit_amount_cents anywhere → 400 (requirement f); allocation default = full X to refund if inv.StripePaymentIntentID != “” else out_of_band; existing Idempotency-Key middleware is the retry dedup. Routed to a dedicated service method CreateAndIssueCommitRelief — ONE coordinator tx (create-and-issue; no draft window), BeginTx(TxTenant): (1) per-invoice CN advisory lock via the existing CreateUnderInvoiceLockTx discipline — freezes existing-CN sums for the document cap and refund clamp; (2) in-tx re-read invoice: status=paid AND PaymentSucceeded (unpaid → 409 route-to-void; in-flight → existing ADR-059 409; voided/uncollectible → 409); exactly one line and IsCommitLine (mixed → 409); extract GrossPaid, AmountPaidCents; NO invoice-row FOR UPDATE (paid is terminal — void-of-paid blocked at invoice/service.go:794; writer enumeration re-verified at build); (3) find funded grant by (tenant_id, source_invoice_id, grant_kind=‘commit’) — ErrNoRows → typed LOUD error (unlike the void leg’s clean no-op: cash is about to move); customer advisory lock; grant FOR UPDATE re-read G/C/K (lock order: invoice-CN lock → customer advisory → ledger rows — consistent suffix of the ADR-078 global order; drawdown (usage consuming credits)/expiry/void/adjust all serialize on the same advisory lock, so R and K are frozen); (4) resolve r, compute X per the cap formula, validate allocation (sum==X, credit==0, refund ≤ AmountPaid − prior refunds); (5) allocate CN number, INSERT header (draft) + server-built gross-X line + commit_retired_cents=r (real BIGINT column on credit_notes, D9 discipline), then draft→issued via TransitionStatusTx — trivially won in-tx but kept as the single issued-state gate; (6) retire: the CAS-guarded consumed+=r / cn_retired+=r UPDATE + ONE negative ledger entry via appendEntryInTx {entry_type=‘adjustment’, amount=-r, reason=‘commit_refund_retire’, grant_id, funding_invoice_id, credit_note_id, refunded_gross_cents:X, gross_paid_cents, granted_cents, created_at=clock.Now(ctx)} — sibling of ‘commit_void_retire’ so auditors split cancellations from cash refunds; kept out of TotalExpired; emitBalanceCrossings runs in-tx (balance_depleted/low outbox rows — webhook events staged in the same transaction for the dispatcher to deliver); (7) enqueue credit_note.issued (enriched with commit_retired_cents/funding_grant_id) + NEW per-cause outbox event credit.commit_retired {customer_id, grant_id, funding_invoice_id, credit_note_id, retired_cents, refunded_gross_cents, remaining_after_cents} + audit row action=credit_note.commit_relief {r, X, GrossPaid, G, K_before} — all with livemode (the live-vs-test-data flag)/tenant ctx attrs; (8) COMMIT. Requirement (a) holds: crash pre-commit leaves NOTHING (no CN, no retire, no entry); crash post-commit leaves issued+retired with only external legs outstanding — credits are always retired at-or-before cash leaves, never refunded-but-drawable. POST-COMMIT external legs reuse ADR-061 machinery verbatim: Stripe refund keyed hardcount_cn_<cn_id> (refund_status persisted; failed/pending → RetryRefund + refund webhook), proportional tax reversal partial-mode with tax_reversal_pending sweep. Retire primitive: refactor RetireCommitGrantForInvoiceTx into one locked core retireCommitSliceTx(…, slice, entryMeta, bumpCNRetired) with two thin wrappers — void (slice=remaining, no-op on missing/zero, reason=commit_void_retire, no K bump) and relief (explicit slice, loud on missing, reason=commit_refund_retire, K bump). Single-writer set: invoice.Void + relief.
Partial refunds
Section titled “Partial refunds”SUPPORTED, denominated in CREDITS (retire_cents int or “remaining”), cash always server-derived via the telescoping anchor — never operator-supplied cash. Why partials: the migration-month failures (partial concession on churn, wrong-amount corrections caught mid-consumption) are inherently partial-sized, and Stripe/Lago CN parity is partial-by-default; the telescoping formula removes the only technical argument against them (any partial sequence sums to exactly f(ΣK), so partial-vs-full has zero rounding consequence). Why credits-first, not cash-first: (1) one rounding direction — cash is DERIVED with a single RoundHalfToEven site; cash-first needs ceilDiv on the credit side and mixes disciplines (verified: D2’s scheme never over-refunds but shaves the customer up to a cent per partial); (2) grant arithmetic (consumed+=r) never rounds; (3) cash-first degenerates at deep discounts (1 cent maps to many credits, inversion ambiguous under rounding); (4) auditors recompute every CN from (GrossPaid, G, K) alone. Explicit carve-out (from D2): wrong-RATIO/wrong-price fixes are NOT a partial-relief use case — every relief preserves GrossPaid/G by construction, so the runbook is full relief + reissue a corrected commit invoice, with any already-consumed-at-wrong-ratio residual settled out-of-band. Cash-denominated input may ship later as pure UI sugar (client converts at the displayed rate; server still receives credits). “remaining” resolves to live R inside the tx, so a racing drawdown shrinks the relief instead of failing it; explicit r > live R fails typed with the live numbers (no silent shrink of an operator-approved money document).
Edge-case dispositions
Section titled “Edge-case dispositions”- Fully consumed (R=0, C==G): 409 ‘commit fully consumed — nothing refundable’; no CN row, consumed stays consumed.
- Expired grant (expiry sweep already set consumed=amount): R=0 → same 409, message ‘value lapsed per its term’. Expiry is contractual breakage (prepaid value that lapses unredeemed) — earned cash, not refundable; post-expiry goodwill = standalone promotional grant or out-of-band cash, never a cash CN against the commit invoice.
- Expiry racing the relief: both take the customer advisory lock; exactly one of {expiry entry, relief entry} claims each credit; consumed_cents ends == amount_cents, never past it (CAS).
- Drawdown racing the relief (requirement c): serialized on advisory lock + grant FOR UPDATE; explicit-r loser → typed ErrCommitInsufficientRemaining carrying live R and new max, whole tx aborts (no CN, no entry); retire_cents=‘remaining’ loser retires the post-race R with matching f-slice cash.
- remaining < requested r (the task’s over-consumed question): re-derived under the lock; typed 409, never a clamp. True over-consumption (consumed > amount) is structurally impossible — every drain writer CASes consumed+x <= amount; if ever observed via corruption, R<=0 → 409, never negative cash.
- X == 0 for the requested r (deep-discount slice rounds to zero cash): 409 — no zero-total CN, no silent credit donation; operator retires a bigger slice (telescoping loses nothing by waiting). Gap all three designs missed.
- Unpaid commit invoice (requirement d): CN gate STAYS; message splits — unpaid → ‘void the invoice’ (existing RetireCommitGrantForInvoiceTx leg), paid → commit_relief. Pre-payment relief would decouple collected cash from G and corrupt the ratio.
- In-flight payment: existing ADR-059 409 (settle or cancel first).
- Ordinary line-based CN on any commit invoice: still 409 (cash != face makes line-cap semantics wrong). Mixed commit+other-line invoices: relief 409-gated phase 2; trigger = first DP mixed-invoice refund ask; verify migration-month invoices are single-line before shipping the scope cut.
- CreateRefund (POST /v1/invoices/{id}/refund, the 4th create() caller no design enumerated): keeps 409 for commit invoices with a routing message to commit_relief — its cash-denominated AmountCents cannot express retire semantics.
- Voided/uncollectible source: 409 under the in-tx status re-read.
- Void of an issued relief CN: already impossible — Void() blocks all issued CNs as final financial documents (service.go:1097-1099); un-retire will never exist (consumed monotone). Correction path: promotional grant + out-of-band.
- No funded grant row on an invoice with a commit line: typed LOUD error (corrupted state — finalize funds in the same tx), never the void leg’s silent no-op, because cash is about to move.
- Offline-paid commit (RecordOfflinePayment, no PI — the migration-month norm): allocation defaults to out_of_band; CN is the obligation record, cash moves outside; no Stripe leg fires; noted in MANUAL_TEST that the CN is not proof of wire.
- Stripe refund leg fails post-commit (incl. PI drained by out-of-band dashboard refunds, or a disputed charge Stripe refuses to refund): CN issued + credits retired + refund_status=failed → RetryRefund converges on the same hardcount_cn_<id> key; safe direction (customer under-relieved, never over-relieved); attention surface shows failed relief CNs.
- Premium commit G < P (not blocked by validation): pro-rata formula refunds MORE than face per credit — correct, no special case.
- Undiscounted P == G: f degenerates to identity, cash == credits exactly.
- Ratio integrity: no writer may increase amount_cents or decrease consumed_cents on a commit grant — holds today (positive AdjustAtomic inserts a NEW block; all drains monotone CAS); pinned by an invariant test + comment instead of a speculative gate.
- ReverseForInvoice drawdown-void laundering (pre-existing, documented in ADR-078): re-grants drawn credits as a NULL-kind block while the commit’s consumed stays — does not corrupt the cap math; re-flag only via its existing phase-2 trigger.
- Retire-to-zero balance fires credit.balance_depleted via the crossing helper in-tx — expected, not a bug.
Test matrix
Section titled “Test matrix”- Worked-example happy path (real Postgres): GrossPaid=9000, G=10000, drawdown 4000, full relief → CN gross exactly 5400; grant consumed==amount, cn_retired==6000; one -6000 adjustment entry reason=commit_refund_retire carrying credit_note_id and refunded_gross_cents=5400; refund leg keyed hardcount_cn_<id>; proportional tax reversed when the invoice carries tax (net/tax split matches buildCreditNote’s block).
- Cap negatives: 6000 (face) and 5000 (face-minus-face) both impossible; explicit allocation summing above X → 400 carrying 5400; refund channel above AmountPaid − prior refunds → 400 routing to out_of_band.
- Partial sequence + telescoping: r=2000 (X=1800) then ‘remaining’ after further drawdown → each CN total equals f(K_after)−f(K_before); property test: random slice partitions of (GrossPaid,G) sum to f(K_total) exactly, every X >= 0.
- Pathological rounding: GrossPaid=2, G=3, three 1-credit slices → cumulative refund exactly 2 (naive per-slice pays 3); X==0 slice at deep discount → 409, no CN row.
- Concurrent drawdown vs relief (real Postgres, both orders, mutation-verify): explicit-r loser fails typed with NO CN row and NO ledger entry; ‘remaining’ loser retires the shrunken R with matching f-slice cash; removing the consumed+r<=amount CAS or the FOR UPDATE re-read must fail the test.
- Concurrent relief x2 (full+full, full+partial): serialize on invoice-CN lock then grant lock; both succeed iff r1+r2 <= R; totals sum to f(r1+r2); second full → 409 nothing-refundable.
- Exactly-once under retries: same Idempotency-Key replay → one CN, one retire entry, K bumped once; crash injection pre-commit → zero rows persisted (no CN, no entry, consumed and cn_retired unchanged), clean re-run succeeds.
- Expiry interplay: swept grant → 409; expiry racing relief under the advisory lock → exactly one of {expiry entry, relief entry} per credit, consumed ends == amount.
- Requirement-f guards (real wiring): credit_amount_cents in allocation → 400; issued relief CN has NO credit-grant ledger row (GrantForCreditNoteTx unreachable — relief never enters Issue(), so the service.go:866 zero-allocation re-derive can’t fire); ApplyToInvoiceAtomic still skips the funding invoice (postgres.go:626-655 pin).
- Gate coherence: unpaid commit CN → 409 naming void; ordinary lines on paid commit invoice → 409; commit_relief block on non-commit invoice → 400; CreateRefund on commit invoice → 409 naming commit_relief; void of issued relief CN → 409 final-document.
- Void-path regression after the slice refactor: phase-1 wrapper still exactly-once through the legal uncollectible→void sequence; void-retire does NOT bump cn_retired_cents.
- Invariant sweep (real Postgres, interleaved drawdown/relief/expiry): I1 0<=consumed<=amount; I2 Σ relief cash == f(cn_retired_cents) and <= GrossPaid; I3 every commit_refund_retire entry pairs 1:1 with a CAS-won bump; I4 relief CNs carry CreditAmountCents==0; I5 consumed monotone; I6 Σ CN totals <= invoice total; grant.cn_retired_cents == SUM(cn.commit_retired_cents); balance_after chain intact.
- Events/outbox: credit.commit_retired + enriched credit_note.issued enqueued in the SAME tx (rollback shows no rows); balance_depleted fires when relief empties the balance; audit row carries r, X, GrossPaid, G, K_before; livemode attr present on all.
- Stripe leg failure recovery: refund_status=failed → RetryRefund converges on hardcount_cn_<id>, no second ledger entry (positive AND negative assertions); offline-paid path makes zero Stripe calls (fake refunder pin).
- MANUAL_TEST FLOW: hand-build discounted commit → RecordOfflinePayment → draw via usage → partial relief (out_of_band) → full relief → 409 on repeat; dashboard shows remaining/max-refundable from the API (no frontend math).
Blind spots the panel itself caught
Section titled “Blind spots the panel itself caught”- ALL THREE: none enumerated CreateRefund (POST /v1/invoices/{id}/refund, creditnote/service.go:657) as a create() caller — the exhaustive-caller set is Create, CreateAndIssueAdjustment, CreateAdjustmentDraftTx, CreateRefund; phase 2 must route/gate it explicitly or its commit 409 message goes stale.
- ALL THREE: the X==0 degenerate slice — at deep discounts a small retire_cents rounds to zero cash, producing a zero-total CN that silently donates credits; must be a typed 409 (found by adversarial arithmetic, absent from every design’s edge list).
- ALL THREE: chargeback/dispute interplay — Hardcount models no disputes; a disputed commit PI leaves the invoice ‘paid’ while cash is pulled; the card refund leg fails safe at Stripe (refunds on disputed charges rejected → loud refund_status=failed) but the out_of_band channel has no backstop. Accept + name in ADR-080; trigger = first dispute-modeling ask.
- ALL THREE: livemode/ctx-attribute propagation on the new outbox events and audit rows (the repo’s named ctx-attr trap for new writers) — mechanical, but unmentioned by every design; folded into the tx spec and test matrix.
- D1+D3 (caught by D2): out-of-band Stripe-dashboard refunds shrink the PI’s refundable balance — without the existing AmountPaid − prior-refunds channel clamp, relief retires credits then strands the cash leg permanently; grafted.
- D2-specific misses (recorded so they aren’t re-introduced): per-partial customer penny-shave from ceilDiv, drafts-don’t-reserve-cap race, and adjacency to Issue()’s credit-balance re-derive default — all deleted by single-tx create-and-issue.
- D2’s manual-adjustment ‘top-up breaks the ratio’ gate chases a nonexistent writer (positive adjustments insert new NULL-kind blocks, verified in credit/postgres.go drain/grant paths) — downgraded to an invariant test; shipping the gate would violate the no-speculative-machinery rule.
Build notes (verified against the tree)
Section titled “Build notes (verified against the tree)”Verified against the tree (worktree audit-invoice-writer-drift, parked on main, phase-1 code present): money.RoundHalfToEven at internal/platform/money/round.go:25; void-leg primitive at internal/credit/postgres.go:812 (advisory lock → FOR UPDATE re-read → CAS → -remaining entry, reason=commit_void_retire, clean no-op on missing grant); commit gate at internal/creditnote/service.go:240-244; gross-line convention 247-251; per-invoice-lock caps 278-298; refund-channel clamp 577-593; legacy credit-default 553-559 and Issue()’s re-derive 866-871 (the requirement-f hazards — both unreachable via the dedicated relief method); Void() blocks issued CNs 1097-1099 (relief CNs terminal for free); ApplyToInvoiceAtomic skips funding invoices postgres.go:626-655; void-of-paid blocked invoice/service.go:794 (its ‘issue a credit note instead’ message becomes TRUE with this ship); ADR-078 global lock order confirmed (docs/adr/078:66); fund-once dedup index in m0136. Arithmetic verified by running the repo’s exact rounding code: worked example 5400, pathological telescoping 2-vs-3, partition-invariance sweep, D2 ceilDiv values. NUMBERS TO CLAIM across origin/main + all branches before writing: migration m0138+ (0137 is latest here) for cn_retired_cents on customer_credit_ledger + commit_retired_cents on credit_notes (both real BIGINT columns, backfill 0 safe — no relief rows exist); ADR-080 (079 latest here; ADR duplicates fail silently). ADR-080 must record: why create-and-issue diverges from the ADR-061 draft+reconciler shape (operator-initiated, idempotency-keyed, single-tx — so a future reviewer doesn’t ‘fix’ it into a draft flow), the full-settle cap basis (switch to captured cash if partial capture ever ships), the expiry-breakage stance, the wrong-price runbook, and the dispute blind spot. Build order: (1) migration; (2) retire-primitive refactor with void wrapper + regression; (3) CreateAndIssueCommitRelief + cap module (single buildCommitRelief function — the anchor f must have exactly ONE call site computing CN cash; consider a lint-shaped guard if a second appears); (4) API branch in create() + message updates (create() gate split, CreateRefund routing); (5) events/audit/GET commit block {granted, consumed, remaining, max_refundable_gross} plumbed from the backend; (6) tests per matrix; (7) CHANGELOG + MANUAL_TEST FLOW + README same PR. Playbook obligations at build time: re-enumerate invoice-status writers to re-verify paid-is-terminal for commit invoices (the no-invoice-row-lock choice depends on it), and re-run the grant-writer census (drawdown, clawback drain, expiry, void-retire, relief = complete set, all on the customer advisory lock). Wall-clock duration of this comparison/synthesis: ~12 minutes.