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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ommits that decided them (objectstack-ai#20775)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the twelfth stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/triggers/trigger-schedule/src/**` and nothing else. By the
seat's claim (`5903462246`), it is the largest package in the lane that
no in-flight work holds, now that objectstack-ai#20599's PR objectstack-ai#20746 (which edited
`time-relative-trigger.ts`) has landed. Later stages cover the other
packages, so this PR says `Part of` and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 11 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR
objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`, PR objectstack-ai#20757 as
`cba417a8f`). That is **32 sites on 32 lines in 6 files, covering 2
numbers**:

- 18 census sites (every census site this package has);
- 14 sites in test comments, which the census defers;
- no site the gate's grammar cannot see (the package has none, see
Acceptance notes).

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **2 distinct shas**. Neither number has an ADR or ruling record
of its own (a grep of `docs/adr/`, `scripts/adr-anchors/` and the rest
of `docs/` for both numbers finds nothing, and no ADR records the
acting-organization decision or the driver-memory per-call refusal), so
both anchors are commits, per ruling C's order. No number was dropped.

Only comments changed. Every touched source file keeps its line count
(32 lines out, 32 in, over 6 files), so no line citation into these
files moves. Every one of the 32 changed lines carried a dead citation;
there is no reflow line. No code token moves (see the guard below).

**No citation number is added.** The only tracker number on an added
line is the live `objectstack-ai#8844`, on the line it already stood on. Added minus
removed is negative for the two dead numbers and zero for every other
number, and no number is new to the diff. No PR number is the citation
on an added line.

4 dead sites are left on purpose: three `describe` titles, and one
comment that quotes one of those titles verbatim (see the list below).

One more file: a `patch` changeset for `@objectstack/trigger-schedule`,
because the rewritten prose ships (see Changeset below).

## Census: `trigger-schedule`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/triggers/trigger-schedule/`. Each run counts as a reading only
because its board frontier equals the newest issue or pull-request
number, read by a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
trigger-schedule sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `cba417a8f`, run 2026-09-30T03:30:14Z to 03:33:24Z |
enumerated, 186 pages, frontier objectstack-ai#20769 (newest objectstack-ai#20769 before and after)
| 823 | **18** | 18 | 2 | 2 |
| after | head `226be8050`, run 03:37:59Z to 03:41:09Z | enumerated, 186
pages, frontier objectstack-ai#20769 (newest objectstack-ai#20769 before and after) | 805 | **0** |
0 | 0 | 0 |

The before count matches the seat's census and A1 (18 sites: `objectstack-ai#16659`
×17 and `objectstack-ai#16589` ×1, in `schedule-trigger.ts` ×5 and
`time-relative-trigger.ts` ×13). A1 noted that PR objectstack-ai#20746 edited
`time-relative-trigger.ts` today; the before count above is taken on the
base that already holds that edit. The whole-repo drop is 18, exactly
this diff's census sites. The `resolves` tally is 33,038 in both runs,
and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995)
did not move either. The after run was taken on `226be8050`; the head
`14314f49c` adds only the changeset. No run was truncated or discarded:
both enumerations read 186 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `trigger-schedule/src` (14 files). It takes
its verdicts from the before census's own board reading rather than from
a second enumeration: a number is dead when that census reported it
`allocated-but-absent`, and alive when that census judged it on this
board anywhere (its `--list` extraction, 36,840 rows) and did not report
it. Every number this package cites is covered by one or the other, so
no number needed a separate read to be judged; the two dead numbers were
also read one by one on the issues endpoint, and each answers 404.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `cba417a8f` | 159 | **36** | 18 | 15 | 0 | 3 |
| after, `226be8050` | 127 | **4** | 0 | 1 | 0 | 3 |

