Skip to content

ADR-0022: Schedule End Date Inclusive Semantics

Accepted Emmie Territory-Specific

Date: 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:

ReaderConventionNotes
ScheduleBuilder::activeInPeriodinclusive (<= / >=)canonical, fixed 2026-04-25 by e00c96f6b07
ScheduleBuilder::activeOnDate / forDateinclusiveconsistent with canonical
CalcExpectedPresenceAction:23-26inclusiveconsistent
Client\ClientScheduleController::index:31-35inclusive (mixes where + whereDate)consistent
SendScheduleChangeSummaryinclusive (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-39strict (< / >)mismatch
RegistrationPerDayResponse::getRecentPresenceForScheduledClients:94-96strictmismatch
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:31inclusive 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$?":

php
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):

php
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

OptionVerdictReason
Inclusive end_date (last active day)AcceptedMatches 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)RejectedWould 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/exclusiveRejectedMixed 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 EndScheduleAction writes end_date = yesterday, the canonical predicate where('end_date', '>=', today) correctly excludes the row from "active today" without an off-by-one adjustment.
  • Wire shape unaffected — both ScheduleResource and the BasicSchedule TypeScript type already serialize end_date as a Y-m-d string. The semantic convention is what changes; the data shape is untouched.

Negative

  • Five readers misaligned today: GetUserSchedulesAndRegistrationsAction, RegistrationPerDayResponse::getRecentPresenceForScheduledClients, ScheduleReport observer, plus two AuthController Client::schedules eager-loads at :148 and :267. The two AuthController predicates were not surfaced in the original Surveyor M9 recon (mission scope did not enumerate app/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-0022 comment rather than silently flipped.

Enforcement

WhatMechanismScope
Strict < / > boundary on Schedule.start_date / Schedule.end_date outside canonical builder methodsPest arch test — ships with ADR-0023 chokepoint deploymentapp/**
Hand-rolled overlap predicates instead of ScheduleBuilder::activeInPeriod / activeOnDate / forDateCode review + briefing instructionAll 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

TerritoryStateNotes
emmieIn ProgressFirst 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).

Architecture documentation for contributors and collaborators.