ADR-0022: Schedule End Date Inclusive Semantics
Accepted Emmie Territory-SpecificDate: 2026-04-30
Context
Emmie's App\Models\Customer\Schedule carries start_date and end_date columns (DATE NOT NULL and DATE NULL respectively; both hydrate as raw Y-m-d strings — no Carbon cast). End_date is nullable to express open-ended schedules.
The codebase applies at least three different boundary conventions to these columns:
| Reader | Convention | Notes |
|---|---|---|
ScheduleBuilder::activeInPeriod | inclusive (<= / >=) | canonical, fixed 2026-04-25 by e00c96f6b07 |
ScheduleBuilder::activeOnDate / forDate | inclusive | consistent with canonical |
CalcExpectedPresenceAction:23-26 | inclusive | consistent |
Client\ClientScheduleController::index:31-35 | inclusive (mixes where + whereDate) | consistent |
SendScheduleChangeSummary | inclusive (whereBetween) | comment at line 230: "the end date of a schedule is inclusive, so if a schedule has an end date on Wednesday, it is still active that day" |
GetUserSchedulesAndRegistrationsAction:32-39 | strict (< / >) | mismatch |
RegistrationPerDayResponse::getRecentPresenceForScheduledClients:94-96 | strict | mismatch |
ScheduleReport observer (lines 45/49/53) | strict (>) | mismatch |
AuthController:148 (Client::schedules eager-load on login response) | strict (>) | mismatch |
AuthController:267 (Client::schedules eager-load on me / refresh response) | strict (>) | mismatch |
CurrentOccupationController:31 | inclusive against today()->startOfWeek() | idiosyncratic |
Bug archaeology corroborates: at least four fix(schedule):... commits in the three weeks preceding 2026-04-29, plus the EMMIE-0234 fix (e00c96f6b07) explicitly correcting the canonical predicate for "schedules that fully enclosed the queried period." Each reader reinventing the overlap predicate is the structural cause; "is end_date inclusive?" being undocumented is the proximate cause.
The Surveyor M9 schedule-date-mutation-surface field report exhaustively maps the read surface; the campaign report records the deliberation in which Commander chose inclusive end_date as the alliance-wide convention. This ADR codifies that decision.
- Surveyor field report:
reports/emmie/field/2026-04-29-surveyor-schedule-date-mutation-surface.md - Campaign report:
campaigns/emmie/2026-04-29-schedule-date-precursor.md
Decision
The end_date column on App\Models\Customer\Schedule is inclusive: the value stored is the last day on which the schedule is active.
Canonical predicates
For "is the schedule active on a given date $D$?":
where('start_date', '<=', $D)
->where(fn ($q) => $q->whereNull('end_date')->orWhereDate('end_date', '>=', $D))For "does the schedule overlap the period $[S, E]$?" (canonical, see ScheduleBuilder::activeInPeriod):
where('start_date', '<=', $E)
->where(fn ($q) => $q->whereNull('end_date')->orWhereDate('end_date', '>=', $S))Both predicates use inclusive comparisons (<= / >=). Strict < / > is wrong — it excludes schedules whose end_date equals the period start, or whose start_date equals the period end.
Frontend predicates
The same convention applies in TypeScript. apps/user/domains/schedule/schedule/helpers.ts::clientAvailability was flipped from strict > to >= on 2026-04-29 (PR #191) as the first application of this ADR. Comparisons against null end_date use the canonical sentinel exported from apps/user/domains/schedule/helpers.ts::OPEN_ENDED_SCHEDULE_END_DATE = '9999-12-31' (introduced in PR #192).
Storage
End-of-life writers store end_date as the last active day. EndScheduleAction writes end_date = yesterday — meaning yesterday was the last active day, today is inactive. The choice of which day a writer passes (yesterday vs today vs caller-specified) is a separate decision belonging to ADR-0023; this ADR fixes only the meaning of the column.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Inclusive end_date (last active day) | Accepted | Matches the canonical reader (ScheduleBuilder::activeInPeriod), the SendScheduleChangeSummary comment, and the storage convention EndScheduleAction already implements. Lowest-disturbance choice — fixes the five mismatched readers, leaves the canonical reader untouched. |
| Exclusive end_date (first inactive day) | Rejected | Would force rewriting the canonical reader, the summary semantics, and EndScheduleAction simultaneously. Higher blast radius for no compensating benefit — both conventions are equally expressive. |
Half-open [start, end) semantics, mixed inclusive/exclusive | Rejected | Mixed convention is exactly the current bug class. Codifying it would freeze the failure mode. |
Consequences
Positive
- One predicate to maintain — every reader uses inclusive
<=/>=. The four-fixes-in-three-weeks pattern collapses to "if a reader reinvents the predicate, an arch test catches it" (enforcement lands with ADR-0023). - Storage and query symmetric — when
EndScheduleActionwritesend_date = yesterday, the canonical predicatewhere('end_date', '>=', today)correctly excludes the row from "active today" without an off-by-one adjustment. - Wire shape unaffected — both
ScheduleResourceand theBasicScheduleTypeScript type already serialize end_date as aY-m-dstring. The semantic convention is what changes; the data shape is untouched.
Negative
- Five readers misaligned today:
GetUserSchedulesAndRegistrationsAction,RegistrationPerDayResponse::getRecentPresenceForScheduledClients,ScheduleReportobserver, plus twoAuthControllerClient::scheduleseager-loads at:148and:267. The twoAuthControllerpredicates were not surfaced in the original Surveyor M9 recon (mission scope did not enumerateapp/Http/Auth/); they were added on 2026-04-30 during interrogation. All five must be flipped to<=/>=as part of the propagation deployment. Test gaps on boundary equality (Surveyor F-M2) mean the breakage surface is opaque — the ADR-0023 deployment order requires boundary-equality Pest cases to land before the predicate flips.
Risks
- A reader's strict
</>was deliberate. Unlikely — no comment in any of the five sites suggests intent. Mitigation: before flipping each strict comparison, the chokepoint deployment order requires reading the surrounding business context. If a reader is genuinely exclusive by intent, the local exception is documented with a@see ADR-0022comment rather than silently flipped.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
Strict < / > boundary on Schedule.start_date / Schedule.end_date outside canonical builder methods | Pest arch test — ships with ADR-0023 chokepoint deployment | app/** |
Hand-rolled overlap predicates instead of ScheduleBuilder::activeInPeriod / activeOnDate / forDate | Code review + briefing instruction | All Schedule readers |
The Pest arch test ships paired with the ADR-0023 chokepoint deployment, not as a standalone landing — the mutation chokepoint and the read-side rule together close the predicate-bug class. Until then, this ADR + the campaign report are the authority.
Resolved Questions
Why codify before the chokepoint Action lands?
Resolved 2026-04-30. The campaign report originally queued both ADRs for codification "when the chokepoint Action lands." Commander overrode that timing because the first application of inclusive end_date is already in development: clientAvailability was flipped to >= in PR #191 (merged precursor work). Codifying retroactively prevents that change from being misread as a one-off fix. ADR-0023 will reference this one for storage semantics.
Why is the "yesterday vs today vs caller-specified" choice deferred?
Resolved 2026-04-30. Inclusive end_date determines the meaning of the column. The value a writer chooses is independent — both end_date = yesterday and end_date = today are coherent with inclusive semantics; they just mean "the schedule was last active yesterday" vs "the schedule is last active today." That choice is a UX/doctrine question for the chokepoint Action's signature and belongs in ADR-0023.
Does this apply to AmbulatorySchedule?
Resolved 2026-04-30. No. AmbulatorySchedule is explicitly out of scope per Commander framing on 2026-04-29. AmbulatorySchedule has the same start_date / end_date shape and may benefit from the same convention, but a separate disposition is required before any cascade.
Implementation
| Territory | State | Notes |
|---|---|---|
| emmie | In Progress | First application merged in PR #191: frontend clientAvailability flipped from > to >=. Backend read-side propagation in flight via PR #195 (draft, awaiting Commander local PHPStan + ally review): four sites flipped per order (GetUserSchedulesAndRegistrationsAction:32-39, RegistrationPerDayResponse:91-98, AuthController:148, AuthController:267); ScheduleReport::updated() lines 56/60/64 preserved-by-intent (decrement guards under inclusive semantics — flipping would double-decrement the end_date row); General-review commit 7 caught one site missing from Surveyor M9's read-surface inventory (ScheduleReport.php:80 in checkIfClientIsScheduledOnDay() — same gap pattern as AuthController:148/267 which surfaced during interrogation). |