Stock transfers and booking holds: what changes when a claim can expire

Why a warehouse transfer fits in one compare-and-swap, why a calendar hold can lapse with no write at all, and why a durable seat claim is the one that can leak.

November 20265 min readPlatformsInterfaces

Thick scrubbed umber and plum sit at lower left, with vivid green and stone strokes growing longer and drier up and to the right until they fade into warm chalk.

Inventory and booking look like the same problem. There is a finite number of something, several people want the last one at the same moment, and a read-then-write race will happily sell it twice. In August, working with coding agents, I built a multi-warehouse inventory app and a calendar booking app on the same framework, and the two share their basics: every store write is either conditional or an atomic counter, and every action carries an idempotency key so that a retry can be recognised. The calendar’s domain came across as a port of earlier work rather than something I designed from scratch. Their designs still differ, mostly because of one question, which is whether a claim on capacity is permanent. A unit of stock that moves between warehouses stays moved until someone moves it again. A hold on a calendar slot is supposed to vanish by itself if the person holding it wanders off.

The permanent case is the easier one, because the inventory app can keep everything that has to agree in one place. A SKU is a single document holding its level at each location, its reservations and its recent movements. A transfer between two locations changes two levels and records a movement, and since all of that is one record it is one compare-and-swap, so there is no moment at which the stock has left one warehouse and not yet reached the other. The level change and its movement share that write, which means an audited movement can’t exist without its effect and an effect can’t exist without its movement.

Every movement goes through one mutation path, and that path has three failures to survive at once. Two movements that both read the old record and wrote unconditionally would lose one of the two effects, so the read-modify-write runs in a compare-and-swap loop and the loser re-reads. A scanner retries, and the same invocation must not apply twice, so the record carries the id of the last action to change it, and a retry that recognises its own id adopts the stored value instead of applying itself again. And because the effect is written before the replay marker is committed on a separate storage plane, a crash between the two leaves a movement applied and unrecorded. If its id is still the record’s last action, the retry skips the write and commits the marker. A later movement can replace that stamp, so this check recognises the most recent action rather than every earlier unfinished one.

The oversell guard sits on the same path. A shipment, transfer or reservation that would take available stock below zero is refused with a typed conflict before anything commits. Shipping down to exactly zero is legal and one unit past it is not. When two shipments race for the last unit the loser re-reads, the guard finds nothing left, and its caller gets a conflict instead of a negative balance.

One record only goes so far. A history kept inside a document has to be bounded, so each SKU retains its latest 500 movements, dropping the oldest while a running total keeps the ordinals stable. The retained movements explain recent changes; after older deltas have been dropped, they cannot reconstruct the complete stock history. And receiving against a purchase order has to put stock on a SKU and credit a line on the order, which are different records. That operation runs as a saga, a sequence of separately committed phases that a retry can resume, with both writes stamped by the same action and a refusal for any receipt that would exceed what was ordered. Closing a stock count is a saga too, recovering through an applied flag. The atomic boundary is one SKU; operations crossing records need a recovery protocol.

The calendar is arranged differently, and it keeps two separate claims for two separate purposes. The first is the hold, a five-minute claim on a slot while someone finishes booking. What I like about it is that nothing has to happen for a hold to end. A hold counts only if it is marked as held and its expiry is later than now, and the query for open slots applies that test as it reads, so a hold lapses by the clock without a write. Ending a hold doesn’t depend on a timer that could be lost, a sweeper that could fall behind or a job that could be delivered twice.

The second claim is the seat, and the hold is not what prevents double booking. To admit a booking the app looks up the idempotency key, checks for a live hold belonging to someone else, takes a snapshot of the seat count, allocates a booking id, claims a seat, creates the booking record, converts the hold, records the run and adjusts the load. The snapshot is advice; the seat claim is the decision. Anything can happen between reading a count and writing a booking, so the count can only advise. The decision is a compare-and-swap on the slot’s seat ledger, which answers admitted, full or already held. The ledger stores the set of booking ids and not a number, and each id is pinned to the digest of the invocation, so a retry arrives with the same id and finds it already in the set instead of taking a second seat. A counter could tell you how many seats are gone, but not whether one of them is yours.

The cost of this arrangement is in the gap between the two writes. The seat ledger and the booking record are written separately, and the seat ledger has no expiry. If the process dies after claiming the seat and before creating the record, a retry with the same key can recognise that seat and create the record, provided the earlier admission checks still pass. If recovery never completes, the ledger names a booking that doesn’t exist, that seat stays taken, and the app had nothing that reclaimed it. I chose that order, and the opposite one would be worse, since a booking record with no seat behind it is a promise the capacity check never approved. Two admissions racing for the last seat can’t both win, because they meet at one compare-and-swap. That protects the capacity decision; completing the booking still takes a separate write.

So the temporary claim is the one that cleans up after itself, and the durable one is the one that can leak. An abandoned hold costs a slot five minutes. An orphaned seat costs that seat until somebody does something about it. A completed stock transfer has no expiry and nothing to give back, so its level change and movement record share a write. A hold needs the opposite property, that it can be safely forgotten, which is why ending one takes no write at all. Both apps refuse rather than overwrite when capacity runs out. What differs is the set of records you have to reason about and the failure you accept, and both follow from whether the claim was ever meant to last.

Chris Chabot, November 2026