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.
  • 2026-05-08 — extended scope to AmbulatorySchedule (cross-sibling port). New §E AmbulatorySchedule Extension subsection. Resolved Question on AmbulatorySchedule reversed (was "out of scope," now "in scope"). Implementation table grows a new row. Triggered by Surveyor M12 narrow-surface recon (campaigns/emmie/2026-05-07-ambulatoryschedule-mutation-surface-recon.md).
  • 2026-05-13 — F-L2 disposition flipped from "convention-only" to "repair migration shipped." Trigger event Schedule row 7049 chokepoint UI block (beta deploy of PR #195 + PR #218 same day) proved the convention-only stance operationally false. Surveyor M13 (Schedule, ~6043 misaligned rows / ~3198 overlap-conflict cohort) + Surveyor M14 (AmbulatorySchedule, 5 rows / 0 overlap) sized the cohorts; EMMIE-0258 (PR #231 merged) + EMMIE-0259 (PR forthcoming) shipped the backfill migrations. New §Consequences subsection "Forward-only chokepoint over editable historical data" added — cross-territory generalized rule for any future chokepoint design decision. Triggered by independent spy-validation: M13 surfaced the pattern; M14 confirmed by independent observation.

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 / Schedule.end_date and (per 2026-05-08 amendment) AmbulatorySchedule.start_date / AmbulatorySchedule.end_date routes through a single canonical Action per model. Direct *->update(['start_date' => ...]), *->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).

The two models share doctrine (week-shaped, inclusive end_date, one chokepoint Action, caller-specified end-of-life via DTO, soft-delete-on-underflow) but use separate chokepoint Actions and DTOs because the receiver type is part of the dependency contract — a single Normalize…Action accepting either model would require a sum type that PHP doesn't express cleanly. The two arch tests share regex shape and allow-list philosophy but live in separate Pest test files for the same reason: receiver disambiguation is by file scope.

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.

AmbulatorySchedule Extension (Amendment 2026-05-08)

