Repository navigation
fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) - #20746
Conversation
…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>
…h Date.UTC 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
…c-instant-from-parts
📓 Docs Drift CheckThis PR changes 3 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Inputs: card #20599 (body and all five comments, 5895526582 included), PR #20746 (body, file list, 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
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…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>
…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>
Fixes #20599
Clause-②: yes
What changes
Date.UTC(year, …)andnew Date(year, …)read a year from 0 to 99 as 1900 + year (ECMA-262MakeFullYear). 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/coregains one root export,wallClockToUtcMs(parts: WallClockParts): number. It isDate.UTCwithout the remap, built asnew Date(0), thensetUTCFullYear, thensetUTCHours.monthis 1-12. Every component rolls over past its end the wayDate.UTCrolls it, and aNaNcomponent givesNaN. It sits besidezonedWallClockToUtcMsinutils/datetime.ts, and the root barrel'sexport *carries it, sopackages/core/src/index.tsis untouched.zonedWallClockToUtcMs(the wall clock and the zone-offset read), and through itzonedDateStartToUtcMs;isoWeekLabelFromCalendarDay;bucketKeyToCalendarRange; andfilter-tokens(proxyDay,startOfPeriod,daysInMonth,addMonthsClamped);service-analytics:preview-evaluator.tsbucketDate's week key, anddataset-executor.tsisoWeekKeyOfUtcMs. The census found the second one; the card did not list it;trigger-schedule:time-relative-trigger.tsstartOfUtcDay/endOfUtcDay.Intl'syearpart is an ERA year, so 1 BC reads1. 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_York0001-01-01 00:00is pinned: it gives0001-01-01T04:56:02.000Z.filter-tokensspells its day with core'stemporalStorageFormdaterule. Before, a hand-rolledymd()wrote the year unpadded. That spelling was unreachable for 0001..0099 whileDate.UTCthrew those years into the 1900s. This change makes it reachable:{1976_years_ago}would have become50-09-30, which names no day. So the fix pads it, and a macro step into 0100..0999 is padded too ({720000_days_ago}gives0055-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.tsis unchanged (H3). Its door is fixed through core.Census (before any fix, at
origin/main4dfff176b9)git grep -n "Date\.UTC("andgit grep -nE "new Date\(\s*[^)\"'\x60]*,"over non-test tracked files, all packages and scripts. That gave 49Date.UTC(lines and 6 multi-argumentnew Date(lines. Every multi-argumentnew Date(hit is a comment, or a single-argument call caught by the pattern.4dfff176b9)datetime.ts:143zonedWallClockToUtcMswall clockPOST /importdatetimecell, measured belowdatetime.ts:174offset readdatetime.ts:314/:317ISO-week labelbucketDateKey(week)of a stored year-50 instant (in-memory aggregation, analytics)datetime.ts:374–:414bucketKeyToCalendarRange0050from a SQL driver's bucket expression, drilledfilter-tokens.ts:198proxyDay,:215–:219startOfPeriodnow, the clock, at every caller (filterTokenContextFrom(ctx, new Date())or unset)filter-tokens.ts:225daysInMonthfilter-tokens.ts:243addMonthsClamped{N_months_ago}/{N_years_ago}; the grammar's N is unbounded (DATE_MACRO_PARAM_RE)service-analyticspreview-evaluator.ts:367week keyservice-analyticsdataset-executor.ts:841isoWeekKeyOfUtcMscompareToalignment on a week ordinal in year 50trigger-scheduletime-relative-trigger.ts:118/:123offsetDays/withinDaysare unbounded ints in spec, so an offset reaches 1..99formulastdlib.ts:59calendarDayUtcnow()only (the pinned evaluation clock)formuladepends onspecalone and cannot import coreformulastdlib.ts:102addMonthsUtcdaysInMonthexamples/app-todotask.functions.ts:47service-messagingpreference-resolver.ts:359nowMsclockservice-smssms-daily-quota.ts:141nowclockdriver-mongodbmongodb-pipeline-evaluator.testkit.ts:92/:147/:151calendar-day.ts:170, restimport-coerce.ts:453,driver-sqlsql-driver.ts:6892/:6893/:6989/:15303,driver-turso:71filter-number-comparand-declared-type.ts:909scripts/**(check-osv-exemptions,pm/*,qa/qa-rollup,sync-release-index-currency)Reach, measured at the door
The in-process route harness drives
POST /api/v1/data/:object/importand reads back throughPOST /api/v1/data/:object/queryover ObjectQL and SqlDriver. The base reading restorespackages/core/src/utils/{datetime,filter-tokens}.tsto4dfff176b9and rebuilds core; core is the only package on this door that the diff touches. The PostgreSQL 16.13 server ran attimezone=Asia/Shanghai.0050-01-01 10:00, no zone1950-01-01T10:00:00.000Z, ok 2 / errors 00050-01-01T10:00:00.000Z0050-01-01 10:00, Asia/Shanghai1950-01-01T02:00:00.000Z0050-01-01T01:54:17.000Z(LMT +08:05:43)2026-07-15 10:00control2026-07-15T10:00:00.000Z/…02:00:00.000Z0001/0050/0100at…-01-01T10:00Z, export as written1-01-01 …,50-01-01 …,100-01-01 …unpadded)1901-01-01T10:00Z,1950-01-01T10:00Z,0100exact; Asia/Shanghai:1950-01-01T10:05:43ZMechanism hypotheses (zone 2), as measured
datetime.ts, at the listed lines. The offset read also needed the zone's era for a year-0 probe.service-analyticsandtrigger-schedule;restneeds no import (H3);formulacannot import core, and needs no change: its two sites are clock-only or benign;zonedWallClockToUtcMswith no zone equals the helper, but only through its documented fallback.datetimepath goes throughDate.parse. A cell with a time goes throughzonedWallClockToUtcMs, which is now correct, soimport-coerce.tsis untouched.Date.UTChalf is gone:bucketDateKey('0050-01-01T10:00Z', week)is week 52 of 0049, not of 1949.bucketKeyToCalendarRangespans padded keys (0050,0050-Q4,0050-12,0050-01-01) in their own years. The round trip frombucketDateKeytobucketKeyToCalendarRangestill does not hold below year 1000, at any granularity:bucketDateKeyspells those years unpadded (50,50-Q1,50-01,50-01-01,49-W52), and the range function reads only\d{4};0050-W01answersnull;daysInMonth(day 0). The helper rolls identically. Nine rollover cases are pinned in 0001..0099, plus four 2026 cases equal toDate.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).packages/core/src/utils/datetime-year-below-100.test.ts(160 cases): the helper, rollover andNaN;zonedWallClockToUtcMsandzonedDateStartToUtcMswith no zone, UTC, Asia/Shanghai and America/New_York;bucketDateKeyweek in UTC and Asia/Shanghai;bucketKeyToCalendarRangeyear, quarter, month and day, plus the 2026 week control.packages/core/src/utils/filter-tokens-year-below-100.test.ts(26): year and month macros into 0001, 0050 and 0099 in UTC and Asia/Shanghai, the February-29 clamp in years 48, 50 and 100, and day steps padded.packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts(42): the previewbucketDateweek, and thecompareToordinals frombucketOrdinalOfDayandbucketKeyAtOrdinal.packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts(20):offsetDaysandwithinDayswindows, and the claim scope.packages/rest/src/import-datetime-year-below-100.test.ts(74). This is thedomain:cliseat's round-trip pin, in theirexport-date-year-pad.test.tsharness pattern:parseDateCellon two hosts;POST /importof a CSV with no zone, UTC and Asia/Shanghai;GET /export(csv and json) and re-imported into a fresh stack, under no zone, Asia/Shanghai and America/New_York, for 0001, 0050, 0099, 0100 and 2026.Blocked-by: #20599); once it lands, the step changes nothing, and PR fix(rest): /export writes a date or datetime cell with a four-digit year, so an export of a year below 1000 re-imports (#20602) #20688 can drop its owndt: undefinedexclusion for 0001 and 0050.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/)node scripts/ablation-replace.mjsreplaced the helper's body withDate.UTC(parts.year, …): anchor 1 → 0, blobfda5c68ba9bb→9d0def6aaa50. Thenpnpm --filter @objectstack/core build, andablation-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 weekexpected '1949-12-26' to be '0049-12-27', and windowgte '1950-09-30T00:00:00.000Z'. Every 0100, 2026,NaNand padding control stayed green. The direction was red, as predicted.fda5c68ba9bb, andgit diff HEADis 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.4dfff176b9(blob match: yes), rest's pin file gave 38 failed / 36 passed. Exactly the 0001, 0050 and 0099 rows failed;imports every rowstayed green, which is the silentok.Tests and gates (after the final commit,
da39ddacc0)pnpm --filter PKG test:bucketDateKeyand the filter tokens).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 typecheckis green for core, service-analytics, trigger-schedule and rest. Each program's--listFilesincludes the new test files.node scripts/pm/dispatch-gates.mjs --commands: 64 commands, all exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3), and answered 0 afterturbo run build --filter='./packages/*' --filter='./packages/*/*'.--ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN..tsfiles, all matched byeslint.config.mjs'sfilesglobs;--format jsonread 10 files, 0 errors and 0 warnings;--print-configshows noparserOptions.projectorprojectService. 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.driver-sqlleg of Temporal Conformance. This container has no MySQL server, and nodriver-sqlsource imports a changed helper. CI runs it.Not in this PR
calendar-day.ts, which PR fix(spec): nextUtcCalendarDay and utcInstantMs read years 0001..0099 as written, not as 1900..1999 (#20550) #20591 already corrected.datecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 / [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602), and MySQL's read-back (driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280). [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602 and driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280 are not addressed here.domain:cliseat. This PR removes theBlocked-bycause.Acceptance notes
bucketDateKey,isoWeekLabelFromCalendarDayand service-analyticsbucketKeyAtOrdinalspell 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, andbucketKeyToCalendarRange's week arm answersnulleven for a padded key. This belongs to the unpadded-year family of [finding]/exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602.formulakeeps a private calendar-day copy (stdlib.ts:59), reached only throughnow(), and a day-count use ofDate.UTC(:102). Both read right for 1..99, so they are unchanged.examples/app-todohas the same day-count shape.driver-mongodb'smongodb-pipeline-evaluator.testkit.tsreads an ISO string throughDate.UTCand would model a year-50 instant in 1950. It is a test model, not product; noted with no carrier.calendarPartsInTzreadsIntl'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