ADR-0023: Schedule Mutation Chokepoint
Accepted Emmie Territory-SpecificDate: 2026-04-30 Amendments: 2026-05-06 — added "Snap-down underflow disposition" (EMMIE-0248), resolving a regression surfaced during PR #195 review.
Context
Emmie's App\Models\Customer\Schedule is conceptually week-shaped — schedules start on a Monday, end on a Sunday, no mid-week starts or partial weeks. Production rows are week-aligned today by ally convention. Zero code enforces this:
- Migration columns are plain
DATE NOT NULL/DATE NULL. No CHECK constraint. - Model casts (
Schedule::casts, line 217) carry no week-alignment hook. - FormRequest validation (
ScheduleRequest::rules) enforcesY-m-dformat +DateIsWeekday(day)(the date's weekday must match thedayenum field) but does not enforce Monday-start. - Factory (
ScheduleFactory::definition, line 56) returns any business weekday. - Seeder (
ScheduleSeeder.php:330) does direct query-builderinsert()— bypasses the model, observer, audit, and any future normalizer.
The mutation surface fragments across eight write paths, five of which violate doctrine (full evidence in Surveyor M9 §Flow Analysis):
| # | Path | Doctrine status |
|---|---|---|
| 1 | ScheduleController::store/update → ScheduleRequest → UpdateScheduleAction | PASS — canonical |
| 2 | EndScheduleAction::execute(Schedule) (sets end_date = yesterday) | PASS |
| 3 | RecreateClientSchedulesOnStatusChangeAction (transaction-wrapped) | PASS |
| 4 | UpdateUserAction::handleActiveStatusChange:188-193 — mass update(['end_date' => yesterday]) | ADR-0019 violation |
| 5 | UpdateProfileAction:53-55 — same shape, also overwrites historical end_dates pre-PR #191 | ADR-0019 violation + data corruption |
| 6 | Client\ClientScheduleController::massDestroy:55 — Schedule::whereIn(...)->update(['end_date' => today]) | ADR-0011 + ADR-0019 violations, end_date = today |
| 7 | ClientController::update:246-248 — per-row $schedule->update([...]) in controller closure | ADR-0011 + ADR-0019 violations, request-supplied end_date |
| 8 | ClientController::delete:444 — $client->schedules->each->update(['end_date' => today]) | ADR-0011 + ADR-0019 violations, end_date = today |
Three different "end the schedule effective now" semantics live in active code: yesterday (paths 2, 3, 4, 5), today (paths 6, 8), and request-supplied (path 7). Same business intent, three different stored values.
ADR-0022 codifies the read-side convention (inclusive end_date). This ADR codifies the write-side enforcement. Two precursor PRs landed before this ADR: PR #191 (Medic, EMMIE-0241 bug fixes) and PR #192 (Engineer, EMMIE-0242 hygiene).
- 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
All mutation of Schedule.start_date and Schedule.end_date routes through a single canonical Action. Direct Schedule::*->update(['start_date' => ...]), Schedule::*->update(['end_date' => ...]), whereIn(...)->update(...), and each->update(...) patterns against the date columns are forbidden by Pest arch test (Phase 1) and PHPStan rule (Phase 2, candidate for script-development/phpstan-warroom-rules).
Wrapping, not replacing
The schema stays untouched: start_date date NOT NULL, end_date date NULL. The chokepoint Action validates week-alignment — start_date must be a Monday; end_date must be a Sunday or null — and rejects mid-week input with a ValidationException before delegating to property assignment + save() inside a transaction.
No column rename, no (year, iso_week) migration, no frontend type change, no wire-format break. The recent composite index (client_id, end_date, start_date) from 2026-04-25 stays valid.
Action shape
The exact name and signature land with the Engineer deployment. The doctrine constraints:
- One Action owns date mutation. Working name
NormalizeAndUpdateScheduleDatesAction. Implements ADR-0011 (final readonly, singleexecute(), constructor DI), ADR-0012 (FormRequest → DTO input), ADR-0019 (explicit property assignment, no mass update). Wraps its body inConnectionInterface::transaction(...)per ADR-0021'sEnforceActionTransactionsRule. EndScheduleActionis retained but extended to take anEndScheduleInputDtocarrying the effective-end day. The yesterday-vs-today inconsistency across paths 2/4/5 (yesterday) and 6/8 (today) is resolved by caller-specified: each end-of-life caller passes the day it intends. Default isyesterday(preserving existing behavior). The Action validates the day is a Sunday (or coerces — TBD in deployment).- Sibling Actions cover the controller-extracted paths. New Actions for
MassEndClientSchedulesAction(path 6), client-status-QUIT cascade (path 7, likely folded intoUpdateClientAction), and client-delete cascade (path 8, likely folded intoDeleteClientAction). Each callsEndScheduleAction::executeper row inside its own transaction. - Factory and seeder snap to the doctrine.
ScheduleFactory::definitionconstrained to Monday-alignedstart_datematching thedayenum.ScheduleSeederrewritten to use the factory (or the chokepoint Action) — bulk-insert performance optimization deferred until profiling shows it matters.
Caller migration
| Existing site | Migrates to |
|---|---|
UpdateScheduleAction | Routes its date mutations through the chokepoint Action (or absorbs the normalizer logic — TBD in deployment); retains its existing role for non-date fields. |
EndScheduleAction | Rewritten to take an EndScheduleInputDto with effective-end day. |
UpdateUserAction::handleActiveStatusChange | Replaces mass update([...]) with iterate-and-call EndScheduleAction::execute per schedule. |
UpdateProfileAction (deactivation branch) | Same as UpdateUserAction. (whereNull('end_date') filter already added in PR #191.) |
Client\ClientScheduleController::massDestroy | Moved into MassEndClientSchedulesAction; controller reduced to FormRequest → DTO → Action. |
ClientController::update (status QUIT cascade) | Moved into the relevant client-update Action; controller reduced to FormRequest → DTO → Action. |
ClientController::delete (cascade) | Moved into the relevant client-delete Action; same. |
ScheduleFactory::definition | Constrained to Monday-aligned start_date. |
ScheduleSeeder::run | Rewritten to use factory + chokepoint write path; direct query-builder insert retired. |
Roughly 12 caller-site rewrites. Compare to ~37 backend + ~15 frontend sites for the replacing option.
Snap-down underflow disposition (amendment 2026-05-06, EMMIE-0248)
EndScheduleAction snaps the caller-supplied effective end day down to the previous Sunday before delegating to the chokepoint (rationale: snap-down preserves the contract that the schedule is not active on the caller-supplied input day). When the caller invokes end-of-life mid-week on a schedule whose start_date is the same week's Monday, the snapped Sunday lands strictly before start_date. The chokepoint's end_date >= start_date invariant rejects this.
The original deployment shipped without resolving this case; the chokepoint threw ValidationException and the surrounding transaction rolled back. PR #195 review surfaced five caller wrappers that hit it during normal operation:
| Wrapper | Trigger |
|---|---|
DeleteWorkplaceAction | Workplace deleted mid-week with a same-week schedule |
RecreateClientSchedulesOnStatusChangeAction | Client status flip mid-week with a same-week schedule |
EndOpenClientSchedulesAction (via ClientController::update QUIT cascade) | Client → QUIT mid-week with a same-week open schedule |
MassEndClientSchedulesAction (via ClientScheduleController::massDestroy, ClientController::delete) | Mass-end / client-delete mid-week with a same-week schedule |
EndOpenUserSchedulesAction (via UpdateUserAction::handleActiveStatusChange, UpdateProfileAction) | User deactivated mid-week with a same-week schedule |
Disposition: EndScheduleAction detects the underflow case (snapped Sunday strictly before schedule->start_date) and soft-deletes the schedule inside a transaction, bypassing the chokepoint. Under ADR-0022 inclusive end_date semantics, a schedule whose effective end day predates its first inclusive week never had a meaningful active period — soft-deletion is the honest representation. Schedule's existing SoftDeletes trait preserves the row + audit trail; deleted_at is set, start_date and end_date are untouched.
Alternatives considered and rejected:
- B2 — clamp to one week (
start = Monday,end = following Sunday): expands schedule lifetime past caller intent; produces a spurious "schedule was active for one week" audit trail. - C — caller-side guard at every wrapper: spreads one semantic decision across five iterate-and-call loops; each site needs its own test pinning the new guard.
- Document and let throw: operational regression — aborts unrelated transactions (workplace deletion, user deactivation cascade, mass-end). Same outcome as no fix.
Caller behavior unchanged. Wrappers continue to pass every eligible schedule unconditionally to EndScheduleAction::execute(). The decision lives in one place. This preserves the chokepoint doctrine: write-side date-mutation invariants stay strict in NormalizeAndUpdateScheduleDatesAction; lifecycle-shaped exceptions (delete-on-underflow) live in EndScheduleAction as the end-of-life Action.
Enforcement: Pest unit tests on EndScheduleAction cover the underflow branch (delete called, chokepoint not invoked) and the boundary (first valid Sunday → chokepoint invoked, no delete). Pest integration test exercises the full-stack soft-delete on a same-week schedule. The §G arch tests are unaffected — $schedule->delete() is not a date-column mutation.
Read-side propagation (paired with ADR-0022)
The chokepoint deployment also flips five strict < / > predicates to inclusive <= / >=:
GetUserSchedulesAndRegistrationsAction:32-39RegistrationPerDayResponse::getRecentPresenceForScheduledClients:94-96ScheduleReportobserver (lines 45/49/53)AuthController:148(Client::scheduleseager-load on login response)AuthController:267(Client::scheduleseager-load onme/ refresh response)
The two AuthController predicates were not in the original Surveyor M9 read surface; they surfaced during interrogation on 2026-04-30 (mission scope had not enumerated app/Http/Auth/).
Pest boundary-equality test cases land first (red), then the predicate flips (green). The deployment order requires this sequencing.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Wrapping normalizer (this ADR) | Accepted | ~12 caller sites. Schema untouched. Frontend untouched. Chokepoint catches every mutation path through one Action. ~80% of the structural-correctness win at ~30% of the replacing blast radius. Schema can still be tightened later (CHECK constraint, generated columns) without reopening the doctrine question. |
Replacing — (year, iso_week) typed columns | Rejected | ~37 backend + ~15 frontend sites. Wire-format break to ScheduleResource and ClientScheduleResource. Loses the recent composite-index perf gain. Makes misalignment representationally impossible — but inherits all current predicate bugs unchanged. The bug class is predicate-driven, not representation-driven; replacing solves the wrong half at higher cost. |
| Database CHECK constraint enforcing weekday | Rejected as primary mechanism | Production MySQL version unverified (Surveyor scope limitation). Even if available, CHECK constraints catch violations after the write attempt — too late for a useful error message. The chokepoint Action produces a domain-specific ValidationException before the round-trip. CHECK stays open as a Phase 2 belt-and-suspenders option. |
Status quo + arch test forbidding mass Schedule::update([...]) | Rejected | Catches paths 4–8 but doesn't address path 1's lack of week-alignment validation. Allies hitting the API with a mid-week start_date would still get accepted writes. Wrapping is the same caller-migration cost and adds the week-alignment guard. |
Consequences
Positive
- One write surface, one validation point — week-alignment check and ADR-0019 explicit property assignment live in one Action. Adding new doctrine (e.g., NEN 7510 audit hook for Schedule changes when ADR-0001 Phase 2 reaches Schedule) means one site to extend.
- End-of-life semantics consolidated — caller-specified
EndScheduleInputDtoresolves the yesterday-vs-today inconsistency without forcing every caller into the same UX choice. - Three controllers shed mass-update violations —
Client\ClientScheduleController,ClientController::update,ClientController::deleteall become FormRequest → DTO → Action. Three ADR-0011 + ADR-0019 violations close in one campaign. - Read-side bug class closes — paired predicate flips on the five strict-
</>readers eliminate the boundary mismatch. - Path remains open for the replacing option later — if production shows mid-week values slipping in via raw SQL or scrapers, the chokepoint can be tightened to require typed input without renegotiating the campaign.
Negative
- WP5 — historical end_date corruption (real production bug class, partially fixed). Pre-PR #191,
UpdateProfileAction:53-55ranSchedule::newQuery()->where('user_id', $user->id)->update(['end_date' => yesterday])with nowhereNull('end_date')filter. Every schedule belonging to a self-deactivating user — including schedules already ended in the past — had itsend_datere-stamped to "yesterday." Surveyor M9 surfaced this; the Surveyor debrief flagged it as undersold ("should have been a separate Critical-or-High finding rather than a sub-bullet"). PR #191 added the filter, halting the bleed. Production rows already corrupted by the pre-fix mass update are not retroactively repaired. A separate disposition is required if Commander wants a forensic sweep + repair migration. Out of scope for this campaign. - WP8 — orphan-active-row surface at parent soft-delete (data-integrity concern, normalized by migration). Pre-migration,
ClientController::delete:444cascaded$client->schedules->each->update(['end_date' => today])while soft-deleting the parent Client. Surveyor M9 debrief flagged the resulting state — "orphaned active rows pointing at a soft-deleted client" — as a data-integrity surface beyond the doctrine violation: schedules withend_date = today(active for one more day under inclusive semantics) pointing at a parent that no longer resolves through the default Eloquent relationship. The migration consolidates the cascade into the relevantDeleteClientActionand routes the end-of-life throughEndScheduleAction::execute(yesterday)consistently with WP2. The doctrine fix incidentally closes the orphan-row ambiguity. ScheduleSeederrewrite touches dev/CI test-DB shape — bulk-insert was fast for seeding; rewriting will slow CI seeding measurably. Mitigation: factory + Action can be batched in a single transaction per client.- Caller code becomes more verbose —
UpdateUserAction::handleActiveStatusChangewas three lines; will be ~6–8 (iterate, call Action per row). The verbosity is the doctrine working — each end-of-life write becomes auditable through the canonical path.
Risks
- Mid-mission scope drift to
AmbulatorySchedule—ClientController::update:248-251runs the same controller-mutates-end_date pattern onambulatorySchedules. Mitigation: Engineer deployment order explicitly excludes AmbulatorySchedule; a follow-up disposition decides whether AmbulatorySchedule gets the same Action (likely yes, but separate ticket). - Boundary-equality test gap — current test surface (Surveyor M9 §Test Coverage) does not cover the strict-vs-inclusive equality cases. Mitigation: Pest cases land first in the chokepoint deployment, before any predicate flip.
- Wire-format drift between
ScheduleResourceandClientScheduleResource—ScheduleResourceexportsstart_date: string;ClientScheduleResourceexportsstart_date: CarbonImmutable(parsed). The chokepoint touches model writes, not resources, so this divergence (Surveyor F-M1) survives the deployment unless explicitly bundled. Mitigation: F-M1 is on the campaign's "deferred" list. The Engineer deployment order should fold the resource unification into scope. - PHPStan rule is Phase 2 — the Pest arch test prevents new violators in
app/**; the cross-territory PHPStan rule (phpstan-warroom-rules) is a separate cadence. Until then, only the arch test enforces. Mitigation: ship the arch test in the chokepoint deployment; the PHPStan rule follows on its own track.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
Mass update([...]) on Schedule touching date columns | Pest arch test | app/** |
Direct property mutation of Schedule.start_date / Schedule.end_date outside the chokepoint Action and EndScheduleAction | Pest arch test (extension) | app/** |
Strict < / > boundary on Schedule.start_date / Schedule.end_date (companion to ADR-0022) | Pest arch test | app/** |
| Chokepoint Action signature drift (DatabaseManager injection, missing transaction wrap) | Existing ADR-0011 + ADR-0021 PHPStan rules | app/Actions/** |
| Phase 2 — PHPStan rule equivalent of the arch tests above | script-development/phpstan-warroom-rules (per ADR-0021 §Future rules) | All consuming territories — but rule is emmie-shaped today; cross-territory transferability deferred until a second territory shows the same pattern |
Resolved Questions
Why "wrapping" instead of "replacing"?
Resolved 2026-04-29 (deliberation in 2026-04-29-schedule-date-precursor.md). Replacing makes misalignment representationally impossible but at ~37 backend + ~15 frontend sites, breaks the wire format, invalidates the recent composite-index perf work, and inherits the predicate bugs unchanged. Wrapping addresses both write and read surfaces (paired with ADR-0022) at ~12 caller sites with no schema change. Commander's reframing on intake — "the comparison alone isn't the only problem, also the setting of the dates is a problem" — moved the value proposition from "fix predicate bugs" to "collapse mutation surface to a single normalized chokepoint." Wrapping delivers exactly that.
What if wrapping turns out to be the wrong shape?
Resolved 2026-04-30. The decision is reversible. If post-merge soak shows mid-week values still slipping in (via raw SQL, scrapers, or new ally-introduced write paths), the chokepoint Action can be tightened or replaced with the typed-column option. The replacing path remains open. Reversibility is a key reason wrapping is the right starting point — smallest change that establishes the doctrine, with room to escalate.
Does this apply to AmbulatorySchedule?
Resolved 2026-04-30. No. AmbulatorySchedule is explicitly out of scope per Commander framing. AmbulatorySchedule has the same start_date / end_date shape and a ClientController::update:248-251 controller-mutation site mirroring the Schedule pattern. A separate disposition is required before any cascade. The chokepoint Action's signature should be reviewable for AmbulatorySchedule reuse but should not assume it.
Why isn't this rolled into ADR-0011?
Resolved 2026-04-30. ADR-0011 is the cross-project doctrine "Actions own all multi-write business logic and transaction wrapping." This ADR is the territory-specific application "all Schedule date mutations route through one specific Action." The structural shape (Action + DTO + property assignment + transaction) is ADR-0011 + ADR-0012 + ADR-0019; the chokepoint identity is emmie-specific. Folding into ADR-0011 dilutes the cross-project doctrine with territory detail.
What about the seeder bypassing the chokepoint?
Resolved 2026-04-30. ScheduleSeeder.php:330 does direct query-builder insert() for performance reasons. The chokepoint deployment rewrites the seeder to use the factory (constrained to Monday-aligned dates). If profiling shows the factory route is too slow for CI, the bulk-insert path can be retained — but constrained to factory-produced data, so output is doctrine-compliant.
Does the scrapers/ directory bypass the chokepoint?
Resolved 2026-04-30 (during interrogation). Surveyor M9 §Scope Limitations flagged scrapers/ as uninvestigated, with the warning "if scrapers/ writes to the schedules table, it would slip past every Action-level guard." Verified: the directory contains a single file, scrapers/zorgboeren.py, a 59-line BeautifulSoup web scraper that fetches care-farm contact data from zorgboeren.nl and exports to Excel via pandas. Zero database connection, zero SQL, zero references to the schedules table or any emmie-internal model. The chokepoint is not bypassed today. Forward-looking risk: the Pest arch test on app/** does not enforce on Python code under scrapers/. If a future scraper writes directly to schedules (raw SQL, ORM, or otherwise), it would bypass the chokepoint Action and the week-alignment validation. The Engineer deployment-order pre-flight should re-verify scrapers/ for new write paths whenever the directory grows beyond the current single file.
Implementation
| Territory | State | Notes |
|---|---|---|
| emmie | Complete | PR #195 merged 2026-05-06 at e47dae1c (branch refactor/EMMIE-0243-schedule-mutation-chokepoint, deleted post-merge). Original Engineer deployment landed 6 commits per the order; General review added commit 7 flipping ScheduleReport:80 (checkIfClientIsScheduledOnDay) — a Schedule date strict comparator missing from Surveyor M9's read-surface inventory. Two pre-merge regressions surfaced and closed on the same branch: (1) EMMIE-0248 underflow — EndScheduleAction snapped-down Sunday could land before start_date for in-this-week schedules, rejecting unrelated cascades (workplace delete, user/client deactivation, mass-end); resolved by soft-deleting the schedule when underflow detected (B1 disposition, §D.2 amendment). (2) Overlap-validator stale-input regression (ally review P1) — ValidateScheduleOverlap ran on $schedule->start_date / end_date before the chokepoint reassigned them, so UpdateScheduleAction queried with stale dates and RecreateClientSchedulesOnStatusChangeAction queried with null start_date (silent comparator-fail bypass for any schedule with non-null end_date); resolved by changing __invoke signature to take proposed dates explicitly. AmbulatorySchedule remains explicitly excluded. |
| Other territories | Not Applicable | Schedule is an emmie-only entity. PHPStan rule transferability deferred until a second territory exhibits the same pattern. |