Skip to content

fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) - #20746

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20599-utc-instant-from-parts
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20599-utc-instant-from-parts

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20599
Clause-②: yes

What changes

Date.UTC(year, …) and new Date(year, …) read a year from 0 to 99 as 1900 + year (ECMA-262 MakeFullYear). Core built UTC instants from parts that way, so every day of 0001..0099, inside the supported range 0001..9999, landed in the 1900s with no error.

  • @objectstack/core gains one root export, wallClockToUtcMs(parts: WallClockParts): number. It is Date.UTC without the remap, built as new Date(0), then setUTCFullYear, then setUTCHours. month is 1-12. Every component rolls over past its end the way Date.UTC rolls it, and a NaN component gives NaN. It sits beside zonedWallClockToUtcMs in utils/datetime.ts, and the root barrel's export * carries it, so packages/core/src/index.ts is untouched.
  • Every site the census below confirms now builds through it. There is no per-site copy:
    • core: zonedWallClockToUtcMs (the wall clock and the zone-offset read), and through it zonedDateStartToUtcMs; isoWeekLabelFromCalendarDay; bucketKeyToCalendarRange; and filter-tokens (proxyDay, startOfPeriod, daysInMonth, addMonthsClamped);
    • service-analytics: preview-evaluator.ts bucketDate's week key, and dataset-executor.ts isoWeekKeyOfUtcMs. The census found the second one; the card did not list it;
    • trigger-schedule: time-relative-trigger.ts startOfUtcDay / endOfUtcDay.
  • The offset read also takes the zone's era. Intl's year part is an ERA year, so 1 BC reads 1. A wall clock early on 0001-01-01 in a zone west of UTC probes the offset in year 0. Without the era, that probe reads a year late and the answer is garbage. America/New_York 0001-01-01 00:00 is pinned: it gives 0001-01-01T04:56:02.000Z.
  • filter-tokens spells its day with core's temporalStorageForm date rule. Before, a hand-rolled ymd() wrote the year unpadded. That spelling was unreachable for 0001..0099 while Date.UTC threw those years into the 1900s. This change makes it reachable: {1976_years_ago} would have become 50-09-30, which names no day. So the fix pads it, and a macro step into 0100..0999 is padded too ({720000_days_ago} gives 0055-06-15). This is a mandatory fix under the "a shipped defect this change touches" rule, and it is not the bucket-key padding family (see "Not in this PR").
  • packages/rest/src/import-coerce.ts is unchanged (H3). Its door is fixed through core.

Census (before any fix, at origin/main 4dfff176b9)

