Part 3 of 7 · Waitlist manager series ~5 min read

How a freed slot gets noticed

A slot frees up three ways: a customer cancels, staff mark a no-show, or a fresh opening appears on the calendar. Any of those sends an event to the offer engine. The engine reads the waitlist, looks at the freed slot, keeps only the entries that actually fit it, sorts what’s left in the fair order, and decides whether to offer the slot to someone or hand it back. The whole decision is plain Python. No model. No vector search. Every rule lives in the rules doc, where staff can edit it without a deploy.

Key takeaways

  • A cancellation, a no-show, or a new opening sends a freed-slot event to the engine.
  • Eligibility filters the list first — right service, right party size, date inside the window, matching staff preference.
  • Survivors are sorted in fair order: join time by default, lifted by a priority flag.
  • Four moves per slot: nobody fits, make an offer, roll on, or hand back to staff.
  • The engine never calls a model. The decision is entirely deterministic.

The decision flow, per freed slot

Decision flow per freed slot A vertical decision flow diagram. At the top, an input box "Freed-slot event" with the slot's service, date, time, party capacity, and staff member. Below that, a step "Filter the waitlist" — keep only entries whose service, party size, date window, and staff preference all match the slot. Below that, a check "Anyone eligible?" — if no, route to "Nobody fits" (leave the slot open, tell staff). If yes, continue. The next step "Sort in fair order" — apply the order from the rules doc; join time by default, lifted by a priority flag. The next step "Read offer state for this slot" — look at DynamoDB to see whether an offer is already live and who has already been tried. If an offer is currently live, do nothing (a window timer is running). If the previous offer timed out or was declined, pick the next untried eligible entry. The next check "Anyone left to try?" — if the eligible list is exhausted or the per-slot try cap is reached, route to "Hand back to staff". Otherwise, if this is the first offer, route to "Make an offer"; if a previous person already timed out, route to "Roll on" (next person, fresh window). Each terminal box — Nobody fits, Make an offer, Roll on, Hand back to staff — emits an event to EventBridge with the move and the slot and entry context. A note at the bottom: the rules doc holds every rule; the engine only enforces them — change the order or the window and the next freed slot uses the new value. Freed-slot event service · date · time · staff Step 1 Filter the waitlist service, party, window, pref Step 2 Anyone eligible? none → nobody fits Step 3 Sort in fair order join time, lifted by priority Step 4 Read offer state (DDB) live offer → wait else pick next untried Step 5 Anyone left to try? exhausted → hand back Nobody fits leave open, tell staff Make an offer top eligible person Roll on next, fresh window Hand back list exhausted if none exhausted cap hit first after time-out The rules doc holds every rule — change the order or window and the next freed slot uses it.
Fig 3. The engine’s decision tree, per freed slot. Five steps decide which of four moves applies. The rules doc holds the eligibility checks and the order; the engine only enforces them.

What triggers the engine: three ways a slot frees

The engine doesn’t poll all day — it wakes on an event. Three things send one. A cancellation: the customer cancels through your booking tool, which fires a webhook to a Function URL, or a staffer marks the slot cancelled in the calendar and a small sync Lambda notices. A no-show: staff tap “no-show” at the front desk a few minutes after the appointment time, which frees the rest of that slot. A new opening: someone adds availability — a stylist picks up an extra hour, a table is reconfigured — and that new gap counts as a freed slot too. Each one becomes a wl.slot_freed event carrying the slot’s service, date, time, party capacity, and staff member.

Keeping the engine event-driven matters for both cost and speed. There’s nothing running between slots, so the bill is near zero on a quiet day; and when a slot does free, the first offer goes out in seconds, not on the next poll.

Eligibility: who actually fits this slot

Before anyone is sorted, the list is filtered down to entries that can really take the slot. The rules doc spells out the checks in plain prose, and the engine applies them in order. Service: a colour slot only fits people waiting for a colour. Party size: a two-top doesn’t fit a party of four; a four-top can fit a two if the rules doc allows down-fitting, or not if it doesn’t. Date window: the slot’s date has to fall between the entry’s earliest and latest dates. Staff preference: if someone asked specifically for Jess, they’re only eligible for Jess’s slots. An entry that fails any check is dropped for this slot — it stays on the list for the next one.

This filter is the difference between a useful system and a noisy one. Offering a Tuesday slot to someone who only wants Saturdays, or a kids’ cut to someone waiting for a colour, trains people to ignore the texts. Filtering first means every offer that goes out is one the person could actually say yes to.

Fair order: written down, the same every time

The survivors are sorted by the order in the rules doc. The default is first-come, first-served by the joined timestamp — the person who’s been waiting longest gets first refusal. A priority flag can lift an entry above plain join order: a regular who got bumped by a previous cancellation, a VIP, or someone staff have promised. The doc names exactly how priority interacts with join time (for example, “priority entries first, then by join time within each tier”), so two staffers reading it get the same answer.

The point of writing the order down is fairness you can defend. When a customer asks “why did she get the slot and I didn’t?”, the answer is in the doc and the audit log, not in whoever happened to be at the desk.

Four moves, always

Every freed slot, every time, lands in exactly one of four moves. The names are simple on purpose.

  • Nobody fits. The filter left no eligible entry. Leave the slot open and post a note to staff so they can fill it by phone or take a walk-in. Most slots in a thin-list business land here, and that’s fine — the system just got out of the way.
  • Make an offer. There’s a top eligible person and no offer is live yet. Send them the slot and start a claim window. Write a row to the wl-offers DynamoDB table marking the offer as live with its expiry time.
  • Roll on. The previous person’s window ended or they declined, and there’s a next eligible person who hasn’t been tried. Send the same slot to them with a fresh window. Mark the previous attempt as timed-out or declined in wl-offers.
  • Hand back to staff. The eligible list is exhausted, or the per-slot try cap in the rules doc is reached. Return the slot to staff with a short note: how many people were tried, and that the list is spent. The slot stays open for a walk-in or a phone fill.

State that keeps it deterministic and double-book-proof

The engine reads one DynamoDB table to make the call: wl-offers, which records the live offer per slot and the history of who’s been tried — (slot_id, offer_seq, entry_id, status, window_ends_at). With that, the move logic is a few dozen lines of Python and zero magic. A given slot, a given list, and a given offer history always produce the same move. And because only one offer per slot is ever marked live, the next person is never texted while someone else is still inside their window — which is the rule that, together with the conditional-write claim in Part 5, makes double-booking impossible.

Why this path uses no model

The engine could call a model to “decide” who deserves the slot, or to write a cleverer offer. It doesn’t. Two reasons. First, who gets offered a slot has to be utterly predictable and defensible — if the rules doc says first-come among those who fit, that’s who gets it. A model in that loop introduces variance staff can’t reason about and a customer can’t be told. Second, slots free up at the busiest moments, when speed matters; a deterministic filter-and-sort runs in milliseconds. Bedrock fires elsewhere — on the inbound parsing lane in Part 2 and on the monthly summary in Part 6 — not on the offer path.

Next post: how an offer reaches the next person — channel choice, quiet hours, the claim link and countdown, and the four guardrails on every send.

All posts