Call Schedule Constraints — Design

Motivation

Establish DDD/hexagonal architecture as the standard pattern for the codebase, using call schedule constraints as the reference implementation. Constraints are configuration that the solver will eventually consume, but solver integration is out of scope here.

Constraint Types

Two buckets of constraints, all program-scoped and associated with schedules via per-type join tables.

Bucket A: Rest Constraints

Rules about the gap between two shifts for a resident.

MinRestConstraint — covers three ACGME rules in one model:

ACGME Rule Configuration
8h rest between clinical work (6.21) restMode=FIXED, fixedHours=8, shiftFilter=ALL
14h rest after 24h in-house call (6.21) restMode=FIXED, fixedHours=14, afterMinDurationHours=24, shiftFilter=ALL
EM: rest >= shift length between ED shifts restMode=PROPORTIONAL, multiplier=1.0, shiftFilter=BY_CALL_TYPES([ED])

Fields: - id, program_id, name - rest_mode — FIXED_HOURS or PROPORTIONAL - fixed_hours — set when FIXED_HOURS (e.g., 8, 14) - multiplier — set when PROPORTIONAL (e.g., 1.0 = shift length) - after_min_duration_hours — optional trigger: only applies after shifts >= N hours (nil = always) - shift_filter — which shifts this applies between (ALL, BY_CALL_TYPES, BY_TEMPLATES) - resident_filter — which residents (PGY range + tags)

Bucket B: Limit Constraints

Rules about measuring aggregate shift activity for a resident.

MaxShiftFrequency — Q3 rule (ACGME 6.27):

ACGME Rule Configuration
No more than every 3rd night, averaged over 4 weeks maxEveryNNights=3, averagingWeeks=4
IM: strict every-third-night (no averaging) maxEveryNNights=3, averagingWeeks=0

Fields: - id, program_id, name - max_every_n_nights — e.g., 3 for Q3 - averaging_weeks — e.g., 4; 0 = strict (no averaging) - shift_filter, resident_filter

RequiredDayOff — 1 day in 7 rule (ACGME 6.21.b):

ACGME Rule Configuration
1 day in 7, averaged over 4 weeks daysPerWeek=1, averagingWeeks=4
EM: 1 day in 7, strict per week daysPerWeek=1, averagingWeeks=0

Fields: - id, program_id, name - days_per_week — e.g., 1 - averaging_weeks — e.g., 4; 0 = strict per week - shift_filter, resident_filter

MaxWeeklyHours — 80h rule (ACGME 6.20):

ACGME Rule Configuration
80h/week averaged over 4 weeks maxHours=80, averagingWeeks=4
EM: 60h/week in ED maxHours=60, averagingWeeks=4, shiftFilter=BY_CALL_TYPES([ED])

Fields: - id, program_id, name - max_hours — e.g., 80, 60 - averaging_weeks — e.g., 4 - shift_filter, resident_filter

Shared Value Objects

ShiftFilter — which shifts a constraint applies to: - filter_type — ALL, BY_CALL_TYPES, BY_TEMPLATES - ids — list of call_type_id or template_id (empty when ALL)

Include-only semantics. No exclude mode.

ResidentFilter — which residents a constraint applies to: - pgy_min, pgy_max — optional PGY range - resident_tag_ids — optional list with AND logic (must have ALL tags)

Nil/empty means “applies to all residents.”

Schedule Association

Constraints are program-level entities associated with schedules via per-type join tables (like the existing call_schedule_templates pattern):

Per-type join tables give proper FK integrity to each constraint’s detail table.

Double duty: The association serves two purposes: 1. “Apply this constraint when solving this schedule” 2. “When checking this constraint, also consider shifts from the other schedules it’s associated with”

Associating a constraint with a new schedule automatically widens the cross-schedule scope for all other schedules sharing that constraint.

Domain Layer

All types live within the existing call domain: internal/features/call/domain/.

domain/
  constraint_min_rest.go            # MinRestConstraint
  constraint_max_shift_frequency.go # MaxShiftFrequency
  constraint_required_day_off.go    # RequiredDayOff
  constraint_max_weekly_hours.go    # MaxWeeklyHours
  constraint_shared.go              # ShiftFilter, ResidentFilter, enums

Each file contains: - Domain struct with unexported fields - Constructor with validation (NewMinRestConstraint(...)) - Reconstitute function for loading from persistence - Getters

No behavior beyond construction validation — these are configuration entities, not aggregates with state transitions.

Repository Interfaces

One repository per constraint type, defined in domain/:

type MinRestConstraintRepository interface {
    Get(ctx context.Context, id uuid.UUID) (*MinRestConstraint, error)
    Save(ctx context.Context, constraint *MinRestConstraint) error
    Delete(ctx context.Context, id uuid.UUID) error
    ListByProgram(ctx context.Context, programID uuid.UUID) ([]*MinRestConstraint, error)
    ListBySchedule(ctx context.Context, scheduleID uuid.UUID) ([]*MinRestConstraint, error)
    AssociateWithSchedule(ctx context.Context, constraintID, scheduleID uuid.UUID) error
    DissociateFromSchedule(ctx context.Context, constraintID, scheduleID uuid.UUID) error
}

Same pattern for the other three types.

Persistence

Constraint Tables

CREATE TABLE call_min_rest_constraints (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    program_id UUID NOT NULL REFERENCES programs(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    rest_mode TEXT NOT NULL CHECK (rest_mode IN ('fixed_hours', 'proportional')),
    fixed_hours INT,
    multiplier DOUBLE PRECISION,
    after_min_duration_hours INT,
    shift_filter_type TEXT NOT NULL CHECK (shift_filter_type IN ('all', 'by_call_types', 'by_templates')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT valid_rest_config CHECK (
        (rest_mode = 'fixed_hours' AND fixed_hours IS NOT NULL AND multiplier IS NULL) OR
        (rest_mode = 'proportional' AND multiplier IS NOT NULL AND fixed_hours IS NULL)
    ),
    UNIQUE(program_id, LOWER(name))
);

-- Shift filter detail: which call types or templates
CREATE TABLE call_min_rest_constraint_shift_filters (
    constraint_id UUID NOT NULL REFERENCES call_min_rest_constraints(id) ON DELETE CASCADE,
    ref_id UUID NOT NULL, -- call_type_id or template_id depending on parent's filter_type
    PRIMARY KEY (constraint_id, ref_id)
);

-- Resident filter detail: which tags
CREATE TABLE call_min_rest_constraint_resident_tags (
    constraint_id UUID NOT NULL REFERENCES call_min_rest_constraints(id) ON DELETE CASCADE,
    resident_tag_id UUID NOT NULL REFERENCES resident_tags(id) ON DELETE CASCADE,
    PRIMARY KEY (constraint_id, resident_tag_id)
);

-- PGY range lives on the main table as pgy_min/pgy_max columns

Same pattern for the other three constraint types. Each gets: - Main constraint table - Shift filter detail table - Resident tag filter table - Schedule association join table

Schedule Association Tables

CREATE TABLE call_schedule_min_rest_constraints (
    call_schedule_id UUID NOT NULL REFERENCES call_schedules(id) ON DELETE CASCADE,
    constraint_id UUID NOT NULL REFERENCES call_min_rest_constraints(id) ON DELETE CASCADE,
    PRIMARY KEY (call_schedule_id, constraint_id)
);

Out of Scope