Trade Session State Machine (Canonical)
This merges two slightly different state-machine drafts found in source material into one
canonical version. See docs/business/conflict-resolution-log.md item 2.
⚠️ The most commonly misread part of this flow
Seller B does not wait for Seller A to pay, and vice versa. As soon as a trade session
reaches RESERVED, both sellers can complete their own checkout immediately, in either
order, independently. Fulfillment only begins once both sides have completed checkout. Do
not build any code path that gates one side's checkout availability on the other side's payment
status — that is exactly the miscommunication both source documents independently called out and
corrected.
Diagram
DRAFT
|
v
PENDING
|
v
OFFERED
|
v
ACCEPTED
|
v
RESERVING
|
v
RESERVED
|
+-----------------------+
v v
WAITING_CHECKOUT_A WAITING_CHECKOUT_B <- parallel, independent
| |
v v
CHECKOUT_A_COMPLETE CHECKOUT_B_COMPLETE
| |
+-----------+-----------+
v
LOCKED <- both sides complete (join/barrier)
|
v
FULFILLMENT
|
v
FULFILLED
|
v
COMPLETED
Failure / side states, reachable from most steps above: CANCELLED, EXPIRED,
PAYMENT_FAILED, RESERVATION_FAILED, FULFILLMENT_FAILED, RETURNED, REFUNDED.
Transition table
| From | To | Trigger | Side effect |
|---|---|---|---|
| — | DRAFT | Seller starts composing an offer (not yet submitted) | none external |
DRAFT | PENDING | Offer submitted | offer.created event |
PENDING | OFFERED | Offer delivered/visible to the target seller | offer.created webhook to target's merchant |
OFFERED | ACCEPTED | Target seller accepts | offer.accepted event + webhook; trade session row created; listing set frozen into trade_session_listings_a/b |
OFFERED | EXPIRED | 24h default timeout with no response (docs/business/conflict-resolution-log.md item 12) | offer.rejected-equivalent expiry event |
ACCEPTED | RESERVING | Trade-session engine begins locking both sides' items | reservation.created events (one per listing) |
RESERVING | RESERVED | All listings on both sides successfully locked | reservation.created webhook per side; both WAITING_CHECKOUT_* become reachable |
RESERVING | RESERVATION_FAILED | Any listing became unavailable mid-lock (race with another trade) | reservation.expired-equivalent event; session cancelled, no charges attempted |
RESERVED | WAITING_CHECKOUT_A / WAITING_CHECKOUT_B | Immediately, both in parallel | checkout.started webhook to each side's counterpart merchant, with {tradeSessionId, originalPrice, tradeAmount, currency} |
WAITING_CHECKOUT_A | CHECKOUT_A_COMPLETE | Merchant A-side webhook payment.completed received | checkout.completed event (side A) |
WAITING_CHECKOUT_B | CHECKOUT_B_COMPLETE | Merchant B-side webhook payment.completed received | checkout.completed event (side B) |
WAITING_CHECKOUT_* | PAYMENT_FAILED | Merchant webhook payment.failed, or 72h default timeout (item 12) | checkout.completed-equivalent failure event; session enters recoverable failure state, not silently retried |
both *_COMPLETE | LOCKED | Barrier: both sides report complete | trade.completed event fires here — this is the signal merchants listen for to know it's safe to begin fulfillment |
LOCKED | FULFILLMENT | Merchant(s) begin shipping | fulfillment.started webhook (merchant → TBBN) |
FULFILLMENT | FULFILLED | Merchant(s) report fulfillment done | fulfillment.completed webhook (merchant → TBBN) |
FULFILLED | COMPLETED | Both merchants confirm no open disputes/returns window | terminal state |
any pre-LOCKED state | CANCELLED | Either seller cancels, or ops intervenes | trade.created-equivalent cancellation event; any completed reservations released |
post-FULFILLED | RETURNED / REFUNDED | Merchant-driven, reported via webhook | informational only — TBBN does not currently process the actual return/refund, only records the state for analytics/reputation |
Multi-item trades
A session's A side and B side are each an array of listings (trade_session_listings_a,
trade_session_listings_b), not a single listing — N↔M is supported from ACCEPTED onward. The
RESERVING/RESERVED states apply to the whole set on a side: a side is not RESERVED until
every listing in its set is locked, and a single unavailable listing in the set fails the whole
side's reservation (RESERVATION_FAILED), not a partial trade.
A/B assignment convention
Purely a storage convention (docs/business/conflict-resolution-log.md item 3): on
offer→session conversion, the existing listing's owner (the one who was offered to) becomes
sellerA; the offer's proposer becomes sellerB. No business rule may depend on which slot a
seller occupies — the parallel-checkout behavior above applies identically to both.