Skip to content
Small robots pass a claim token for a vacant clinic slot through a database fence
A cancelled appointment creates a vacancy, but it does not authorize the first name on a waitlist to become a confirmed appointment. Eligibility, the offer, the claim, and recovery after failure need separate ownership.

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 WAITING candidates by hard eligibility and deterministic order, create one OFFERED record and durable hold for one vacancy, and record a claim as an ACCEPTED command 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.

A waitlist candidate moves from WAITING through hard eligibility and deterministic ordering to OFFERED, then to ACCEPTED command handoff after a claim, while declined, expired, and withdrawn branches remain terminal
Acceptance does not create an appointment in place. A durable hold and command receipt preserve the handoff so a separate replacement command can create the appointment.

Once the states are explicit, notification and database responsibilities become explicit too.

StateMeaningNext responsibility
WAITINGAn eligible waitlist entry that has not received a concrete offerRe-evaluate the vacancy and policy fit
OFFEREDAn offer linked to an expiry time and capacity holdRecord the candidate response, expiry, and delivery attempts
ACCEPTEDThe candidate claimed the offer and a downstream command can consume the durable holdHand off to the appointment-creation command
DECLINEDThe candidate or staff declined the terminal offerPreserve the reason and suppression policy before considering another candidate
EXPIREDThe offer, hold, or start time closed the offerReconcile before retrying
WITHDRAWNStaff or recovery withdrew the offerPreserve 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:

  1. Hard eligibility: Check tenant, clinic, treatment, doctor, time window, and resource requirements.
  2. Policy inputs: Read the clinic policy version and batch BookingReliabilityDecision as read-only inputs. Do not recompute the decision or copy a customer tier into the appointment database.
  3. Deterministic keyset order: Use slotFit DESC → priorityRank DESC → waitingSince ASC → entryId ASC. The final entryId prevents ties at the same time and priority from changing order.
  4. Bounded pages and time budget: Cap page size, candidate count, page count, and elapsed time instead of scanning indefinitely.
  5. 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.

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 vacancyKey has at most one active OFFERED or ACCEPTED offer.
  • Every OFFERED and ACCEPTED offer 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 → ACCEPTED and 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.
The availability database obtains a vacancy fence and commits one OFFERED hold, notification follows after commit, and the claim API verifies owner, revision, and expiry before recording a replayable ACCEPTED receipt
A notification failure does not roll back 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.

BoundaryDesign rule
tenant and clinicPass the same WaitlistScope to every entry, offer, hold, and event query
Candidate identityUse an opaque public ref and short-lived claim token instead of an internal integer ID
Policy provenanceRecord policyVersion, digest, decision ID, and expiry on the offer and event
ObservabilityLog state, reason code, and correlation ID instead of names, phone numbers, or raw support notes
Scope errorsTreat 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.

CategoryScope verified in this article
Current implementationBounded 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 designHard eligibility first, one active offer per vacancy, database fence/CAS authority, opaque references, separate notification and acceptance, and appointment command handoff
Operational readinessMigrations V18/V19, contention/scope/PII/replay tests, feature-flag rollout from false to allowlist, bounded reconcile, alerts, and runbook
RoadmapCandidate 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.

Comments

Leave a note or reaction with your GitHub account.