ADR-0031: Datetime Instant-vs-Wall-Clock Classification
Accepted Emmie UniversalDate: 2026-06-02 Compliance: ISO 27001 (A.5.33 temporal record integrity) · AVG · NEN 7510
Context
Datetime handling is a known weakness across the fleet. The pilot that produced this ADR began as an internal review of two daymate PRs standardizing API↔app datetime on RFC 3339 (DAY-0166), widened to a fleet sweep, and landed on emmie as the pilot territory — highest compliance burden (ISO 27001 + AVG + NEN 7510), highest volume, sharpest analogue. Full narrative: campaigns/emmie/2026-06-01-datetime-handling-pilot.md.
The root condition. emmie runs config('app.timezone') = 'UTC' (config/app.php:147) with a separate local_timezone = 'Europe/Amsterdam' (:148). Every datetime category error in the territory traces to confusion across that split.
The driving mental model (from DAY-0166). Every datetime field is exactly one of:
- Instant — a moment on the global timeline (record-created, risk-assessment-started). Store and serialize as UTC RFC 3339 (
...Z). Converting it to a display timezone is correct and expected. - Wall-clock — a time-of-day or calendar date in a local frame (a registration's 09:00 daypart, a schedule's Monday
start_date, a birth date). Stored naive, never UTC-converted. Applying a timezone shift to a wall-clock value is the category error.
Most datetime bugs are a value of one category being handled as the other.
What the emmie recon found (Surveyor deep-dive, reports/emmie/field/2026-06-01-surveyor-datetime-handling-layer-deep-dive.md):
- Wire serialization is uniform — Laravel's default
serializeDateemits offset/Z-form RFC 3339 for every cast datetime, instant and wall-clock alike (verified: no customserializeDateoverride exists). Zero space-separated naive leaks inapp/Http/Resources(verified 0). The wire is uniform, which means the wire string cannot tell a consumer which category a field is — both arrive as...Z. - The load-bearing defect (F-C1):
starts_at/ends_atare wall-clock values stored as UTC. Built as"$date $time"(Amsterdam daypart wall-clock09:00/13:00/17:00/19:00) →CarbonImmutable::parse()in app-UTC → stored09:00labeled UTC. Reads format at UTC and get09:00back — coincidentally correct by symmetric timezone-ignorance. It breaks the instant a real conversion is applied. - That break was live and costing money.
CalculateRevenueTotalsActionapplied->setTimezone('Europe/Amsterdam')to these wall-clock-as-UTC values, shifting month boundaries +2h; late-night last-of-month MONTH-financed registrations fell outside the SQLBETWEENand were silently counted as €0 (EMMIE-0317, fixed in PR #303 by removing the three spurious conversions — the wall-clock disposition applied as a point fix). - No classification exists anywhere. No column is declared instant or wall-clock; nothing regresses if a cast flips. Cast inventory is divergent (4×
datetime, 2×date, 1×immutable_datetime, 1×immutable_dateforstart_dateacross models;AmbulatorySchedulehas nocasts()at all). - Surface counts: 130 backend
::parse/createFromFormatinapp/(~22 genuinely risky raw-wire parses); 341 frontendnew Date()inapps/(137 no-arg "now", ~187 wire-string parses — the risk subset). One empirically-traced consequence: the same logical "10:00" reads back as10:00,12:00, or02:00depending on which serialization path produced the string. - Existing right-pattern precedent (F-I1):
NormalizeAndUpdateScheduleDatesAction::parseStrict(ADR-0023) — regex-gatesY-m-d,createStrict()calendar-validates, round-trips. Wall-clock-DATE-specific; generalizable to instants with an explicit-timezone step. - Existing format layer (F-I2):
apps/helpers/dates.ts(~25 exports) pinstimeZone: 'Europe/Amsterdam'on everyIntl.DateTimeFormat. The format side is timezone-aware and extendable; the parse side is the gap.
Consumer surface (2026-06-02 recon). The Vue SPA (frontend/apps/{admin,auth,client,user}) is effectively the sole consumer of wall-clock output fields, via hand-written vue-services adapters (ADR-0013). No OpenAPI document, no generated API client, no mobile app, no public API spec pins the wire shape. The WordPress plugin is inbound (PluginSettingsController serves a download; the WP intake form writes interested-clients via CreateInterestedClientAction) — not a reader of registration/schedule datetimes. scrapers/ is outbound Python with no emmie DB access. A wire-format change therefore lands against one consumer under our control, coordinated with the frontend migration — the necessary precondition for the §Decision wire-format change.
Tooling context (the trigger for this ADR's timing). TypeScript 6.0 (2026-03-23) ships Temporal type definitions in lib.esnext.temporal; Temporal reached TC39 Stage 4 (2026-03-11, ES2026). But TS ships types, not runtime: Chrome 144+/Edge/Firefox 139+ have native Temporal, Safari has none — not even Technology Preview, and iOS is Safari-engine-only. Node 26 ships it unflagged. For a public-facing healthcare frontend with clinicians on iPads, native Temporal is unavailable at the client; a polyfill is mandatory.
This ADR formalizes the classification as doctrine and defines the stack-specific mechanisms that enforce it. It is the gate for the paused pilot's Phase 2–4.
Decision
1. Classification doctrine (the spine — stack-agnostic)
Every datetime field in emmie is declared instant or wall-clock. The declaration is authoritative: casts, parsers, serialization, and frontend handling all follow from it.
| Category | emmie fields | Storage | Conversion rule |
|---|---|---|---|
| Wall-clock | starts_at/ends_at (DailyRegistration, AmbulatoryRegistration, Evaluation); start_date/end_date (LearningGoal, Contract, Client, Financing, CareAgreement, Schedule, AmbulatorySchedule); start_time/end_time/time (Evaluation, Mdo, IntroductoryMeeting, AmbulatorySchedule, reports); birth_date | Naive, in the Amsterdam frame | Never timezone-converted. Serialized naive (see §1a). |
| Instant | Eloquent created_at/updated_at/deleted_at; RiskAssessment.started_at | UTC | Converted to Europe/Amsterdam for display only. Serialized as RFC 3339 ...Z. |
starts_at/ends_at are formalized as wall-clock, not migrated to UTC instants (Commander disposition 2026-06-01). These times genuinely are local (a 09:00 daypart is 09:00 in Amsterdam, full stop); migrating storage to "technically correct" UTC instants would be a large, risky multi-tenant backfill to encode a semantic that is already local. The storage stays exactly as-is. What changes: the wall-clock nature becomes declared, enforced, and reflected on the wire, so the next ->setTimezone() is caught before it ships.
The cardinal rule, now doctrine: a wall-clock value is never passed through a timezone conversion. EMMIE-0317 was a single instance of breaking it.
1a. The wire reflects the category (self-describing wire)
Wall-clock fields serialize naive; instant fields serialize RFC 3339 Z. The wire string becomes self-describing — "2026-05-27 09:00:00" is unambiguously a wall-clock value; "2026-05-27T09:00:00Z" is unambiguously an instant. Formats:
| Category | Wire format | Example |
|---|---|---|
| Wall-clock datetime | Y-m-d H:i:s (no T, no offset) | "2026-05-27 09:00:00" |
| Wall-clock date | Y-m-d | "2026-05-27" |
| Wall-clock time | H:i:s | "09:00:00" |
| Instant | RFC 3339 Z | "2026-05-27T09:00:00.000000Z" |
This is a frontend wire-contract change for the wall-clock fields currently emitting ...Z. It is safe because the Vue SPA is the sole consumer (see §Context consumer surface) and the change lands in the same release as the R2 frontend migration. Schedule date columns (uncast raw strings) and time columns already emit naive — this aligns the 5 datetime-cast calendar columns with them.
Why this over "the frontend just knows each field's category": keeping the uniform ...Z wire and having call sites pick the right parser by field-knowledge leaves a silent footgun — call the instant parser on a wall-clock field and you are +2h off, silently, exactly like today. A self-describing wire turns that into a loud failure: Temporal.Instant.from("2026-05-27 09:00:00") throws (no offset), and Temporal.PlainDateTime.from("…Z") throws (unexpected offset). Wrong-parser becomes a crash at the boundary, not a wrong number downstream.
2. Backend mechanism (Carbon — unchanged stack)
Temporal does not touch PHP. The backend half is Carbon, generalizing the parseStrict precedent:
R1 — TZ-strict instant-parse wrapper. A shared helper modeled on
NormalizeAndUpdateScheduleDatesAction::parseStrict, generalized to instants with an explicit-timezone argument. The ~22 raw-wire instant parses (notablyRiskAssessment.started_atatUpdateRiskAssessmentAction:58/RiskAssessmentResource:46) route through it. Wall-clock parses continue to use the format-pinned, TZ-naive path (createFromFormat('Y-m-d', …)) — correct because a wall-clock date has no instant.R3 — custom wall-clock cast classes (carries category + serialization + storage frame). Three custom
CastsAttributesclasses —WallClockDateTime,WallClockDate,WallClockTime— replace the divergentdatetime/datecasts on every declared wall-clock column. Each cast:- hydrates the attribute in the Amsterdam frame without applying a UTC conversion;
- serializes naive (per §1a) — making a wall-clock field structurally unable to emit a
Zon the wire; - is, by its type, the column's category declaration in code (replacing the implicit, divergent
datetime/date/immutable_dateinventory).
The 5 calendar-date columns casting
datetime(LearningGoal, Contract, Client, Financing, CareAgreement) move toWallClockDate;AmbulatorySchedulegains acasts()method using these classes.Honest limit: the cast closes the wire leak structurally (a wall-clock field cannot serialize a
Z). It does not structurally prevent a->setTimezone()call on the hydrated value — the cast still returns aCarbonImmutable, which has the method. A truly type-enforced wall-clock (a value object with nosetTimezone, the PHP analogue ofTemporal.PlainDateTime) is a larger lift PHP has no native support for; Carbon is the house tool. The read-side conversion ban therefore stays arch-test-enforced (R4), not type-enforced. The cast + the arch test together are the guard.R4 — Level-1 arch test. Once R1/R3 exist: a Pest arch test bans raw
CarbonImmutable::parseof request input outside the wrapper, bans->setTimezone()on a declared wall-clock attribute, and asserts every declared wall-clock column uses aWallClock*cast (so a future model can't silently regress todatetime). Mirrors the ADR-0023 §G chokepoint discipline; this is the EMMIE-0317 regression guard at CI time.R5 — F-C1 canary test. A test pinning the boundary behavior of a UTC-stored late-night last-of-month
starts_atthrough the revenue path. Partially satisfied by the EMMIE-0317 RED→GREEN canary in PR #303; formalize as the standing guard.
3. Frontend mechanism (Temporal — the new substrate)
R2 — the frontend parse layer is built on Temporal, extending apps/helpers/dates.ts (parse side). The reason Temporal is the right substrate and not cosmetic: its type system makes the classification un-ignorable. Temporal.Instant, Temporal.PlainDateTime, Temporal.PlainDate, and Temporal.PlainTime are distinct types that do not silently coerce — exactly the guard the ~187 raw new Date(wireString) sites lack today.
Polyfill: temporal-polyfill (FullCalendar, ~20KB gzipped). Safari has zero native Temporal, so a polyfill ships to a real fraction of users; the ~20KB build covers Instant/ZonedDateTime/PlainDate/PlainTime/Duration — everything the helper needs — at roughly half the bytes of the full-spec @js-temporal/polyfill.
Helper surface (added to apps/helpers/dates.ts, parse side). With the self-describing wire from §1a, each parser maps one wire shape to one Temporal type, and the wrong shape throws:
import { Temporal } from 'temporal-polyfill'
const AMS = 'Europe/Amsterdam'
// Instant fields arrive as RFC 3339 `Z`. Display via .toZonedDateTimeISO(AMS).
// Throws if handed a naive wall-clock string (no offset) — loud failure.
export function parseInstant(wire: string): Temporal.Instant {
return Temporal.Instant.from(wire)
}
// Wall-clock datetime arrives naive ("2026-05-27 09:00:00"). Throws on a `Z` string.
export function parseWallClockDateTime(wire: string): Temporal.PlainDateTime {
return Temporal.PlainDateTime.from(wire.replace(' ', 'T'))
}
export function parseWallClockDate(wire: string): Temporal.PlainDate {
return Temporal.PlainDate.from(wire) // "2026-05-27" — never a UTC midnight instant
}
export function parseWallClockTime(wire: string): Temporal.PlainTime {
return Temporal.PlainTime.from(wire)
}Call-site migration of the ~187 sites is a separate Engineer pass landing in the same release as the §1a wire change — the two are coupled (the existing new Date() sites change input shape the moment the wire flips). The helper scaffold is soldier-ready now.
Options Considered
Classification disposition for starts_at/ends_at
| Option | Verdict | Reason |
|---|---|---|
| Formalize as wall-clock, ban TZ conversion | Accepted | These times are genuinely local. Cheapest, correct semantic, zero storage risk. Storage untouched; the wall-clock nature becomes declared + enforced. |
| Migrate to true UTC instants | Rejected | Large, risky multi-tenant backfill (touches storage + every read) to encode "technically correct" UTC for a semantic that is actually local. Solves a problem we don't have at the cost of one we'd create. |
Wire-format discrimination
| Option | Verdict | Reason |
|---|---|---|
Self-describing wire — wall-clock serializes naive, instant serializes Z (via custom casts) | Accepted | Turns wrong-parser into a loud failure at the boundary instead of a silent +2h. Folds the wire decision into R3's cast mechanism (one source of category truth). Safe because the Vue SPA is the sole consumer, coordinated with the R2 migration. |
Keep uniform ...Z wire; frontend picks parser by field-category knowledge | Rejected | Leaves the silent footgun: call parseInstant on a wall-clock field → silently +2h, the EMMIE-0317 failure mode reborn on the frontend. Type system catches mixing results, not the initial wrong-parser choice. |
Frontend substrate
| Option | Verdict | Reason |
|---|---|---|
| Temporal (polyfilled) | Accepted | Type system enforces instant-vs-wall-clock at the boundary — the exact guard the 187 sites lack. ES2026 standard; TS6 ships the types. Aligns the frontend with the classification doctrine at the type level. |
| Luxon / date-fns / dayjs | Rejected | Solves parsing/formatting but does not type-distinguish instant from wall-clock — the same value can be mishandled. Adds a library we'd replace with Temporal within a release or two anyway. |
Raw new Date() discipline + lint rule | Rejected | Date is a single type that conflates both categories; no type can express "this must be wall-clock." Discipline-only enforcement is what we already (don't) have. |
| Wait for native Temporal (drop the polyfill) | Rejected | Safari has zero support and no announced timeline; iOS clients are primary on a healthcare app. Indefinite wait. |
Polyfill build
| Option | Verdict | Reason |
|---|---|---|
temporal-polyfill (~20KB) | Accepted | Covers every API the helper uses at ~half the bytes. Right default for a Safari-serving, bundle-conscious healthcare frontend. |
@js-temporal/polyfill (~56KB) | Rejected | Full-spec (exotic non-ISO calendars, complete edge fidelity) the F-M3/F-L1 sites never touch. Heavier for no benefit here. |
Consequences
Positive
- The EMMIE-0317 class is closed at the layer it lives in. Backend wall-clock attributes can't be
->setTimezone()'d past the R4 arch test; the R5 canary regression-guards the revenue path specifically. - The wrong-parser footgun is eliminated. With a self-describing wire (§1a), parsing a wall-clock field as an instant (or vice versa) throws at the boundary instead of returning a wrong number — both backend (cast can't emit a
Z) and frontend (Temporal type rejects the wrong shape). - Frontend category errors become compile-time + boundary errors. A
Temporal.PlainDatecannot be compared to aTemporal.Instant, and a mis-shaped wire string throws on parse. - One cast, one truth. R3's
WallClock*casts carry the category, the storage frame, and the wire format in one place — a future model can't silently regress, and there's nothing to keep in sync across casts and Resources. - One shared vocabulary across the stack. Backend casts, the parse wrapper, and the frontend helper all speak "instant vs wall-clock."
- Standards-aligned, future-proof frontend. Temporal is ES2026; the polyfill drops out for free as Safari ships native support (no code change, just bundle shrink).
- Storage untouched. No migration, no backfill, no multi-tenant data risk.
Negative
- A Temporal polyfill ships to all users. ~20KB gzipped reaches every client until Safari ships native support. Accepted cost for a healthcare frontend where iOS/Safari is a primary client. Quantify against the current bundle during the R2 build.
- The wire change couples to the frontend migration. The §1a serialization flip and the R2 call-site migration must ship in the same release — every existing
new Date(wallClockField)site changes input shape the moment the wire flips. Larger coordinated landing than a helper-only change. (Mitigant: per the empirical trace, V8 parses naive"2026-05-27 09:00:00"as local time, so for Amsterdam-locale clients the flip is coincidentally an immediate improvement, not a regression — but the coupling discipline holds.) - Two date mental models coexist — Carbon on the backend, Temporal on the frontend. Mitigated by the shared classification vocabulary, but it is genuinely two APIs. (Temporal is frontend-only; PHP has no equivalent and Carbon stays.)
- Temporal would not have caught EMMIE-0317. The bug was backend Carbon. The frontend substrate guards the frontend class (F-M3/F-L1); the backend class is guarded by R1/R3/R4/R5. This ADR must not be mis-sold as "Temporal fixes the revenue bug."
- ~187 call sites still carry the bug until migrated. The helper landing is necessary but not sufficient; the Engineer migration pass is where the risk actually retires.
- Backend wall-clock type-enforcement is partial. The cast blocks wire leaks structurally but cannot block a
->setTimezone()call on the hydratedCarbonImmutable— that ban is arch-test-level (R4), not type-level. A no-setTimezonewall-clock value object is deferred (PHP has no nativePlainDateTime).
Risks
- WordPress-facing response echo. The WP surface is inbound, but implementation must verify no WP-facing endpoint echoes a wall-clock datetime in a response that some WP-side code parses as
...Z. Mitigation: pre-flight grep of WP-reachable controllers/Resources during the R3 deployment; the consumer audit (§Context) found no such path, but it was not exhaustive over the WP plugin's own code. - Polyfill/native divergence. A site that works on polyfilled Safari but hits a native-Temporal edge on Chrome (or vice versa). Mitigation: load the polyfill unconditionally until Safari support is fleet-baseline, accepting the bytes over the divergence risk.
- R4 arch test false-positives on legitimate instant conversions. Banning
->setTimezone()broadly would catch the legitimate instant-display conversions. Mitigation: the ban is scoped to attributes declared wall-clock (the classification table /WallClock*cast usage is the allow/deny basis), mirroring ADR-0023 §G's receiver-scoped approach. - R3 cast flip changes runtime read types. Moving 5 columns from
datetimetoWallClockDatechanges the hydrated type at every read site that compares casts. Mitigation: per-model, test-guarded, sequenced behind the canary — same discipline as ADR-0023's cast-adjacent work.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
Wall-clock field leaking a Z on the wire | Custom WallClock* cast (R3) — structurally serializes naive | app/Models/** |
Declared wall-clock column not using a WallClock* cast | Pest arch test (R4) | app/Models/** |
->setTimezone() / TZ-conversion of a declared wall-clock attribute | Pest arch test (R4) | app/** |
Raw CarbonImmutable::parse of request/wire input outside the instant-parse wrapper | Pest arch test (R4) | app/** |
Revenue-path month-boundary behavior on late-night last-of-month starts_at | Pest feature canary (R5) | CalculateRevenueTotalsAction family |
Raw new Date(wireString) in apps/ outside dates.ts | ESLint rule (candidate) + the typed Temporal helper making the right path the easy path | apps/** |
| Cross-territory PHPStan rule equivalent | script-development/phpstan-warroom-rules | Deferred — gating condition is "second territory adopts," not "emmie proven." |
Enforcement ladder: R4 (Level 1, CI) is the backend target end-state; the cast (R3) is itself structural enforcement of the wire contract. The frontend lint rule is Level 1 once the helper exists. Until then the classification table in §Decision is Level 4 doctrine.
Resolved Questions
Is starts_at-as-wall-clock-UTC an intentional convention to formalize, or a bug to fix?
Resolved 2026-06-01 (Commander). Formalize as wall-clock; ban timezone conversion. The times are genuinely local; storage migration would encode a UTC semantic the data doesn't have, at large multi-tenant risk. See §Options.
Why Temporal and not a mature library like Luxon?
Resolved 2026-06-02 (Commander). The value is the type-level instant-vs-wall-clock distinction, which no Date-wrapping library provides. Temporal is the ES2026 standard; TS6 ships the types; the polyfill drops out for free as Safari catches up. Confining it to the dates.ts boundary contains the ecosystem-immaturity risk.
Should wall-clock fields serialize naive so the wire is self-describing?
Resolved 2026-06-02 (Commander). Yes — see §1a. The alternative (uniform ...Z, frontend picks parser by field-knowledge) leaves the silent wrong-parser footgun. The wire-contract change is safe because recon confirmed the Vue SPA is the sole consumer of these fields (no generated client / mobile / public spec; WP plugin is inbound). It folds into R3's cast mechanism and turns the footgun into a loud failure. Carries one cost: the wire flip and the R2 frontend migration must land in the same release.
Does Temporal fix the revenue bug?
Resolved 2026-06-02. No. EMMIE-0317 was backend Carbon and is fixed/guarded by R1/R3/R4/R5. Temporal guards the frontend category-error class only. The two halves share the classification doctrine but are independent mechanisms on independent stacks.
Implementation
| Territory | State | Notes |
|---|---|---|
| emmie — EMMIE-0317 point fix | Complete | PR #303 merged — three ->setTimezone() calls removed from CalculateRevenueTotalsAction; RED→GREEN DB-backed canary (0 → 500). The wall-clock disposition applied as a point fix ahead of this ADR. |
| emmie — classification doctrine (§1) | Not Started | This ADR. Gate for Phase 2–4. |
| emmie — backend mechanism (R1/R3/R4/R5) | Not Started | Instant wrapper + WallClock* casts (carry category + naive serialization) + arch test + canary. Unblocked by this ADR's disposition. |
| emmie — wire change (§1a) + frontend Temporal helper (R2) | Not Started | WallClock* casts flip the wire to naive; temporal-polyfill + dates.ts parse layer + ~187-site migration land in the same release as the wire flip (coupled). WP-echo pre-flight grep required. |
| Other territories | Not Applicable (yet) | Pilot-then-propagate. Fleet adoption is a separate decision after emmie proves out (deferred.md). Backend mechanisms reusable fleet-wide; the Temporal frontend choice is re-decided per territory at propagation time. |