Its src-comment column equals the census's 18, which is the control on
the second instrument. The 123 live citations are the same in both
readings, and the drop of 32 citations is exactly the rewritten sites. A
third, raw reading (every `#` followed by 2 to 6 digits, whatever
surrounds it) finds 162 occurrences and 36 dead before, 130 and 4 after.
Beyond the gate's grammar it sees 3 tokens, none a tracker reference:
the maintainer decision-batch ordinals `batch objectstack-ai#13`, `objectstack-ai#116` and `objectstack-ai#118`,
which the gate's `NON_CITATION_HEADS` excuses by design.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (21 lines blame to the anchor itself; for the other 11,
`merge-base --is-ancestor` of anchor and blamed commit exits 0).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#16659` | 34/6 | 30/4 | `ecdfc9411` (PR objectstack-ai#17334): a time-triggered
flow (`schedule` or `time_relative`) declares its acting organization on
its start node as `config.organization`; the engine lifts it onto the
binding; both time triggers refuse to bind a flow that declares none,
naming it at `error` and THROWING so the engine records the refusal
instead of reporting the flow bound; the run carries the declared
organization as `tenantId`; and the time-relative sweep's own query
carries it too, so the sweep SELECTS inside that organization (the
review finding F2 its diff names), with a store that cannot honour the
scope reported at `error` and an object the engine exempts from scoping
disclosed at bind. Its body names `objectstack-ai#16659` twice (the three consequences
pinned on both drivers, and the proof registered), and its diff names it
on 65 added lines. The anchor the spec stage (`0f6dcac5e`) and the lint
stage (`f29c83db1`) already give the same number |
| `objectstack-ai#16589` | 2/2 | 2/0 | `555a89cbd` (PR objectstack-ai#17005): `driver-memory` gains
a third seam, `assertCallNotTenantScoped`, called first in every driver
door that accepts `DriverOptions`, which REFUSES a call the engine
tenant-scoped instead of discarding the scope and answering every
organization's rows; row-level isolation is deliberately not
implemented. Its message does not carry the number, but its own diff
writes the mechanism the two lines describe and names `objectstack-ai#16589` 30 times
(the `[objectstack-ai#16589] Seam 3` markers and the guard's docblock), so it is the
commit that decided it. New to the sweep |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 2), and both are ancestors of
the base (`merge-base --is-ancestor`, exit 0 for both; control leg:
stage 1's landing `422db788a` exit 0; reverse leg, base against
`ecdfc9411`, exit 1; the history is complete, `--is-shallow-repository`
false, 15,160 commits; each anchor lies deeper than the control, 1,616
and 1,814 commits behind the base). Each of the 2 numbers answers 404 on
the issues endpoint, which serves pull requests too. Independently, the
package's own shipped `CHANGELOG.md` pairs `ecdfc94` with `objectstack-ai#16659` (line
149) and `assertCallNotTenantScoped` with `objectstack-ai#16589` (line 238).

## Wordings to check

