Repository navigation
fix(core,objectql)!: a datetime names a year from 1000 to 9999 at both engine doors; a date keeps 0001..9999 (#20280) - #20843
Conversation
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…; time keeps its class (#20280) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…d the date years stay read (#20280) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…oth doors; 1000 read; date and time unmoved (#20280) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…its year-1000 edge row (#20280) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…used; the floor and the date years stay read (#20280) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…d objectql (#20280) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…tetime-year-floor
📓 Docs Drift Check16 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 142 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 303dd621fc30a3d5d5a9274687418f2a2c4645ac && git checkout 303dd621fc30a3d5d5a9274687418f2a2c4645ac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1741c5dcb62d11bc9b59702dd67080ef6a7ac9e8 a3afb404b6418eaa640f223e06d0ca5ed71286e0 && git checkout -B drift-repro 1741c5dcb62d11bc9b59702dd67080ef6a7ac9e8 && git merge --no-ff a3afb404b6418eaa640f223e06d0ca5ed71286e0
node scripts/docs-audit/affected-docs.mjs --json 1741c5dcb62d11bc9b59702dd67080ef6a7ac9e8 |
Contract reviewServed-tier: PR #20843 on card #20280 (the Check-runs on this head, read once just before this record was written: 34 runs, 26 ① Derived judgmentsAccept sets, door by door and spelling by spelling; each judged against the diff.
Triage's answer A (5905050200), item by item: a ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…mit that decided it (objectstack-ai#20850) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the sixteenth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/plugins/plugin-webhooks/src/**` and nothing else. By the seat's claim (`5909908590`), it is the next of the three single-site packages left in the lane, taken one package per stage. 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 15 (the latest is PR objectstack-ai#20836, landed as `bbe03f406`). That is **1 site on 1 line in 1 file, covering 1 number**: - `src/translations/index.ts:26`, the census's one site in this package at the base. No test comment, test string, source string or gate-invisible spelling in this package carries a dead number (see the supplementary reading below), so the census site is the whole population. The rewritten line now cites the commit in this repository that decided what it describes: **1 commit sha**. No ADR or ruling record records that decision (see the per-number table), so ruling C's commit rung applies. No number was dropped. Only a comment changed. The file keeps its line count (1 line out, 1 in), so no line citation into it moves. No code token moves (see the guard below). **No citation number is added**: the only tracker number on the added line is the live `objectstack-ai#12069`, which already stood on it. The rewritten line is byte-identical to the line the six sibling translation sets already carry (`plugin-approvals:26`, `plugin-audit:28`, `plugin-security:26`, `plugin-sharing:26`, `service-messaging:29`, `service-storage:29`), landed by stages 1, 2, 4, 6, 7 and 9. **No changeset.** The rewritten docblock does not reach `dist`, and `dist` is byte-identical with and without the rewrite (see Changeset below), so this PR takes `skip-changeset`. ## Census: `plugin-webhooks`, 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/plugins/plugin-webhooks/`. 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` | plugin-webhooks sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `bbe03f406`, run 2026-09-30T11:09:05Z to 11:12:38Z | enumerated, 187 pages, frontier objectstack-ai#20841 (newest objectstack-ai#20841 before and after) | 748 | **1** | 1 | 1 | 1 | | after | head `01312906e`, run 11:26:38Z to 11:30:05Z | enumerated, 187 pages, frontier objectstack-ai#20843 (newest objectstack-ai#20842 before; objectstack-ai#20843 after, a pull request opened 11:29:02Z inside the run, so the frontier equals the newest at the run's end) | 747 | **0** | 0 | 0 | 0 | The whole-repo drop is 1, exactly this diff's census site. The `resolves` tally is 33,194 in both runs, and `resolves-as-pull-request` (1,985) and `cross-repo-unjudged` (1,018) did not move either. Neither run was truncated or discarded: both enumerations read 187 pages at the newest frontier. The seat's census counted 1 here at `6bff748b`, and the base agrees: `6bff748b` is an ancestor of the base, and the one commit between them that touches this package's `src` (`7184436a4`, a re-translation of generated object leaves) does not touch `translations/index.ts:26`. **Supplementary instrument, the whole scope.** The census does not read test files or strings. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `plugin-webhooks/src` (34 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 the gate's own census-scope extraction judged it and the census did not report it. 4 numbers are covered by neither, because they stand only in test files. Each was read on its own through the read-only tools: `objectstack-ai#10619` answers as an issue; `objectstack-ai#8556`, `objectstack-ai#13565` and `objectstack-ai#15687` answer as pull requests. The probe's controls: `objectstack-ai#11671` and the known issue `objectstack-ai#10619` answer 404 on the pull-request endpoint. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `bbe03f406` | 369 | **1** | 1 | 0 | 0 | 0 | | after, `01312906e` | 368 | **0** | 0 | 0 | 0 | 0 | Its src-comment column equals the census's 1, which is the control on the second instrument. The 368 live citations are the same in both readings (363 by the census's judgement, 5 by the single reads), none is cross-repo, none is unjudged, and the drop of 1 citation is exactly the rewritten site. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 378 occurrences before and 377 after, the same drop of 1. The 9 tokens beyond the gate's grammar are the same before and after, and none is dead (see Acceptance notes). ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#11671` | 1/1 | 1/0 | `09b4f4e4e` (PR objectstack-ai#12557): the extract command's `--source-hashes` option writes a per-locale provenance companion, `LOCALE.source-hashes.generated.ts`, beside the generated translation bundles, recording for each leaf the source revision it is still a byte copy of, per maintainer ruling objectstack-ai#12069 Option A. That is exactly what `:25-26` say. Its message names `objectstack-ai#12069` but not `objectstack-ai#11671`; its own diff names `objectstack-ai#11671` on 16 added lines, the first of them its changeset's headline. `git blame` puts `:26` in `30928a615` (PR objectstack-ai#12724, which wires the serving-time read of that companion into all nine bundle sets), a descendant of the anchor. Reused: stages 1, 2, 4, 6, 7 and 9 gave the identical line in six sibling packages this anchor | The sha matches exactly one commit (`git rev-parse --disambiguate`, count 1) and is an ancestor of the base (`merge-base --is-ancestor`, exit 0; reverse leg, base against the anchor, exit 1; control legs exit 0: stage 1's landing `422db788a`, and the repository's root commit, 15,200 commits deep against the anchor's 3,802; the history is complete, `--is-shallow-repository` false, 15,201 commits). `objectstack-ai#11671` answers 404 on the issues endpoint and on the pull-request endpoint, read one by one. No ADR, `scripts/adr-anchors/` file or ruling record names `objectstack-ai#11671`, `objectstack-ai#12069` or the source-hash companion (`git grep` over `docs/adr` and `scripts/adr-anchors`: one unrelated `source-hash cache` hit in ADR-0080), so ruling C's first rung is empty. ## Wordings to check - **Tag swap in place.** 「(maintainer ruling objectstack-ai#12069 Option A, objectstack-ai#11671)」 became 「(maintainer ruling objectstack-ai#12069 Option A, commit 09b4f4e)」. It is the form, and the byte-identical line, the six sibling translation sets carry. ## Sites left - **In `src`: none.** No test title, assertion message, operator log string, runtime refusal, quoted maintainer ruling or generated file in this package carries a dead number. - **Outside `src`, listed and left, not edited in this stage:** - `scripts/i18n-extract.config.ts:17` names the dead `objectstack-ai#11671` (beside the live `objectstack-ai#12069`); the anchor for whoever next edits it is `09b4f4e4e`. The file is outside `files[]` and outside the census surface. - `tsconfig.scripts.json:1` names the dead `objectstack-ai#11351` (404 on the issue and pull-request endpoints). - `tsconfig.test.json:3` and `:56` name the dead `objectstack-ai#13176` (404 on both endpoints; stage 4 measured it dead). - The other numbers in those files and in `vitest.config.ts` are live: `objectstack-ai#12069`, `objectstack-ai#12559`, `objectstack-ai#10868`, `objectstack-ai#10756`, `objectstack-ai#5475`, `objectstack-ai#14062`, `objectstack-ai#5286`, `objectstack-ai#5449`, `objectstack-ai#12542`, `objectstack-ai#10772`, `objectstack-ai#10374`, `objectstack-ai#12642`. - The release-owned `CHANGELOG.md` names `objectstack-ai#11671` on 2 lines. - `package.json`, `tsconfig.json` and `tsup.config.ts` name none. `files[]` lists `README.md`, but the package has none. ## Mechanical guard: no code token moves The guard compares, base `bbe03f406` against head, over the one touched `.ts` file: - **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 at the head `01312906e`: 50 base leaf tokens, **0 files with a token change** on either reading (exit 0). - Comment control (「Recording alone changes nothing a user sees」 to 「... alters ...」): 0 files changed, as expected (exit 0). - Positive control, a code token renamed (`undefined, zhCNGeneratedSourceHashes)` to `...SourceHashesX)`): DIFFER on the identifier in both readings (exit 1). - Positive control, a string-literal key changed (`'es-ES'` to `'es-EZ'`): DIFFER on the string literal in both readings (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 (`929fa19989e2`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset: none (`skip-changeset`) `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. Measured on the built package (A3), after `pnpm --filter '@objectstack/plugin-webhooks...' build` at `01312906e`, where the package's own `tsup` ran and cleaned its output folder: - The rewritten line, its whole docblock (`:22-55`, which sits on the unexported `const enSource`) and the old number appear in no `dist` file (0 in all 20, source maps included). Nor do its neighbours `:23`, `:25`, `:27` and `:30`, or a never-written negative phrase. - Positive controls, text that does ship: `WebhooksTranslations` from this same file is in `index.js`, `index.cjs` and both translations chunks (8), and `zhCNGeneratedSourceHashes` is in both translations chunks (4). A docblock line of this package, `auto-enqueuer.ts:33` (「Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).」), is in `index.d.ts` and `index.d.cts`. - **Byte identity.** The package was rebuilt with the base line put back (through `scripts/ablation-replace.mjs`, with the file proven equal to the base blob `686646fac51d` during the leg), and again after the proven restore. The sha256 of all 20 `dist` files is identical in all three builds: JS entries, declaration files, chunks and source maps. So nothing this PR rewrites is published, and a changeset would announce a change no consumer can observe. No `.changeset/*` file is added or touched. ## Gates (head `01312906e`) - **Citation judging, as CI runs it:** `node scripts/check-issue-citations.mjs` exits 0 (「every citation this change adds resolves」). `pnpm check:issue-citations` exits 0 (self-test, 114 cases, 8 batteries). - **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 `01312906e` (change set derived from git: 1 path against the merge base `bbe03f406`) derived 50 commands, all among the dispatch list's 65. - All 65 of the dispatch list ran, each with its exit code captured before any pipe, and all 65 exit 0; none exited 3. - `--ran`, fed each command with its exit code, reports 50 derived, 50 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. - A full `turbo run build` over `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in a directory this diff touches): `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, plus `node scripts/check-changeset-fixed.mjs`, which the dispatch named. Each exits 0. - **Tests and typecheck, under the verify lock, at `01312906e`:** - `pnpm --filter @objectstack/plugin-webhooks test`: 13 files pass and 160 tests pass, which is every tracked test file under `src/`. - `pnpm --filter @objectstack/plugin-webhooks typecheck` exits 0, and `tsc --noEmit --listFiles` puts the touched file in the program (the config includes `src` and excludes no test file). - `pnpm --filter @objectstack/plugin-webhooks check:test-typecheck` exits 0 (0 files held in the package's debt ledger). - **Lint, as a proven narrowing:** eslint with inline config disabled, over the touched `.ts` file, gives 1 file, 0 errors and 0 warnings (its `--format json` output). The file is in eslint's own population (not reported ignored; `dist/index.js`, the control, reads 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 changed file for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked** (objectstack-ai#20636, including the `clause #N` position). At the base and at the head, `#N-word` is on 0 lines. `#A/#B` is on 8 lines (8 second numbers), and every second number is live: `objectstack-ai#8022` (3 lines), `objectstack-ai#7986` (2) and `objectstack-ai#8043` (2) by the census's own judgement, and `objectstack-ai#8565` read on its own as a pull request. `option #N`, `clause #N` and URL-spelled links are on 0 lines. So the claim's 0 / 8 / 0 / 0 / 0 hold, and nothing dead hides behind them. The one other raw token beyond the grammar is `PD objectstack-ai#12` at `auto-enqueuer.ts:919`, a Prime Directive ordinal the extractor excuses on purpose. - **Outside-`src` residue.** Three dead numbers in files outside `src` and outside `files[]` (`objectstack-ai#11671`, `objectstack-ai#11351`, `objectstack-ai#13176`, listed above) are left, as stages 13 to 15 left theirs. They ship nothing and move no gate. - **The census instrument did not truncate in this stage.** Both enumerations read 187 pages at the newest frontier. - **Anchor for the next stages:** `objectstack-ai#11671` → `09b4f4e4e`. `service-realtime/src/translations/index.ts:27` still carries the dead number in the identical sentence. - **Base.** The branch is on `main` at `bbe03f406`. `main` has since moved five commits (to `688ddef3c`). None touches `packages/plugins/plugin-webhooks`, `scripts/check-issue-citations.mjs`, `scripts/pm/dispatch-gates.mjs` or `.changeset/config.json`. 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>
…our digits, and the reader reads the week key the writer writes (objectstack-ai#20760) (objectstack-ai#20865) Fixes objectstack-ai#20760 Clause-②: no ## What changes Triage direction 5903751640, as ruled: one writer, one reader, no local padding. Nothing in any driver's SQL moves. - **One writer: `@objectstack/core` `bucketDateKey` and its ISO week label.** A private `bucketKeyYear(year)` spells the year of a bucket key with four digits, and every arm goes through it: `year`, `quarter`, `month`, `day` (through a private `bucketDayKey`) and the week label (`isoWeekLabelFromCalendarDay`). A year from 1000 to 9999 is spelled as before. No export is added or removed. - **One reader: `bucketKeyToCalendarRange`.** Its patterns already required a four-digit year. The defect was the week arm: it checks a key against the label the writer gives the reconstructed Monday, and that label was unpadded, so `0050-W01` answered `null`. Its bounds are now spelled by the writer's own day key (`fmt` is `bucketDayKey`), so the two share one spelling. It does not accept the unpadded spelling (`50-06`, `49-W52`): nothing writes it after this change. - **`service-analytics` `bucketKeyAtOrdinal` (the declared cross-lane line).** It spelled every granularity itself and carried a private copy of the ISO week rule (`isoWeekKeyOfUtcMs`, deleted). It now computes only the UTC instant the ordinal's bucket starts at, and core's `bucketDateKey` spells the key (a local `bucketKeyAt`, which `calendarDayAt` now delegates to). No second padding. ## Before and after (function level) Core at base `c90f9fb6e2`, read from a temporary copy of the base `datetime.ts`, against this branch: | instant | base | this branch | |:--|:--|:--| | `0050-06-15T10:00Z` | `50` · `50-Q2` · `50-06` · `50-06-15` · `50-W24` | `0050` · `0050-Q2` · `0050-06` · `0050-06-15` · `0050-W24` | | `0050-01-01T10:00Z` | `50` · `50-Q1` · `50-01` · `50-01-01` · `49-W52` | `0050` · `0050-Q1` · `0050-01` · `0050-01-01` · `0049-W52` | | `0999-06-15T10:00Z` | `999` · `999-Q2` · `999-06` · `999-06-15` · `999-W24` | `0999` · `0999-Q2` · `0999-06` · `0999-06-15` · `0999-W24` | | `2026-06-15T10:00Z` (control) | `2026` · `2026-Q2` · `2026-06` · `2026-06-15` · `2026-W25` | identical | `bucketKeyToCalendarRange(key, 'week')`: `0050-W01`, `0049-W52` and `0999-W24` answered `null` at base. They now answer `0050-01-03`..`0050-01-10`, `0049-12-27`..`0050-01-03` and `0999-06-10`..`0999-06-17`. `2026-W01` answers `2025-12-29`..`2026-01-05` on both. ## The zone-2 hypotheses - **H1, the callers: held, and the census found two more writers.** Each named caller follows the helper with no edit: - `objectql` `in-memory-aggregation.ts` `bucketDateValue` is a thin delegate (pinned); - `objectql` `having-filter.ts` names the helper in a comment only; its `day` bucket's `date` class now holds a real `YYYY-MM-DD`; - `driver-memory` `memory-analytics.ts` `aggregateWithTimeBuckets` delegates (pinned); - `service-analytics` `analytics-service.ts`, the drill ranges, calls `bucketKeyToCalendarRange` (pinned through `queryDataset`); - `service-analytics` `dataset-executor.ts`: `calendarDayAt` delegates, `alignedCompareBucketKey` reads through the reader (pinned), and `bucketKeyAtOrdinal` is H2; - `core` `compensated-sum.ts` names the helper in a comment only. Two `service-analytics` writers call no helper and spell the year unpadded: `preview-evaluator.ts` `bucketDate` and `dimension-labels.ts` `formatDateBucket`. Both are outside the declared surface (Acceptance notes). - **H2: held.** `bucketKeyAtOrdinal` built its own keys at every granularity. It now calls the helper (above). - **H3: SQLite and PostgreSQL pad, measured; MySQL is NOT MEASURED.** - SQLite 3.53.4: pinned below. The keys equal `bucketDateKey` at `year`, `quarter`, `month` and `day` through a `datetime` and a `date` column. SQLite buckets `week` in memory, not in SQL. - PostgreSQL 16.13: measured live on a throwaway local server. This branch's `SqlDriver` ran `initObjects`, `create` and `aggregate` over a `datetime` and a `date` column holding 0050-06-15, 0050-01-01, 0999-06-15 and 2026-06-15. The keys equal `bucketDateKey` at all five granularities, 0 mismatches in 10 cells, `0049-W52` from `IYYY"-W"IW` included. This was a scratch harness, not committed (Acceptance notes). - MySQL: NOT MEASURED. This container has no MySQL server (no binary, no Docker daemon). The reference manual gives `%Y` and `%x` as four digits. - No dialect was found whose bucket SQL does not pad, and no driver changes. - **H4: read at function level, not reached, and no refusal added.** - Year 0 keys `0000`, as SQLite's `strftime('%Y')` does. - Year −1 keys `-1`, never the padded fragment `00-1`. SQLite answers `-001`. - Year 10000 keys `10000`. SQLite answers NULL. - The reader answers `null` for both the −1 and the 10000 key. - Both engine doors refuse these years for a `date` and a `datetime` value. The shape is pinned in the core file below. - **H5: held.** Early January 0050 keys `0049-W52`, and `0049-W52` spans `0049-12-27`..`0050-01-03`. This is pinned in core, objectql, service-analytics and the PostgreSQL reading. ## Pins One new file beside each face. Every expected key is spelled literally, and each file covers 0050, 0999 and the 2026 control: - core `datetime-bucket-key-four-digit-year.test.ts`: - the writer at every granularity, for 0001, 0050, 0999 and 2026, as a `Date` and as epoch ms too; - the Asia/Shanghai week-year; - the reader round-tripping every written key; - the week ranges above (`0050-W01` included); - the unpadded spelling answering `null`; - H4. - objectql `in-memory-aggregation-four-digit-year.test.ts`: the engine's in-memory `groupBy` at every granularity. - driver-memory `memory-analytics-four-digit-year.test.ts`: the memory cube face at every granularity. - driver-sql `sql-driver-bucket-key-four-digit-year.test.ts`: `SqlDriver` on SQLite against core's `bucketDateKey`, for 0050-06-15. - service-analytics `bucket-key-four-digit-year.test.ts`: - `bucketKeyAtOrdinal` against the grouped key at every granularity; - `alignedCompareBucketKey` restating `0049-06` / `0049-W24` as `0050-06` / `0050-W24`; - a `queryDataset` drill-down from `0050-W01` finding `0050-01-03`..`0050-01-10`. Two existing files had comments stating the unpadded spelling as current (`datetime-year-below-100.test.ts` in core, `week-key-year-below-100.test.ts` in service-analytics). Their comments are corrected, and their lenient readers now require the four-digit year. ## Ablation: red/green on the padding At `3716880ded`. The padding line is byte-identical at the PR head. - **Mutation.** `scripts/ablation-replace.mjs` turned `return year >= 0 ? String(year).padStart(4, '0') : String(year);` into `return String(year);` (anchor 1 → 0, blob `fe67aac16ed5` → `5a14574b2faf`). - **It reached `dist/`.** Core was rebuilt, and `ablation-dist-preflight --absent` passed: the marker was absent from all 14 built files. objectql and service-analytics read core from `dist/`. - **Red:** - core `datetime*`: 101 failed / 151 passed of 252; - objectql: 6 of 6 failed; - driver-memory: 5 of 5 failed; - driver-sql: 4 failed / 8 passed. The 8 are the SQL cells, green because SQLite pads on its own; only the in-memory cells go red; - service-analytics: 30 failed / 102 passed of 132. - **Restore.** The blob equals HEAD's `fe67aac16ed5` and `git diff HEAD` is empty. Core was rebuilt, and preflight in default mode found the marker in 2 built files with the tree clean. - **Green:** 252 / 6 / 5 / 12 / 132. ## Verification (head `c03977051a`) `main` at `05a7547c9f` (objectstack-ai#20843) was merged in. It edits one comment in `datetime.ts`, and this branch's two range sentences now state its `datetime` floor. - **Suites** (`vitest run`, at `076ba0c599`, after the merge and a rebuild of the touched closure): - core: `local` 61 files / 1793 passed, `repo` 3 / 48; - objectql: `local` 345 / 6779; - driver-memory: 66 / 1475; - driver-sql: 202 passed, 11 skipped (the live cells, which have no URL here) / 3266 tests; - service-analytics: 142 / 3282. - **At `c03977051a`,** which changes only the driver-sql pin: every face's pins plus driver-sql's `date-bucket` and `date-bucket-storage` suites pass, 36 / 252 / 6 / 5 / 57. - **Typecheck:** `typecheck` exits 0 for all five packages. `tsc --listFiles` shows each new test file in a compiled program. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 67 families at `c03977051a`, and all 67 ran there. 67 of 67 exited 0. `--ran` reconciles: 67 derived, 67 run, 0 NOT MEASURED, 0 unrun. - `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET: only the touched closure was built). Both exited 0 after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71/71 tasks). The seat corrected this bullet from `os-dev-report` 5912276902; the head is unchanged. - The first run caught one `as any` on the driver-sql pin's aggregate options (`check:query-options-erasure`, test surface 236 → 237). It was typed in `c03977051a`, and the ratchet holds at 236. - **Lint (narrowed, proven).** `eslint --no-inline-config --format json` ran over the 9 changed `.ts` files: 9 files, 0 errors, 0 warnings, none reported as ignored. The config covers all 9. It enables no type-aware linting (no `parserOptions.project` anywhere, stated in `eslint.config.mjs` itself), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Clause-② measured Six published outputs change spelling for a year below 1000: - `bucketDateKey`; - the in-memory `groupBy` keys; - the memory cube labels; - the `compareTo` merge keys; - `bucketKeyToCalendarRange`, which now answers for a padded week key; - the display label `formatDateBucket` gives an in-memory month or day key: it was `1950-06`, a wrong century, and is now `50-06`, what it gives the SQL key. Each is now what the SQL path answered already: SQLite pinned, PostgreSQL measured. The reader drops no key it read before, because the unpadded spelling never matched its four-digit patterns. So `Clause-②: no` stands. ## Acceptance notes - **`dimension-labels.ts` `formatDateBucket` (service-analytics).** It relabels a date dimension's rows when labels resolve. For a padded key below 1000 it answers `year` `0050` → `1970`, reading a pure-digit key as epoch seconds because its year check admits only 1000..9999. It answers `month` `0050-06` → `50-06` and `day` `0050-06-15` → `50-06-15`. Function level. It is the same unpadded-year family as objectstack-ai#20602, it sits outside the declared surface, and it is reported for the family card. - **`preview-evaluator.ts` `bucketDate` (service-analytics, the draft preview).** It spells the year unpadded at `year` / `quarter` / `month` / `day`. Its `week` key is the Monday's `YYYY-MM-DD` (pinned so), not the ISO label. Outside the surface. - **`driver-mongodb` `mongodb-aggregation.ts`.** A header comment says a year before 1000 "labels '0999' here and '999' in memory". This PR makes it false. The file is outside the declared surface, so it is left for its next editor. - **`@objectstack/verify` `checkDateBucketParity`.** It probes 2024 and 2025 instants only, so the parity device cannot see this family on any driver. No gate change here. - **The live dialect matrix (driver-sql `live-dialect-matrix.testkit.ts`).** It is not extended to this pin. A MySQL `datetime` below 1000 is objectstack-ai#20280's ground, and its reading is not measured here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- 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 #20280
Clause-②: no (narrowing)
What this changes
The
datetimehalf of the card, by triage's answer A (comment 5905050200): adatetimevalue names a year from 1000 to 9999, and one before year 1000 is refused at both engine doors. Adatekeeps 0001..9999, and atimecolumn's range does not move.packages/core/src/utils/temporal-storage-form.ts:FIRST_SUPPORTED_YEARis now{ date: 1, datetime: 1000 }, andisOutsideTemporalYearRangeasksFIRST_SUPPORTED_YEAR[kind]. No dialect switch: the range is the contract on every backend. The write door (the record validator) and the comparand door both reach it throughisUninterpretableTemporalComparand, so neither door needed a second edit to the range. Thedaterule's four-digit padding reads thedatebound (FIRST_SUPPORTED_YEAR.date), so it still pads 0001..0999.packages/objectql/src/temporal-comparand-door.ts: thedatetimeyear class names 1000 to 9999. Adatetimein 0001..0999 gets its own consequence sentence, because it sorts and compares as the instant it names on every backend. The misorder sentence ("does not sort as an instant") would be false for it. The existing wire codes are unchanged:INVALID_FILTER/ 400 for a comparand, andVALIDATION_FAILEDwith the field'sinvalid_datecode for a written value.timeclass keeps its meaning. Atimerefusal's class ("an instant whose UTC year falls outside the years 0001 to 9999, so no time of day is read from it") used to ask thedatetimerange. It now asks the four-digit years explicitly, through core'sdaterange over the instant thedatetimerule reads. The corpus below shows everytimeanswer byte-identical.datetimeare corrected intemporal-storage-form.ts,temporal-comparand.ts,datetime.ts,record-validator.ts, and one comment-only line inpackages/spec/src/data/calendar-day.ts(the spec lane's file; declared here and in the report).The published accept sets that narrow (per door, per spelling)
Each one refuses a
datetimewhose UTC year falls in 0001..0999, and accepted it at the base:VALIDATION_FAILED/invalid_date). Coversengine.insert,engine.update(one row and many) and the dry-runengine.validate, so alsoPOST /api/v1/data/:object,PATCH /api/v1/data/:object/:idand each row ofPOST /api/v1/data/:object/import. Spellings: an ISO instant string, a bareYYYY-MM-DD(midnight UTC), a zone-naiveYYYY-MM-DD HH:MM, and aDate. A number was already refused there, because adatetimeis stored as text, so no number spelling narrows at this door.INVALID_FILTER/ 400). Coverswhere, a per-aggregationfilterandhaving, on the engine and onPOST /api/v1/data/:object/query. Spellings: a string, aDateand a number (epoch milliseconds). The analytics native-SQL strategy asks the same predicate (service-analyticscomparand-shape.ts), so it declines such a comparand and the query reaches this door. That is read from source; its suite is green.1000-01-01T00:00:00+08:00(year 999 in UTC) is refused, and0999-12-31T23:00:00-02:00(year 1000 in UTC) is read.Nothing widens: across the corpus below, no answer moved from refused to accepted.
Before and after, measured (scratch harness, not committed)
Every door answer went through the public engine API on a recording driver:
find(where),aggregate(a per-aggregationfilter, andhavingonmin),validate,insertandupdate. The fields were adate, adatetimeand atime. There were 69 values: 12 years, from -1 and 0 up to 9999 and 10000, each as a string, a number and aDate, plus zone-crossing offsets, impossible days, non-ISO spellings, bare days, zone-naive stamps, bare-integer strings, placeholders and junk. That is 1242 answers. Base4b45afaed5against head65728398b2(the source commit; later commits are tests and the changeset):datefield: 0 of 414 answers moved.timefield: 0 of 414 moved.datetimefield: 189 moved. 126 are narrowings over 24 values in 0001..0999, in every spelling (108 accepted-to-refused, plus 18validateverdicts from valid to invalid). 63 are refusals whose words moved: the range now reads 1000 to 9999. Six of those, in 0001..0999, were already refused for their spelling or day (0500-02-30T10:00:00Z,0500/07/15 10:00, a bare-integer string) and are now named by their year class first, as a year past 9999 already was.Stored rows before the floor (ADR-0087: refused with guidance, never shifted)
Nothing rewrites a stored
datetimebelow 1000. It reads back as before; on MySQL a year in 0001..0099 still presents a century late (ADR-0053 D-F2; ruling 5859414357, C: no mysql2 parser change). An operator finds such rows with$lton1000-01-01T00:00:00.000Z, a comparand both doors admit. A PATCH that leaves the field out is accepted. A write that carries a year below 1000 is refused, so the field is written again with an instant from year 1000 on, ornull.packages/rest/src/data-date-read-year-below-100.test.tspins exactly this at the REST door: the census, the refused comparand below the floor, a PATCH of another field on such a row, a refused PATCH that keeps the year, and an accepted PATCH onto 1000. The changeset carries one ADR-0087 marker,not-required (no-migration-prescription): the change refuses a value, and no key, export or stored metadata shape moves. Its body has no FROM/TO table and no arrow.Tests
All at head
a3afb404b6(after mergingorigin/mainb28054654d), processTZ=America/New_York:@objectstack/core60 files, 1729 passed ·@objectstack/objectql344 files, 6773 passed ·@objectstack/driver-memory65 files, 1470 passed ·@objectstack/service-analytics141 files, 3267 passed.@objectstack/driver-sql209 passed, 3 skipped files (4047 passed, 95 skipped), on SQLite and on a private PostgreSQL 16.13 (server zone Asia/Shanghai).@objectstack/rest233 files, 4571 passed, 33 skipped, on SQLite and the same PostgreSQL.Temporal Conformance (live PG + MySQL)runsdriver-sql's year-range file there, including its new year-1000 row.check:test-typechecktest layer held.d767c1ca81; the merge did not touch the file, blob17abe8b044both sides):node scripts/ablation-replace.mjsset{ date: 1, datetime: 1000 }back to{ date: 1, datetime: 1 }(anchor 1 to 0, blob17abe8b044to7a7adacdb9). Core was rebuilt, andablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs(exit 0, with--source-marker).datetime0001..0999 refusals on both doors, thehavingtwin, and the year-0050 write control.git diff HEADempty, core rebuilt, preflight--absentexit 0 with a clean tree. Green again: 109 of 109 and 69 of 69.eslint --no-inline-config --format jsonover the 19 changed TS files reported 19 files, 0 errors, 0 warnings. Population:eslint.config.mjsline 971,files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], covers all 19. Invariance: the config enables no type-aware linting (its lines 327-328), so this diff moves no untouched file's verdict.node scripts/pm/dispatch-gates.mjs --commandsata3afb404b6derived 89. All were run with recorded exit codes.--ranreports "89 derived, 87 run, 2 NOT-MEASURED, 0 UNRUN". The two NOT MEASURED arecheck:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3 (PREREQUISITE NOT MET: they read every package'sdistand this worktree built only the touched closures).The pins, and each one's fate
temporal-storage-form.test.tsandtemporal-comparand.test.ts;engine-temporal-year-range.test.ts(both doors, every position),engine-aggregate-having-temporal-door.test.ts(the year-1 control became a refusal row; year 1000 is read),engine-temporal-write-real-day-iso.test.ts(the0050-01-01T10:00:00Zwrite);data-temporal-year-range.test.ts,import-date-cell-iso-real-day.test.ts(thedtcell0001-01-01is now refused per row by the write door behind the reader),import-datetime-year-below-100.test.ts(the reader layer is kept; the import door refuses each row below 1000 and stores 1000 and 2026; the round trip runs at 1000 and 2026), anddata-query-calendar-day-year-below-100.test.ts(the whole-day bound is measured at1000-01-01; the card's0050-01-01bounds and row are pinned as refusals).dateas the control: the year-4 leap day in core, anddate0001..0999 in every file above (still accepted).memory-20264-temporal-year-range.test.tsand driver-sqlsql-driver-20264-temporal-year-range.test.ts. They write straight through the driver, and theirdatetimerows below 1000 are what a row stored before the floor holds. Headers are corrected, and a year-1000 edge row is added. Also kept: the [finding] outside calendar-day.ts, a year from 0001 to 0099 is still read as 1900..1999: Date.UTC's two-digit-year remap in core's datetime and bucket helpers, filter-tokens and the REST import's datetime cell #20599 / specnextUtcCalendarDayanswers null for every day in years 0001..0099 (Date.UTC reads them as 1900..1999), so a datetime$lte/$betweenmax on such a day skips whole-day widening and misses that day's rows #20550 unit pins on parts, week keys and calendar-day helpers (coredatetime-year-below-100.test.ts, service-analyticsweek-key-year-below-100.test.ts, speccalendar-day.test.ts), since no door is in their path.data-date-read-year-below-100.test.ts(rest) now writes itsdatetimevalues below 1000 through the driver, as stored-before-floor rows, and pins the operator path above.Acceptance notes
invalid_datesentence ("must be a valid datetime (ISO-8601)"), as it did for the 0001..9999 range: naming the range there needs a newvalidation-message.tskey inpackages/spec, outside this card's surface. Raised in the report.{1977_years_ago}on adatetimestill reaches the driver. It compares as the instant it names (year 49; measured, correct rows). The same path lets placeholders that land past 9999 or before year 1 through, with wrong answers. That class is older than this change; the measured case is in the report.minorfor@objectstack/coreand@objectstack/objectql, whose source moves. REST and the analytics service narrow through them without a source change of their own, and the changeset config's one version group moves them in lockstep.Generated by Claude Code