@@ -179,3 +179,156 @@ rendering.
179179 Rejected as a complete fix: it helps the formula path but not the
180180 write/filter asymmetry, and it bakes in UTC midnight (Phase 2's tz-aware
181181 ` today() ` supersedes it).
182+
183+ ---
184+
185+ ## Phase 2 — Detailed Design (implementation plan)
186+
187+ > ** Status:** Phase 1 landed in ` @objectstack/driver-sql ` (PR #1968 ) — ` Field.date `
188+ > is now a tz-naive ` YYYY-MM-DD ` string at the write/read boundary. This section
189+ > details Phase 2 (the reference-timezone model) for review. It is the plan, not
190+ > yet a commitment to ship; each slice below is independently revertable and gated
191+ > behind "reference timezone unset → UTC" (today's behavior).
192+
193+ ### Reframe: two axes, two sources, two risk profiles
194+
195+ ADR-0053's "timezone model" is, in the code, ** two distinct concerns** that must
196+ be solved separately. Conflating them in one change is the chief risk of Phase 2.
197+
198+ | Axis | Question it answers | Source | Boundary |
199+ | ------| --------------------| --------| ----------|
200+ | ** Compute-tz** | "what calendar day is it / which bucket does this fall in?" — ` today() ` /` daysFromNow() ` , analytics bucketing, cron day-boundaries | the ** execution context** (user / org / job) | threaded * into* evaluation |
201+ | ** Render-tz** | "show this UTC instant in the viewer's zone" — ` datetime ` display | the ** viewer** | applied at the * presentation* boundary; storage stays UTC |
202+
203+ Everything below is organized along this split.
204+
205+ ### The reference-timezone resolver
206+
207+ A single resolver computes the active timezone for an execution context, with this
208+ precedence:
209+
210+ | Layer | Stored where | Read path |
211+ | -------| --------------| -----------|
212+ | User override | ` sys-user-preference ` (` key='timezone' ` ) — today a generic k/v store that the engine never reads (` sys-user-preference.object.ts ` ) | by ` (user_id, key) ` |
213+ | Org default | a ** ` tenant ` -scoped settings manifest** (new; e.g. ` localization ` /` timezone ` ) | ` service-settings ` — tenant scope is keyed by ` ExecutionContext.tenantId ` = ` activeOrganizationId ` , which ** is** the org under one-org-per-environment (ADR-0002), so no new scope or schema migration is needed. The settings reactive client (` settings-service.ts ` ` createClient ` ) gives a cheap in-memory snapshot. |
214+ | Fallback | — | ` 'UTC' ` (flag-off default; identical to today) |
215+
216+ ** Where it resolves — one chokepoint.** Fold the resolution into
217+ ` resolveExecutionContext ` (` runtime/.../resolve-execution-context.ts ` ), which already
218+ queries ` sys_member ` / permission-sets per request; reading one user-pref + one
219+ settings snapshot there is consistent and benefits from any future caching. Add
220+ ** ` timezone?: string ` to ` ExecutionContextSchema ` ** (` spec/.../execution-context.zod.ts ` ).
221+ The timezone is then resolved ** once per request** and rides the existing context
222+ plumbing to every consumer. This is the * only* new data Phase 2 introduces.
223+
224+ ** Three execution branches** (the resolver's input differs by entry path):
225+
226+ | Entry path | Identity available today | TZ source |
227+ | ------------| --------------------------| -----------|
228+ | Interactive HTTP | ` userId ` + ` tenantId ` (resolved from session) | user override → org default → UTC |
229+ | Scheduled job / cron | none — runs ` isSystem ` , handler gets only ` { jobId, data } ` | the ** job's own ` timezone ` field** (` sys_job.timezone ` , already wired through croner) |
230+ | Flow / record-change trigger | ` userId ` only (` AutomationContext ` — ` automation-service.ts ` ) | needs a ` timezone ` added to ` AutomationContext ` ; org default |
231+
232+ ### The shared DST-safe primitive
233+
234+ Phase 2 invents ** no** new timezone math. The DST-correct pattern already exists and
235+ is proven in ` service-messaging ` 's ` preference-resolver.ts ` (` wallClockInTz ` /
236+ ` minutesOfDayInTz ` ), which uses ` Intl.DateTimeFormat({ timeZone }).formatToParts() ` .
237+ Extract it once — ` partsInTz(instant, tz) → { y, m, d, … } ` and
238+ ` calendarDayUtc(instant, tz) → Date ` — into a shared util consumed by formula,
239+ analytics, and rendering. Same single-source-of-truth discipline as Phase 1's
240+ ` toDateOnly() ` helper. ** Never** hand-roll offset arithmetic (breaks across DST).
241+
242+ ### The three decisions
243+
244+ ** D1 — ` today() ` /` daysFromNow() ` /` daysAgo() ` return a ` Date ` at * UTC-midnight of the
245+ reference-tz calendar day*** — ` new Date(Date.UTC(y, m, d)) ` where ` (y,m,d) ` are the
246+ calendar parts computed in the reference tz. ** Not** a date-only string; ** not**
247+ "local-midnight-as-instant".
248+
249+ This is forced by how comparison actually works. ` record.due_date == today() ` never
250+ compares a string to ` today() ` : when cel-js faults on ` string <op> Timestamp ` ,
251+ ` hydrateOverloadStrings ` (` cel-engine.ts ` ) rehydrates the date-only field string via
252+ ` Date.parse("2026-06-15") ` = ** UTC midnight** . So the field side is * always* a
253+ UTC-midnight ` Date ` at compare time. For ` today() ` to compare cleanly it must also be
254+ a UTC-midnight ` Date ` of the same calendar day. The driver filter path agrees
255+ (` coerceFilterValue ` /` toDateOnly ` does ` getUTC* ` on a ` Date ` ), and Phase 1 stores the
256+ UTC calendar day. UTC-midnight-of-the-reference-tz-day is the * one* representation
257+ consistent with all three boundaries (CEL hydration, driver filter, Phase-1 storage).
258+
259+ > ** The trap:** representing ` today() ` as the reference-tz * local* midnight as an
260+ > instant (e.g. ` 2026-06-15T07:00:00Z ` for ` America/Los_Angeles ` ) re-introduces the
261+ > exact tz-shifted instant this ADR removes, and it would ** not** equal the
262+ > UTC-midnight-hydrated field — the silent-miss bug returns.
263+
264+ Bonus: making ` daysFromNow(n) ` /` daysAgo(n) ` compute ` calendarDay ± n → UTC-midnight `
265+ ** also fixes the "keeps wall-clock time" defect** (` stdlib.ts:36 ` ) for free. The
266+ ` now() + duration("Nh") ` escape hatch remains for genuine sub-day instants.
267+
268+ ** D2 — tz-aware analytics buckets in-memory (JS), uniformly; do not emit
269+ dialect-specific ` date_trunc … AT TIME ZONE ` .** Keep DB-side bucketing only for the
270+ UTC/no-tz fast path. ` date_trunc(… AT TIME ZONE tz) ` is Postgres-only; SQLite has no
271+ timezone database and MySQL needs tz tables loaded — splitting behavior by dialect
272+ yields * different bucket boundaries on different drivers for the same data* (a
273+ correctness landmine the sqlite-heavy test matrix wouldn't catch). The two existing
274+ UTC-hardcoded JS bucketers (` service-analytics ` ` preview-evaluator.ts ` ` bucketDate ` ,
275+ ` dimension-labels.ts ` ` formatDateBucket ` ) swap ` getUTC* ` for the shared ` partsInTz `
276+ util — correct on every dialect, same DST-safe code as D1. The seam is ready:
277+ ` dataset-executor.ts ` already carries a ` timezone ` field (hard-coded ` 'UTC' ` ).
278+ * Accepted cost:* when tz ≠ UTC the GROUP BY can't be pushed down, so wide
279+ aggregations pull more rows. Mitigate by keeping the DB-side UTC fast path when the
280+ reference tz is unset or ` 'UTC' ` , still pushing the date-range * filter* to the DB and
281+ only bucketing in memory, and revisiting a Postgres-only pushdown later if a real
282+ workload needs it. Ship correct-and-uniform first.
283+
284+ ** D3 — wire report schedules onto the existing ` CronJobAdapter ` ; do not delete the
285+ fields.** ` sys-report-schedule.object.ts ` documents ` cron_expression ` as "reserved
286+ for the next milestone when a cron-capable scheduler adapter is available, it wins
287+ over ` interval_minutes ` " — that adapter now exists (` service-job ` ` cron-job-adapter.ts ` ,
288+ croner + per-job ` timezone ` , already wired for ` sys_job ` ). ` report-service.ts `
289+ ` advanceSchedule ` still reads only ` interval_minutes ` and ignores both
290+ ` cron_expression ` and ` timezone ` . When ` cron_expression ` is present, schedule via
291+ croner with the timezone; keep ` interval_minutes ` as the tz-agnostic fallback. This
292+ flips two author-facing-but-runtime-dead fields to ** live** in one move — the
293+ * enforce* resolution the spec-liveness gate wants (record this PR as ledger
294+ evidence). Report schedules run ` SYSTEM_CTX ` , so their tz source is the schedule's
295+ own ` timezone ` field (same model as ` sys_job ` ) — self-contained, off the resolver's
296+ critical path.
297+
298+ ### Critical blind spot to fix regardless of tz
299+
300+ ` applyFormulaPlan ` (` objectql ` ` engine.ts ` , called from ` find ` /` findOne ` ) evaluates
301+ read-time formula fields with ** only ` { record } ` ** — no ` now ` , no ` execCtx ` , no
302+ timezone. Today that means ` today() ` inside a formula field uses real wall-clock UTC.
303+ ` find ` /` findOne ` already hold ` opCtx.context ` (the ` ExecutionContext ` ); thread
304+ ` { now: nowSnap, timezone, user, org } ` through — mirroring ` applyFieldDefaults ` ,
305+ which already does this correctly. Worth doing on its own (pinned ` now ` for
306+ determinism + computed fields can reference user/org), independent of timezone.
307+
308+ ### Implementation map
309+
310+ | Slice | Touch points (file) | Axis | Risk |
311+ | -------| ---------------------| ------| ------|
312+ | 1. Resolver + ` ExecutionContext.timezone ` | ` resolve-execution-context.ts ` , ` execution-context.zod.ts ` , new settings manifest | plumbing | none (default UTC) |
313+ | 2. ` applyFormulaPlan ` context threading | ` objectql/engine.ts ` | compute | low |
314+ | 3. tz-aware ` today() ` /` daysFromNow() ` /` daysAgo() ` + shared ` partsInTz ` util | ` formula/stdlib.ts ` , ` cel-engine.ts ` , ` formula/types.ts ` | compute | behind flag |
315+ | 4. Render-tz in template formatters + email path | ` formula/template-engine.ts ` (date/datetime formatters already take ` locale ` → add ` timeZone ` ); ` plugin-email ` rendering currently bypasses the formatter pipeline and needs routing through it | render | low blast radius (one centralized formatter) + one outlier |
316+ | 5. Analytics bucket tz | ` service-analytics ` ` preview-evaluator.ts ` , ` dimension-labels.ts ` , ` dataset-executor.ts ` | compute | highest (dialect — see D2) |
317+ | 6. Report schedule → croner+tz | ` plugin-reports/report-service.ts ` | compute | low; doubles as liveness cleanup |
318+
319+ Cron day-boundaries (` sys_job ` ) need ** no change** — already tz-wired via croner.
320+
321+ ### Open prerequisites
322+
323+ - ** Confirm cel-js supports ` duration() ` + Timestamp arithmetic** (the documented
324+ ` now() + duration("Nh") ` escape hatch). No ` duration ` usage exists in the formula
325+ package today; if unsupported this is a small prerequisite patch.
326+ - ** Email rendering is architectural, not a parameter.** ` plugin-email ` renders via a
327+ naive ` String() ` path with no formatter/locale/tz; routing it through the formula
328+ template engine (or porting the formatters) is the real cost in slice 4.
329+
330+ ### Rollback
331+
332+ Every slice is feature-flaggable behind "reference timezone unset → UTC". With no org
333+ reference timezone configured, the resolver returns ` 'UTC' ` and all compute/render
334+ paths are byte-for-byte today's behavior — the safe default and the rollback target.
0 commit comments