Skip to content

Spend Pipeline

Marcus Richards edited this page Oct 6, 2026 · 1 revision

Spend Pipeline

check → reserve cap → PayPal AUTHORIZE hold → escrow registered
      → evidence in → predicate evaluated → idempotent capture

SpendPipeline (permit/flow.py) is the only caller of paypal.authorize() and verifier.register(). ReleaseVerifier (settlement/verifier.py) is the only caller of paypal.capture().

spend() step by step

Step What happens On failure
6a Merchant binding If the rail has a merchant_account_id, the merchant must equal it BLOCKED merchant_not_bound, no reservation
6a2 Approval gate Above the permit's approval threshold → request approval instead of spending Returns pending_principal_approval, no reservation. See Principal Approvals
6b check() Four-clause gate. ALLOWED reserves cap BLOCKED receipt, PayPal untouched
6c Track Operation recorded before the external call —
6d Authorize PayPal hold, idempotency key {permit_id}:{auth_id}:authorize Sandbox may raise NeedsPayerApproval → ApprovalRequired (reservation stays held)
6e Recheck Permit revoked or expired during authorize? Void the hold, release the reservation: revoked_during_auth / expired_during_auth
6f Register Escrow created → AUTHORIZED receipt Registration re-checks the permit too, and fails closed

release() (verifier) order of checks

  1. Unknown escrow → REFUSED unknown_escrow.
  2. Already resolved → return the recorded outcome. Never re-capture.
  3. UNKNOWN → unknown_reconcile_first. CLEANUP_PENDING → cleanup_pending. No PayPal calls.
  4. Admission recheck. If the permit is missing, revoked or expired → REFUSED and the hold is voided.
  5. Ledger chain must verify → otherwise REFUSED broken_chain:* and the hold is voided.
  6. Predicate must pass → otherwise REFUSED predicate:* and the hold is voided.
  7. Capture with PayPal-Request-Id = {permit_id}:{auth_id}:capture.
    • COMPLETED → CAPTURED. Reserved moves to captured and rolls up.
    • PENDING or timeout → UNKNOWN.
    • Any other status → FAILED, then the hold is voided.

If a void times out, the escrow goes CLEANUP_PENDING and the reservation stays held.

Release predicates

Type Value Passes when
D delivery_hash sha256(delivered_bytes) equals the precommitted artifact_hash (constant-time compare)
A acceptance_signature HMAC-SHA256 over escrow_id|artifact_hash|amount_cents with the acceptance key. It is bound to one escrow

The acceptance key in code is a hard-coded demo key, labelled not for production.

Escrow states

AUTHORIZED → CAPTURED | VOIDED | REFUSED, plus UNKNOWN (reconcile) and CLEANUP_PENDING (retry cleanup).

Sandbox approval path

Sandbox orders need a payer. spend() raises ApprovalRequired. The HTTP service answers 202 with operation_id, order_id and approval_url. POST /api/operations/<id>/resume authorizes the same order. Never call spend again for the same purchase: that would double-reserve the cap.

Clone this wiki locally