ADR-0034: Ambulatory Financing Care-Type / Rate-Interval Coupling
Accepted Emmie Territory-SpecificDate: 2026-06-19 (amended 2026-06-24) Status: Accepted — ratified by the Commander 2026-06-19. Chokepoint + arch-test dispatched immediately; the data-repair migration and optional DB CHECK are held for the prod breach-check / MySQL-version recon (Open Questions 1–2); frontend affordance is a Scout follow-on. Amended 2026-06-24 — the invariant-A guard is split into two single-execute, model-taking Actions (see §Amendment below). Board: WR-0032. Campaign: campaigns/emmie/2026-06-19-financing-care-type-interval-coupling.md; amendment campaigns/emmie/2026-06-24-adr-0034-guard-split-n1-amendment.md. Sibling: ADR-0023 (Schedule Mutation Chokepoint) — same structural pattern, same territory.
Context
emmie's revenue family scopes per-interval revenue on financings.rate_interval. The HOUR/MINUTE siblings UNION in ambulatory_registrations; the MONTH/DAY/DAYPART/PART/ROUTE siblings JOIN daily_registrations only. This is correct only while an invariant holds: an ambulatory registration is never financed at a non-HOUR/MINUTE interval. Breach it and the ambulatory row's revenue silently drops or mis-resolves (financial-integrity class of EMMIE-0317).
The recon (reports/emmie/field/2026-06-19-surveyor-financing-care-type-interval-coupling.md) established that two orthogonal invariants are in play, and that enforcing only one leaves the bug open:
- Invariant A (registration-side, interval-keyed): an
AmbulatoryRegistrationmust reference a financing withrate_interval ∈ {HOUR(1), MINUTE(5)}. This is what protects revenue correctness. Today enforced only byValidAmbulatoryFinancingon two registration FormRequests; care-type-blind. - Invariant B (financing-side, care_type-keyed): a financing with
funding_care_type = AMBULATORY(2)must haverate_interval ∈ {HOUR, MINUTE}. Data hygiene. Enforced nowhere.
The revenue family never reads funding_care_type (verified absent). An ambulatory registration may legally carry a HOUR/MINUTE financing of any care_type, so B alone does not close the defect — the corrupt-reassign case never trips B's AMBULATORY antecedent. The domain author already half-implements the three-way coupling (ambulatory registration ↔ AMBULATORY financing ↔ HOUR/MINUTE) in the seeder and the location-closed auto-create path, but enforces only fragments on disjoint write paths.
Mutation surface — where the invariants leak (all unguarded against the invariant unless noted):
| # | Path | Writes | Guard today |
|---|---|---|---|
| 1 | CreateFinancingAction:61,68 ← CreateFinancing | interval + care_type | none (bare Rule::enum) |
| 2 | UpdateFinancingAction:90,97 ← UpdateFinancing | interval + care_type | none |
| 3 | ReassignAndDeleteFinancingAction:39 | re-points daily+ambulatory financing_id | none |
| 4 | MarkRegistrationsAsLocationClosedAction:64 | auto-attaches care-type-selected financing to ambulatory rows | none (interval-unfiltered) |
| 5 | UpdateAmbulatoryRegistrationAction:36 ← FR :82 | ambulatory financing_id | A only (interval), care-type-blind |
| 6 | SetAmbulatoryRegistrationsFinancingIdsAction:22 ← FR :34 | ambulatory financing_id | A only |
Reachability is through normal UI (financing form + reassign picker offer all intervals unfiltered), not admin-only. ApplyProductChangesAction:42 mutates Product.rate_interval, not the financing's — out of scope.
Decision
Adopt the three-way coupling. emmie shall enforce BOTH invariant A (ambulatory registration ⟹ HOUR/MINUTE financing) and invariant B (AMBULATORY financing ⟹ HOUR/MINUTE interval). Documented explicitly: B does not substitute for A — A is the revenue-defect fix.
Enforce at a financing-mutation chokepoint Action, not per-site. All writes to
financings.rate_interval/financings.funding_care_type, and all registrationfinancing_idre-points that can attach a financing to an ambulatory row (sites 1–6), route through a single validation point that throws a UI-legible typed exception on violation. This is the ADR-0023 analogue.Pin with a Level-1 arch test forbidding direct
financingsinterval/care_type writes outside the chokepoint, plus an invariant test asserting (a) noAmbulatoryRegistrationreferences a non-HOUR/MINUTE financing and (b) no AMBULATORY financing carries a non-HOUR/MINUTE interval. MirrorsScheduleMutationChokepointTest§G.DB CHECK constraint (
funding_care_type <> 2 OR rate_interval IN (1,5)) is optional belt-and-braces for invariant B only, gated on confirming MySQL ≥ 8.0.16 across tenants. It is never the sole mechanism (no UI error; doesn't touch invariant A).Exception: reuse/widen
InvalidFinancingForAmbulatoryExceptionfor the registration-side reject; for the financing-form reject either widen its constructor to accept aFinancingor add a sibling. Disambiguate from the existingInvalidFinancingException/InvalidFinancingTypeException.
Consequences
- The revenue family's interval-keyed table-scoping becomes enforced rather than assumed — the EMMIE-0317-class silent undercount is closed at its root.
- A new write path that touches financing interval/care_type or attaches a financing to an ambulatory row must go through the chokepoint or it fails the arch test at CI — the leak mode that produced WR-0032 (and grew a 4th site between June and now) is structurally prevented.
- Forward-only chokepoint over editable historical data (per ADR-0023's 2026-05-13 generalized rule): the chokepoint guards new mutations; pre-existing breaching rows are not retroactively fixed by it. The ADR must ship paired with a backfill/data-repair migration OR an explicit
backfill deferredentry here, decided by the prod breach-check (Open Question 1). - Multi-tenant (ADR-0008): any DB CHECK or repair migration ships via
customers:migrateacross all tenant DBs; a CHECK fails on tenants with breaching rows until repaired. - Frontend follow-on: the financing form + reassign picker need a matching interval filter (Scout handoff) so the UI doesn't offer choices the backend now rejects.
Amendment — 2026-06-24 (Guard split for Action-pattern compliance)
The original implementation (#461) shipped the invariant-A guard as one Action carrying two public methods — execute(?int $financingId, ?int $clientId) (throws) and sanitize(?int $financingId) (returns id-or-null). That deviates from Architectural Principle #2 (Actions are final readonly with a single execute()). A review-surfaced N+1 made the deviation load-bearing: three callers feed a constant financing id into a per-row loop, so the guard re-runs findSole($financingId) once per row; the resolve-once fix requires the caller to pass a pre-resolved Financing model, which is impossible without adding a model-taking method — compounding the Principle-#2 violation rather than fixing it.
Amendment: split the single dual-method guard into two single-execute, model-taking Actions in App\Actions\Model\Financing\:
AssertAmbulatoryRegistrationFinancingAction::execute(?Financing $financing, ?int $clientId): void— throwsInvalidFinancingForAmbulatoryExceptionon a breaching financing; null financing is valid. Used by the user-initiated attach sites where a 422 is actionable (SaveAmbulatoryRegistrationAction,SetAmbulatoryRegistrationsFinancingIdsAction,ReassignAndDeleteFinancingAction).SanitizeAmbulatoryRegistrationFinancingAction::execute(?Financing $financing): ?int— returns the id when valid, null when breaching (never throws). Used by the batch auto-attach path (MarkRegistrationsAsLocationClosedAction) where a transaction-aborting 422 is not user-actionable.
Both take an already-resolved ?Financing. Callers resolve the financing once (idiomatic findSole at the call site — no third "resolve" Action) and pass the model, so the per-row N+1 is eliminated as a side effect of restoring the Action pattern. The shared predicate ValidateFinancingCareTypeIntervalAction::intervalIsValidForAmbulatory(int) is unchanged.
Caller resolve-once discipline (behaviour-preserving):
ReassignAndDeleteFinancingActionresolves the replacement financing only when ≥1 ambulatory row is present ($ambulatoryRegistrations->isNotEmpty()), preserving the daily-only path's zero-lookup behaviour pinned by the'should reassign a daily-only fixture regardless of the replacement interval'integration test.SetAmbulatoryRegistrationsFinancingIdsActionearly-returns on empty input, else resolves once before the loop.MarkRegistrationsAsLocationClosedActionalready holds the resolved model — passes it directly.
Enforcement update: FinancingCareTypeIntervalChokepointTest §FC.2 (every financing_id write under app/Actions/Model/AmbulatoryRegistration/ + the allow-listed ReassignAndDeleteFinancingAction routes through the invariant-A guard) now accepts routing through either Action. §FC.1 is unchanged. The Integration invariant test is unchanged — behaviour is preserved exactly.
This supersedes the "two entry points on one Action" framing in §Decision-2/5 and in the backend/CLAUDE.md projection. Rationale + deliberation: campaigns/emmie/2026-06-24-adr-0034-guard-split-n1-amendment.md.
Enforcement (escalation ladder)
| Level | Mechanism |
|---|---|
| 1 | Arch test — invariant + chokepoint-bypass ban (Armorer) |
| 3 | Optional DB CHECK for invariant B (version-gated) |
| 4 | This ADR projected into emmie backend/CLAUDE.md |
Open Questions (NEEDS-RECON before soldier dispatch)
LATENT vs LIVE— RESOLVED 2026-06-19 (Commander ran the corrected query — zero rows across tenants). The invariant is LATENT, not LIVE: no data-repair / backfill migration required. The chokepoint ships as a pure forward-only guard.- MySQL version per tenant (
SELECT VERSION()) — decides DB CHECK feasibility. - Chokepoint shape — one normalize-and-validate Action invoked by both writers, vs a thin validator the re-point/auto-create sites also call. Engineer to propose at implementation.
Implementation plan (post-ratification)
- Engineer: chokepoint Action + exception, reroute sites 1–6, ADR projection into
backend/CLAUDE.md. - Armorer: Level-1 arch test (invariant + bypass-ban); optional DB CHECK migration if Open Q2 clears.
- Scout: frontend affordance recon/fix (financing form + reassign picker interval filter).
- Data repair: gated on Open Q1; backfill migration or documented deferral.
References
- Campaign:
campaigns/emmie/2026-06-19-financing-care-type-interval-coupling.md - Field reports:
reports/emmie/field/2026-06-01-surveyor-ambulatory-month-revenue-table-mismatch.md,reports/emmie/field/2026-06-19-surveyor-financing-care-type-interval-coupling.md - Sibling ADR: ADR-0023 (Schedule Mutation Chokepoint). Related: ADR-0008 (Multi-Tenancy), ADR-0011 (Action Class Architecture), ADR-0019 (Explicit Model Hydration).