[Operations 1.1] A Waitlist Is Not a Queue of Names

Patient A cancels a 10:00 appointment. Patient B has been waiting for that same time, while Patient C has a VIP priority signal but does not match the doctor or treatment. One notification attempt fails, and two scheduler instances discover the vacancy at nearly the same time.
Implementing this as waitlist.sortBy { it.createdAt } followed by notification.send() quickly raises four harder questions:
- Is B actually eligible for this tenant, clinic, treatment, doctor, and time window?
- If the offer is written but notification fails, who owns the vacancy?
- When two workers promote the same candidate, how many capacity holds are created?
- If B retries the same claim or opens an expired link, which response can the service reproduce?
The conclusion is simple:
A waitlist is a state machine, not a list of names. Filter
WAITINGcandidates by hard eligibility and deterministic order, create oneOFFEREDrecord and durable hold for one vacancy, and record a claim as anACCEPTEDcommand handoff separate from appointment creation.
Filling a vacancy is a state problem, not just a candidate query
Section titled “Filling a vacancy is a state problem, not just a candidate query”Reading candidates and promoting a candidate are not the same transaction. The current clinic-appointment implementation keeps that boundary explicit. WaitlistCandidateMatcher reads bounded keyset pages and batches BookingReliabilityDecision, but it does not lock rows. The actual promotion happens when WaitlistOfferService locks the entry and resource again and revalidates the facts.

Once the states are explicit, notification and database responsibilities become explicit too.
| State | Meaning | Next responsibility |
|---|---|---|
WAITING | An eligible waitlist entry that has not received a concrete offer | Re-evaluate the vacancy and policy fit |
OFFERED | An offer linked to an expiry time and capacity hold | Record the candidate response, expiry, and delivery attempts |
ACCEPTED | The candidate claimed the offer and a downstream command can consume the durable hold | Hand off to the appointment-creation command |
DECLINED | The candidate or staff declined the terminal offer | Preserve the reason and suppression policy before considering another candidate |
EXPIRED | The offer, hold, or start time closed the offer | Reconcile before retrying |
WITHDRAWN | Staff or recovery withdrew the offer | Preserve the cause and correlation data |
The state machine is not only a storage model. The operations screen first reads clinic-level readiness and metrics, selects an item from the action queue, checks its evidence and permitted command, and records the terminal outcome decision.
ACCEPTED must not be used as a synonym for CONFIRMED. WaitlistOfferClaimService currently returns a holdId and expiry data; creating the appointment is outside that service. That separation lets claim handling and the appointment command retry independently.
Reduce candidates first, then apply policy
Section titled “Reduce candidates first, then apply policy”If the service orders the waitlist before checking eligibility, an unqualified candidate can temporarily occupy a vacancy. Keep the sequence fixed:
- Hard eligibility: Check
tenant,clinic, treatment, doctor, time window, and resource requirements. - Policy inputs: Read the clinic policy version and batch
BookingReliabilityDecisionas read-only inputs. Do not recompute the decision or copy a customer tier into the appointment database. - Deterministic keyset order: Use
slotFit DESC → priorityRank DESC → waitingSince ASC → entryId ASC. The finalentryIdprevents ties at the same time and priority from changing order. - Bounded pages and time budget: Cap page size, candidate count, page count, and elapsed time instead of scanning indefinitely.
- Revalidation: Immediately before promotion, recheck the entry, policy stamp, and resource in the same transaction.
A VIP signal cannot bypass this sequence. VIP is a policy input that may set priorityRank among eligible candidates; it is not permission to inspect another tenant or take a CONFIRMED appointment. If BookingReliabilityDecision is STALE or UNAVAILABLE, automatic promotion must not be finalized. The service must follow the configured path: staff review, a fresh read, or the next candidate.
Keep one active offer for one vacancy
Section titled “Keep one active offer for one vacancy”When two schedulers read the same cancellation event, a Redis lock alone is not enough. Redis can reduce advisory duplication, but the database transaction and version/CAS fence remain authoritative for state, owner, and capacity.
The design has three core invariants:
- A
vacancyKeyhas at most one activeOFFEREDorACCEPTEDoffer. - Every
OFFEREDandACCEPTEDoffer is connected to an availability capacity hold. - Candidate discovery is optimistic and reproducible; promotion and claim use resource-first locking and CAS.
WaitlistOfferService therefore obtains the vacancy fence, checks hard eligibility, policy stamp, and candidate version in a caller-owned Exposed transaction, then commits the offer, hold, and event together. If another worker commits first, the second worker reads the database result and stops before sending a duplicate notification.
A claim is a durable handoff, not proof of notification
Section titled “A claim is a durable handoff, not proof of notification”Receiving a notification does not mean the candidate owns the vacancy. The claim API accepts a public identifier, tenant and clinic scope, expectedVersion, and an idempotency key, then checks the following:
- It verifies that the offer and entry share the same scope. An opaque reference from another scope must not produce an error that reveals whether it exists.
- It locks the offer, hold, and entry in the required order and validates owner, state, version, expiry, start time, and resource occupancy.
- It applies
OFFERED → ACCEPTEDand the hold transition with CAS, then appends a command receipt. - A repeated idempotency key replays the same result instead of creating another hold.
- Stale decisions, expired offers, occupied slots, and version conflicts return reproducible conflicts rather than a fake success.

