Skip to content

ADR-0023: Schedule Mutation Chokepoint

Accepted Emmie Territory-Specific

Date: 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) enforces Y-m-d format + DateIsWeekday(day) (the date's weekday must match the day enum field) but does not enforce Monday-start.
  • Factory (ScheduleFactory::definition, line 56) returns any business weekday.
  • Seeder (ScheduleSeeder.php:330) does direct query-builder insert() — 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):

#PathDoctrine status
1ScheduleController::store/updateScheduleRequestUpdateScheduleActionPASS — canonical
2EndScheduleAction::execute(Schedule) (sets end_date = yesterday)PASS
3RecreateClientSchedulesOnStatusChangeAction (transaction-wrapped)PASS
4UpdateUserAction::handleActiveStatusChange:188-193 — mass update(['end_date' => yesterday])ADR-0019 violation
5UpdateProfileAction:53-55 — same shape, also overwrites historical end_dates pre-PR #191ADR-0019 violation + data corruption
6Client\ClientScheduleController::massDestroy:55Schedule::whereIn(...)->update(['end_date' => today])ADR-0011 + ADR-0019 violations, end_date = today
7ClientController::update:246-248 — per-row $schedule->update([...]) in controller closureADR-0011 + ADR-0019 violations, request-supplied end_date
8ClientController::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:

  1. One Action owns date mutation. Working name NormalizeAndUpdateScheduleDatesAction. Implements ADR-0011 (final readonly, single execute(), constructor DI), ADR-0012 (FormRequest → DTO input), ADR-0019 (explicit property assignment, no mass update). Wraps its body in ConnectionInterface::transaction(...) per ADR-0021's EnforceActionTransactionsRule.
  2. EndScheduleAction is retained but extended to take an EndScheduleInputDto carrying 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 is yesterday (preserving existing behavior). The Action validates the day is a Sunday (or coerces — TBD in deployment).
  3. Sibling Actions cover the controller-extracted paths. New Actions for MassEndClientSchedulesAction (path 6), client-status-QUIT cascade (path 7, likely folded into UpdateClientAction), and client-delete cascade (path 8, likely folded into DeleteClientAction). Each calls EndScheduleAction::execute per row inside its own transaction.
  4. Factory and seeder snap to the doctrine. ScheduleFactory::definition constrained to Monday-aligned start_date matching the day enum. ScheduleSeeder rewritten to use the factory (or the chokepoint Action) — bulk-insert performance optimization deferred until profiling shows it matters.

Caller migration

Existing siteMigrates to
UpdateScheduleActionRoutes its date mutations through the chokepoint Action (or absorbs the normalizer logic — TBD in deployment); retains its existing role for non-date fields.
EndScheduleActionRewritten to take an EndScheduleInputDto with effective-end day.
UpdateUserAction::handleActiveStatusChangeReplaces 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::massDestroyMoved 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::definitionConstrained to Monday-aligned start_date.
ScheduleSeeder::runRewritten 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:

WrapperTrigger
DeleteWorkplaceActionWorkplace deleted mid-week with a same-week schedule
RecreateClientSchedulesOnStatusChangeActionClient 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-39
  • RegistrationPerDayResponse::getRecentPresenceForScheduledClients:94-96
  • ScheduleReport observer (lines 45/49/53)
  • AuthController:148 (Client::schedules eager-load on login response)
  • AuthController:267 (Client::schedules eager-load on me / 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

OptionVerdictReason
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 columnsRejected~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 weekdayRejected as primary mechanismProduction 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([...])RejectedCatches 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 EndScheduleInputDto resolves 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::delete all 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-55 ran Schedule::newQuery()->where('user_id', $user->id)->update(['end_date' => yesterday]) with no whereNull('end_date') filter. Every schedule belonging to a self-deactivating user — including schedules already ended in the past — had its end_date re-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:444 cascaded $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 with end_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 relevant DeleteClientAction and routes the end-of-life through EndScheduleAction::execute(yesterday) consistently with WP2. The doctrine fix incidentally closes the orphan-row ambiguity.
  • ScheduleSeeder rewrite 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::handleActiveStatusChange was 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 AmbulatoryScheduleClientController::update:248-251 runs the same controller-mutates-end_date pattern on ambulatorySchedules. 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 ScheduleResource and ClientScheduleResourceScheduleResource exports start_date: string; ClientScheduleResource exports start_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

WhatMechanismScope
Mass update([...]) on Schedule touching date columnsPest arch testapp/**
Direct property mutation of Schedule.start_date / Schedule.end_date outside the chokepoint Action and EndScheduleActionPest arch test (extension)app/**
Strict < / > boundary on Schedule.start_date / Schedule.end_date (companion to ADR-0022)Pest arch testapp/**
Chokepoint Action signature drift (DatabaseManager injection, missing transaction wrap)Existing ADR-0011 + ADR-0021 PHPStan rulesapp/Actions/**
Phase 2 — PHPStan rule equivalent of the arch tests abovescript-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

TerritoryStateNotes
emmieCompletePR #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 underflowEndScheduleAction 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 territoriesNot ApplicableSchedule is an emmie-only entity. PHPStan rule transferability deferred until a second territory exhibits the same pattern.

Architecture documentation for contributors and collaborators.