Skip to content

ADR-0034: Ambulatory Financing Care-Type / Rate-Interval Coupling

Accepted Emmie Territory-Specific

Date: 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 AmbulatoryRegistration must reference a financing with rate_interval ∈ {HOUR(1), MINUTE(5)}. This is what protects revenue correctness. Today enforced only by ValidAmbulatoryFinancing on two registration FormRequests; care-type-blind.
  • Invariant B (financing-side, care_type-keyed): a financing with funding_care_type = AMBULATORY(2) must have rate_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):

#PathWritesGuard today
1CreateFinancingAction:61,68CreateFinancinginterval + care_typenone (bare Rule::enum)
2UpdateFinancingAction:90,97UpdateFinancinginterval + care_typenone
3ReassignAndDeleteFinancingAction:39re-points daily+ambulatory financing_idnone
4MarkRegistrationsAsLocationClosedAction:64auto-attaches care-type-selected financing to ambulatory rowsnone (interval-unfiltered)
5UpdateAmbulatoryRegistrationAction:36 ← FR :82ambulatory financing_idA only (interval), care-type-blind
6SetAmbulatoryRegistrationsFinancingIdsAction:22 ← FR :34ambulatory financing_idA 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

  1. 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.

  2. Enforce at a financing-mutation chokepoint Action, not per-site. All writes to financings.rate_interval / financings.funding_care_type, and all registration financing_id re-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.

  3. Pin with a Level-1 arch test forbidding direct financings interval/care_type writes outside the chokepoint, plus an invariant test asserting (a) no AmbulatoryRegistration references a non-HOUR/MINUTE financing and (b) no AMBULATORY financing carries a non-HOUR/MINUTE interval. Mirrors ScheduleMutationChokepointTest §G.

  4. 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).

  5. Exception: reuse/widen InvalidFinancingForAmbulatoryException for the registration-side reject; for the financing-form reject either widen its constructor to accept a Financing or add a sibling. Disambiguate from the existing InvalidFinancingException / 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 deferred entry here, decided by the prod breach-check (Open Question 1).
  • Multi-tenant (ADR-0008): any DB CHECK or repair migration ships via customers:migrate across 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 — throws InvalidFinancingForAmbulatoryException on 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):

  • ReassignAndDeleteFinancingAction resolves 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.
  • SetAmbulatoryRegistrationsFinancingIdsAction early-returns on empty input, else resolves once before the loop.
  • MarkRegistrationsAsLocationClosedAction already 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)

LevelMechanism
1Arch test — invariant + chokepoint-bypass ban (Armorer)
3Optional DB CHECK for invariant B (version-gated)
4This ADR projected into emmie backend/CLAUDE.md

Open Questions (NEEDS-RECON before soldier dispatch)

  1. LATENT vs LIVERESOLVED 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.
  2. MySQL version per tenant (SELECT VERSION()) — decides DB CHECK feasibility.
  3. 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).

Architecture documentation for contributors and collaborators.