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.
Two buckets of constraints, all program-scoped and associated with schedules via per-type join tables.
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)
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
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.”
Constraints are program-level entities associated with schedules
via per-type join tables (like the existing
call_schedule_templates pattern):
call_schedule_min_rest_constraints (call_schedule_id
FK, constraint_id FK)call_schedule_max_shift_frequency_constraints
(call_schedule_id FK, constraint_id FK)call_schedule_required_day_off_constraints
(call_schedule_id FK, constraint_id FK)call_schedule_max_weekly_hours_constraints
(call_schedule_id FK, constraint_id FK)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.
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.
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.
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 columnsSame pattern for the other three constraint types. Each gets: - Main constraint table - Shift filter detail table - Resident tag filter table - Schedule association join table
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)
);2026-02-28-soft-targets-future.md)