Repository navigation
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
Conversation
…ear, so a year below 1000 re-imports Every export date and datetime cell now takes its day from core's temporalStorageForm date rule (imported), which pads 0001..0999 and leaves a year outside 0001..9999 unpadded. The business-timezone path reads the zone's year from the instant, never from Intl's era year. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 15 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 5eda1f9dcbb3059f993386592e5cc903d2902e38 && git checkout 5eda1f9dcbb3059f993386592e5cc903d2902e38
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa && git checkout -B drift-repro 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b && git merge --no-ff 81b61a6b188d20beda1d5aa79e2bc7df213e2dfa
node scripts/docs-audit/affected-docs.mjs --json 6f1f1c1035b581ce37b957b5ea18bed4a0bb270b
|
…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>
…floor After merging main, the create door refuses a datetime before year 1000 (the floor landed with PR #20843), so the pin's route rows for 0500 and 0999 could no longer be created and the whole route layer went red. The route rows before 1000 now carry a date only, 0099 joins the years, and a boundary row pins the one datetime cell the export's padding still reaches at the routes: 1000-01-01T02:00:00.000Z, which America/New_York reads on 0999-12-31. Two comments and the changeset no longer say the import would take a padded datetime before year 1000: it refuses one now, as the write doors do. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20602 (body and all seven comments: triage 5886127452, the predecessor claim 5894935136 and report 5895487340, the seat answer 5895522655, the engine pointer 5902858739, the takeover claim 5916503658 and its report 5917691304), PR #20688 (body, the three-file list, Check-runs on the head, read by this act (2026-09-30, after the dev's 18:54Z reading): 41 runs, 34 distinct names after collapsing to latest-per-name — 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in_progress. The seven required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core (and 1/6 to 6/6), Dogfood Regression Gate (and 1/3 to 3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The two the dev saw in_progress (Check Changeset, Part-of PR must not also close its card) have since completed success. The file list holds no governed path. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…stant is built from parts (wallClockToUtcMs) (objectstack-ai#20746) Fixes objectstack-ai#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 objectstack-ai#20688's | | the same, with the year padded as PR objectstack-ai#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 objectstack-ai#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). - `packages/core/src/utils/datetime-year-below-100.test.ts` (160 cases): the helper, rollover and `NaN`; `zonedWallClockToUtcMs` and `zonedDateStartToUtcMs` with no zone, UTC, Asia/Shanghai and America/New_York; `bucketDateKey` week in UTC and Asia/Shanghai; `bucketKeyToCalendarRange` year, 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 preview `bucketDate` week, and the `compareTo` ordinals from `bucketOrdinalOfDay` and `bucketKeyAtOrdinal`. - `packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts` (20): `offsetDays` and `withinDays` windows, and the claim scope. - `packages/rest/src/import-datetime-year-below-100.test.ts` (74). This is the `domain:cli` seat's round-trip pin, in their `export-date-year-pad.test.ts` harness pattern: - `parseDateCell` on two hosts; - `POST /import` of a CSV with no zone, UTC and Asia/Shanghai; - rows created, sent through `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. - The round trip pads an exported year below 1000 to four digits before re-importing, as PR objectstack-ai#20688's export will write it. That half is PR objectstack-ai#20688's (`Blocked-by: objectstack-ai#20599`); once it lands, the step changes nothing, and PR objectstack-ai#20688 can drop its own `dt: undefined` exclusion 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/`) - **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 - spec `calendar-day.ts`, which PR objectstack-ai#20591 already corrected. - The unpadded year in bucket keys and the export (objectstack-ai#20534 / objectstack-ai#20602), and MySQL's read-back (objectstack-ai#20280). objectstack-ai#20602 and objectstack-ai#20280 are not addressed here. - PR objectstack-ai#20688 stays drafted by the `domain:cli` seat. This PR removes the `Blocked-by` cause. ## 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 objectstack-ai#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](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20602
Clause-②: no
What changes
GET /api/v1/data/:object/exportwrote adateordatetimecell'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) andPOST /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'sdatebranch,utcWallClockandzonedWallClocknow take the day from one private helper,calendarDay.temporalStorageForm,daterule, imported from@objectstack/core(not mirrored). It pads 0001..0999 and leaves a year outside 0001..9999 unpadded.@objectstack/restalready depends on@objectstack/core; nopackages/core/**edit.zonedWallClockno longer reads the year fromIntl'syearpart, which is an ERA year (year 0, 1 BC, reads1): padding it would spell0001-01-01T03:00:00Zin New York as0001-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 withsetUTCFullYear, neverDate.UTC.packages/rest/src/rest-server.tsis untouched; the pins drive the real routes.This round: merge of
main, and what it changed for this PRThe seat held this PR behind #20599 (answer
5895522655, option B). #20599 has landed (PR #20746,a6866da0c), and this round mergedorigin/main9509ea106ainto the branch asc0c254921a(a merge commit; no rebase, no force-push;git merge-treewas clean).mainalso carries PR #20843 (05a7547c9f, #20280): adatetimenames a year from 1000 to 9999 at both engine doors, and adatekeeps 0001..9999. That moved this PR's own pin: atc0c254921athe route layer ofexport-date-year-pad.test.tswent red (80 passed | 63 skipped, every routebeforeAllfailed), because the create door now refuses the pin'sdatetimerows for 0500 and 0999 (400 VALIDATION_FAILED, fielddt, codeinvalid_date). So the dispatched step, "drop thedt: undefinedexclusion for 0001 and 0050", cannot be taken as written: those rows cannot be created at all. What81b61a6b18does instead:packages/rest/src/export-date-year-pad.test.ts): the route rows before year 1000 carry adateonly; 0099 joins the years (formatter census and routes); a boundary row pins the onedatetimecell the export's padding still reaches at the routes, the instant1000-01-01T02:00:00.000Z, which America/New_York reads on0999-12-31(exported0999-12-31 21:03:58, re-imported as the same instant). The module note says why.export-format.ts, one in the pin) no longer say the import "would take" the era-year spelling: after 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 the import refuses it, as the write doors do. Comment-only; no behaviour inexport-format.tsmoved (its blob went8904e5c4b1b4tod318ab58eb98on that one comment).datetime0500-01-01 10:00:00re-imports: it names thedatecell and the zone-boundarydatetimecell that do, and says adatetimestored 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/exportandPOST /api/v1/data/:object/importhandlers of aRestServeroverObjectQLplusSqlDriver(better-sqlite3:memory:) and the real metadata protocol, driven in-process, business timezone from the resolvedExecutionContext, into a fresh stack for the import. A throwaway probe (deleted, not in the diff), atc0c254921a, CSV, xlsx and JSON, business timezone none / Asia/Shanghai / America/New_York, hostTZunset (UTC) andTZ=America/New_York./importdate0001, 0050, 0099, 0500 (-01-01)0001-01-01etc., padded, all zonesdate20262026-01-01datetime0001, 0050, 0099, 0500 at 10:00ZVALIDATION_FAILED,dtinvalid_date(72 of 72)datetime1000 at 10:00Z1000-01-01 10:00:00/18:05:43/05:03:58datetime2026 at 10:00Z2026-01-01 10:00:00/18:00:00/05:00:00datetime1000-01-01T02:00:00.000Z1000-01-01 02:00:00/1000-01-01 10:05:43/0999-12-31 21:03:58datetime0050, 0500 at 10:00Z written through the driver (a row stored before the floor)0050-01-01 10:00:00,0500-01-01 10:00:00(and zone clocks), paddeddtinvalid_date(36 of 36)Every row the create door takes round-trips exactly: 144 of 144 legs. The seat's concern for this landing order, a padded
datetime0001..0099 stored 1900 years late with no error, has no path left: the create door refuses such adatetime, 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.tsat9509ea106a(the merge'smainparent; blob5791dbaeb33bproven on disk, restored to HEAD8904e5c4b1b4withgit diff HEADempty): thedatecells export1-01-01,50-01-01,99-01-01,500-01-01, the New York boundary cell999-12-31 21:03:58, the pre-floor rows50-01-01 10:00:00; the import refuses every one asinvalid_date. Per format:ok 4, errors 6with no zone and in Asia/Shanghai,ok 3, errors 7in America/New_York; at the headok 8, errors 2everywhere (the two pre-floor rows).Ablations at
81b61a6b18, each throughnode scripts/ablation-replace.mjs(anchor hit once, blob moved, restore proven: blobd318ab58eb98equals HEAD andgit diff HEADempty); the subject resolves throughsrc/by relative import, so nodistleg:calendarDayreturns 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 whosedateis before 1000, the boundary row included.zonedWallClockspells the day'sgetUTCFullYear()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'sdatetimehalf biting on its own.Earlier readings by the predecessor round (head
e739a50fa0, base6981abfd26), still describingexport-format.tsas it is: H0 (the base exported500-01-01and500-01-01 10:00:00and the import refused the row) and the one-shot H1 census of 1050formatCellValuecells (years 1000, 2026, 9999 and +010000 byte-identical except 5 cells whose zone day is0999-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;dateanddatetime; zones none / UTC / Asia/Shanghai / America/New_York / unknown), the 2026 control's exact cells,Dateand 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, underscripts/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), hostTZunset and again underTZ=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 --listFilesOnlylists the pin file.@objectstack/restis byte-unchanged (calendarDayis private), so no downstream consumer owes a test.Gates
At
81b61a6b18(merge base9509ea106a):node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 60 commands, all exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (PREREQUISITE NOT MET, nodist) and are green afterpnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2.--ran:60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(the whole tree, not narrowed): exit 0, 229 s.Acceptance notes
placed_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280's floor means adatetimein 0001..0999 is refused at the create door, so no such row exports. Every row the doors take round-trips exactly (table above).packages/rest/src/import-datetime-year-below-100.test.ts(PR fix(core): read a year from 0001 to 0099 as written wherever a UTC instant is built from parts (wallClockToUtcMs) #20746, read-only here): itspadExportYearstep 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-enddatetimecheck below 1000 in a zone. Not edited.datetimewhose 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.datetimebefore 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.packages/rest/src/import-prepare.tsxlsxDateToNaiveCellspells 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(claim5916503658); the branch's first two commits are the predecessor seat's (sessionsession_local_1d2a197c-c20e-4e90-9be8-413d4d432289).