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.
- 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) 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 / 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:
- 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.
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:
| # | Path | Doctrine status |
|---|---|---|
| 1 | ClientController::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) |
| 2 | UpdateAmbulatoryScheduleAction:22-23 — direct property writes ($schedule->start_date = …; $schedule->end_date = …) inside the existing canonical write Action | EXEMPT (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 vector | Schedule path (existing) | AmbulatorySchedule path (after port) |
|---|---|---|
Client status → QUIT (with remove_from_planning) | EndOpenClientSchedulesAction | NEW EndOpenClientAmbulatorySchedulesAction (replaces ClientController:258 inline each->update) |
Client delete() cascade | MassEndClientSchedulesAction (called from ClientController::delete:453) | NEW MassEndClientAmbulatorySchedulesAction (called from same controller) |
| User deactivation | EndOpenUserSchedulesAction (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:
NormalizeAndUpdateAmbulatoryScheduleDatesAction— chokepoint twin ofNormalizeAndUpdateScheduleDatesAction. Validates Mondaystart_dateand Sunday-or-nullend_date, transaction-wrapped. Constructor:ConnectionInterface,ValidationFactory(matches the Schedule chokepoint signature byte-for-byte except theScheduleparameter type).EndAmbulatoryScheduleAction— twin ofEndScheduleActionwith §D.2 underflow disposition baked in upfront (snap effective end day down to previous Sunday; if snapped Sunday strictly beforestart_date, soft-delete the schedule via the existingSoftDeletestrait; otherwise delegate to the chokepoint).EndOpenClientAmbulatorySchedulesAction— twin ofEndOpenClientSchedulesAction(QUIT cascade). Iterates$client->ambulatorySchedules()->whereNull('end_date')->get(); callsEndAmbulatoryScheduleActionper row in a transaction.MassEndClientAmbulatorySchedulesAction— twin ofMassEndClientSchedulesAction(Client-delete cascade). Iterates$client->ambulatorySchedules; skips schedules already ended in the past or today; callsEndAmbulatoryScheduleActionper row.EndOpenUserAmbulatorySchedulesAction— twin ofEndOpenUserSchedulesAction(User-deactivation cascade). Queries via the newUser::ambulatorySchedules()relation, applies the inclusive>=boundary filter, callsEndAmbulatoryScheduleActionper 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:
EndAmbulatoryScheduleInputDto—final readonly,CarbonImmutable $effective_end_date, named constructorsyesterday()/today()/on(). Twin ofEndScheduleInputDto.NormalizeAmbulatoryScheduleDatesInputDto—final readonly,string $start_date,?string $end_date. Twin ofNormalizeScheduleDatesInputDto.
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 outsideNormalizeAndUpdateAmbulatoryScheduleDatesActionandEndAmbulatoryScheduleAction. The Schedule §G.1 entriesClientController.phpandUpdateAmbulatoryScheduleAction.phpsimultaneously 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$scheduleheuristic). TheUpdateAmbulatoryScheduleAction.phpentry on §G.2 simultaneously drops — the rewrite delegates to the chokepoint, no direct property writes. - §AS.3 — No new strict
</>comparators onAmbulatorySchedule.start_date/AmbulatorySchedule.end_datewithout an@see ADR-0022marker 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 ofapp/) but the receiver-disambiguation challenge is identical —ScheduleReport.phpandAmbulatoryScheduleReport.phpboth 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
| 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; legacy script-customer tenant repaired 2026-05-07). 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. 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 withwp5_schedule_repair_logaudit 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_schedulesmigration'sDATE(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. Seecampaigns/emmie/2026-05-13-schedule-monday-sunday-alignment-repair.mdandcampaigns/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: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.
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-onlyentry in the ADR's §Consequences that names the specific trigger condition under which the disposition would be re-opened (e.g., "first user report ofValidationExceptionon 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
ValidationExceptionon 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).
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.ClientController::update:248-251runs the same controller-mutates-end_date pattern onambulatorySchedules. ValidateAmbulatoryScheduleOverlapstale-input regression (analogous to chokepoint-merge §Pre-merge regression #2).ValidateAmbulatoryScheduleOverlapreads$schedule->start_date/end_datedirectly. If the chokepoint port reordersUpdateAmbulatoryScheduleActionso 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
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 (ScheduleMutationChokepointTest.php §G.1) | app/** |
Direct property mutation of Schedule.start_date / Schedule.end_date outside the chokepoint Action and EndScheduleAction | Pest 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 scheme | Pest 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 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. 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
EndAmbulatoryScheduleActionexists 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
| Territory | State | Notes |
|---|---|---|
| emmie — Schedule | 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. |
| emmie — AmbulatorySchedule | In Progress | Cross-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 territories | Not Applicable | Schedule 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." |