Dispute region
A dispute is not a machine of its own. It is the fifth region of the order machine (packages/commerce/orders/order.status-projection.ts), next to order, payment, fulfillment and payout. The region mirrors disputes.status; none means the order has no disputes row yet. Every dispute event is an order event: transitionOrder writes the dispute row in the same transaction as the other status columns, and the history is the from_dispute_status / to_dispute_status pair on order_events, with metadata.disputeId naming the row. There is no separate dispute log and no separate dispute workflow: the order's Restate object runs it.
No diagram code provided
Statuses
| Status | Meaning |
|---|---|
none | No disputes row. The only state from which dispute_opened is accepted. |
open | The buyer raised the dispute. Awaiting admin triage. |
under_review | An admin picked the case up. The only state a resolution can leave from. |
waiting_buyer | The admin asked the buyer for more. |
waiting_seller | The admin asked the seller for more. |
resolved_refund_buyer | Closed for the buyer. Terminal. |
resolved_release_seller | Closed for the seller. Terminal. |
resolved_partial_refund | Closed with a split: the buyer keeps the item and part of the money. Terminal. |
withdrawn_by_buyer | The buyer took it back. Terminal, seller-favourable. |
dismissed_by_admin | An admin closed it without a resolution. Terminal, seller-favourable. |
While the region is in open, under_review, waiting_buyer or waiting_seller (ACTIVE_DISPUTE_STATUSES), the order sits at disputed, payout_status = blocked, and the auto-accept timer is fenced by the noActiveDispute guard.
Who moves it
The role rules live on the region, not in a table: every dispute event travels with the role that fires it, and the guards (byAdmin, byBuyerOrAdmin, bySellerOrAdmin, byBuyer, bySeller) read it.
| Event | Fired by | From | To |
|---|---|---|---|
dispute_opened | buyer, seller or admin (never a webhook or cron) | none | open |
dispute_triaged | admin | open | under_review |
dispute_party_asked { party } | admin | under_review, waiting_buyer, waiting_seller | waiting_buyer or waiting_seller |
dispute_party_responded | the awaited party, or an admin for them | waiting_buyer / waiting_seller | under_review |
dispute_evidence_added | the awaited party | waiting_buyer / waiting_seller | under_review (from anyone else it is logged, not a move) |
dispute_withdrawn | buyer or admin | open, under_review | withdrawn_by_buyer |
dispute_dismissed | admin | open, under_review | dismissed_by_admin |
dispute_resolved_refund | admin | under_review | resolved_refund_buyer |
dispute_resolved_seller | admin | under_review | resolved_release_seller |
dispute_resolved_partial | admin | under_review | resolved_partial_refund |
dispute_archived | system, after the hold period | any closure | no move: stamps disputes.closed_at, the status keeps saying which decision was reached |
The generated table is DISPUTE_REGION_TRANSITIONS in order.status-transitions.generated.ts.
A closure is fired twice
The five closure events (dispute_withdrawn, dispute_dismissed, dispute_resolved_refund, dispute_resolved_seller, dispute_resolved_partial) each move two things, and they move them on two separate fires of the same event.
- The first fire, by the deciding side (an admin, or the buyer withdrawing), moves the dispute region into its closure and nothing else. The settling guards on the
orderandpayoutregions read the region as it stands before the transition, so on this fire they do not match. - The second fire, by the saga's settlement actor (
role: system) after the settlement claim and the provider call, settles the order and the payout. Each closure is guarded on its own persisted status (RESOLUTION_STATUS_BY_EVENT): adispute_resolved_refundcannot cancel an order whose dispute was resolved for the seller, and a retried first fire can never be taken for the second.
The money fence
The guards do not move money; packages/commerce/orders/order.claims.ts does, under the order-row lock and before any provider call.
fenceDisputeOpendecides whetherdispute_openedmay create the row: it refuses an open once a settlement claim is standing, so a refused open writes nothing (there is noopen_failedstatus).claimSettlementrecords which closure is settling (orders.payout_status = releasablefor the seller-favourable ones,SELLER_FAVOURABLE_CLOSURES) before the gateway is touched. The second fire of the closure completes the move once the provider confirms.
packages/features/orders/__tests__/settlement-claims.integration.test.ts is the contract for this ordering.
What the order does with each closure
No diagram code provided
| Closure | Order | Payment | Payout |
|---|---|---|---|
resolved_refund_buyer | cancelled | refunded | stays blocked (nothing to release) |
resolved_release_seller | completed | unchanged | released |
resolved_partial_refund | completed | partially_refunded | released (the refunded portion is clawed back in the saga's partial-refund effect) |
withdrawn_by_buyer | completed | unchanged | released |
dismissed_by_admin | completed | unchanged | released |
See Order lifecycle for the other four regions, and the admin /statecharts/orders simulator, whose dispute buttons carry the firing role and offer both fires of each closure.