Skip to content

Commit 6c439f2

Browse files
os-warrenclaude
andauthored
fix(service-automation): resolve {TODAY() +/- n} on one calendar, not two (#14852) (#15826)
The offset branch of the flow template resolver did its day arithmetic on the LOCAL calendar (`getDate` / `setDate`) and rendered the result on the UTC one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the instant by exactly n x 24h only while every local day in the window is 24 hours long. Across a spring-forward that window is 23 hours and across a fall-back 25; when the resulting hour of slack crosses a UTC midnight, the rendered date is a day early (spring-forward) or a day late (fall-back). Spell the branch on one calendar - UTC, the same one both returns already render on. The bare `{TODAY()}` / `{NOW()}` forms never entered this branch and do not move; the offset forms now agree with them. This introduces no timezone concept. Measured over 34 zones x every 30 minutes of 2026 x offsets {+1, -1} (1,191,360 instant-offset pairs): the mixed spelling disagrees with the UTC day in 190 of them across 24 DST-observing zones, the new spelling in none. `template-date-offset-dst.test.ts` pins 14 measured red cells, each an instant that satisfies both conditions the flip needs at once - the local day shift straddles the zone's transition, and the hour of slack crosses a UTC midnight. Each cell carries an inline control asserting the old spelling DISAGREES there, so a green run cannot be read as "the fix works" when it really means "these instants are not in a transition window". The oracle in `template-functions.test.ts` was re-spelled on one calendar for the same reason: it had re-stated the defect it was checking against. Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 53d02e9 commit 6c439f2

4 files changed

Lines changed: 281 additions & 2 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition.
6+
7+
The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back).
8+
9+
The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none.
10+
11+
The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition.
12+
13+
Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question.
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #14852 — the `{TODAY() +/- n}` / `{NOW() +/- n}` offset branch resolves on
5+
* ONE calendar (UTC), the same one the bare forms already render on.
6+
*
7+
* ## Why this file cannot be written to run only at `TZ=UTC`
8+
*
9+
* The two spellings — local `setDate(getDate() + n)` and UTC
10+
* `setUTCDate(getUTCDate() + n)` — are behaviourally INDISTINGUISHABLE at
11+
* `TZ=UTC`, which is precisely why nothing in CI ever went red on the defect
12+
* and why it shipped. Every case below therefore fakes BOTH halves of the
13+
* environment: a DST-observing zone (`process.env.TZ`, re-read by V8 on the
14+
* next `Date` operation) AND an instant whose day shift crosses that zone's
15+
* own transition.
16+
*
17+
* ## The mechanism, stated once
18+
*
19+
* `setDate` preserves WALL-CLOCK time, so shifting the local calendar by n
20+
* days moves the INSTANT by exactly n x 24h only while every local day in the
21+
* window is 24 hours long. Across a spring-forward that window is 23 hours;
22+
* across a fall-back, 25. The rendering is `toISOString()` — UTC. So a flip
23+
* needs TWO conditions AT ONCE:
24+
*
25+
* 1. the local day shift straddles the transition -> the instant moves
26+
* 23h or 25h instead of n x 24h; and
27+
* 2. that one hour of slack crosses a UTC midnight -> the rendered DAY, not
28+
* merely the instant, comes out wrong.
29+
*
30+
* Condition (2) is what pins the instants below to the UTC hour [00:00, 01:00)
31+
* for a forward offset and [23:00, 24:00) for a backward one: those are the
32+
* only hours where one hour of slack changes which UTC day you land on.
33+
* Condition (1) is what pins the DAY to each zone's own transition. Miss
34+
* either and the cell is GREEN against the broken code — a single-point sweep
35+
* is exactly what let the identical two-calendar shape sit unnoticed in a
36+
* hotcrm test helper for months (`objectstack-ai/hotcrm#1462`).
37+
*
38+
* NOTE the direction is not one-signed: spring-forward renders a day EARLY
39+
* (23h falls short of the midnight it had to cross), fall-back renders a day
40+
* LATE (25h overshoots one). Both are pinned.
41+
*
42+
* ## The inline control is load-bearing
43+
*
44+
* Each cell also evaluates the OLD mixed spelling directly and asserts it
45+
* DISAGREES with the truth. Without that, a green run would be ambiguous
46+
* between "the fix works" and "these instants are not actually in a transition
47+
* window" — the second being the failure mode that hid this bug. With it, the
48+
* file proves its own instants are live before it credits the fix.
49+
*
50+
* ## Deliberately NOT pinned here
51+
*
52+
* Whether these tokens should be timezone-AWARE at all is a separate and
53+
* larger question (#14852 explicitly does not propose it). The bare
54+
* `{TODAY()}` resolves to the UTC day, and the controls below hold it there in
55+
* every zone; this file only makes the offset branch AGREE with the bare one.
56+
*/
57+
58+
import { describe, it, expect, vi } from 'vitest';
59+
import { interpolateString } from './template.js';
60+
61+
const ctx = {} as any;
62+
63+
function tpl(expr: string, vars: Record<string, unknown> = {}): unknown {
64+
return interpolateString(`{${expr}}`, new Map(Object.entries(vars)), ctx);
65+
}
66+
67+
const REAL_TZ = process.env.TZ;
68+
69+
/** Run `fn` with the process on `zone` and the clock frozen at `instant`. */
70+
function at<T>(zone: string, instant: string, fn: () => T): T {
71+
process.env.TZ = zone;
72+
vi.useFakeTimers({ toFake: ['Date'] });
73+
vi.setSystemTime(new Date(instant));
74+
try {
75+
return fn();
76+
} finally {
77+
vi.useRealTimers();
78+
if (REAL_TZ === undefined) delete process.env.TZ;
79+
else process.env.TZ = REAL_TZ;
80+
}
81+
}
82+
83+
/** The defect, spelled out: day arithmetic on the LOCAL calendar, rendered on UTC. */
84+
function mixedCalendarSpelling(instant: string, n: number): string {
85+
const d = new Date(instant);
86+
d.setDate(d.getDate() + n);
87+
return d.toISOString().slice(0, 10);
88+
}
89+
90+
/**
91+
* The truth, computed from NEITHER spelling: the instant plus n x 24h, in UTC.
92+
* `{TODAY() + n}` means "n days after the UTC day it is now", and a UTC day is
93+
* always 24 hours — so this is the definition, not a second implementation.
94+
*/
95+
function utcDayShift(instant: string, n: number): string {
96+
return new Date(Date.parse(instant) + n * 86_400_000).toISOString().slice(0, 10);
97+
}
98+
99+
interface Cell {
100+
zone: string;
101+
/** Frozen clock, always written in UTC. */
102+
instant: string;
103+
offset: number;
104+
/** The same instant as local wall clock, so the window is readable. */
105+
local: string;
106+
kind: 'spring-forward' | 'fall-back';
107+
}
108+
109+
/**
110+
* Red cells — every one MEASURED, not guessed: each is an instant at which the
111+
* mixed spelling actually disagrees with the truth in that zone, taken from a
112+
* 34-zone x 30-minute sweep of 2026. Both hemispheres, both transition
113+
* directions, and three zones whose standard offset is not a whole hour
114+
* (St_Johns -03:30, Chatham +12:45, Adelaide +09:30 via Sydney's sibling rule)
115+
* so a whole-hour assumption cannot hide in the fix.
116+
*/
117+
const DST_CELLS: Cell[] = [
118+
{ zone: 'America/New_York', instant: '2026-03-08T00:30:00Z', offset: 1, local: 'Sat 2026-03-07 19:30 EST', kind: 'spring-forward' },
119+
{ zone: 'America/New_York', instant: '2026-03-08T23:30:00Z', offset: -1, local: 'Sun 2026-03-08 19:30 EDT', kind: 'spring-forward' },
120+
{ zone: 'America/New_York', instant: '2026-10-31T23:30:00Z', offset: 1, local: 'Sat 2026-10-31 19:30 EDT', kind: 'fall-back' },
121+
{ zone: 'America/New_York', instant: '2026-11-02T00:30:00Z', offset: -1, local: 'Sun 2026-11-01 19:30 EST', kind: 'fall-back' },
122+
{ zone: 'America/Los_Angeles', instant: '2026-03-08T00:30:00Z', offset: 1, local: 'Sat 2026-03-07 16:30 PST', kind: 'spring-forward' },
123+
{ zone: 'America/St_Johns', instant: '2026-03-08T00:30:00Z', offset: 1, local: 'Sat 2026-03-07 21:00 NST', kind: 'spring-forward' },
124+
{ zone: 'Europe/London', instant: '2026-03-29T00:30:00Z', offset: 1, local: 'Sun 2026-03-29 00:30 GMT', kind: 'spring-forward' },
125+
{ zone: 'Europe/Berlin', instant: '2026-03-29T00:30:00Z', offset: 1, local: 'Sun 2026-03-29 01:30 CET', kind: 'spring-forward' },
126+
{ zone: 'Asia/Jerusalem', instant: '2026-03-27T23:30:00Z', offset: -1, local: 'Sat 2026-03-28 02:30 IDT', kind: 'spring-forward' },
127+
{ zone: 'Australia/Sydney', instant: '2026-10-03T00:30:00Z', offset: 1, local: 'Sat 2026-10-03 10:30 AEST', kind: 'spring-forward' },
128+
{ zone: 'Australia/Adelaide', instant: '2026-10-03T00:30:00Z', offset: 1, local: 'Sat 2026-10-03 10:00 ACST', kind: 'spring-forward' },
129+
{ zone: 'Pacific/Auckland', instant: '2026-09-26T00:30:00Z', offset: 1, local: 'Sat 2026-09-26 12:30 NZST', kind: 'spring-forward' },
130+
{ zone: 'Pacific/Chatham', instant: '2026-09-26T00:30:00Z', offset: 1, local: 'Sat 2026-09-26 13:15 +1245', kind: 'spring-forward' },
131+
{ zone: 'America/Santiago', instant: '2026-09-06T00:30:00Z', offset: 1, local: 'Sat 2026-09-05 20:30 -04', kind: 'spring-forward' },
132+
];
133+
134+
const label = (c: Cell) => `${c.zone} @ ${c.instant} (${c.local}) {TODAY() ${c.offset > 0 ? '+' : '-'} ${Math.abs(c.offset)}}`;
135+
136+
describe('#14852 {TODAY() +/- n} resolves on one calendar, across DST transitions', () => {
137+
for (const c of DST_CELLS) {
138+
it(`${c.kind}: ${label(c)}`, () => {
139+
const expr = `TODAY() ${c.offset > 0 ? '+' : '-'} ${Math.abs(c.offset)}`;
140+
at(c.zone, c.instant, () => {
141+
const expected = utcDayShift(c.instant, c.offset);
142+
143+
// CONTROL FIRST — if this passes, the cell is not in a
144+
// transition window and the assertion below would be vacuous.
145+
// (It is the whole reason a TZ=UTC-only test is worthless here.)
146+
expect(
147+
mixedCalendarSpelling(c.instant, c.offset),
148+
`${label(c)}: the mixed spelling must DISAGREE here, otherwise this cell pins nothing`,
149+
).not.toBe(expected);
150+
151+
expect(tpl(expr), label(c)).toBe(expected);
152+
});
153+
});
154+
}
155+
156+
it('every red cell is live — the control disagrees in all of them', () => {
157+
const live = DST_CELLS.filter((c) =>
158+
at(c.zone, c.instant, () => mixedCalendarSpelling(c.instant, c.offset) !== utcDayShift(c.instant, c.offset)),
159+
);
160+
expect(live.length, 'a cell that no longer flips has stopped guarding the fix').toBe(DST_CELLS.length);
161+
});
162+
163+
it('both directions are represented — a day EARLY and a day LATE', () => {
164+
const dirs = new Set(
165+
DST_CELLS.map((c) =>
166+
at(c.zone, c.instant, () =>
167+
mixedCalendarSpelling(c.instant, c.offset) < utcDayShift(c.instant, c.offset) ? 'early' : 'late',
168+
),
169+
),
170+
);
171+
expect([...dirs].sort()).toEqual(['early', 'late']);
172+
});
173+
});
174+
175+
describe('#14852 the offset branch serves NOW() too — same mutation, same calendar', () => {
176+
for (const c of DST_CELLS.filter((x) => x.offset === 1).slice(0, 4)) {
177+
it(`${c.zone} @ ${c.instant}: {NOW() + 1} is exactly +24h`, () => {
178+
at(c.zone, c.instant, () => {
179+
expect(tpl('NOW() + 1')).toBe(new Date(Date.parse(c.instant) + 86_400_000).toISOString());
180+
});
181+
});
182+
}
183+
});
184+
185+
describe('#14852 the offset may come from a variable — same branch', () => {
186+
it('{TODAY() + days} with days=1 lands on the UTC day too', () => {
187+
const c = DST_CELLS[0];
188+
at(c.zone, c.instant, () => {
189+
expect(tpl('TODAY() + days', { days: 1 })).toBe(utcDayShift(c.instant, 1));
190+
});
191+
});
192+
});
193+
194+
// ── Fences: what this change must NOT have moved ──────────────────────────
195+
196+
describe('#14852 fences — the bare forms and the non-DST zones are untouched', () => {
197+
const ALL_ZONES = [...new Set(DST_CELLS.map((c) => c.zone))];
198+
199+
it('bare {TODAY()} still resolves to the UTC day in every zone, including inside a transition window', () => {
200+
for (const c of DST_CELLS) {
201+
at(c.zone, c.instant, () => {
202+
expect(tpl('TODAY()'), `${c.zone} @ ${c.instant}`).toBe(c.instant.slice(0, 10));
203+
});
204+
}
205+
});
206+
207+
it('bare {NOW()} still renders the instant itself', () => {
208+
for (const zone of ALL_ZONES) {
209+
at(zone, '2026-03-08T00:30:00Z', () => {
210+
expect(tpl('NOW()'), zone).toBe('2026-03-08T00:30:00.000Z');
211+
});
212+
}
213+
});
214+
215+
it('zones that do not observe DST are unaffected — both spellings already agreed there', () => {
216+
for (const zone of ['UTC', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Perth']) {
217+
for (const instant of ['2026-03-08T00:30:00Z', '2026-10-31T23:30:00Z', '2026-06-15T12:00:00Z']) {
218+
at(zone, instant, () => {
219+
expect(mixedCalendarSpelling(instant, 1), `${zone} @ ${instant}`).toBe(utcDayShift(instant, 1));
220+
expect(tpl('TODAY() + 1'), `${zone} @ ${instant}`).toBe(utcDayShift(instant, 1));
221+
});
222+
}
223+
}
224+
});
225+
226+
it('an ordinary instant in a DST zone is unaffected — the local day is 24h there', () => {
227+
for (const zone of ALL_ZONES) {
228+
at(zone, '2026-06-15T12:00:00Z', () => {
229+
expect(mixedCalendarSpelling('2026-06-15T12:00:00Z', 1), zone).toBe('2026-06-16');
230+
expect(tpl('TODAY() + 1'), zone).toBe('2026-06-16');
231+
});
232+
}
233+
});
234+
235+
it('a large offset still lands on the UTC day (the consumers use +90 and +120)', () => {
236+
const c = DST_CELLS[0];
237+
at(c.zone, c.instant, () => {
238+
expect(tpl('TODAY() + 90')).toBe(utcDayShift(c.instant, 90));
239+
expect(tpl('TODAY() + 120')).toBe(utcDayShift(c.instant, 120));
240+
});
241+
});
242+
243+
it('the process timezone is restored after every case', () => {
244+
expect(process.env.TZ).toBe(REAL_TZ);
245+
});
246+
});

packages/services/service-automation/src/builtin/template-functions.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,15 @@ describe('over-denial controls — the diagnostic is not a blanket refusal (#110
202202
it('NOW()/TODAY() whole-token macros are unchanged', () => {
203203
expect(String(tpl('NOW()'))).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
204204
expect(String(tpl('TODAY()'))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
205+
// #14852: the ORACLE has to be spelled on one calendar too. With
206+
// `setDate`/`getDate` it re-stated the very two-calendar defect it was
207+
// checking against, so across a DST transition it would have FOLLOWED
208+
// the implementation instead of catching it. Indistinguishable at
209+
// TZ=UTC, which is why it read as correct for as long as it did; the
210+
// transition windows themselves are pinned in
211+
// `template-date-offset-dst.test.ts`.
205212
const plus90 = new Date();
206-
plus90.setDate(plus90.getDate() + 90);
213+
plus90.setUTCDate(plus90.getUTCDate() + 90);
207214
expect(tpl('TODAY() + 90')).toBe(plus90.toISOString().slice(0, 10));
208215
});
209216

packages/services/service-automation/src/builtin/template.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,20 @@ function resolveToken(token: string, variables: VariableMap, context: Automation
224224
}
225225
}
226226
const now = new Date();
227-
if (offset) now.setDate(now.getDate() + sign * offset);
227+
// ONE calendar, and it is UTC — the same one both returns render on
228+
// two lines below (#14852). The old spelling did the day arithmetic
229+
// on the LOCAL calendar (`getDate`/`setDate`) and rendered on the UTC
230+
// one. That is accidentally equivalent only while the local day is 24
231+
// hours long: `setDate` preserves wall-clock time, so across a DST
232+
// transition the instant moves 23h (spring-forward) or 25h
233+
// (fall-back) instead of n x 24h, and the rendered date lands a day
234+
// early or a day late for the one UTC hour whose shortfall/overshoot
235+
// crosses midnight. Measured over 34 zones x every 30 minutes of 2026
236+
// x offsets {+1, -1} (1,191,360 pairs): the mixed spelling flips 190
237+
// of them across 24 DST-observing zones, this spelling flips none.
238+
// Pinned by `template-date-offset-dst.test.ts`, which cannot go red
239+
// at TZ=UTC -- there the two spellings are indistinguishable.
240+
if (offset) now.setUTCDate(now.getUTCDate() + sign * offset);
228241
if (fn === 'NOW') return now.toISOString();
229242
return now.toISOString().slice(0, 10);
230243
}

0 commit comments

Comments
 (0)