OFFERED. A retried claim returns the same receipt, while stale, expired, and occupied states flow into recovery and the next policy decision.Enforce tenant boundaries and privacy in the storage model
Section titled “Enforce tenant boundaries and privacy in the storage model”A waitlist is not a search feature for patient names or phone numbers. Store scope and opaque IDs internally, and expose an unpredictable public reference through the API.
| Boundary | Design rule |
|---|---|
tenant and clinic | Pass the same WaitlistScope to every entry, offer, hold, and event query |
| Candidate identity | Use an opaque public ref and short-lived claim token instead of an internal integer ID |
| Policy provenance | Record policyVersion, digest, decision ID, and expiry on the offer and event |
| Observability | Log state, reason code, and correlation ID instead of names, phone numbers, or raw support notes |
| Scope errors | Treat a reference from another tenant or clinic like a not-found result so existence is not exposed |
These boundaries let staff explain why a candidate was next without mixing another patient’s data into a result or log. Provenance is not decoration; it is required for replay, appeals, and recovery.
Recover after notification failure and rollout off
Section titled “Recover after notification failure and rollout off”Turning the waitlist feature off does not mean deleting state that already exists. When enabled=false or a tenant is outside the allowlist, new dispatch and notification work may stop, but the following work must remain possible:
- Close expired offers and holds.
- Reclaim delivery outbox leases and bound retries.
- Reconcile stale offers, missing holds, and stuck commands.
- Preserve suppression rules and event history.
- Avoid creating a duplicate offer for the same vacancy when the feature is enabled again.
Rollback follows the same rule. Do not delete offers or holds with manual SQL. Use an operational command and recovery service that records the state transition, reason, and correlation ID. Without that history, fixing a notification outage can reopen capacity and offer the same time to two candidates.
Separate current implementation, approved design, and readiness
Section titled “Separate current implementation, approved design, and readiness”This article was written by comparing the clinic-appointment develop source with the Issue #170 design and runbook on 2026-08-14. The existence of code does not mean every clinic has enabled the feature in production.
| Category | Scope verified in this article |
|---|---|
| Current implementation | Bounded candidate and decision batches in WaitlistCandidateMatcher, offer and hold transitions in WaitlistOfferService, owner/version/expiry checks in WaitlistOfferClaimService, expiry and stale recovery in WaitlistRecoveryService, and delivery API models |
| Approved design | Hard eligibility first, one active offer per vacancy, database fence/CAS authority, opaque references, separate notification and acceptance, and appointment command handoff |
| Operational readiness | Migrations V18/V19, contention/scope/PII/replay tests, feature-flag rollout from false to allowlist, bounded reconcile, alerts, and runbook |
| Roadmap | Candidate fairness metrics, staff bulk review, more notification providers, and shadow-to-ENFORCE observation of real clinic policy |
So “waitlist core is implemented” means that the service boundaries and tests are prepared. It does not mean that a particular clinic’s production allowlist and operating evidence are complete. Before deployment, verify separately that expiry, suppression, and reconcile continue in OFF, that a notification-provider outage cannot change claim state, and that contention on one vacancy leaves exactly one active offer.
The important question is state ownership, not notification speed
Section titled “The important question is state ownership, not notification speed”Filling a vacancy quickly matters. The more important question is not “Who received the text first?” but this:
“This candidate passed hard eligibility in the same tenant and clinic, was ordered under this policy version, and claimed this vacancy’s hold within this version fence. Even after notification failure or request replay, the service can explain the same state and receipt.”
Once the service can say that, a waitlist is an operable appointment domain model rather than an array of contacts. The next article will expose this core state through a public delivery API and a staff operations screen.
More visual companions
Section titled “More visual companions”- AppointmentPlan and capacity
- From product BOM to appointment flow
- Clinic appointment visual companions
Sources
Section titled “Sources”- clinic-appointment repository
- Waitlist delivery requirements
- Waitlist delivery API contract
- Waitlist core runbook
- Issue #170 waitlist core design
- Waitlist delivery and fencing design
- WaitlistCandidateMatcher
- WaitlistOfferService
- WaitlistOfferClaimService
- WaitlistRecoveryService
- ResourceAllocationRepository
Comments
Leave a note or reaction with your GitHub account.