- **Tag swaps in brackets or parentheses.** 「[objectstack-ai#16659]」 became 「[commit
ecdfc94]」 on 18 lines, 「(objectstack-ai#16659)」 became 「(commit ecdfc94)」 at
`schedule-trigger.ts:251`, `:372` and `time-relative-trigger.ts:50`, and
「(objectstack-ai#16589)」 became 「(commit 555a89c)」 at `time-relative-trigger.ts:615`
and `time-relative-trigger.test.ts:988`.
- **Section rules.** `schedule-trigger.test.ts:327`,
`time-relative-trigger.test.ts:794` and `:862`: the 16-character phrase
replaces the 6-character number and the trailing rule loses 10
characters, so each line keeps its width exactly. The `:862` heading
keeps 「F2」 beside the sha; F2 is the selection finding `ecdfc9411`'s own
diff names.
- **「before objectstack-ai#16659」** at `time-relative-trigger.ts:365` and `:543`
became 「before commit ecdfc94」: before that commit the sweep queried
with `isSystem` alone, which is the unscoped selection both sentences
describe.
- **「the objectstack-ai#16659 defect」** at `time-relative-trigger.ts:561` and
`time-relative-trigger.test.ts:1371` became 「the defect commit ecdfc94
fixed」: a commit fixes a defect, it is not one, and the widening both
sentences name is the selection half that commit closed.
- **`schedule-trigger.test.ts:512`.** 「the exact defect objectstack-ai#16659's own
refusal was shaped to avoid」 became 「the exact defect commit ecdfc94's
own refusal was shaped to avoid」: the defect is a refusal that logs and
arms anyway, and that commit is where the refusal became a throw so the
engine records it.
- **`schedule-trigger.test.ts:588`.** 「(the objectstack-ai#16659 suite above)」 became
「(commit ecdfc94's refusal suite above)」, so the pointer still lands
on the refusal suite at `:337`.

## The 4 sites left

- **Test strings, 3 sites on 3 lines**, all `describe` titles, left as
stages 1 to 11 left theirs: `schedule-trigger.test.ts:337` and `:462`,
`time-relative-trigger.test.ts:805` (all `objectstack-ai#16659`).
- **One comment that quotes a kept title verbatim:**
`schedule-trigger.test.ts:71` points the reader at 「`ScheduleTrigger —
the acting-organization refusal (objectstack-ai#16659)` below」, the exact text of the
`describe` title at `:337`. The number there belongs to the quotation,
so it stays with the title it quotes: rewriting it would point at a
title that does not exist. It moves when the title does.
- No source string, operator log string, assertion message, quoted
maintainer ruling or generated file in this package carries a dead
number.
- Outside `src`, the package's `CHANGELOG.md` names `objectstack-ai#16659` on 6 lines
and `objectstack-ai#16589` on 2 (lines 149, 153, 238, 273, 297, 300, 302, 304). It is
release-owned and deliberately not edited here (see Acceptance notes).
The package `README.md`, which also ships, names neither number.

## Mechanical guard: no code token moves

The guard compares, base `cba417a8f` against head, over all 6 touched
`.ts` files:

- **Reading 1**, the TypeScript parser's leaf nodes (a `forEachChild`
walk, so comments are trivia and JSDoc nodes are never visited). String
and template literals are therefore read in full.
- **Reading 2**, the full token stream in parser context (a
`getChildren` walk, so punctuation and keywords are included; JSDoc
nodes skipped).

Results:

- Real run: 10,192 base leaf tokens, **0 files with a token change** on
either reading (exit 0).
- Comment control in `schedule-trigger.ts` (「the same way `schedule`
is.」 to 「the same way as `schedule`.」): 0 files changed, as expected
(exit 0).
- Positive control, a code token added in `time-relative-trigger.ts`
(`resolveBindingOrganization(binding)` given `as FlowTriggerBinding`):
DIFFER, 1,215 to 1,216 leaf tokens and 2,760 to 2,762 full tokens (exit
1).
- Positive control, one digit changed inside a kept test title
(`schedule-trigger.test.ts:462`, `objectstack-ai#16659` to `objectstack-ai#16658`): DIFFER on the
string literal (exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path, and each landed
(anchor 1 to 0, blob changed). Each restore was proven byte-identical to
the HEAD blob (`651170483856`, `c85aadbd168d`, `78a5dea4a463`), with
`git diff HEAD` empty and a clean tree afterwards.

A first version of reading 2 used TypeScript's context-free scanner and
was discarded before any control ran: it opened template tokens on
backticks it could not place and swallowed comment text into them, so it
reported comment edits as token changes (4 files) while reading 1 read
0. The parser-context stream replaced it, and every figure above is from
the replacement.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/trigger-schedule`
(`.changeset/20596-trigger-schedule-provenance-anchors.md`) is included.
Its body is stage 11's, word for word, with the package name changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`, and the package is not private. After the build:

- `ecdfc9411` appears 3 times in each of `dist/index.js` and
`dist/index.mjs`: the inline comments at `schedule-trigger.ts:777` and
`time-relative-trigger.ts:585` and `:702`, which the bundle keeps.
- It appears twice in each of `dist/index.d.ts` and `dist/index.d.mts`:
the `FlowTriggerBinding.organization` docblock
(`schedule-trigger.ts:32`) and the sweep-context docblock
(`time-relative-trigger.ts:50`).
- `555a89cbd` appears once in each JS entry
(`time-relative-trigger.ts:615`).
- Positive controls, one unchanged line beside each shipped rewrite,
land exactly where their neighbours do: four neighbours once in each JS
file and 0 in the declaration files, and two once in each declaration
file and 0 in the JS files.
- A never-written negative phrase appears nowhere in `dist`.
- Neither dead number is left in `dist`.

## Gates (head `14314f49c`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
exits 0. `node scripts/check-issue-citations.mjs` exits 0: the
diff-scoped run judged 1 added citation across 2 files, the live
`objectstack-ai#8844`, and it resolves.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0 (the
sibling-package prose-id baseline holds, no growth).
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `14314f49c` (after a fresh fetch)
derived 59 commands. They are all 53 derived at dispatch, plus
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure`, `check:type-check-coverage`,
`check:type-check-debt` and `check:where-matcher`.
- Each ran with its exit code captured before any pipe, and all 59 exit
0.
- `--ran`, fed each command with its exit code, reports 59 run, 0 NOT
MEASURED (a derived zero), 0 unrun, and exits 0.
- A full `turbo run build` of `./packages/*` and `./packages/*/*` ran
first under the shared verify lock (71 of 71 tasks, exit 0), so no gate
hit an unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/trigger-schedule test`: 8 files pass and
170 tests pass. `vitest list --filesOnly` names 8 files, all the tracked
test files, the 4 touched ones included.
- `pnpm --filter @objectstack/trigger-schedule typecheck` exits 0, and
`tsc --listFiles` holds all 14 files under `src/`, the 6 touched ones
included.
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 6 touched `.ts` files, gives 6 files, 0 errors and 0 warnings
(its `--format json` output). All 6 are in eslint's own population
(`isPathIgnored` is false for each; a `dist` file, as the control, is
ignored). `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 7 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package: `#N-word` none, `#A/#B` none, `option #N`
none, at the base and at the head, which is the claim's 0 / 0 / 0. The
two `pre-objectstack-ai#10220` spellings in `time-relative-trigger.test.ts` are
extracted by the gate as this repository's `objectstack-ai#10220`, which the census
judges live.
- **`CHANGELOG.md` is left.**
`packages/triggers/trigger-schedule/CHANGELOG.md` names `objectstack-ai#16659` and
`objectstack-ai#16589` on 8 lines. It is release-owned (AGENTS.md, Documentation
Guardrails), a deferred surface of the citation gate, and ⛔ not part of
this stage.
- **「The card」 phrases are left.** 8 comment lines in 5 files of this
package speak of 「this card」, 「that card」 or 「the card」. They carry no
number and neither instrument sees them. The one beside a rewritten
line, `schedule-trigger.test.ts:329` (「the card's consequence (3)」),
sits under the heading `:327` that now names `ecdfc9411`, whose own
message pins those three consequences, so it keeps a referent. The rest
are unchanged, as in stages 8 to 11.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#16589` →
`555a89cbd` is new to the sweep; `driver-memory`'s own `src` still names
`objectstack-ai#16589` on 29 lines in 4 files (26 of them comments; corrected by the
seat from the dev report, which measured it), all outside this lane's
stage surface. `objectstack-ai#16659` → `ecdfc9411` reuses the spec and lint stages'
anchor.
- **Base.** The branch is on `main` at `cba417a8f`. `main` has since
moved three commits (`0d9349fea`, `7053333e1`, `f284ab26d`). Their 9
files are one changeset, ADR-0053, and sources and tests under
`service-analytics` and `service-automation`. They touch nothing under
`trigger-schedule`, nor `scripts/check-issue-citations.mjs`,
`.changeset/config.json` or the `doc-authoring-prose-id` baseline.
`service-automation` is a dev dependency of this package, but this diff
moves no code token, so nothing here can interact with it. No merge was
taken; the merge queue rebuilds on the merged generation.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ear, so an export of a year below 1000 re-imports (objectstack-ai#20602) (objectstack-ai#20688)

Fixes objectstack-ai#20602
Clause-②: no

## What changes

`GET /api/v1/data/:object/export` wrote a `date` or `datetime` cell's
year unpadded, so a day in the years 0001 to 0999 left the export short
(`500-01-01`, `999-12-31 21:03:58`) and `POST
/api/v1/data/:object/import`, which reads a four-digit year only,
refused the platform's own file.

One source file changes, `packages/rest/src/export-format.ts`, on the
three paths triage named:

- `formatDate`'s `date` branch, `utcWallClock` and `zonedWallClock` now
take the day from one private helper, `calendarDay`.
- **The pad rule: core's `temporalStorageForm`, `date` rule, imported
from `@objectstack/core`** (not mirrored). It pads 0001..0999 and leaves
a year outside 0001..9999 unpadded. `@objectstack/rest` already depends
on `@objectstack/core`; no `packages/core/**` edit.
- `zonedWallClock` no longer reads the year from `Intl`'s `year` part,
which is an ERA year (year 0, 1 BC, reads `1`): padding it would spell
`0001-01-01T03:00:00Z` in New York as `0001-12-31`, a day a year later
than the instant's. The zone's year is the instant's UTC year, plus one
when the zone has reached January while UTC is in December, minus one
the other way round. The day is built with `setUTCFullYear`, never
`Date.UTC`.
- `packages/rest/src/rest-server.ts` is untouched; the pins drive the
real routes.

## This round: merge of `main`, and what it changed for this PR

The seat held this PR behind objectstack-ai#20599 (answer `5895522655`, option B).
objectstack-ai#20599 has landed (PR objectstack-ai#20746, `a6866da0c`), and this round merged
`origin/main` `9509ea106a` into the branch as `c0c254921a` (a merge
commit; no rebase, no force-push; `git merge-tree` was clean).

`main` also carries PR objectstack-ai#20843 (`05a7547c9f`, objectstack-ai#20280): **a `datetime`
names a year from 1000 to 9999 at both engine doors**, and a `date`
keeps 0001..9999. That moved this PR's own pin: at `c0c254921a` the
route layer of `export-date-year-pad.test.ts` went red (`80 passed | 63
skipped`, every route `beforeAll` failed), because the create door now
refuses the pin's `datetime` rows for 0500 and 0999 (`400
VALIDATION_FAILED`, field `dt`, code `invalid_date`). So the dispatched
step, "drop the `dt: undefined` exclusion for 0001 and 0050", cannot be
taken as written: those rows cannot be created at all. What `81b61a6b18`
does instead:

- **The pin** (`packages/rest/src/export-date-year-pad.test.ts`): the
route rows before year 1000 carry a `date` only; 0099 joins the years
(formatter census and routes); a boundary row pins the one `datetime`
cell the export's padding still reaches at the routes, the instant
`1000-01-01T02:00:00.000Z`, which America/New_York reads on `0999-12-31`
(exported `0999-12-31 21:03:58`, re-imported as the same instant). The
module note says why.
- **Two comments** (one in `export-format.ts`, one in the pin) no longer
say the import "would take" the era-year spelling: after objectstack-ai#20280 the
import refuses it, as the write doors do. Comment-only; no behaviour in
`export-format.ts` moved (its blob went `8904e5c4b1b4` to `d318ab58eb98`
on that one comment).
- **The changeset** no longer says a `datetime` `0500-01-01 10:00:00`
re-imports: it names the `date` cell and the zone-boundary `datetime`
cell that do, and says a `datetime` stored before year 1000 exports
padded and is refused by the import, as by the write doors.

## Measured, on the merged tree

Harness: the real `POST /api/v1/data/:object`, `GET
/api/v1/data/:object/export` and `POST /api/v1/data/:object/import`
handlers of a `RestServer` over `ObjectQL` plus `SqlDriver`
(better-sqlite3 `:memory:`) and the real metadata protocol, driven
in-process, business timezone from the resolved `ExecutionContext`, into
a fresh stack for the import. A throwaway probe (deleted, not in the
diff), at `c0c254921a`, CSV, xlsx and JSON, business timezone none /
Asia/Shanghai / America/New_York, host `TZ` unset (UTC) and
`TZ=America/New_York`.

| row | create door | exported cell (none / Shanghai / New York) |
`/import` | stored back |
|:--|:--|:--|:--|:--|
| `date` 0001, 0050, 0099, 0500 (`-01-01`) | 201 | `0001-01-01` etc.,
padded, all zones | ok | identical, 72 of 72 legs |
| `date` 2026 | 201 | `2026-01-01` | ok | identical |
| `datetime` 0001, 0050, 0099, 0500 at 10:00Z | **400**
`VALIDATION_FAILED`, `dt` `invalid_date` (72 of 72) | no row | none |
none |
| `datetime` 1000 at 10:00Z | 201 | `1000-01-01 10:00:00` / `18:05:43` /
`05:03:58` | ok | identical |
| `datetime` 2026 at 10:00Z | 201 | `2026-01-01 10:00:00` / `18:00:00` /
`05:00:00` | ok | identical |
| `datetime` `1000-01-01T02:00:00.000Z` | 201 | `1000-01-01 02:00:00` /
`1000-01-01 10:05:43` / **`0999-12-31 21:03:58`** | ok | identical |
| `datetime` 0050, 0500 at 10:00Z written through the driver (a row
stored before the floor) | not the door | `0050-01-01 10:00:00`,
`0500-01-01 10:00:00` (and zone clocks), padded | row refused, `dt`
`invalid_date` (36 of 36) | nothing stored |

Every row the create door takes round-trips exactly: 144 of 144 legs.
The seat's concern for this landing order, a padded `datetime`
0001..0099 stored 1900 years late with no error, has no path left: the
create door refuses such a `datetime`, and a row stored before the floor
exports padded and is refused loudly by the import, never stored.

**Control, the same probe with `export-format.ts` at `9509ea106a`** (the
merge's `main` parent; blob `5791dbaeb33b` proven on disk, restored to
HEAD `8904e5c4b1b4` with `git diff HEAD` empty): the `date` cells export
`1-01-01`, `50-01-01`, `99-01-01`, `500-01-01`, the New York boundary
cell `999-12-31 21:03:58`, the pre-floor rows `50-01-01 10:00:00`; the
import refuses every one as `invalid_date`. Per format: `ok 4, errors 6`
with no zone and in Asia/Shanghai, `ok 3, errors 7` in America/New_York;
at the head `ok 8, errors 2` everywhere (the two pre-floor rows).

**Ablations at `81b61a6b18`**, each through `node
scripts/ablation-replace.mjs` (anchor hit once, blob moved, restore
proven: blob `d318ab58eb98` equals HEAD and `git diff HEAD` empty); the
subject resolves through `src/` by relative import, so no `dist` leg:

- `calendarDay` returns the unpadded spelling: `111 failed | 61 passed
(172)`; no failing test names 1000, 2026 or 9999; the failures are the
below-1000 formatter cells and every route row whose `date` is before
1000, the boundary row included.
- Only the zone path unpadded (`zonedWallClock` spells the day's
`getUTCFullYear()` unpadded, the era-year correction kept): `14 failed |
158 passed (172)`: the ten zoned formatter cells before 1000
(Asia/Shanghai and America/New_York), the year-boundary pin, and exactly
the boundary route row in America/New_York in CSV, xlsx and JSON. That
is the new route row's `datetime` half biting on its own.

Earlier readings by the predecessor round (head `e739a50fa0`, base
`6981abfd26`), still describing `export-format.ts` as it is: H0 (the
base exported `500-01-01` and `500-01-01 10:00:00` and the import
refused the row) and the one-shot H1 census of 1050 `formatCellValue`
cells (years 1000, 2026, 9999 and +010000 byte-identical except 5 cells
whose zone day is `0999-12-31`, now padded; 0001..0999 padded;
out-of-range zoned cells now spell the rule's year instead of the era
year).

## Tests

`packages/rest/src/export-date-year-pad.test.ts`, 172 tests: the
formatter census (years 0001, 0050, 0099, 0500, 0999, 1000, 2026, 9999;
`date` and `datetime`; zones none / UTC / Asia/Shanghai /
America/New_York / unknown), the 2026 control's exact cells, `Date` and
epoch-ms inputs, the year-boundary pin, and the route round trip per
row, format and business timezone (the xlsx leg also asserts text
cells).

All at `81b61a6b18`, under `scripts/pm/os-verify-lock.sh`:

- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2 src/export-date-year-pad.test.ts
src/import-datetime-year-below-100.test.ts`: `Tests 218 passed (218)`,
host `TZ` unset and again under `TZ=America/New_York`.
- `pnpm --filter @objectstack/rest run test`: `Test Files 239 passed
(239)`, `Tests 4824 passed | 106 skipped (4930)`.
- `pnpm --filter @objectstack/rest run test:repo`: `Tests 8 passed (8)`.
- `pnpm --filter @objectstack/rest run typecheck`: exit 0,
`check:test-typecheck: OK`; `tsc -p tsconfig.test.json --listFilesOnly`
lists the pin file.
- The public surface of `@objectstack/rest` is byte-unchanged
(`calendarDay` is private), so no downstream consumer owes a test.

## Gates

At `81b61a6b18` (merge base `9509ea106a`):

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands`: 60 commands, all exit 0. `check:dual-build-cjs-loads` and
`check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`, no
`dist`) and are green after `pnpm exec turbo run build
--filter='./packages/*' --filter='./packages/*/*' --concurrency=2`.
`--ran`: `60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN`.
- NOT MEASURED locally, by the tool's own account: the six families
whose argv takes a value from the workflow, and the eleven whole-root
families; CI runs them.
- `pnpm lint` (the whole tree, not narrowed): exit 0, 229 s.

## Acceptance notes

1. **The landing order the seat set is met, and the round trip it asked
about is not a case any more.** objectstack-ai#20599 has landed, and objectstack-ai#20280's floor
means a `datetime` in 0001..0999 is refused at the create door, so no
such row exports. Every row the doors take round-trips exactly (table
above).
2. **`packages/rest/src/import-datetime-year-below-100.test.ts`** (PR
objectstack-ai#20746, read-only here): its `padExportYear` step is now a byte-for-byte
no-op on every cell this export writes for a day in 0001..9999, so on
every cell its own round trip exports. Its round trip runs at 1000 and
2026 only (the floor), so it never carried a year below 1000 through the
export; this PR's boundary row is the end-to-end `datetime` check below
1000 in a zone. Not edited.
3. **Out-of-range years in a business timezone.** A `datetime` whose
zone day falls in year 0 or before spells the rule's year (`0-12-31`)
instead of the era year (`1-12-31`). The import refuses both, and the
write doors refuse such years.
4. **Observed, not filed:** the create door refuses a well-formed ISO
`datetime` before year 1000 with the sentence "At must be a valid
datetime (ISO-8601)", which names the spelling rather than the
1000..9999 range; the record validator chose one sentence per kind on
purpose. No carrier.
5. **Not changed, dormant:** `packages/rest/src/import-prepare.ts`
`xlsxDateToNaiveCell` spells an xlsx date cell's year unpadded. An xlsx
date cell is an Excel serial (from 1900, or 1904), and the export writes
text cells, so no workbook reaches it with a year below 1000.

Taken over in this round by session `session_01VvcEokUG1tvVxkceYfR5XB`
(claim `5916503658`); the branch's first two commits are the predecessor
seat's (session `session_local_1d2a197c-c20e-4e90-9be8-413d4d432289`).

---------

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
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