Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

Settlement saga

The state machine, the journal and idempotency.

Code-checkedread against the code it describes; nothing was run

Verified againstRead against the saga code listed at the foot of the page (PR #1954). Run only against the fake PayPal; one lot of three holds ran on the sandbox, no whole auction.

Page last changed 7 Nov 2026 (from the git history)

After the hammer, GAVEL runs a settlement saga. It is a pure state machine (step(journal, event) has no I/O, no clock and no randomness) driven by a runner that saves the journal before each effect.

Settlement saga: every state and legal move

Fake PayPal
Settlement saga: every state and legal moveState machine of a settlement. The main path is HELD, BIDDING, WINNER_SELECTED, CAPTURING, CAPTURED, VOIDING, SETTLED. A definite capture failure moves CAPTURING to FALLBACK_RUNNER_UP, which returns to CAPTURING for the next bidder or goes to VOIDING when none is left. Unknown answers, invariant breaks, mismatches and exhausted void retries end in FAILED_NEEDS_REVIEW, which needs a person. Below the states, a journal lane shows that intent is saved before each PayPal call and the outcome after it.HELDBIDDINGWINNER_SELECTEDCAPTURINGFALLBACK_RUNNER_UPCAPTUREDVOIDINGSETTLEDstart_auctionhammerbegindefinite_failnext_candidatecapture_ok / adoptedbegin_voidall_releasedcancel (HELD or BIDDING)no_candidateno_candidates_leftFAILED_NEEDS_REVIEWterminal: a person must look, nothing is retried blindlyinvariantfrom WINNER_SELECTED, CAPTURING, FALLBACK_RUNNER_UPunknown_deadlinefrom CAPTURING: the capture answer stayed unknown for 10 minutesmismatchfrom CAPTURING or CAPTURED: capture amount or identity differs from the planvoid_budget_exhaustedfrom VOIDING: a void still failed after 5 resumesA red corner on a state means it can move here.1One request id per capture, a hashof auction, op, hold, amount andgeneration. Same on every resume.2An unknown answer is not a failure:it waits. Never the next bidder,never a new request id.3Only a definite refusal falls back,after a truth read shows no capture.JOURNAL: written before and after each PayPal callhammer savedWINNER_SELECTEDintent savedcapture + request idPayPal capture callsame id on retrycapture_ok savedCAPTURED, intent clearedbegin_void savedother holds targetedall_released savedSETTLEDheldchargedreleasedneeds reviewbrass top rule: a state where money moves

Scroll the figure sideways, or open it full size.

Verified against the fake PayPal. Not run end to end on the PayPal sandbox as a whole auction; the sandbox runs recorded in docs/GATES.md cover single calls and the nightly specs. States and moves are copied from the transition table in packages/payments/src/saga/machine.ts.
Text version of this diagram

The main path is HELD, BIDDING (start_auction), WINNER_SELECTED (hammer), CAPTURING (begin), CAPTURED (capture_ok or adopted), VOIDING (begin_void), SETTLED (all_released).

  • cancel moves HELD or BIDDING to VOIDING. no_candidate moves WINNER_SELECTED to VOIDING.
  • definite_fail moves CAPTURING to FALLBACK_RUNNER_UP. next_candidate returns to CAPTURING. no_candidates_left moves it to VOIDING.
  • FAILED_NEEDS_REVIEW is terminal. It is reached by invariant (from WINNER_SELECTED, CAPTURING, FALLBACK_RUNNER_UP), unknown_deadline (from CAPTURING, after ten minutes), mismatch (from CAPTURING or CAPTURED) and void_budget_exhausted (from VOIDING).
  • Each transition is one compare-and-set save of the journal. The capture intent is saved before the PayPal call and cleared when the outcome is saved.

Three ideas

  1. The journal comes first. Every transition is one compare-and-set save of the journal before the next effect. The capture intent (capture, amount, request id) is saved before the PayPal call.
  2. An unknown answer is not a failure. If a capture times out or returns a 5xx, the settlement stays CAPTURING with an unknown clock. It never moves to the next bidder. After ten minutes it goes to FAILED_NEEDS_REVIEW.
  3. Only a definite refusal falls back. After a definite failure the saga reads the truth first; if there is no capture it records the failure and moves to the next candidate at that candidate's own price.

Idempotency

Every PayPal call carries a deterministic request id: gv1- followed by a hash of the auction id, the operation, the hold id, the amount in cents and a generation number. The generation only changes when parameters change, never on retry or resume, so a resumed capture repeats the same call and PayPal answers it once.

Limits you should know

SettingValueMeaning
voidConcurrency3At most three voids in flight
maxVoidResumes5A void that fails five resumes goes to review
unknownDeadlineMs600000Ten minutes for an unknown capture outcome

Where this lives in the code

Edit on GitHub

Last updated on