Why a catering job is a saga that only moves forward
A catering job takes weeks and touches three objects and Stripe. No transaction can hold it. This is how the bakery runs it as a chain of small ones, how it undoes a step without rolling anything back, and why a step that comes in the wrong order is refused.
A breakfast for forty, over three weeks
An inquiry arrives for a breakfast spread for 40 people at a made-up venue, on a Saturday more than seven open days ahead. Staff send a quote. A few days later the customer accepts it and has 15 minutes of bakery time to pay a deposit of a quarter, or the day’s place goes back. Two days before the event a reminder is due. The kitchen bakes, a van delivers, and a balance invoice falls due seven days later. In the smoke test this job comes to 58,000 cents, deposit and balance together.
Along the way the job holds one of the day’s two catering places in that day’s Schedule, takes two payments through Billing and Stripe, and has to notice time passing when nobody touches it.
What a simpler design does, and where it breaks
One transaction around the whole job would hold its locks for weeks, and no transaction spans two Durable Objects, let alone Stripe. A process that waits in memory for the next step is evicted long before the next step comes. A choreography where each object reacts to the others’ events works until something has to be undone, and then nobody owns the question of what “undone” means for this job.
The 1987 paper “Sagas”, by Hector Garcia-Molina and Kenneth Salem, gives the shape that survives: a long transaction written as a sequence of short ones, each committed on its own, with a compensating transaction for each step that undoes its effect in business terms. No lab page shows this one yet, and the lab is where the family of techniques is explained.
One object, one step at a time
Each catering job is a CateringEvent object, one per inquiry. Its states are the twelve of CateringStatus in crates/domain/src/catering.rs, from inquiry through quoted, accepted, confirmed, in_production, delivered, invoiced and paid to closed, with declined, expired and cancelled as the other three ends. Every step is one command, decided against the state and committed with its events, its outbox rows and its key in one transactionSync. A step is started by a person (a route), by an event from the Queue, such as Billing’s PaymentSucceededbecoming RecordDeposit, or by a timer.
The timers are a pure function of the state, timers() in the same file: the follow-up reminder and the quote’s expiry while quoted, the deposit’s lapse while accepted, the event_soon reminder while confirmed or in production, and invoice_due while invoiced. The object rewrites its timers rows from that list in the transaction of every change, so there are never more than two saga rows at once. Each row holds an id such as quote-expiry, a purpose, a wall time and a business time. Cloudflare’s documentation gives an object a single alarm at a time, so the object arms it for its earliest row, and when the clock is moved in the console every wall time is worked out again from its business time.
An alarm runs at least once, the same documentation says, and is retried when it fails. So each timer runs its command under the key timer:<row>:<due>, and a second run finds the key and appends nothing. A timer that fires when its condition no longer holds, a lapse after the deposit was paid, emits nothing.
That makes the object the job’s process manager, in Hohpe and Woolf’s sense: one place that keeps the state of the sequence and decides the next step. Because it is a single writer, two steps never race inside it, and no other service has to coordinate them.
Undone by a step of its own
A saga cannot roll back what already committed in another object, so each failure has a compensation that is a new step. When the deposit does not come in time, the deposit-lapse row runs LapseDeposit: the job goes from accepted back to quoted, and CateringDepositLapsed tells the day’s Schedule to release the place. When a confirmed job is cancelled, CateringCancelled releases the place, and Billing refunds the deposit if the event was more than seven calendar days away.
A compensation can itself be a call to another system that fails. A deposit that lands after the quote expired cannot confirm anything, so the object asks Billing to refund it in full at Stripe, under the delivery’s id and the reason goodwill. While Billing cannot be reached the object answers 503, the Queue delivers the event again, and the id makes the repeat ask for the same refund. The undo is retried until it lands, the way the step was.
Some steps have no undo. Cancellation is taken from confirmed only, so once the kitchen has started the job runs to delivery and its invoice. No saga step voids an invoice: as built on 2026-11-06, only the nightly wipe does, when Billing voids every open one at Stripe before it deletes itself.
Only forward
The log only grows. Going from accepted back to quoted is a new event, not the acceptance removed. A command that does not fit the current state is refused with 409 and the rule out-of-order, and nothing is written: a start of production before the deposit, a delivery before production, a cancellation after it. The bakery does not hold such a command for later, because a step that arrives early was usually sent from an old page. Repeating a step in the state it leads to is not an error and emits nothing.
Each of these refusals is proven over HTTP. On 2026-11-06 scripts/smoke-catering.sh passed 238 checks, among them the five 409s of decline, start of production, delivery, cancellation and staff cancellation, each from two states, each leaving the event byte for byte as it was (docs/roadmap.md, PR 7).
What it costs
- A timer row and an alarm run per timed step. Alarm runs that arm again are counted against
REARM_GUARD, 100 a UTC day for one object (workers/api/src/objects/stream.rs); past it the object waits for its next command. - A 409 the client must handle. The catering pages read the rule and say “That step is not open in the event’s current state. Refresh to see where it is.” (
apps/web/src/lib/catering-errors.ts), instead of trying again. - No isolation. Between steps the job is visible half done: a place held with no deposit, a delivery with no invoice yet. Richardson lists this as the pattern’s price.
- A quote you cannot watch expire. A quote is valid until the opening of the 14th open day after it was sent, 16 calendar days or more, and the clock moves at most 14 days (
MAX_OFFSET_Sincrates/domain/src/clock.rs). The expiry is proven on the seed’s quoted job only.
Try it
Sign in and send an inquiry from the catering page. Quote it in the console, accept it, then advance the clock by 15 minutes before paying: the place is released and the job is quoted again. Accept again and pay the deposit with the test card 4242 4242 4242 4242. Start production, then try to cancel: 409 out-of-order. Mark it delivered and pay the balance on the event’s page, and the console’s list of reminders shows each one the job fired.
Sources
- Sagas, Hector Garcia-Molina and Kenneth Salem, Proceedings of the 1987 ACM SIGMOD Conference: a long-lived transaction as a sequence of transactions, each with a compensating transaction. Cited from print; not read on 2026-11-06.
- Alarms, Cloudflare’s Durable Objects documentation, read on 2026-11-06: one alarm at a time per object, a new one replacing the old, run at least once and retried with exponential backoff from 2 seconds, up to 6 retries.
- SQLite-backed Durable Object Storage, Cloudflare’s Durable Objects documentation, read on 2026-11-06: transactionSync() rolls back when its callback throws, and takes only synchronous storage operations.
- Pattern: Saga, Chris Richardson, after his Microservices Patterns (2018), read on 2026-11-06: a sequence of local transactions, choreographed by events or orchestrated from one place, with compensating transactions in place of a rollback and no isolation between sagas.
- Process Manager, Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns (2003), read on 2026-11-06: one central unit that keeps the state of a sequence and decides the next step from the results so far.
- The lab, ihaveasegway.com, read on 2026-11-06: five lab pages, none of them on sagas.
Back to sagas by alarm on How it’s built, to the decision that chose them, ADR 0004, or to how an event reaches the next object.