The Schedule chokepoint port (PR #195, merged 2026-05-06) shipped with AmbulatorySchedule explicitly excluded — the §G.1 arch test allow-list carries ClientController.php (line 258 — $ambulatorySchedule->update(['end_date' => ...])) and UpdateAmbulatoryScheduleAction.php (direct start_date / end_date property writes inside the Action) as documented exemptions. Surveyor M12 (2026-05-07) re-mapped the AmbulatorySchedule mutation surface and produced an authoritative inventory; the recon-driven cost differential (~⅓ of Schedule's port) made full sibling parity tractable in a single PR.

Commander disposition (2026-05-07): AmbulatorySchedule is week-shaped same as Schedule. Mutation-side chokepoint port mirrors Schedule's pattern with three adjustments — (a) greenfield Action design (no EndAmbulatoryScheduleAction to extend, so §D.2 underflow disposition bakes in upfront), (b) full cascade fan-out parity (QUIT + Client-delete + User-deactivation), and (c) F-L2 production rows are convention-only week-aligned (no forensic / repair migration; ISMS register entry).

Mutation surface (Surveyor M12 inventory)

Two write sites total — substantially narrower than Schedule's eight:

#PathDoctrine status
1ClientController::update:257-258$client->ambulatorySchedules()->whereNull('end_date')->each(static fn($s) => $s->update(['end_date' => …])) (QUIT branch)ADR-0011 + ADR-0019 + this ADR violation (allow-listed §G.1)
2UpdateAmbulatoryScheduleAction:22-23 — direct property writes ($schedule->start_date = …; $schedule->end_date = …) inside the existing canonical write ActionEXEMPT (allow-listed §G.1 + §G.2) — replaced by the new chokepoint

Read surface is already inclusive (AmbulatoryScheduleReport:60, AmbulatoryScheduleBuilder, ValidateAmbulatoryScheduleOverlap, MarkRegistrationsAsLocationClosedAction, CalcExpectedPresenceAction) — flipped in PR #206 (commits 74e58b1c28 + 3e583963f2). No additional read-side work required.

Cascade fan-out — full sibling parity (Commander disposition)

Schedule has three end-of-life cascade vectors. AmbulatorySchedule has one today (QUIT). Full parity wires the missing two:

Cascade vectorSchedule path (existing)AmbulatorySchedule path (after port)
Client status → QUIT (with remove_from_planning)EndOpenClientSchedulesActionNEW EndOpenClientAmbulatorySchedulesAction (replaces ClientController:258 inline each->update)
Client delete() cascadeMassEndClientSchedulesAction (called from ClientController::delete:453)NEW MassEndClientAmbulatorySchedulesAction (called from same controller)
User deactivationEndOpenUserSchedulesAction (called from UpdateUserAction:186 + UpdateProfileAction:53)NEW EndOpenUserAmbulatorySchedulesAction (called from same Actions)

The User-deactivation cascade closes Surveyor M12's F-L4 sibling-parity gap. Today UpdateUserAction and UpdateProfileAction cascade Schedule but not AmbulatorySchedule — a deactivated mentor stays attached to active AmbulatorySchedule rows, contradicting the contract that is_active=false ends a user's planning. The ADR codifies this as a bug, not by-design.

User model gains a new ambulatorySchedules() HasMany relation parallel to Client::ambulatorySchedules() (modeling gap closure — the user_id FK exists on ambulatory_schedules but User did not expose the relation).

Action shape (greenfield)

Five new Actions plus retirement of UpdateAmbulatoryScheduleAction direct writes:

  1. NormalizeAndUpdateAmbulatoryScheduleDatesAction — chokepoint twin of NormalizeAndUpdateScheduleDatesAction. Validates Monday start_date and Sunday-or-null end_date, transaction-wrapped. Constructor: ConnectionInterface, ValidationFactory (matches the Schedule chokepoint signature byte-for-byte except the Schedule parameter type).
  2. EndAmbulatoryScheduleAction — twin of EndScheduleAction with §D.2 underflow disposition baked in upfront (snap effective end day down to previous Sunday; if snapped Sunday strictly before start_date, soft-delete the schedule via the existing SoftDeletes trait; otherwise delegate to the chokepoint).
  3. EndOpenClientAmbulatorySchedulesAction — twin of EndOpenClientSchedulesAction (QUIT cascade). Iterates $client->ambulatorySchedules()->whereNull('end_date')->get(); calls EndAmbulatoryScheduleAction per row in a transaction.
  4. MassEndClientAmbulatorySchedulesAction — twin of MassEndClientSchedulesAction (Client-delete cascade). Iterates $client->ambulatorySchedules; skips schedules already ended in the past or today; calls EndAmbulatoryScheduleAction per row.
  5. EndOpenUserAmbulatorySchedulesAction — twin of EndOpenUserSchedulesAction (User-deactivation cascade). Queries via the new User::ambulatorySchedules() relation, applies the inclusive >= boundary filter, calls EndAmbulatoryScheduleAction per row.

UpdateAmbulatoryScheduleAction is rewritten to delegate date columns to NormalizeAndUpdateAmbulatoryScheduleDatesAction (mirroring UpdateScheduleAction's post-port shape — it retains its existing role for non-date fields and the ValidateAmbulatoryScheduleOverlap invocation).

DTOs:

  • EndAmbulatoryScheduleInputDtofinal readonly, CarbonImmutable $effective_end_date, named constructors yesterday() / today() / on(). Twin of EndScheduleInputDto.
  • NormalizeAmbulatoryScheduleDatesInputDtofinal readonly, string $start_date, ?string $end_date. Twin of NormalizeScheduleDatesInputDto.

The ValidateScheduleOverlap stale-input regression (chokepoint merge §Pre-merge regression #2) is a documented hazard — ValidateAmbulatoryScheduleOverlap currently reads $schedule->start_date / end_date directly, same shape as the Schedule validator pre-fix. The chokepoint port must change __invoke's signature to take proposed dates explicitly, or pre-assign before validation. Engineer order requires this audit upfront.

FormRequest

CreateOrUpdateAmbulatorySchedule already validates date_format:Y-m-d and after_or_equal:start_date. The chokepoint Action owns Monday/Sunday enforcement (consistent with how NormalizeAndUpdateScheduleDatesAction owns it for Schedule, not ScheduleRequest). No DateIsWeekday rule attach required — keeping the FormRequest at format-level matches the Schedule precedent and avoids splitting the contract across two layers.

Factory

AmbulatoryScheduleFactory::generateDates snaps start_date to Monday (currently produces any business weekday). end_date snaps to Sunday when non-null (currently mirrors start_date weekday). Mirrors §F.1 of Schedule's deployment.

Note hasScheduleConflicts uses inclusive <=/>= already (verified by reading) — no read-side adjustment needed at the factory layer. The factory's existing do { … } while ($conflicts) retry loop continues to drive uniqueness; week-alignment narrows the search space without breaking the loop's termination guarantees.

Production data shape (F-L2 — convention-only)

The 2025-08-13 add_start_date_column migration back-filled existing rows from DATE(created_at). Production AmbulatorySchedule rows are therefore convention-only week-aligned — the same situation as Schedule's WP5 forensic finding (EMMIE-0250). Disposition (Commander 2026-05-07): no forensic / repair migration. Same disposition shape as Schedule's Q2-only cohort (2,894 rows documented-not-repaired). ISMS register entry covers AmbulatorySchedule historical state separately.

Operational consequence: the chokepoint Action enforces week-alignment going forward only. Pre-existing rows with non-Monday start_date or non-Sunday end_date continue to read fine (read surface is shape-agnostic) but will be rejected by the chokepoint if they ever pass through UpdateAmbulatoryScheduleAction — which means a UI update of any field on an existing non-aligned row would fail with ValidationException. This is the same trade-off accepted for Schedule; the rate of encounter is bounded by how often historical rows are edited.

Read-side propagation

Already done. PR #206 (74e58b1c28 + 3e583963f2) flipped AmbulatoryScheduleReport:60 to inclusive >= and SARGable plain where. PR #206 also added the @see ADR-0022 per-line marker scheme (the §G.3 enforcement). Commit 3e583963f2 covered the SARGable rewrite (P2 ally amendment).

Enforcement (new arch test)

A new Pest arch test tests/Architecture/AmbulatoryScheduleMutationChokepointTest.php mirrors the Schedule §G structure. Three guards:

  • §AS.1 — No *->update([...]) containing 'start_date' / 'end_date' keys outside NormalizeAndUpdateAmbulatoryScheduleDatesAction and EndAmbulatoryScheduleAction. The Schedule §G.1 entries ClientController.php and UpdateAmbulatoryScheduleAction.php simultaneously drop from §G.1 (Schedule arch test) — those callers no longer mass-update AmbulatorySchedule, the new chokepoint owns it.
  • §AS.2 — No direct $ambulatorySchedule->start_date = … / $ambulatorySchedule->end_date = … assignment outside the chokepoint Action. Heuristic: variable name $ambulatorySchedule (parallel to Schedule's $schedule heuristic). The UpdateAmbulatoryScheduleAction.php entry on §G.2 simultaneously drops — the rewrite delegates to the chokepoint, no direct property writes.
  • §AS.3 — No new strict < / > comparators on AmbulatorySchedule.start_date / AmbulatorySchedule.end_date without an @see ADR-0022 marker on the immediately-preceding non-blank line. Same per-line marker scheme as §G.3. The arch test surface is wider than §G.3 (it reads all of app/) but the receiver-disambiguation challenge is identical — ScheduleReport.php and AmbulatoryScheduleReport.php both legitimately need decrement-guard markers.

The two arch tests share regex shape and allow-list philosophy. Splitting them into two files is deliberate: receiver disambiguation is by file scope, and a single arch test reading both column-name spaces would double-fire on receivers that share property names (Financing, ClientNotation already false-positive on Schedule — adding AmbulatorySchedule false-positives to the same test would obscure the diagnostic output).

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; legacy script-customer tenant repaired 2026-05-07). 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. PR #209 (EMMIE-0250, merged 2026-05-07) repaired HIGH 43 + Q1-only 126 rows on legacy script-customer tenant via Medic-scripted conservative-rule migration with wp5_schedule_repair_log audit table. Q2-only 2,894 rows documented-not-repaired (no registration witness). Other tenants out of scope per Commander field knowledge.
  • F-L2 — Schedule + AmbulatorySchedule historical residue (DISPOSITION FLIPPED 2026-05-13). Was "convention-only per Commander disposition 2026-05-07." Trigger event Schedule row 7049 (chokepoint UI block on UI re-submit, surfaced when PR #195 + PR #218 deployed to customer 4's beta on 2026-05-13) proved the convention-only stance operationally false: every historical misaligned row is a latent UI block under the chokepoint's forward-only enforcement. Surveyor M13 + M14 sized the cohorts; EMMIE-0258 (Schedule, PR #231 merged 2026-05-13) + EMMIE-0259 (AmbulatorySchedule, PR forthcoming) shipped backfill migrations cloned from WP5 (PR #209). Customer 4 cohort: ~6043 Schedule misaligned rows (Phase 1 ~2347 repaired, Phase 2 ~3198 deferred to per-row remediation) + 5 AmbulatorySchedule rows (tactically cleared 2026-05-13, migration runs as no-op for this tenant but ships as forward-deployed infrastructure for other tenants). The 2025-08-13 add_start_date_column_to_ambulatory_schedules migration's DATE(created_at) backfill was an additional structural compounder — operationally moot at customer 4 (only 2 pre-2025-08-13 AS rows) but expected to dominate cohort sizing on older tenants. See campaigns/emmie/2026-05-13-schedule-monday-sunday-alignment-repair.md and campaigns/emmie/2026-05-13-ambulatoryschedule-monday-sunday-alignment-repair.md. The cross-territory generalized lesson is captured in the new §Consequences subsection "Forward-only chokepoint over editable historical data" below.
  • 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.

Forward-only chokepoint over editable historical data (Cross-Territory Amendment 2026-05-13)

Pattern observed twice in seven days. Schedule's chokepoint (PR #195) shipped 2026-05-06 forward-only; AmbulatorySchedule's chokepoint (PR #218) shipped 2026-05-08 forward-only. Both followed the same disposition: "convention-only" historical residue, no backfill, accept that pre-existing misaligned rows would continue to read fine but couldn't be re-edited through the chokepoint. The disposition assumed the residue would stay latent.

The disposition was operationally false. When customer 4's beta deployed both chokepoints on 2026-05-13, Schedule row 7049 surfaced within hours as a ValidationException UI block. Root cause: UpdateScheduleAction::execute() takes start_date from the request payload and passes it straight to the chokepoint regardless of whether the user touched it — a frontend re-submit of the existing non-Monday date is rejected. Every historical misaligned row became a latent UI block the moment a user attempted ANY edit. The "convention-only" stance ignored this re-submit dynamic.

Generalized rule (cross-territory, applies to any future chokepoint design):

Whenever a write-side chokepoint is introduced over data that is currently editable through user-facing flows (UI re-submit, admin tools, automated jobs), the chokepoint's deployment must be paired with EITHER:

(a) A backfill migration that aligns pre-existing data with the chokepoint contract, ready to deploy in the same window as the chokepoint, OR

(b) An explicit F-L#: backfill deferred — convention-only entry in the ADR's §Consequences that names the specific trigger condition under which the disposition would be re-opened (e.g., "first user report of ValidationException on UI re-submit of unchanged value").

The "deploy chokepoint, defer backfill, hope it stays latent" pattern proved operationally false on Schedule (EMMIE-0258) and AmbulatorySchedule (EMMIE-0259) on 2026-05-13. The trigger condition for re-opening "convention-only" is now known: any user-facing edit flow that re-submits unchanged values to the chokepoint will hit ValidationException on every historical misaligned row.

Why this is ADR-grade and not enforceable below. PHPStan rules and arch tests can guard "is the chokepoint Action being called from the right places?" — they cannot guard "did the chokepoint design reckon with historical residue?" That decision is made during ADR drafting and lives in the design process. The discipline is Commander-facing (during ADR review) and General-facing (during chokepoint deployment-order drafting).

Cross-territory applicability. Same lesson applies to any chokepoint deployed against editable historical data in any territory (kendo, ublgenie, entreezuil, daymate, brick-inventory). When drafting a new chokepoint ADR, §Consequences must include an explicit backfill disposition (option (a) or (b) above). When a Commander reviews a chokepoint ADR, this paragraph is the question to ask: "what happens to existing rows that don't satisfy the new contract?"

Spy-validated independence. Surveyor M13 (Schedule recon, 2026-05-13 morning) flagged the pattern as a "second occurrence" hypothesis based on the M13 author's General observation. Surveyor M14 (AmbulatorySchedule recon, 2026-05-13 afternoon) independently surfaced the same pattern as Distress Signal #1 in §Adjacent Concerns without prompting from M13's analysis. Two missions, two independent spy observations → load-bearing pattern, not opportunistic. The amendment is anchored in evidence, not inference.

Risks

  • AmbulatorySchedule cross-sibling port (resolved 2026-05-08). ClientController::update:248-251 runs the same controller-mutates-end_date pattern on ambulatorySchedules. Resolved by the 2026-05-08 amendment — AmbulatorySchedule is now in scope under §AmbulatorySchedule Extension. Surveyor M12 mutation-surface recon (2026-05-07) sized the port at ~⅓ of Schedule's cost (1 external mutation site, 1 Action-internal site, 0 cascade Actions today). Single-PR feasible; full cascade fan-out parity (QUIT + Client-delete + User-deactivation) per Commander disposition.
  • ValidateAmbulatoryScheduleOverlap stale-input regression (analogous to chokepoint-merge §Pre-merge regression #2). ValidateAmbulatoryScheduleOverlap reads $schedule->start_date / end_date directly. If the chokepoint port reorders UpdateAmbulatoryScheduleAction so the validator runs before the chokepoint reassigns dates, overlap validation queries with stale (or null, on new instances) values. Mitigation: Engineer order audits this upfront and changes __invoke's signature to take proposed dates explicitly — same fix shape used on the Schedule validator post-PR-#195.
  • 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 test (ScheduleMutationChokepointTest.php §G.1)app/**
Direct property mutation of Schedule.start_date / Schedule.end_date outside the chokepoint Action and EndScheduleActionPest arch test (ScheduleMutationChokepointTest.php §G.2)app/**
Strict < / > boundary on Schedule.start_date / Schedule.end_date (companion to ADR-0022) — per-line @see ADR-0022 marker schemePest arch test (ScheduleMutationChokepointTest.php §G.3)app/**
Mass update([...]) on AmbulatorySchedule touching date columns (2026-05-08 amendment)Pest arch test (AmbulatoryScheduleMutationChokepointTest.php §AS.1)app/**
Direct property mutation of AmbulatorySchedule.start_date / AmbulatorySchedule.end_date outside the AmbulatorySchedule chokepoint Action and EndAmbulatoryScheduleAction (2026-05-08 amendment)Pest arch test (AmbulatoryScheduleMutationChokepointTest.php §AS.2)app/**
Strict < / > boundary on AmbulatorySchedule.start_date / AmbulatorySchedule.end_date — per-line @see ADR-0022 marker scheme (2026-05-08 amendment)Pest arch test (AmbulatoryScheduleMutationChokepointTest.php §AS.3)app/**
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. AmbulatorySchedule being a second model within emmie does NOT trigger Phase 2 — gating condition is "second territory," not "second model."

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?

Initial answer (2026-04-30): No. AmbulatorySchedule was explicitly out of scope per Commander framing.

Reversed (2026-05-07): Yes. Commander disposition lifted the M9-era exclusion after Surveyor M10's read-side re-mission (AmbulatoryScheduleReport:60 flipped in PR #206) demonstrated AmbulatorySchedule was treated as week-shaped on the read side already. Surveyor M12 (2026-05-07) re-mapped the AmbulatorySchedule mutation surface and confirmed the port is tractable in a single PR (~⅓ of Schedule's port cost). The 2026-05-08 amendment codifies this — see §AmbulatorySchedule Extension above.

What's different about AmbulatorySchedule's port:

  • Greenfield Action design — no EndAmbulatoryScheduleAction exists today, so §D.2 underflow disposition bakes in upfront rather than surfacing post-merge as it did for Schedule (EMMIE-0248).
  • Full cascade fan-out parity — Commander disposition: wire QUIT + Client-delete + User-deactivation. The User-deactivation cascade closes Surveyor M12's F-L4 sibling-parity gap and adds a new User::ambulatorySchedules() HasMany relation.
  • F-L2 historical-row repair: convention-only. No forensic / repair migration. Same disposition shape as Schedule's Q2-only cohort (2,894 rows documented-not-repaired). ISMS register entry covers AmbulatorySchedule historical state separately.
  • Read side already done. PR #206 closed the read-surface; the chokepoint port is mutation-only.
  • Separate arch test file. tests/Architecture/AmbulatoryScheduleMutationChokepointTest.php — receiver disambiguation by file scope (a single arch test reading both column-name spaces would obscure diagnostics).

Why two chokepoint Actions instead of one parameterized one? Receiver type is part of the dependency contract. A single Normalize…Action accepting either Schedule | AmbulatorySchedule would require a sum type that PHP doesn't express cleanly; the two Actions share doctrine but differ in signature.

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
emmie — ScheduleCompletePR #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.
emmie — AmbulatoryScheduleIn ProgressCross-sibling chokepoint port per §AmbulatorySchedule Extension (Amendment 2026-05-08). PR #218 open and ready-for-review under EMMIE-0252 (10 commits on refactor/EMMIE-0252-ambulatoryschedule-mutation-chokepoint, head cc7cbc9f43); all CI gates green (Architecture 69/69, Unit 1598+, Integration 4m14s, PHPStan 0 NEW, Pint, Rector, Migration Check). Surveyor M12 inventory (2026-05-07) is the recon basis. Read side already complete via PR #206 (commits 74e58b1c28 + 3e583963f2). Pre-existing F-M1 type-safety fix (DeleteAmbulatoryScheduleAction:19) shipped in PR #213 (merged 2026-05-07) — not part of the chokepoint port itself. Both PR #195 pre-merge regression classes (validator stale-input + EMMIE-0248 underflow) pre-empted from commit 1; one pre-merge CI fix on AmbulatoryScheduleReportTest PresenceData seed (commit cc7cbc9f43) exposed a pre-existing PR #206 false-positive (codified as new Engineer SOP rule).
Other territoriesNot ApplicableSchedule and AmbulatorySchedule are emmie-only entities. PHPStan rule transferability deferred until a second territory exhibits the same pattern. AmbulatorySchedule being a second model within emmie does NOT trigger Phase 2 — gating condition is "second territory," not "second model."

Architecture documentation for contributors and collaborators.