Skip to content

fix(merchant): a paid purchase whose gate cannot open is an owed entitlement, not a lost one (#403) - #651

Merged
Amperstrand merged 1 commit into
fix/640-ambiguous-swap-outcomefrom
fix/403-owed-entitlements
Oct 6, 2026
Merged

Amperstrand merged 1 commit into
fix/640-ambiguous-swap-outcomefrom
fix/403-owed-entitlements

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

What

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 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 terminal expired state 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 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: 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 auth forced 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.
  • The conformance lane's gate-failure shape is covered by these; the kill-mid-Receive and dropped-response windows remain red in the lane and are the Priority: P1 — the TollGate-level business-transaction record; generalizes the still-open #403 into a mechanism. #502 business-transaction record (RELEASE-NOTES states this).

Fixes #403 (the gate-failure shape; refund semantics remain out of scope by design).

…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.
@Amperstrand
Amperstrand merged commit 2de3f80 into fix/640-ambiguous-swap-outcome Oct 6, 2026
@Amperstrand
Amperstrand deleted the fix/403-owed-entitlements branch October 6, 2026 12:34
@Amperstrand
Amperstrand restored the fix/403-owed-entitlements branch October 6, 2026 12:36
@Amperstrand

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant