Repository navigation
fix(merchant): a paid purchase whose gate cannot open is an owed entitlement, not a lost one (#403) - #651
Merged
Amperstrand merged 1 commit intoOct 6, 2026
Conversation
…tlement, not a lost one (#403) A successful Receive is irreversible: the customer's value is in the operator's wallet. When ndsctl auth then failed, the old path rolled back the in-memory session and answered a bare session-error — the operator kept the value while the customer had neither service nor a recoverable claim, the exact invariant AGENTS.md forbids. The paid purchase is now recorded as an owed entitlement in a durable, atomically-written, fsync'd store (owed-grants.json, the same discipline as the Lightning quote store) BEFORE the response completes, keyed by the receive reference the customer can already quote. A per-record monitor retries the grant with backoff+jitter until it succeeds or its window passes (ms grants expire when their paid time is gone; data grants after 24h; both converge to a loud terminal expired state, record kept for audit — never an infinite retry). A restart reloads the store and relaunches the monitors; the grant applies exactly once (processing flag plus persisted granted transition); the customer is told access will start automatically and NOT to pay again (payment-received-grant-pending). State machine, per the repo's money-moving documentation rule: - before value moves: nothing owed; on Receive success the entitlement becomes owed only if the grant fails; - if the next operation (gate open) fails: entitlement persisted, retried; - if the process dies: store survives; startup loader relaunches monitors; - restart convergence: grant applied once when NDS accepts; - duplicates: keyed by note reference — one claim per note. Refunding remains deliberately out of scope (expired = operator action); the general business-transaction record is #502.
Collaborator
Author
|
This squash landed into its base branch (fix/640-ambiguous-swap-outcome) instead of main during the train merge race. The content reached main intact via #660 (which carried this branch) — main's final tree hash equals the verified release stack, so nothing was lost or duplicated. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A successful
Receiveis irreversible: the customer's value is in the operator's wallet. Whenndsctl auththen failed, the old path rolled back the in-memory session and answered a baresession-error— the operator kept the value while the customer had neither service nor a recoverable claim.The paid purchase is now recorded as an owed entitlement in a durable, atomically-written, fsync'd store (
owed-grants.json, the same discipline as the Lightning quote store) before the response completes, keyed by the receive reference the customer can already quote. A per-record monitor retries the grant with backoff+jitter until it succeeds or its window passes (milliseconds grants expire when their paid time is gone; data grants after 24 h; both converge to a loud terminalexpiredstate with the record kept for audit — never an infinite retry). A restart reloads the store and relaunches the monitors; the grant applies exactly once (processing flag plus a persistedgrantedtransition); the customer is told access will start automatically and NOT to pay again (payment-received-grant-pending).State machine, per the repo's money-moving documentation rule: nothing owed before value moves; a failed grant persists the entitlement; a process death leaves the store intact and startup relaunches; restart converges by granting once when NDS accepts; duplicates are keyed by note reference — one claim per note.
Evidence
TestPaidPurchaseWhoseGateFailsIsOwedThenGrantedExactlyOnce: Receive succeeds,ndsctl authforced to fail (registered-but-preauth client), entitlement durable before the response, session rolled back; NDS recovers → exactly one grant, one allotment's worth, store marked granted.TestOwedGrantSurvivesRestartAndConverges: the crash-window shape (store seeded, "process died") → a fresh merchant over the same store grants exactly once.TestOwedTimeGrantExpiresPastItsWindow,TestOwedGrantIsNotDuplicatedForTheSameReference.Fixes #403 (the gate-failure shape; refund semantics remain out of scope by design).