git grep -n "Date\.UTC(" and git grep -nE "new Date\(\s*[^)\"'\x60]*," over non-test tracked files, all packages and scripts. That gave 49 Date.UTC( lines and 6 multi-argument new Date( lines. Every multi-argument new Date( hit is a comment, or a single-argument call caught by the pattern.

site (line at 4dfff176b9) can a year below 100 reach it? disposition
core datetime.ts:143 zonedWallClockToUtcMs wall clock yes: the POST /import datetime cell, measured below helper
core datetime.ts:174 offset read yes: once the wall clock keeps year 50, the probe instant is in year 50 (and in year 0 for 0001 west of UTC) helper + era
core datetime.ts:314 / :317 ISO-week label yes: bucketDateKey(week) of a stored year-50 instant (in-memory aggregation, analytics) helper
core datetime.ts:374–:414 bucketKeyToCalendarRange yes: a padded key such as 0050 from a SQL driver's bucket expression, drilled helper
core filter-tokens.ts:198 proxyDay, :215–:219 startOfPeriod no: derived from now, the clock, at every caller (filterTokenContextFrom(ctx, new Date()) or unset) helper (the family closes)
core filter-tokens.ts:225 daysInMonth reachable, benign: a month's length is the same in Y and 1900 + Y for Y in 1..99 helper
core filter-tokens.ts:243 addMonthsClamped yes: {N_months_ago} / {N_years_ago}; the grammar's N is unbounded (DATE_MACRO_PARAM_RE) helper
service-analytics preview-evaluator.ts:367 week key yes: a preview row in year 50 helper
service-analytics dataset-executor.ts:841 isoWeekKeyOfUtcMs yes: compareTo alignment on a week ordinal in year 50 helper
trigger-schedule time-relative-trigger.ts:118 / :123 yes: offsetDays / withinDays are unbounded ints in spec, so an offset reaches 1..99 helper
formula stdlib.ts:59 calendarDayUtc no: reads now() only (the pinned evaluation clock) unchanged; formula depends on spec alone and cannot import core
formula stdlib.ts:102 addMonthsUtc reachable, benign: day count only, same as daysInMonth unchanged
examples/app-todo task.functions.ts:47 benign, the same day-count shape unchanged
service-messaging preference-resolver.ts:359 no: nowMs clock unchanged
service-sms sms-daily-quota.ts:141 no: now clock unchanged
driver-mongodb mongodb-pipeline-evaluator.testkit.ts:92 / :147 / :151 test model, imported only by tests unchanged (Acceptance notes)
spec calendar-day.ts:170, rest import-coerce.ts:453, driver-sql sql-driver.ts:6892 / :6893 / :6989 / :15303, driver-turso :71 comments none
spec filter-number-comparand-declared-type.ts:909 the constant 2026 none
scripts/** (check-osv-exemptions, pm/*, qa/qa-rollup, sync-release-index-currency) tooling; fixed 2026 constants or 2020s dates read from files none

Reach, measured at the door

The in-process route harness drives POST /api/v1/data/:object/import and reads back through POST /api/v1/data/:object/query over ObjectQL and SqlDriver. The base reading restores packages/core/src/utils/{datetime,filter-tokens}.ts to 4dfff176b9 and rebuilds core; core is the only package on this door that the diff touches. The PostgreSQL 16.13 server ran at timezone=Asia/Shanghai.

SQLite, base PostgreSQL 16, base SQLite and PostgreSQL 16, this branch
0050-01-01 10:00, no zone 1950-01-01T10:00:00.000Z, ok 2 / errors 0 same 0050-01-01T10:00:00.000Z
0050-01-01 10:00, Asia/Shanghai 1950-01-01T02:00:00.000Z same 0050-01-01T01:54:17.000Z (LMT +08:05:43)
2026-07-15 10:00 control 2026-07-15T10:00:00.000Z / …02:00:00.000Z same same as base
export, then import, 0001 / 0050 / 0100 at …-01-01T10:00Z, export as written refused, ok 0 / errors 3 (1-01-01 …, 50-01-01 …, 100-01-01 … unpadded) same same: the export's padding is PR #20688's
the same, with the year padded as PR #20688's export writes it 1901-01-01T10:00Z, 1950-01-01T10:00Z, 0100 exact; Asia/Shanghai: 1950-01-01T10:05:43Z same all three exact, both zones

Mechanism hypotheses (zone 2), as measured

  • H1 held, and one site more: the root is core's datetime.ts, at the listed lines. The offset read also needed the zone's era for a year-0 probe.
  • H2 held:
    • a new core root export, used by core, service-analytics and trigger-schedule;
    • rest needs no import (H3);
    • formula cannot import core, and needs no change: its two sites are clock-only or benign;
    • no existing export fits: zonedWallClockToUtcMs with no zone equals the helper, but only through its documented fallback.
  • H3 held. PR fix(rest)!: /import reads a date, datetime or time cell only in ISO 8601, the export shape or a year-first date, on a real day, with a four-digit year (#20534) #20601's bare-day datetime path goes through Date.parse. A cell with a time goes through zonedWallClockToUtcMs, which is now correct, so import-coerce.ts is untouched.
  • H4 partly falsified. The Date.UTC half is gone: bucketDateKey('0050-01-01T10:00Z', week) is week 52 of 0049, not of 1949. bucketKeyToCalendarRange spans padded keys (0050, 0050-Q4, 0050-12, 0050-01-01) in their own years. The round trip from bucketDateKey to bucketKeyToCalendarRange still does not hold below year 1000, at any granularity:
    • bucketDateKey spells those years unpadded (50, 50-Q1, 50-01, 50-01-01, 49-W52), and the range function reads only \d{4};
    • the week arm validates against the unpadded label, so even a padded SQL key 0050-W01 answers null;
    • that is the unpadded-key family, which the dispatch excluded (out-of-scope finding 1).
  • H5 held. The rollover is load-bearing at Q4's and December's end (month 13), a day key's end (day 32) and daysInMonth (day 0). The helper rolls identically. Nine rollover cases are pinned in 0001..0099, plus four 2026 cases equal to Date.UTC.

Pins

Each file runs its zone-free sites on a UTC host and on an Asia/Shanghai host (process.env.TZ, asserted to have taken).

Every expected instant is spelled as an ISO string, never computed by the code under test.

Reverse verification (committed first, rebuilt, and checked in dist/)

  • Mutate. node scripts/ablation-replace.mjs replaced the helper's body with Date.UTC(parts.year, …): anchor 1 → 0, blob fda5c68ba9bb → 9d0def6aaa50. Then pnpm --filter @objectstack/core build, and ablation-dist-preflight.mjs @objectstack/core 'Date.UTC(parts.year' said the marker is present in 2 built files. Result: core 120 failed / 66 passed, service-analytics 28 / 14, trigger-schedule 12 / 8, rest 38 / 36. For example, expected '1950-01-01T00:00:00.000Z' to be '0050-01-01T00:00:00.000Z', preview week expected '1949-12-26' to be '0049-12-27', and window gte '1950-09-30T00:00:00.000Z'. Every 0100, 2026, NaN and padding control stayed green. The direction was red, as predicted.
  • Restore. The blob equals HEAD fda5c68ba9bb, and git diff HEAD is empty. After a rebuild, the preflight with --absent 'Date.UTC(parts.year' finds the marker in none of 14 files, and the tree is clean. Result: 186 / 42 / 20 / 74 passed.
  • Base leg. With core's two files at 4dfff176b9 (blob match: yes), rest's pin file gave 38 failed / 36 passed. Exactly the 0001, 0050 and 0099 rows failed; imports every row stayed green, which is the silent ok.

Tests and gates (after the final commit, da39ddacc0)

  • pnpm --filter PKG test:
    • core 60 files / 1728 tests;
    • service-analytics 140 / 3266;
    • trigger-schedule 8 / 170;
    • rest 229 / 4461 passed and 55 skipped;
    • objectql 337 / 6687 (a consumer of bucketDateKey and the filter tokens).
  • The non-SQL temporal suite under TZ=America/New_York, asserted to have taken, all green: core, formula 42 / 1240, driver-memory 65 / 1470, driver-mongodb 29 / 661 (172 skipped), service-analytics.
  • pnpm --filter PKG typecheck is green for core, service-analytics, trigger-schedule and rest. Each program's --listFiles includes the new test files.
  • node scripts/pm/dispatch-gates.mjs --commands: 64 commands, all exit 0. check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3), and answered 0 after turbo run build --filter='./packages/*' --filter='./packages/*/*'. --ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • eslint, narrowed:
    • population: the 10 changed .ts files, all matched by eslint.config.mjs's files globs;
    • count: --format json read 10 files, 0 errors and 0 warnings;
    • invariance: --print-config shows no parserOptions.project or projectService. That is, no type-aware linting, and the only cross-file inputs are two baselines this diff does not touch, so no untouched file's verdict can move.
  • NOT MEASURED: the live driver-sql leg of Temporal Conformance. This container has no MySQL server, and no driver-sql source imports a changed helper. CI runs it.

Not in this PR

Acceptance notes

  • Out-of-scope finding 1, reported to the seat and not filed from here. bucketDateKey, isoWeekLabelFromCalendarDay and service-analytics bucketKeyAtOrdinal spell a year below 1000 unpadded. SQL drivers' bucket expressions pad it (strftime('%Y')), so the in-memory and pushed-down keys differ for those years, and bucketKeyToCalendarRange's week arm answers null even for a padded key. This belongs to the unpadded-year family of [finding] /export writes a date / datetime cell with a year below 1000 unpadded (0500-01-01 → 500-01-01), so the export does not re-import #20602.
  • formula keeps a private calendar-day copy (stdlib.ts:59), reached only through now(), and a day-count use of Date.UTC (:102). Both read right for 1..99, so they are unchanged. examples/app-todo has the same day-count shape.
  • driver-mongodb's mongodb-pipeline-evaluator.testkit.ts reads an ISO string through Date.UTC and would model a year-50 instant in 1950. It is a test model, not product; noted with no carrier.
  • The forward direction calendarPartsInTz reads Intl's era year too. It matters only for an instant whose local day is before 0001-01-01, which is outside the supported range.

Generated by Claude Code

…t-year remap

wallClockToUtcMs replaces Date.UTC at zonedWallClockToUtcMs (and its
offset read), the ISO-week label, bucketKeyToCalendarRange, filter-tokens,
service-analytics' week keys and trigger-schedule's day window.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…ger-schedule patch

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…c-instant-from-parts

# Conflicts:
#	packages/services/service-analytics/src/preview-evaluator.ts
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/service-analytics, @objectstack/trigger-schedule, touching 14 documentable anchor(s).

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via bucketKeyToCalendarRange (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 01e78dceeffb28477bcdbcab26f951b4cbef78ec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c51899929d80e5eab145ff60cfcd8f26dc329925 — the merge of head da39ddacc0adb5624a5e3a84d379b16d6869e9c5 into base 01e78dceeffb28477bcdbcab26f951b4cbef78ec, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c51899929d80e5eab145ff60cfcd8f26dc329925 && git checkout c51899929d80e5eab145ff60cfcd8f26dc329925
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01e78dceeffb28477bcdbcab26f951b4cbef78ec da39ddacc0adb5624a5e3a84d379b16d6869e9c5 && git checkout -B drift-repro 01e78dceeffb28477bcdbcab26f951b4cbef78ec && git merge --no-ff da39ddacc0adb5624a5e3a84d379b16d6869e9c5

node scripts/docs-audit/affected-docs.mjs --json 01e78dceeffb28477bcdbcab26f951b4cbef78ec

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 01e78dceeffb28477bcdbcab26f951b4cbef78ec → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: da39ddacc0adb5624a5e3a84d379b16d6869e9c5
Local-runs: none

Inputs: card #20599 (body and all five comments, 5895526582 included), PR #20746 (body, file list, git diff origin/main... at the head; merge-base 01e78dceef), and the head's check-runs. Read-only: nothing built, run or re-run. Every instant below was traced by hand through ECMA-262 MakeDay / MakeTime / TimeClip and the tz database's local mean time before each zone's first transition (Asia/Shanghai +08:05:43, America/New_York -04:56:02).

Check-runs on the head, read by this act (2026-09-30T01:56Z) and not waited for: success — Auto Label, Dogfood Verify CLI, Dogfood Regression Gate 1/3 and 3/3, Build Core, Type Check · source gates, Type Check · debt ledger, Check Documentation Links, filter, Check Changeset, Check PR Size, Flag docs affected by code changes, Part-of PR must not also close its card, Governed Surface Queue Guard, No other open PR may claim the same single-writer path, No other open PR may claim the same issue, The card this PR closes must claim this branch; skipped — Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in); in_progress — Test Core 1/6 to 6/6, Dogfood Regression Gate 2/3, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Type Check · consumer gates, Type Check · workspace. None failed at read time. The landing rule's "every check green" stays the seat's to read when they finish.

① Derived judgments

  1. Public surface: @objectstack/core gains wallClockToUtcMs(parts: WallClockParts): number through utils/datetime.ts and the barrel's export * from './utils/datetime.js' (index.ts:50; index.ts untouched, as the body says). Right. new Date(0), then setUTCFullYear(y, m-1, d), then setUTCHours(h, mi, s, ms) is Date.UTC's MakeDay / MakeTime / MakeDate / TimeClip chain with MakeFullYear left out: month 13 and month 0 roll through MakeDay's year carry, day 0 and day 32 through its day offset, hour 24 and hour -1 through MakeTime, a NaN or non-finite part gives NaN, and an instant past ±8.64e15 ms is clipped to NaN. Every sentence of the shipped TSDoc holds. WallClockParts and CalendarParts are unchanged, and no api-surface baseline lists core's exports, so nothing else regenerates.
  2. zonedWallClockToUtcMs / zonedDateStartToUtcMs: the accept set is unchanged; only the answer for a year 0..99 moves. Right. Traced: 0050-01-01 10:00 in Asia/Shanghai — wall-as-UTC 10:00Z; probe 1 reads local 18:05:43, offset +29143 s; probe 2 at 01:54:17Z reads local 10:00:00, same offset — 0050-01-01T01:54:17.000Z, the changeset's and body's figure. 0001-01-01 00:00 in America/New_York — probe 1 lands at local 0000-12-31 19:03:58, year 0, whose Intl year part is the era year 1 BC, read as 1 - 1 = 0; offset -17762 s; probe 2 at 04:56:02Z reads local 0001-01-01 00:00 — 0001-01-01T04:56:02.000Z, the body's figure. Without the era the first probe reads 0001-12-31 and the iteration settles about a year off, so "garbage" is fair. zonedDateStartToUtcMs('0001-01-01', 'Asia/Shanghai') is 0000-12-31T15:54:17.000Z as pinned (a year-0 instant, which toISOString spells 0000-). One thing the prose does not say: year 0 itself moves too — a 0000-01-01 10:00 cell used to be stored as 1900 silently; it now builds year 0 and the record validator refuses it (isOutsideTemporalYearRange, record-validator.ts:1280). That is the declared supported range finally reaching this door: right, and an omission in the changeset rather than a defect.
  3. era: 'short' in the offset read. Right, and it changes nothing else the read uses. The read takes parts by type, never by position; adding era adds one era part and a literal, while year: 'numeric', the 2-digit month / day / hour / minute / second and hourCycle: 'h23' are unaffected in en-US. 1 - eraYear on BC is the ISO proleptic mapping (1 BC is year 0). A fragility, not a defect at this head: the arm keys on the literal 'BC', ICU's en-US abbreviated era; any other spelling would fall silently to the AD arm, and the America/New_York pin is what guards that, on CI's Node and ICU.
  4. isoWeekLabelFromCalendarDay, and through it bucketDateKey(week). Right. 0050-01-01 is a Saturday (721,719 days before Thursday 2026-01-01; 721,719 mod 7 is 5); its Thursday is 0049-12-30; Jan 4 0049 is a Monday; so week 52 of 0049 — the changeset's "week 52 of 0049, not of 1949" is what the code computes. The label is still spelled 49-W52 (the unpadded family, [finding] /export writes a date / datetime cell with a year below 1000 unpadded (0500-01-01 → 500-01-01), so the export does not re-import #20602's); the pin reads it numerically and says why.
  5. bucketKeyToCalendarRange. Right. Every arm traced: year utcDay(y+1, 1, 1); quarter startMonth + 3 is 13 for Q4 and rolls to next January; month mo + 1 is 13 for December, likewise; day d + 1 is 32 on the 31st and rolls to the next month, and fmt pads the year to four so 0050-01-01 validates against itself and is no longer null; week utcDay(isoYear, 1, 4). 0050-W01 still answers null because the week arm validates against the unpadded label — H4's residue, rightly left to the unpadded family.
  6. filter-tokens: proxyDay, startOfPeriod, daysInMonth, addMonthsClamped. Right. daysInMonth(year, month0) is day 0 of 1-based month month0 + 2, that is the last day of month0 (for December, month 13 is next January and day 0 of it is Dec 31). addMonthsClamped passes targetMonth + 1 (1..12) and the four time parts through. Every rollover the seat named — Q4's end, December's end, a day key's end, daysInMonth's day 0, addMonthsClamped — is preserved by item 1's MakeDay chain.
  7. asYmd now spells through temporalStorageForm(d, 'date'). Right as a fix; one undeclared edge. Against the old ymd(): identical for a year above 9999 (10000-09-30 both — padStart(4) on five digits is a no-op), for year 0 and for a negative year (unpadded both); padded for 0001..0999 (declared, 0100..0999 included — the mandatory fix under the touched-shipped-defect rule stands, since {1976_years_ago} would otherwise have become 50-09-30, which names no day); and different for an Invalid Date: old NaN-NaN-NaN, new Invalid Date (the rule hands the Date back and String() spells it). Reachable, because DATE_MACRO_PARAM_RE bounds N by nothing and {300000_years_ago} steps past the ±271,821-year Date range; but both spellings name no day, the temporal-comparand door runs before token resolution and judges neither, and a driver compares either as text that matches nothing. The changeset does not state this change of spelling. Judged an omission on an already-uninterpretable value, below the FAIL line; the seat may ask for one sentence.
  8. service-analytics: preview-evaluator.ts bucketDate(week) and dataset-executor.ts isoWeekKeyOfUtcMs. Right. month from calendarPartsInTzOrUtc is 1-based, as the helper takes it; Jan 4 of the Thursday's year goes through the helper. bucketOrdinalOfDay reaches the fix through boundInstantMs and core's zonedDateStartToUtcMs, so a year ordinal is now the year as written. The pin's Mondays (0049-12-27, 0050-06-13, 0099-12-28, 0100-01-04) follow from item 4's arithmetic. In scope under triage's "census first, so the family closes in one PR".
  9. trigger-schedule: startOfUtcDay / endOfUtcDay. Right. utcDayParts gives a 1-based month; the end bound carries 23:59:59.999; @objectstack/core is already a dependency of the package. The pin's day counts (739,616 / 721,719 / 703,822 / 703,457 / 272) are the proleptic-Gregorian distances from 2026-09-30 to 0001-09-30 / 0050-09-30 / 0099-09-30 / 0100-09-30 / 2026-01-01.
  10. Census, and the sites left alone. Right. At the head, the only non-test Date.UTC( calls left under packages/** are formula/stdlib.ts:59 (calendarDayUtc, called at :248 / :252 / :256 with now() only) and :102 (addMonthsUtc: a day count — 1900 is 0 mod 4 and 1901..1999 holds no century year, so a month is the same length in Y and 1900 + Y for Y in 1..99 — and the result Date is built by setUTCMonth, which keeps the year), service-messaging/preference-resolver.ts:359 (nowMs), service-sms/sms-daily-quota.ts:141 (now), spec/filter-number-comparand-declared-type.ts:909 (the constant 2026), and driver-mongodb's .testkit.ts (not exported by the barrel; imported by tests only). No driver-sql source imports a changed helper. rest/import-coerce.ts is rightly unchanged: the bare-day datetime path is Date.parse of the day plus T00:00:00.000Z (:519) and the clock path is zonedWallClockToUtcMs (:514).
  11. Pins against the triage roster (0001, 0050, 0099, 0100 and a 2026 control; UTC and Asia/Shanghai; two host zones; each site; the export-then-import round trip the domain:cli seat asked for). Right. Every expected instant is an ISO literal. The filter-tokens day steps check out (720,000 days before 2026-09-30 is 0055-06-15; 739,000 is 0003-06-08). The round trip pads the exported year before re-import — see ③.
  12. Shipped prose. Changeset: every factual sentence holds against the head, with two imprecisions — "{1976_years_ago} resolves to 0050-09-30" is true for a 2026-09-30 reference day the sentence does not name; "Every year from 0100 on builds exactly as before" is true of the instant while the text's own earlier bullet declares the 0100..0999 macro-spelling change. PR body: the census table, H1 to H5, the reach table's instants, the Shanghai and New_York figures, and "packages/core/src/index.ts is untouched" are true against the code; test counts and local gate output are the dev's measurements and are not re-judged here.

② Semver level

.changeset/20599-utc-instant-from-parts.md: @objectstack/core minor, @objectstack/service-analytics patch, @objectstack/trigger-schedule patch. Matches what the diff publishes. Core adds one root export, and minor is the floor a yes declaration demands; analytics and trigger-schedule change behaviour at existing exports only; @objectstack/rest gains a test file and publishes nothing, so it rightly has no entry. No published accept set narrows — the helper takes the domain Date.UTC took, NaN in gives NaN out — so the declaration carries no direction arm. The Clause-② line on the body and the claim reads yes with no arm: right, the declaration limb alone hits, as the claim said. Check Changeset is green on the head.

③ Boundary flags

Implemented-by: claude/issue-20599-utc-instant-from-parts
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 02:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit a6866da Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20599-utc-instant-from-parts branch September 30, 2026 02:25
huangyiirene pushed a commit that referenced this pull request Sep 30, 2026
…port-year-pad

Brings in PR #20746 (core builds a UTC instant from parts with
wallClockToUtcMs), so the export -> import round trip for datetime years
0001..0099 can be measured against a base that reads the padded year right.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants