This document captures the design for rebuilding call scheduling to align with our Scheduling Principles.
Once created, a shift cannot be modified. To “change” a shift: 1. Create new shift with desired properties 2. Delete (or mark cancelled) the old shift 3. Reassign as needed
This simplifies auditing and reasoning about state.
Assignments are never updated or deleted. Instead:
superseded_at = NULLsuperseded_at = NOW(), insert new recordsuperseded_at = NOW(),
no new recordDatabase constraint ensures only one active assignment per shift:
UNIQUE (shift_id) WHERE superseded_at IS NULLEvery assignment records how it was created: - solver
— Promoted from solver proposal - manual — Admin created
directly - swap — Result of resident swap -
coverage — Someone covering for another -
reassign — Plan changed before shift occurred
No freeform text required from users — attribution is systematic.
If a shift is in the past and has an active assignment with no exception recorded, we assume the scheduled resident worked it. Users only need to record exceptions (swaps, coverage, no-shows).
This eliminates the burden of marking every shift “completed.”
Originally considered a separate
call_assignment_exceptions table. Instead, exceptions are
just new assignments:
| Scenario | What happens |
|---|---|
| Normal completion | Nothing — assignment stays, assumed worked |
| Swap | Supersede original, new assignment with
source = 'swap' |
| Coverage (sick) | Supersede original, new assignment with
source = 'coverage' |
| No-show | Supersede original, no new assignment (or special handling TBD) |
| Plan change | Supersede original, new assignment with
source = 'reassign' |
Constraints are not attached to templates via hierarchy. They are independent entities that declare their own scope and applicability.
Two categories: 1. Generation-time constraints — Handled by template design (coverage, pairing, scheduling patterns) 2. Assignment-time constraints — Enforced by solver (eligibility, limits, rest, fairness, preferences)
The existing call_shift_template_relationships and
call_shift_relationships tables may be unnecessary. If
coverage requires multiple roles (senior + junior), that’s handled by:
1. Creating multiple templates 2. Generating multiple shifts for the
same time period 3. Eligibility constraints on each template
The association is implicit (same time window) rather than explicit foreign keys. This is TBD — may revisit if explicit relationships prove necessary.
When constraints need exceptions:
Global → Program → Schedule → Template → Instance
Time-range and resident-level overrides deferred for now.
Shift (Aggregate Root)
├── ShiftID (identity)
├── ProgramID
├── TemplateID (nullable — for manually created shifts)
├── SiteID
├── CallTypeID
├── StartsAt, EndsAt
├── CreatedAt
│
├── Behaviors:
│ ├── CreateFromTemplate(template, date) → Shift
│ ├── CreateManual(program, site, callType, times) → Shift
│ └── Cancel() → Event/tombstone (TBD)
Assignment (Entity, owned by Shift or standalone TBD)
├── AssignmentID (identity)
├── ShiftID
├── ResidentID
├── Source (enum: solver, manual, swap, coverage, reassign)
├── CreatedAt
├── SupersededAt (nullable)
│
├── Behaviors:
│ ├── Create(shift, resident, source) → Assignment
│ ├── Supersede() → sets SupersededAt
│ └── IsCurrent() → bool
Template (Entity)
├── TemplateID (identity)
├── ProgramID
├── SiteID
├── CallTypeID
├── Name
├── StartTime, DurationMinutes
├── ApplicableDays ([]int, ISO weekdays)
├── IsActive
├── EligibilityCriteria (min/max PGY, required tags, eligible rotations)
│
├── Behaviors:
│ ├── GenerateShifts(dateRange) → []Shift
│ ├── IsResidentEligible(resident, date) → bool
│ └── Deactivate() → stops future generation
Constraint (Entity, various types)
├── ConstraintID (identity)
├── ProgramID
├── ConstraintType (enum)
├── IsHard (bool)
├── Weight (int, for soft constraints)
├── Config (type-specific parameters)
├── Scope (what this applies to)
│
├── Behaviors:
│ ├── IsSatisfied(assignment, context) → bool
│ ├── Violations(assignments) → []Violation
│ └── Penalty(assignment, context) → int
| Type | Category | Hard/Soft | Example |
|---|---|---|---|
eligibility_pgy |
Eligibility | Hard | PGY-3+ for ICU |
eligibility_tag |
Eligibility | Hard | Must have “senior” tag |
eligibility_rotation |
Eligibility | Hard | Must be on eligible rotation |
limit_per_period |
Limits | Hard or Soft | Max 6 night shifts/month |
min_rest_hours |
Rest | Hard | Min 10h between shifts |
max_consecutive_hours |
Rest | Hard | Max 24h continuous duty |
balance_shift_type |
Fairness | Soft | Balance weekends evenly |
balance_total |
Fairness | Soft | Balance total shifts |
preference_avoid |
Preference | Soft | Resident avoids Saturdays |
preference_prefer |
Preference | Soft | Resident prefers mornings |
Template.GenerateShifts(dateRange) creates shifts for
applicable days| Principle | Implementation |
|---|---|
| The Plan | Solver proposals stored in solve job results |
| The Record | Assignments with superseded_at IS NULL |
| The Rules | Constraint entities evaluated by solver and compliance checker |
Implement core domain models in Go with unit tests: -
Shift entity with creation behaviors -
Assignment entity with supersede logic -
Template entity with shift generation - Constraint
interfaces and implementations
No database yet — test domain logic in isolation.
Design database schema to support domain model: - Derive tables from entities - Implement repositories - Add integration tests
Update solver to work with new domain model: - Load problem from new schema - Generate proposals - Promotion workflow
Build RPC endpoints and frontend: - CRUD for templates, shifts, assignments - Constraint management - Solver invocation and proposal review