1. Waitlist Intake, Scoring and Nightly Re-Rank
Trigger: Waitlist Request submitted, or staff add a patient manually; plus a nightly scheduled re-score of every active entry.
Actions in order:
- Write the structured preferences. Reject the entry back to staff if visit_type_code, notice_required_hours or days_available is missing. An entry without those three cannot be matched and will only generate bad offers.
- Apply waitlist-active, plus same-day-ok and short-notice-ok where the patient said yes.
- Compute waitlist_priority_score from four inputs: clinical_priority_band as the dominant weight, days_waiting, an acceptance-history component rewarding patients who have claimed before, and a responsiveness component that gently demotes patients with three or more ignored offers. Clinical band always outranks the behavioral components. A clinically urgent patient is never demoted for being slow to answer a text.
- Send a plain confirmation: “Hi {{contact.first_name}}, you're on the waitlist at {{practice_name}}. If something opens up that matches what you told us, we'll text you a link: first to claim it gets it. Update what works for you any time: {{availability_link}}.” It sets the expectation that offers are a race, which prevents most of the complaints.
- Nightly: recompute days_waiting and waitlist_priority_score for every active entry. Expire entries past latest_useful_date by applying waitlist-paused and sending one check-in: “Hi {{contact.first_name}}, still want to stay on the {{practice_name}} waitlist? Confirm or update here: {{availability_link}}.” No answer in 7 days moves them to waitlist-removed with a task for staff review.
- Nightly hygiene: flag entries whose days_waiting has passed the practice's threshold without a single matching offer. That pattern almost always means their stated availability cannot be matched by the real schedule, and the fix is a Waitlist Consult call, not another month of waiting.
Exit: entry removed, or patient booked and no longer waiting.
2. Slot Vacated Detection, Eligibility Gates and Candidate Matching
Trigger: an appointment is canceled, rescheduled away, or released by a provider; or a no-show is converted to a vacated slot.
Actions in order:
- Create the Slot Fill card at Slot Vacated. Write slot_id, slot_start, slot_end, slot_duration_minutes, slot_provider, slot_location, slot_visit_type_code, vacated_at, vacated_reason. Compute lead_time_hours.
- Gate A: reinstatement grace. If vacated_reason is patient rescheduled and the reschedule is still in flight, wait 10 minutes before offering. Nothing is more damaging than giving away a slot the original patient is still mid-way through moving.
- Gate B: short-notice eligibility. If short_notice_eligible is no for this visit type, move to Unfilled with reason not short-notice eligible, notify the provider that the time is free, and stop. This gate is clinician-owned and non-negotiable.
- Gate C: minimum lead time. If lead_time_hours is below the minimum recorded for this visit type, move to Unfilled with reason lead time too short, and create a task offering the time for admin, catch-up or an overbooked patient.
- Gate D: quiet hours. If the offer would send outside the configured window, hold it. For a slot more than 14 hours out, queue to the next window. For a slot inside the window where the only possible contact is now, hold it and create a staff task. A human may call; automation does not text patients at midnight to fill a nine o'clock.
- Build the candidate set. Filter on: visit_type_code match, provider_preference satisfied, location_preference match, slot_start weekday present in days_available, time_window_preference match, slot_start inside the date range, lead_time_hours at or above notice_required_hours, same-day flag satisfied if the slot is today, offer_cooldown_until in the past, waitlist_status active, at least one consented channel, and slot_duration_minutes adequate for the visit type.
- Rank by waitlist_priority_score descending, then by days_waiting descending as the tiebreak. Write candidates_matched_count and move to Candidates Matched.
- If the count is zero, move to Unfilled with reason no eligible candidates, notify the provider, and log the slot's attributes. A pattern of zero-candidate slots at a particular time is a waitlist-composition problem worth fixing.
- Choose the wave strategy from lead_time_hours and hand to workflow 3.
Exit: handed to workflow 3, or terminal Unfilled, or Withdrawn if the original patient reinstates.
3. Tiered Offer Wave Engine
Trigger: stage becomes Candidates Matched.
Conditions: the three strategies:
- Comfortable, lead time over 48 hours: wave 1 to the top 3 with a 30-minute exclusive window; wave 2 to the next 10 with 20 minutes; broadcast after that.
- Tight, lead time 6–48 hours: wave 1 to the top 3 with a 12-minute window; wave 2 to the next 8 with 10 minutes; broadcast after that.
- Urgent, lead time under 6 hours: skip the waves entirely, broadcast immediately to every eligible candidate, and in parallel create voice-call tasks for the top 3. At this lead time a phone call is the highest-yield channel and the text is the backstop.
Actions in order:
- Generate a distinct single-use claim_token for each candidate in the wave, bound to this slot_id and this contact, with an expiry set to the window length.
- Send individually, one message per candidate, never as a group. SMS: “Hi {{contact.first_name}}, an earlier appointment just opened at {{practice_name}}: {{slot_day}} {{slot_time}} at {{slot_location}}, {{slot_duration}} minutes. First to claim gets it. Your link: {{claim_link}}. Expires in {{window_minutes}} min. Not for you? Reply NO and we'll keep you on the list.” Email subject line: “An earlier appointment is available at {{practice_name}}”. No visit type, no reason, no preparation detail, no provider specialty, in either.
- Increment offers_sent_count and set last_offer_sent_at on every candidate contacted. Apply offer-pending. Write offers_outstanding and offer_wave on the card. Move the card to the matching offer stage.
- Wait the window length, or exit immediately on a successful claim.
- On window expiry with no claim: invalidate every token from that wave, apply offer-expired to those contacts, increment offers_ignored_count for non-responders and offers_declined_count for explicit declines, recompute lead_time_hours, and re-filter the remaining candidates against the new shorter lead time: patients whose notice_required_hours no longer fits drop out rather than being offered something they cannot use.
- Open the next wave. If the strategy has escalated because lead time has fallen, switch strategies mid-flight: a Tight slot that is now under 6 hours goes straight to broadcast.
- Broadcast wave: all remaining eligible candidates, individual sends, 1-second stagger to avoid carrier throttling, window set to whatever lead time allows. Copy adds urgency honestly: “Hi {{contact.first_name}}, this just opened for {{slot_day}} {{slot_time}} and it's open to everyone on the list now: {{claim_link}}. First claim gets it.”
- Decline handling: an inbound NO invalidates that patient's token, applies offer-declined, and sets offer_cooldown_until to a short cooldown so they are not immediately re-offered a similar slot an hour later. It never removes them from the waitlist.
Exit: a claim is locked, the broadcast window expires, or slot_start is reached.
4. Claim, Atomic Lock and Double-Book Prevention
Trigger: a claim is submitted from Offer Claim, or BO receives an explicit claim in conversation.
This is the workflow that makes the build safe. Every path runs through it; nothing else may book the slot.
Actions in order:
- Validate the token: exists, matches this slot_id, matches this contact, not expired, not already used. Any failure routes to the already-gone path at step 6.
- Attempt the lock. Check the card is still in an offer stage and claimed_by is empty. If both hold, write claimed_by and claimed_at in a single operation and move the card to Claimed: Pending Confirm. The stage transition is the lock; nothing downstream books without it.
- Invalidate everything else in the same step. Kill every other outstanding token for this slot_id, clear offer-pending from those contacts, and set offers_outstanding to zero. This must happen in the same action block as the lock, not after a wait. A gap here is exactly where a double-booking appears.
- Place the Standby Hold on the provider's calendar immediately, before any confirmation message goes out, so a staff member booking manually in the next thirty seconds sees the slot as taken.
- Verify the fit before writing the real appointment: duration still adequate, no conflicting booking created in the interim, provider still available, slot_start still in the future. Any failure releases the lock, notifies staff, and reopens the card to Open Broadcast rather than silently booking something wrong.
- Already-gone path. A patient arriving on a dead token sees a plain page: “This appointment has already been claimed. You're still on the waitlist and we'll let you know next time something opens. Update your availability here: {{availability_link}}.” Send the same as a reply if they texted. No error screens, no silence. A losing claimant who is told clearly stays on the list; one who hits a broken page stops answering.
- BO path. If a patient claims in conversation, BO calls this workflow and waits for its verdict before saying anything. BO never confirms from its own view of the record.
- On a successful lock, hand to workflow 5.
Exit: lock held and confirmed, or lock released and the card reopened.
5. Confirmation, Preparation Delivery and Reminder Compression
Trigger: stage becomes Claimed: Pending Confirm.
Actions in order:
- Write the appointment to the provider's calendar with the real duration and buffers. Set confirmed_at, compute time_to_fill_minutes from vacated_at, release the Standby Hold into the booking, move the card to Booked.
- Update the contact: offers_accepted_count plus one, apply fill-won, remove waitlist-active, set waitlist_status to paused for this visit type so they do not receive offers for an appointment they now have.
- Send confirmation: “You're booked, {{contact.first_name}}, {{slot_day}} {{slot_time}} at {{slot_location}} with {{practice_name}}. Everything you need to know, including anything to do beforehand, is in your secure portal: {{portal_link}}.” Date, time, location, link. Nothing else.
- Preparation branch. If the visit type has preparation requirements, publish them to the portal and send a doorbell only: “There are a couple of things to read before your visit. They're in your portal: {{portal_link}}.” Preparation text routinely reveals the procedure, so it never appears in an SMS body or an email subject line.
- Interpreter and access branch. If interpreter_needed or accessibility_needs_on_file is set, create a staff task immediately with the remaining lead time shown. On a short-notice fill this is the step most likely to fail, so it gets its own alert rather than relying on someone noticing.
- Compressed reminder ladder, chosen by remaining lead time: over 48 hours, reminders at 24 hours and 2 hours. 6–48 hours, one reminder at 2 hours. Under 6 hours, no further reminder at all. The confirmation was the reminder. The standard three-touch sequence is explicitly suppressed so a patient who books for tomorrow morning does not get four messages tonight.
- Notify the provider and the front desk that the slot filled, with the patient identifier and the time to fill. Internal channel only, access-controlled.
- Late cancellation branch. If this newly booked appointment is itself canceled, create a fresh Slot Fill card and run the whole engine again, with the original claimant's cooldown respected.
Exit: appointment reaches slot_start; card moves to Filled.
6. Unfilled Slot Fallback
Trigger: the broadcast window expires with no claim, or slot_start arrives with the card still in an offer stage.
Actions in order:
- Invalidate every outstanding token. Clear offer-pending from all contacted candidates. Move the card to Unfilled and write unfilled_reason.
- Branch: candidates existed but nobody claimed. Create a task to call the top 3 by phone if there is still usable lead time. A voice call converts short-notice slots far better than a text and the system should not give up while a human could still fill it.
- Branch: no eligible candidates. Log the slot's attributes (provider, weekday, time window, visit type) into the unfilled-pattern report. Repeated zero-candidate Thursday mornings is an actionable finding about who you are putting on the waitlist.
- Notify the provider and practice manager that the time is now genuinely free, early enough to be useful for admin, catch-up or relieving an overbooked clinic.
- Update every contacted candidate's counters and set a modest offer_cooldown_until, so a patient who ignored one offer is not hit again twenty minutes later by the next cancellation.
- Feed time_to_fill_minutes, wave reached and unfilled_reason into the digest in workflow 9.
Exit: terminal.
7. Fairness, Cooldown and Offer-Fatigue Control
Trigger: runs continuously as a gate on workflow 3, plus a weekly scheduled review.
Actions in order:
- Hard caps per contact: no more than 3 offers in a rolling 7 days and no more than 1 offer in any rolling 4 hours, regardless of how many slots open. A candidate at cap is skipped silently and the next-ranked candidate is used. The cap exists because the fastest way to destroy a waitlist is to make being on it feel like spam.
- Cooldowns: an explicit decline sets a short cooldown; an ignored offer sets a shorter one; a claim that lost the race sets none at all, because that patient did everything right and should get the next one.
- Fatigue watch: at 3 consecutive ignored offers, apply offer-fatigue-watch and send one preference check rather than another offer: “Hi {{contact.first_name}}, we've sent a few openings that didn't suit. Tell us what actually works and we'll only send those: {{availability_link}}. Or reply PAUSE to pause.”
- At 5 consecutive ignored offers, move to waitlist-paused and create a task for a human call. Continuing to text someone who has ignored five offers teaches them to ignore the sixth, which is the one that mattered.
- Starvation check, weekly: list active entries with high days_waiting and zero offers received. Create a Waitlist Consult task for each. These are the patients the algorithm is quietly failing, and they will never complain until they leave.
- Equity check, weekly: report offer volume by clinical_priority_band and by days_waiting decile. If the behavioral components of the score are crowding out clinical band or long waiters, reweight. Review the weighting with a clinician, not just with whoever is looking at the dashboard.
Exit: runs permanently.
8. Consent, Quiet Hours and Content Guardrail
Trigger: waitlist entry created with no consent, Communication Consent submission, or any inbound opt-out keyword.
Actions in order:
- Capture. Record patient_consent_sms and patient_consent_email separately with consent_captured_at, consent_language_version and consent_source, storing the exact wording shown rather than a summary. A waitlist entry with no consented channel is explicitly a phone-only entry, flagged as such, and is never silently skipped.
- Separation. Scheduling consent is recorded separately from marketing consent. A patient opted out of campaigns still gets their cancellation offers; a patient who opts out of scheduling texts is moved to phone-only rather than dropped off the list.
- On opt-out. Set the channel flag to no, apply the revoked tag, invalidate any outstanding token on that channel, remove the contact from active waves, flag the entry phone-only, and send one confirmation: “You're unsubscribed from texts from {{practice_name}}. You're still on the waitlist: we'll call you instead.”
- Quiet hours. All offers are held to the configured window in the patient's timezone. A slot whose only viable offer time falls in quiet hours becomes a staff call task, not an automated 2am text. Decide the morning cutover time deliberately, opening the window at 7am to fill an 8am slot is reasonable; 5am is not.
- Content guardrail. Before any send, verify the body and subject line against an allow-list of merge fields: first name, practice name, slot day, slot time, location, duration, claim link, availability link, portal link, window length. visit_type_code, slot_visit_type_code, preparation text, provider specialty and any clinical field are blocked outright. A failing send is blocked and raised as an internal alert, never silently rewritten.
- Group-send guardrail. The engine refuses any send with more than one recipient on the message. Offers are always one-to-one, always a per-contact token. There is no configuration that permits a group thread.
Exit: runs permanently as the guard layer.
9. Daily Fill Performance Digest
Trigger: scheduled, each weekday before the first clinic block, plus a weekly roll-up.
Actions in order:
- Summarize yesterday: slots vacated, slots filled, fill rate, median and worst time_to_fill_minutes, and the wave at which each fill happened.
- List every Unfilled card with its unfilled_reason, grouped so patterns are visible rather than scattered.
- Flag today's at-risk gaps: slots already vacated for today or tomorrow still in an offer stage, so a human can pick up the phone while there is still time.
- List offer-fatigue-watch contacts, starved long-waiters, and any waitlist entry rejected for missing required preference fields.
- Send internally only, to the practice manager and scheduling lead, on an access-controlled channel. It contains patient identifiers.
- Weekly, add: fill rate by provider, by weekday and by time window; broadcast-reached share, which tells you whether the narrow first wave is working; zero-candidate slot patterns; and offer-to-claim conversion by wave.
Exit: never.