Lifecycles

Operations that live over time — the payout saga and subscription states — modeled as explicit state machines.

Source src/ports.ts#L773-L780 · SAGA_STATESsrc/operations/requestPayout.tssrc/worker/payouts.ts

A topUp or a spend finishes the instant you call it. A payout can't: it reaches an external rail that answers in its own time. A subscription can't either: it bills period after period.

So economy-lab models each of these as an explicit state machine. The operation becomes a stored record — a Saga or a Subscription — that the background worker advances one safe step at a time. If a crash interrupts the work, the record resumes from its last recorded state. A step that runs twice still counts once.

Why it exists

Picture the alternative: paying a creator synchronously inside requestPayout. You call the provider, wait, post the money, and return. That couples a money posting to a network call the ledger doesn't control.

The failure modes are bad. If the call hangs, the request hangs. If the process dies after the provider pays but before the ledger records it, the books and the provider disagree — with no record of why.

A long-lived record fixes the disagreement by making every intermediate state durable and named. The worker can read where a payout got to and drive it forward. An operator can see a stuck one rather than a silent gap.

The same shape covers anything that outlives one request — a subscription that renews on a schedule, or a promo grant that expires later. Each is a row the worker sweeps, not a thread it holds open.

The payout saga

requestPayout does not pay. It reserves the credits — debit earned → credit payout_reserve, two CREDIT legs that cancel out — and opens a Saga, a record whose state walks through SagaState (see SAGA_STATES).

The full enum declares REQUESTED, RESERVED, SUBMITTED, SETTLED, and FAILED. A live payout starts at RESERVED, because the credits were set aside in the same database transaction that opened the saga.

StateMeaningWhat advances it
RESERVEDCredits locked in payout_reserve; nothing sent to the rail yet.The worker's payouts sweep.
SUBMITTEDThe sweep converted the reserve to USD and called the payout rail.The provider's settlement webhook.
SETTLEDThe rail confirmed; the reserve clears to revenue and an equal sum of USD leaves trust_cash for the creator.Terminal.
FAILEDThe payout was given up on; the reserve returns to the creator's earned.Terminal.
RESERVEDreserve heldSUBMITTEDsent to railSETTLEDterminalFAILEDterminalpayouts sweepreserve → USDsettlementwebhooktimeout · max attempts · reverse→ reserve returns to earnedSagaState also declares REQUESTED; a live payout opens already RESERVED, in the same transaction as the reservation.
The payout saga. Each transition is a compare-and-set that posts its money in the same transaction, so a re-driven step pays at most once. SETTLED and FAILED are terminal; the reserve is released exactly once.

The two forward steps run on different triggers. The worker's payouts sweep handles only the first. It claims due RESERVED sagas, converts the reserve to USD at the locked rate, calls processor.submitPayout, records the providerRef, and advances RESERVED → SUBMITTED (see submitToProvider).

It does not settle itself. Settlement arrives on the provider's verified "payout settled" webhook. That webhook maps to a settlePayout operation, which the worker's inbox drain applies off the request path. That operation runs the SUBMITTED → SETTLED step.

What makes it safe to re-drive

Three guards let the worker retry a step without paying twice.

  • The call to the rail is idempotent, keyed by the saga id (key: saga.id), so a retried submit still pays once. The settlement webhook is deduped on the provider's event id, so a redelivered settlement settles once.
  • Each state change is a compare-and-set. sagas.advance(id, from, to, patch) moves a saga only if it is still in from, and returns false (changing nothing) otherwise, so two sweeps racing the same saga can't both advance it (see SagaStore.advance).
  • A stuck payout is given up on. Past maxPayoutAttempts failed attempts, or after maxPayoutAgeMs in SUBMITTED with no settlement webhook, the sweep force-fails it. It flips the saga to FAILED and, in the same transaction, posts the exact reverse of the reservation (debit payout_reserve → credit earned), returning the credits to the creator (see deadLetter).

That force-fail reversal and the operator's reversePayout share one CAS guard on the saga. A reversal posts the compensating entry only if its compare-and-set wins. A lost CAS means a concurrent settle or reverse already accounted for the reserve, so posting again would return the credits a second time. An operator can force the same early reversal with reversePayout, but only while the money hasn't already left.

Subscriptions

subscribe charges the first period and grants the buyer an Entitlement to the SKU. From there, the worker's subscriptions sweep renews it. Each period it claims a one-charge-per-period key (so overlapping sweeps can't double-bill), debits the subscriber's spendable, pays the seller, and moves the next due date forward.

The renewal claim follows the same CAS pattern as the saga. subscriptions.markBilled sets the next period only if the row still shows the period the sweep claimed, so the loser of a race treats it as a no-op (see SubscriptionStore.markBilled).

A Subscription carries its own SubscriptionStateACTIVE, LAPSED, CANCELED (see SUBSCRIPTION_STATES):

ACTIVE ──▶ LAPSED      a renewal it can't pay, or one that keeps failing, ends it;
                       the SKU entitlement is revoked in the same step
       ──▶ CANCELED    the user or an operator stops it; the current period is
                       not refunded

maxSubscriptionAttempts separates a transient failure from a dead one. The sweep retries a failed charge a few times before the subscription lapses, and a success resets the count to 0. Once the count reaches the cap, the sweep flips ACTIVE → LAPSED, revokes the entitlement, and emits the lapse event in one transaction (see lapseAtomically).

The first period may draw on promo credit; renewals come only from spendable.

The invariant

A long-lived operation moves through its states once, in order, no matter how many times the worker re-drives it or how its triggers race.

Every transition is a compare-and-set against the current state, so a step is applied by exactly one writer. The money posting that accompanies a transition commits in the same database transaction as the state flip, so the books and the record never diverge.

A RESERVED payout pays at most once, and its reserve is released exactly once — by settlement, by timeout-fail, or by an operator reversal, whichever wins the CAS. Never twice, and never zero times.

How it's enforced

The enforcement lives in three places, and you can verify each one in the source.

  • The store's CAS methods. SagaStore.advance and SubscriptionStore.markBilled are conditional updates that return whether they changed a row, so the handler can detect and stand down on a lost race (see SagaStore, SubscriptionStore).
  • One transaction per step. Each forward step posts its ledger entry and advances its record inside store.transaction, so a partial step rolls back whole — a flipped state with no posting, or a posting with no flip, can't persist.
  • Idempotent external calls and dedupe. processor.submitPayout is keyed by the saga id (see Processor), and inbound provider events are deduped by event id before they apply, so a redelivered webhook or a retried submit resolves to the same money move at most once.

Operations and ports that rely on it

requestPayout opens the saga; settlePayout and reversePayout move it. The worker's payouts and subscriptions sweeps and its inbox drain drive transitions off the request path.

The saga and subscription records persist through the SagaStore and SubscriptionStore sub-stores of the Store port, and a payout's disbursement leaves through the Processor port. The CAS-and-single-transaction discipline these lifecycles depend on is the same machinery that keeps the ledger's integrity invariants intact under concurrency.

See also