fix(spec): nextUtcCalendarDay and utcInstantMs read years 0001..0099 as written, not as 1900..1999 (#20550) - #20591
Conversation
…as written, not as 1900..1999 Date.UTC reads a year from 0 to 99 as 1900 + year, so the round trip that proves a bare day real failed for every day of those years and both helpers answered null: a datetime $lte or $between maximum on such a day skipped whole-day widening. The date is now built with setUTCFullYear. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…y in year 0050, over REST on SQLite and PostgreSQL Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…shows each Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 137 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 d8ca92ed06496e3d9e03a613dc1b61b4d63515e6 && git checkout d8ca92ed06496e3d9e03a613dc1b61b4d63515e6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c876a7426d930e9e8d310fdffb1f64ae836cdced 6a5dce7378eb31968ef97e55a579d87ff1b5cdf8 && git checkout -B drift-repro c876a7426d930e9e8d310fdffb1f64ae836cdced && git merge --no-ff 6a5dce7378eb31968ef97e55a579d87ff1b5cdf8
node scripts/docs-audit/affected-docs.mjs --json c876a7426d930e9e8d310fdffb1f64ae836cdced |
Contract reviewServed-tier: ① Derived judgmentsScope read first. Head confirmed two ways:
② Semver level
③ Boundary flags
The three out-of-scope findings, each verified read-only and judged:
Check-runs on this head, one read at 2026-09-29T05:46Z, 32 runs, no failure: 13 success (Check Changeset, Check PR Size, Governed Surface Queue Guard, the three claim guards, Spec property liveness, Type Check · source gates, docs link and drift checks, Auto Label, filter), 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 16 in progress and NOT concluded: Lint & Repo Gates (every Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20550
Clause-②: no
nextUtcCalendarDay(packages/spec/src/data/calendar-day.ts) proves a bareYYYY-MM-DDis a real day by building it and reading it back. It built the date withDate.UTC(y, mo - 1, d), andDate.UTCreads a year from 0 to 99 as 1900 + year, so0050-01-01was built as1950-01-01, the round trip failed, and the helper answerednullfor every day of the years 0001..0099.utcInstantMsasks the same helper about a bare day, so it answerednullfor them too. Adatetime$lteor$betweenmaximum on such a day then skipped whole-day widening (ADR-0053 D-D) and stopped at the day's first instant.The accept set is unchanged: a query on years 0001..0099 now answers what the contract already says (the claim's
Clause-②: no).The change
packages/spec/src/data/calendar-day.ts: a privateutcMidnight(year, monthIndex, day)builds the date asnew Date(0)plussetUTCFullYear(year, monthIndex, day), which takes the year as written and rolls the month and day over exactly asDate.UTCdoes.nextUtcCalendarDayuses it for both the round trip and the next day. The impossible-day refusal is unchanged in kind:2026-02-30,0050-02-30and0100-02-29still roll over and are refused by the round trip.utcInstantMsis byte-identical. Its bare-day arm already delegated the "is this a real day" question tonextUtcCalendarDayand then read the instant withDate.parseof ISO text, which reads year 0050 correctly; its timestamp arm never touchedDate.UTC. So the one construction fixes both helpers. No other helper in the file builds a date.packages/core/**orpackages/drivers/**: every caller imports these helpers from@objectstack/spec(core re-exports them).Verification (final head
6a5dce7378, basef11b5f20a2)Premise, measured before editing:
tsximportingpackages/spec/src/data/calendar-day.tsatf11b5f20a2.nextUtcCalendarDay('0050-01-01')=null,utcInstantMs('0050-01-01')=null; the same for0001-01-01and0099-12-31. Control:'0100-01-01'answers'0100-01-02'and-59011459200000.YYYY-MM-DDwith year 0000..9999, month 00..13 and day 00..32 (4,620,000 strings, 3,652,425 real days), compared with a pure-arithmetic proleptic Gregorian oracle that uses noDate. Base: 36,525 mismatches for each helper, exactly every real day of the years 0000..0099. Head: 0 mismatches for either helper.packages/rest/src/data-query-calendar-day-year-below-100.test.tsthroughPOST /api/v1/data/:object/query, process inAmerica/New_York, withcalendar-day.ts's construction put back toDate.UTC(reverse verification below). SQLite and PostgreSQL 16 answered the same:where opened_at$lte '0050-01-01'y49y49,y50,y50_last$between ['0050-01-01', '0050-01-01']y50,y50_last$lte '2026-07-15'(control)c26,y49,y50,y50_last,y50_next$between ['2026-07-15', '2026-07-15'](control)c26Rows:
y49=0049-12-31T10:00Z,y50=0050-01-01T10:00Z(the card's row),y50_last=0050-01-01T23:59:59.999Z,y50_next=0050-01-02T00:00Z,c26=2026-07-15T14:00Z(the card's control),c26_next=2026-07-16T00:00Z. The next day's midnight stays out in every cell.Reverse verification (fix committed first, mutation through
scripts/ablation-replace.mjs,traprestore to theHEADblob): theutcMidnightbody was replaced byreturn new Date(Date.UTC(year, monthIndex, day));; the anchor fell 1 to 0 and the replacement rose 0 to 1 on disk;pnpm --filter @objectstack/spec build;scripts/ablation-dist-preflight.mjsfound the mutation in 4 built files and the fix's line in none. Thencalendar-day.test.ts: 3 failed / 7 passed (0001-01-01: expected null to be '0001-01-02'); the REST file: 2 failed / 2 passed, the table's base column, identical on both cells. Restore: blobf03557d5bbequalsHEAD, whole-treegit status --porcelainempty, a full spec rebuild, the preflight found the fix in 4 built files and the mutation in none, and both files went green again (10/10, 4/4).Tests at
6a5dce7378:pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2: 574 files, 16,883 passed, 1 todo.@objectstack/rest, the new file with the existing temporal suites (data-temporal-write-real-day-iso,data-temporal-year-range,data-query-date-year-range,data-date-read-year-below-100,data-date-write-iso-only,data-query-epoch-ms-date-comparand,data-query-having-temporal-door,aggregation-filter-temporal-storage-rule,rest-14078-invalid-date-total-arm), with a private PostgreSQL 16 server (zoneAsia/Shanghai) asOS_TEST_POSTGRES_URL: 10 files, 89 passed, 5 skipped. The 5 skips are MySQL cells (OS_TEST_MYSQL_URLunset). The new file ran on SQLite and PostgreSQL, the two drivers the PR fix(objectql)!: a temporal string is written on a real calendar day, and a datetime string in an ISO 8601 spelling, or refused with VALIDATION_FAILED / invalid_date (#20525) #20547 harness runs.driver-sqlsql-driver-calendar-day-upper-bound.test.ts(SQLite and PostgreSQL ran, MySQL not run): 15 passed.driver-memory: the sixmemory-analytics-date-range-*suites andmemory-driver-calendar-day-upper-bound.test.ts(run, not edited): 7 files, 103 passed.pnpm --filter @objectstack/spec typecheckandpnpm --filter @objectstack/rest typecheck: exit 0;tsc --listFilesoverpackages/rest/tsconfig.test.jsonincludes the new test file.eslint --no-inline-config --format jsonover the three touched.tsfiles: 3 files, 0 errors, 0 warnings.eslint --print-configresolves a config for each of them, andeslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so the diff cannot move any untouched file's verdict.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 83 commands, 81 exit 0. Two were NOT MEASURED,PREREQUISITE NOT MET(exit 3), because they need the whole workspace built:pnpm check:dual-build-cjs-loads(44 packages withoutdist/) andpnpm check:type-check-debt(7 dependencies without a built type entry). CI runs both.Full-repo pin sweep: no test asserts
nullfrom either helper for a year below 100 (git grepfor both names applied to a00NN-literal: 0 hits), and no suite asserts a$lte/$between/ date-range upper bound on adatetimefield in those years. So no pin had to change.Acceptance notes
0000-12-31is followed by0001-01-01), where the base answerednull. It is not pinned: 0001..9999 is the supported range, and@objectstack/core'sisOutsideTemporalYearRangerefuses year 0000 at the comparand and write doors before either helper is asked. The helper does not keep a second copy of that range.@objectstack/driver-memoryhas no binding inpackages/rest):$lte '0050-01-01'answersy49,y50; the$betweenmaximum answersy50; the 2026 controls are unchanged.Date.UTCconstructions read an author-given year below 100 as 1900 + year, inpackages/core/src/utils/datetime.tsandpackages/rest/src/import-coerce.ts. For example, the import door stores a CSVdatetimecell0050-01-01 10:00as1950-01-01T10:00:00.000Z. Separately,nextUtcCalendarDay('9999-12-31')answers'10000-01-01', so on SQLite adatetime$lte '9999-12-31'answers no rows. That is unchanged by this PR and measured in the report.Generated by Claude Code