Call Scheduling Domain Redesign

This document captures the design for rebuilding call scheduling to align with our Scheduling Principles.

Goals

  1. Align with scheduling principles — Implement The Plan, The Record, and The Rules as distinct systems
  2. Domain-driven design — Model the domain first, derive persistence from it
  3. Immutability — Shifts and assignments are immutable; changes create new records
  4. Full auditability — Every state change is traceable without explicit user input

Design Decisions

Shifts Are Immutable

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 Use Supersede Pattern

Assignments are never updated or deleted. Instead:

Database constraint ensures only one active assignment per shift:

UNIQUE (shift_id) WHERE superseded_at IS NULL

Source Field Captures Attribution

Every 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.

Default Behavior: Scheduled = Worked

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.”

No Separate Exceptions Table

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 Standalone Entities

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)

Template Relationships May Not Be Needed

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.

Override Hierarchy

When constraints need exceptions:

Global → Program → Schedule → Template → Instance

Time-range and resident-level overrides deferred for now.

Domain Model

Entities

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

Constraint Types

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

Key Operations

  1. Generate shiftsTemplate.GenerateShifts(dateRange) creates shifts for applicable days
  2. Assign resident — Create assignment after checking eligibility
  3. Reassign — Supersede current assignment, create new one
  4. Solve — Find valid assignments for unassigned shifts respecting constraints
  5. Promote proposal — Move solver results to live assignments
  6. Check compliance — Evaluate all constraints against current state

Three Systems Mapping

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

The Record Properties

Implementation Approach

Phase 1: Domain Models + Tests

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.

Phase 2: Persistence

Design database schema to support domain model: - Derive tables from entities - Implement repositories - Add integration tests

Phase 3: Solver Integration

Update solver to work with new domain model: - Load problem from new schema - Generate proposals - Promotion workflow

Phase 4: API + UI

Build RPC endpoints and frontend: - CRUD for templates, shifts, assignments - Constraint management - Solver invocation and proposal review

Open Questions

  1. Shift cancellation — Tombstone record? Soft delete? Event?
  2. No-show handling — Supersede with no replacement? Special status?
  3. Aggregate boundaries — Is Assignment owned by Shift or independent?
  4. Template relationships — Do we need explicit pairing, or is time-window matching sufficient?
  5. Constraint scoping syntax — How do constraints declare what they apply to?

Out of Scope (For Now)