Coverage rules
Coverage requirements & scheduling rules
Read-only view of the spec files that drive scheduling. Edits are intentionally PR-only — open a pull request against main to change them. An in-app editor will land in a later slice.
Human narrative
Scheduling rules
Shift Scheduling Rules
Source of truth. The scheduling engine must conform to this document. When a rule here conflicts with code, the rule wins — change the code. When tuning the app, edit this file first, then update
coverage-requirements.yamland the engine.
Scope
- Two hospitals: CVH and MRH
- Monthly schedule, generated one month at a time
- Single shared pool of physicians covers both hospitals
- Holidays are treated identically to weekend days
Assignment types
A physician's daily assignment is one of:
- Ward — covering a specific ward at one hospital
- ER — one of the emergency room shifts at one hospital
- MUCC — outpatient clinic at MRH (weekdays only, never on holidays/weekends)
- Off — no assignment
Ward coverage
Weekdays (Mon–Fri)
- MRH: 7 wards must be covered
- CVH: 8 wards must be covered
- Each ward is covered by one physician for the entire Mon–Fri block (same person all 5 days)
Weekends and holidays
- MRH: 4 wards must be covered
- CVH: 4 wards must be covered
- Each covered ward is staffed by one physician for the entire weekend/holiday block (Sat + Sun, or the full holiday run)
ER coverage
Weekdays (Mon–Fri), per hospital
Three shifts per day, at each hospital:
- Day: 08:00 – 18:00
- Evening: 17:00 – 23:00
- Night: 18:00 – 08:00 (next morning)
Weekends and holidays, per hospital
Two shifts per day, at each hospital:
- Day: 08:00 – 18:00
- Night: 18:00 – 08:00 (next morning)
MUCC (outpatient clinic)
- Location: MRH only
- Days: Mon–Fri, excluding holidays
- Staffing: 3 to 6 physicians per day
Hard constraints
These must never be violated by the generator. The engine applies them
hardest → softest, mirroring the on-screen "How preferences and quotas
work together" help guide on /doctors:
Time-off (calendar-anchored hard block) — rule #6 / #9.
Recurring blocks (perpetual day-of-week × shift hard block) — rule #10.
Quotas with
min=max=0(faceted category hard block) — rule #13.Quotas with a
max(faceted monthly cap) — rule #13.Quotas with a
min(soft floor — engine biases candidate selection, emitsRULE_QUOTA_UNMETpost-generation when the floor isn't met) — rule #13.One assignment type per day. A physician cannot be assigned Ward + ER, Ward + MUCC, or ER + MUCC on the same day.
One hospital per day. A physician cannot be assigned to both CVH and MRH on the same day.
Post-night rest. A physician who works an ER night shift (18:00–08:00) receives no assignment the following calendar day.
No consecutive night ER. A physician cannot be assigned ER night shifts on two consecutive calendar days.
Holidays = weekends. On any holiday, use weekend coverage rules (ward counts, ER shift counts, no MUCC).
Approved time-off is a hard block. Any date range approved by an admin (vacation, leave, or one-off absence) must produce no assignment for that physician on those dates. The engine treats approved time-off identically to a pre-filled "off" assignment — it is not overrideable by the scheduler without first revoking the approval.
Recurring unavailability is a hard block. A recurring pattern approved by an admin (e.g. "every Wednesday afternoon for clinic") blocks the physician from being assigned to a conflicting shift on matching dates. Pattern granularity is day-of-week + optional half-day (AM/PM).
Shift-type eligibility. Each physician carries a
canWorkmap gating which shift-type buckets they can be assigned:ward,er_day,er_evening,er_night,mucc. Missing keys default to true (eligible). Either the physician (via My Profile) or an admin can flip a key to false; the engine then refuses to place that physician in matching shifts.Time-off blocks. Each physician carries a per-date
timeOffmap from their preferences. Keys are ISO dates (YYYY-MM-DD); values are arrays of shift-eligibility keys to block on that date, or the specialallsentinel meaning the whole day is off. The engine refuses any matching shift on those dates. Distinct from rule #6 (approved time-off requests via admin workflow) —timeOffblocks are set directly through the Doctors preferences editor.Day-of-week × shift blocks. Each physician carries a
dayShiftBlockslist of perpetual block entries in the form"{dow}-{shiftKey}"(dow ∈mon..sun, shiftKey ∈ward,er_day,er_evening,er_night,mucc). The engine refuses a matching assignment whenever the candidate date's weekday lines up with a blocked entry — e.g.tue-er_nightblocks every Tuesday's ER night for that doctor regardless of date.Hospital scope. Each physician carries a
hospitalsAllowedlist. If non-empty, the engine refuses any assignment at a hospital not in the list. Empty / undefined = unrestricted (every hospital).Max consecutive days. Each physician carries a
limits.maxConsecutivecap. The engine refuses an assignment that would push the doctor's working-day streak over the cap.Faceted assignment quotas. Each physician carries a
quotaslist. Each rule scopes a(assignmentType, shiftId, hospital, dayOfWeek, isWeekend)facet to an optionalminand/ormaxcount per calendar month.maxis enforced at canAssign time (RULE_QUOTA_MAX);mindrives candidate priority and emitsRULE_QUOTA_UNMETpost-generation if the floor isn't met. Filter fields combine with AND across fields and OR withindayOfWeek. Empty/missing fields match anything. Replaces the legacymin/maxNightsPerMonthfields — those are auto-migrated into an ER-night quota at load time so existing rosters keep their settings.Must-work pre-placements. Each physician carries an optional
mustWorkmap keyed by ISO date. Each entry pins the doctor to a specific assignment (ER shift, MUCC seat, or named ward) before regular coverage runs, so the slot isn't double-filled. Conflicts (same doctor pinned twice on a date, two doctors claiming the same slot, missing fields, unknown shift for the day-kind) emitRULE_MUST_WORK_CONFLICTand the entry is dropped.
Soft preferences
These are scored when the engine supports multi-objective optimisation. Until then they are logged but not enforced.
- Balance total shifts across physicians over the month.
- Balance weekend/holiday load across physicians over the month.
- Balance night-shift load — spread ER nights evenly; no physician should hold a disproportionate share.
- Shift-type preference — physicians may express a preference (day over night, no weekends, etc.). Honour where possible without violating hard constraints or fairness.
- Hospital preference — physicians may prefer CVH or MRH. Treated as a tiebreaker, never a hard block.
Fairness
The engine must track, per physician over a rolling 3-month window:
- Total assignments
- Weekend/holiday assignments
- Night-shift (ER night) assignments
These counts are surfaced in the Fairness Dashboard (scheduler + admin only) and used as tiebreakers during generation. A physician more than 20% above the group average in any category is deprioritised for that category in the next generation.
Conflict pre-check
When a scheduler manually overrides an assignment, the system must instantly evaluate all hard constraints against the proposed change and surface any violations before saving. The override is still allowed (scheduler discretion) but violations must be acknowledged.
Peer swap protocol
- Doctor A proposes swapping shift X (their assignment) for shift Y (Doctor B's assignment).
- Doctor B receives a notification and approves or rejects.
- On approval, the swap enters pending-scheduler-review state.
- Scheduler reviews the proposed swap against hard constraints and either rubber-stamps or rejects.
- On scheduler approval, both assignments are updated with
source: 'manual'and an audit log entry is written covering both sides of the swap. - Rejected at any step → no change, notification sent to initiating doctor.
Change log
- 2026-04-21: Initial version, captured from stakeholder description.
- 2026-04-23: Added hard constraints 6–7 (time-off, recurring unavailability). Added soft preferences detail, fairness rules, conflict pre-check, and peer swap protocol.
Engine-authoritative spec
Coverage requirements
The engine reads hard_constraints from this file at generation time. Removing an entry disables the rule; adding a new one requires a matching impl in src/lib/scheduling/constraints.ts.
# Coverage requirements for Shift Scheduler.
# Edit this file to tune staffing counts, shift times, and MUCC sizing.
# The engine reads this directly; changes take effect on the next schedule generation.
hospitals:
CVH:
display_name: "CVH"
wards:
weekday_count: 8
weekend_count: 4
# Replace these placeholder names with the real ward identifiers.
names:
- CVH-W1
- CVH-W2
- CVH-W3
- CVH-W4
- CVH-W5
- CVH-W6
- CVH-W7
- CVH-W8
er_shifts:
weekday:
- { id: day, start: "08:00", end: "18:00", overnight: false }
- { id: evening, start: "17:00", end: "23:00", overnight: false }
- { id: night, start: "18:00", end: "08:00", overnight: true }
weekend_and_holiday:
- { id: day, start: "08:00", end: "18:00", overnight: false }
- { id: night, start: "18:00", end: "08:00", overnight: true }
MRH:
display_name: "MRH"
wards:
weekday_count: 7
weekend_count: 4
names:
- MRH-W1
- MRH-W2
- MRH-W3
- MRH-W4
- MRH-W5
- MRH-W6
- MRH-W7
er_shifts:
weekday:
- { id: day, start: "08:00", end: "18:00", overnight: false }
- { id: evening, start: "17:00", end: "23:00", overnight: false }
- { id: night, start: "18:00", end: "08:00", overnight: true }
weekend_and_holiday:
- { id: day, start: "08:00", end: "18:00", overnight: false }
- { id: night, start: "18:00", end: "08:00", overnight: true }
mucc:
days: [mon, tue, wed, thu, fri]
exclude_holidays: true
min_physicians: 3
max_physicians: 6
# Coverage block rules — how many days a single physician covers in a row.
ward_coverage_blocks:
weekday: { start: mon, end: fri, same_physician: true }
weekend: { start: sat, end: sun, same_physician: true }
holiday: { treat_as: weekend, same_physician_for_entire_block: true }
# Hard constraints. The engine reads this list at generation time and after
# every manual edit. Each `id` is looked up in src/lib/scheduling/constraints.ts;
# removing an entry disables the rule, adding one with no registry impl is a
# no-op. To add a new rule: add it here, register a check in constraints.ts,
# and add a test. See scheduling-rules.md for the human-readable narrative.
hard_constraints:
- id: one_assignment_per_day
description: "A physician has at most one assignment type (ward | er | mucc) per calendar day."
- id: one_hospital_per_day
description: "A physician is not assigned to both hospitals on the same calendar day."
- id: post_night_rest
description: "After working an ER night shift (18:00–08:00), the physician has no assignment the next calendar day."
trigger_shift: er_night
rest_days: 1
- id: no_consecutive_night_er
description: "A physician cannot work ER night shifts on two consecutive calendar days."
trigger_shift: er_night
- id: holidays_equal_weekends
description: "Holiday dates use weekend coverage rules (ward counts, ER shift counts, no MUCC). Enforced by coverage shape, not per-row."
- id: shift_eligibility
description: "A physician is only assignable to shift types in their canWork map (ward / er_day / er_evening / er_night / mucc). Missing keys default to eligible."
- id: time_off
description: "Per-date time-off blocks from doctor preferences. Keys are ISO dates; values are arrays of shift-eligibility keys (or 'all' for full-day off). Engine refuses any matching shift on those dates."
- id: day_shift_blocks
description: "Perpetual day-of-week × shift blocks from the Doctors editor. Each entry is '{dow}-{shiftKey}' (dow ∈ mon..sun, shiftKey ∈ ward/er_day/er_evening/er_night/mucc). Engine refuses a matching assignment on any date whose weekday matches a blocked entry."
- id: hospital_scope
description: "Doctor hospital scope from preferences. When set, the engine refuses any assignment at a hospital not in the doctor's allowed list. Empty list = unrestricted."
- id: max_consecutive_days
description: "Hard cap on consecutive working days. Engine refuses an assignment that would push the doctor's streak over the cap."
- id: assignment_quota
description: "Faceted monthly quotas from doctor preferences. Each rule scopes a (assignmentType, shiftId, hospital, day-of-week, weekend?) facet to an optional min and/or max. max is enforced at canAssign time (RULE_QUOTA_MAX); min drives candidate priority and emits RULE_QUOTA_UNMET post-generation if not met."
# must_work: not in this list — it is a pre-placement, not a per-row hard
# rule. Engine reserves each entry before regular coverage runs and emits
# RULE_MUST_WORK_CONFLICT if the entry can't be honored.
# Soft preferences are not enforced yet. See scheduling-rules.md.
soft_preferences: []Treated as weekend coverage
Holidays
# Holidays are treated identically to weekend days by the scheduler.
# Add or remove entries here and regenerate the affected month.
# Format: ISO date (YYYY-MM-DD). Name is for display only.
# Province: Ontario (federal + provincial statutory holidays).
holidays:
- { date: "2026-01-01", name: "New Year's Day" }
- { date: "2026-02-16", name: "Family Day" }
- { date: "2026-04-03", name: "Good Friday" }
- { date: "2026-04-06", name: "Easter Monday" }
- { date: "2026-05-18", name: "Victoria Day" }
- { date: "2026-07-01", name: "Canada Day" }
- { date: "2026-08-03", name: "Civic Holiday" }
- { date: "2026-09-07", name: "Labour Day" }
- { date: "2026-09-30", name: "Truth & Reconciliation" }
- { date: "2026-10-12", name: "Thanksgiving" }
- { date: "2026-11-11", name: "Remembrance Day" }
- { date: "2026-12-25", name: "Christmas Day" }
- { date: "2026-12-26", name: "Boxing Day" }
Auth & access control
Roles & permissions
Roles and Permissions
Three roles. A user has exactly one role. All roles require authentication via Cognito.
admin
- Invite new users (any role) by email
- Deactivate users
- Edit doctor roster via the UI (add, deactivate, update)
- Edit holidays via the UI
- Approve or reject time-off requests and recurring unavailability patterns
- All scheduler permissions
scheduler
- Generate a monthly schedule
- Manually edit a generated schedule (override assignments, with conflict pre-check)
- Publish a schedule (makes it visible to doctors)
- Review and rubber-stamp peer swap requests
- View all doctors' calendars
- View the Fairness Dashboard
- All doctor permissions
doctor
- View the published schedule
- View their own assignments highlighted
- Download their
.icscalendar feed or subscribe via signed URL - Update their own profile (display name, contact email)
- Submit time-off requests (date range + reason)
- Submit recurring unavailability patterns (day-of-week + AM/PM)
- Set shift preferences (day/night preference, hospital preference, no-weekends flag)
- Propose peer swap requests
Invitation flow
- Admin enters email + role on the Invite page.
- System creates a Cognito user in
FORCE_CHANGE_PASSWORDstate and emails a signup link with a one-time token. - Invitee clicks link, sets password, lands on the app with their assigned role.
- Tokens expire after 7 days. Admin can resend.
Audit
Every schedule publish, manual override, user invite, time-off approval, swap approval, and doctor/holiday update is logged with actor, timestamp, and before/after state.
Enhancements (later slices)
Time-off requests
- Doctor submits a date range + reason (vacation, leave, personal).
- Admin reviews and approves or rejects. Approved ranges become hard constraints for the next generation covering those dates.
- Scheduler can see all approved time-off when generating; the engine will not assign those physicians on blocked dates.
Recurring unavailability
- Doctor submits a day-of-week pattern (e.g. "every Wednesday PM — clinic obligation").
- Admin approves. Approved patterns are evaluated each generation and block the physician from conflicting shifts on matching dates.
- Patterns have an optional end date; they expire automatically if set.
Shift preferences
- Doctor sets soft preferences: day-shift preferred, no weekends if possible, hospital preference.
- Preferences feed into the engine's scoring phase (once multi-objective optimisation is implemented).
- Preferences are never hard blocks — coverage always wins.
Fairness Dashboard
- Scheduler and admin can view a rolling 3-month summary per physician: total shifts, weekend/holiday shifts, night shifts.
- Imbalances (>20% above group average) are highlighted.
- Accessible from the scheduler workspace; not visible to doctors.
Peer swap requests
- Doctor A proposes swapping one of their assignments for one of Doctor B's.
- Doctor B approves or rejects in the app.
- On B's approval, scheduler reviews against hard constraints and rubber-stamps or rejects.
- Approved swaps update both assignment records (
source: manual) and write an audit log entry. - See full protocol in
scheduling-rules.md.
iCal feed per doctor
- Each doctor gets a personal signed URL (
/api/calendar/[doctorId]?token=...) returning a.icsfile with their published assignments. - The URL auto-updates as new schedules are published.
- Token can be rotated by the doctor; old tokens immediately stop working.
- The
CalendarFeedTokenmodel is already deployed.