Commit 3062e50
Fixes #20264
Clause-②: yes (narrowing)
A `date` or `datetime` value now names a year from 0001 to 9999, or it
is refused: `INVALID_FILTER` / 400 as a comparand on `where`, a
per-aggregation `filter` and `having`, and `VALIDATION_FAILED` / 400
(`invalid_date`) as a written value. This is triage's ruling on the card
(5858474998): "The supported year range is **0001..9999** for both
`date` and `datetime`." Year 0000 joins the refused range, and the
`date` arm's padding covers 0001..0999. One range function in
`@objectstack/core` answers both doors. No driver source is edited.
**Stop valve (claim 5872518067): it fired.** On a local MySQL 8.0.46, a
`datetime` in years 0001..0099 is still stored right and read back a
century late through mysql2's instant parser. That cell is returned as
`needs_decision` (see the section below). Everything else lands here.
**Patch round 1** (at-tier review 5874841530, amended claim 5874849531)
touches `.changeset/20264-temporal-year-range.md` only; no code or test
changed.
- `Clause-②` is now `yes (narrowing)`, because `@objectstack/core` gains
the root export `isOutsideTemporalYearRange`. The levels, the BREAKING
banner, the ADR-0087 marker and the FROM → TO line are unchanged.
- The note now says a `datetime` `where` bound in year 10000 answered
7/0/0 for `$gt` / `$lt` / `$eq`. The right answer is 0/7/0.
- The note's Unchanged clause now makes one exception to "refused in its
existing words". A `date`-column string whose instant names a year
outside 0001..9999 (`+010000-01-01T00:00:00.000Z`, `-000001-…`, an
out-of-range epoch-millisecond string) is refused with the same code and
status on `where`, the per-aggregation `filter` and `having`, but now in
the year-class words.
- The new head `b559a5d0e` is that commit (`f16ff84ac`) plus a merge of
`origin/main` `b810ddb6f`. Every other file of this PR is blob-identical
to `311ce0640`.
## What changed
- `packages/core/src/utils/temporal-storage-form.ts`
- New export `isOutsideTemporalYearRange(value, kind)`, the one range.
The year is the one the kind's rule reads:
- `datetime`: the UTC year of the instant `canonicalUtcDatetime` reads.
That function now shares one private `instantMs` reader with the range,
so the two cannot drift.
- `date`: a string's leading `YYYY-MM-DD` year. Otherwise, the UTC year
of the instant the value names.
- `time`: never judged.
- The `date` arm pads years 0001..0999 only. Year 0 keeps its unpadded
spelling (`0-06-15`), like every other year outside the range. The rule
stays total, and the `datetime` spelling of any instant is unchanged.
- `packages/core/src/utils/temporal-comparand.ts`:
`isUninterpretableTemporalComparand` asks the range for a `date` or
`datetime` number, `Date` or readable string. `#20240`'s private
`isOutsideCalendarDayYears` (0..9999, `date` only) is removed. `time` is
untouched.
- `packages/objectql/src/temporal-comparand-door.ts`: the year-class
refusal now covers both kinds, in words that name 0001 to 9999. `where`,
the per-aggregation `filter` and `having` inherit the range through the
one predicate. The door asks core's `isOutsideTemporalYearRange` which
class a hit is, and never re-derives the range.
- `packages/objectql/src/validation/record-validator.ts`, the `date` /
`datetime` arm only: a readable value outside the range fails
`invalid_date`, with the same code, constraint and message key as any
other invalid date. This covers insert, update, a multi-row update and
`engine.validate`. The number arm (PR #20423) is not touched.
## Measured: base `b28550818` vs head `f2d96c96c`
Scratch harness, not committed. Drivers: InMemoryDriver, and SqlDriver
on SQLite, on a local PostgreSQL 16.13 (server `Asia/Shanghai`) and on a
local MySQL 8.0.46 (`+08:00`), with `TZ=America/New_York`. Doors: the
engine and REST (`POST /data/:object/query`, `POST /data/:object`).
Data: seven 2026 rows. The harness compares **236 cells: 160 identical,
76 moved**. Every moved cell went from a misorder, a 500 or a stored
non-day to a 400. No in-range cell and no control moved.
| position | value | base: memory · SQLite · PG · MySQL | head, all four
|
|:--|:--|:--|:--|
| `where` `datetime`, `$gt`/`$lt`/`$eq` | year 10000 or −1: number,
`Date`, ISO | 7/0/0 · 7/0/0 · 500 · 500 | 400 `INVALID_FILTER` |
| per-agg `filter` `$gt` / `having` `$gt` on `min(datetime)` | the same
| 7 / 4 groups on all four | 400 `INVALID_FILTER` |
| `where` `datetime` | year 0 | 7/0/0 · 7/0/0 · 500 · 7/0/0 | 400
`INVALID_FILTER` |
| `where` `date` | year 0: number, `Date`, ISO, bare `0000-06-15` |
7/0/0 · 7/0/0 · 500 · 7/0/0 | 400 `INVALID_FILTER` |
| REST create `date` | `+010000-01-01T00:00:00.000Z` / `-000001-…` | 201
verbatim · 201 verbatim · 500 · 500 | 400 `VALIDATION_FAILED` |
| REST create `date` / `datetime` | year 0 | 201 · 201 · 500 · 201 | 400
`VALIDATION_FAILED` |
| edges `0001-01-01`, `9999-12-31T23:59:59.999Z`, 2026 control | every
spelling, every position | read | identical |
H1 held: the card's table reproduces on `origin/main` in every cell. PR
#20261 (#20240) and #20263 had already moved only the `date` 10000 / −1
cells, and those are unchanged.
## The PM's hypotheses
- **H2.** The one place is core's `isOutsideTemporalYearRange`. It is
called by the predicate (and through it by the three comparand
positions, `judgeFilter` and service-analytics' decline) and by the
record validator. Each caller of the storage rule:
- The engine's write coercion (`resolveNowDefault` /
`normalizeExpressionDefault`) runs before `validateRecord` on insert, so
a defaulted year outside the range is refused.
- `SqlDriver.formatInput` and `memory-temporal.ts` read
`temporalStorageForm` and still see an out-of-range year, but only on a
direct driver call that bypasses the engine. Both doors sit in front of
them. Their source is not edited.
- `mongodb-temporal.ts` **keeps its own copy** (`storageDatetimeValue` /
`storageDateValue`). This card needs no edit there, because both doors
are engine-level. The copy's drift is pre-existing (no four-digit
padding for a `Date` year 1..999, no number arm on `date`), and is noted
below, not changed.
- **H3.** The write door is `validateRecord`'s `date` / `datetime` arm,
reached from the engine and REST create, PATCH and the multi-row update.
It is refused there through the same range.
- **H4.** These pins asserted year 0000 as accepted. Each is flipped as
the ruling says:
- core `temporal-comparand.test.ts` IN_RANGE `the first millisecond of
year 0`;
- core `temporal-storage-form.test.ts` padding cases `0000-06-15` and
`0000-01-01`, now `0-06-15` and `0-01-01`;
- objectql `engine-date-year-range-door.test.ts` IN_RANGE `0000-01-01`.
These pins asserted a `datetime` number, `Date` or extended-year string
as read, and are flipped too:
- core `leaves the datetime and time rules alone`;
- objectql `leaves the datetime and time fields alone`;
- objectql `having` UNCHANGED `an extended-year ISO on min(datetime)`;
- REST `data-query-date-year-range.test.ts`'s datetime control. It now
reads a 2026 instant.
## Stop valve: MySQL `datetime` in 0001..0099 (`needs_decision`)
The cell was measured live on MySQL 8.0.46, through REST create then
query, at base and at head (identical):
- `0001-01-01T00:00Z` reads back as `2001-01-01T00:00Z`,
`0001-03-04T10:00Z` as `2004-01-03`, `0050-…` as `1950-…`, `0069-…` as
`1969-…`, `0070-…` as `1970-…`, and `0099-…` as `1999-…`.
- `0100`, `0101`, `0500`, `0999` and `1000` read back as written.
- The stored text (`CAST(… AS CHAR)`) is right in every case.
The mysql2 read parser is not touched here (ADR-0053 D-F2). The two
options are in the `os-dev-report` on #20264. #20280 remains open for
its `datetime` half, per ruling 5859414357.
## DELIBERATE CORRECTION: three pending release notes
`Check Changeset` will be red on these three names by design. Each file
gets one clause, correcting a sentence this change makes false in the
same release. Do NOT restore them from base.
- `.changeset/20240-date-year-four-digits.md`: the `Unchanged` clause
"every `datetime` and `time` cell, the same numbers included" gains the
0001..9999 narrowing.
- `.changeset/20203-epoch-ms-date-comparand.md`: the parenthetical
"refuses one whose year falls outside 0..9999" gains "#20264 … narrows
that to 0001..9999".
- `.changeset/20263-having-temporal-comparand-door.md`: the `Unchanged`
clause "an extended-year instant on a `datetime` column, which that rule
reads" gains "until #20264 … refuses a `datetime` year outside
0001..9999".
The claim's file surface names `.changeset/20264-*.md` only. These three
are an in-place addition, declared here and in the report.
## Tests and gates, measured at `311ce0640`
`311ce0640` is the merge of `origin/main` `dc0ab6a2e` into this branch,
and it carries PR #20423's record-validator number arm. These readings
are its own.
- **Full suites:**
- core: 56 files / 1490 passed, plus `test:repo` 3 / 48.
- objectql: 328 files / 6077 passed, plus `test:repo` 1 / 5.
- rest: 217 files / 3916 passed / 34 skipped, plus `test:repo` 1 / 8.
- driver-memory: 58 files / 1378 passed.
- service-analytics: 132 files / 3093 passed.
- driver-sql, the whole suite: 207 files / 4714 passed / 1 skipped, with
"all 3 dialects were exercised". It ran with `TZ=America/New_York`,
`OS_EXPECT_LIVE_DIALECT_MATRIX=1`, live PostgreSQL 16.13
(`Asia/Shanghai`) and live MySQL 8.0.46 (`+08:00`).
- The new REST file with its live cells: 12 passed on SQLite, PG and
MySQL.
- **Typecheck:** core, objectql, rest, driver-memory and driver-sql exit
0. Test-layer debt is unchanged (core 4 / 4, objectql 40 files / 234
errors, rest 0). Every new or edited test file is in its package's tsc
program, counted with `--listFiles`.
- **Ablation A: the range reverted to the base semantics** (`date` only,
0..9999). The mutation went in through `scripts/ablation-replace.mjs`:
anchor 1 to 0, blob `7801894e` to `8a7dc4b4`. Core was rebuilt, and the
dist preflight found the marker present in 2 built files.
- Mutated: core 7 failed / 90 passed; objectql 11 failed / 56 passed;
rest 4 failed / 19 passed (8 live cells skipped). Every red is a #20264
cell: the `datetime` year class, year 0, or the write door. Every `date`
10000 / −1 cell and every control stayed green.
- Restored: blob equals `HEAD`, `git status --porcelain` is empty, and
after a rebuild the preflight finds the marker absent from 14 files.
core 97, objectql 67 and rest 23 passed.
- **Ablation B: the write door's range call removed** from
`record-validator.ts`. Anchor 1 to 0, blob `a7fd6b04` to `cfbeeebc`.
objectql was rebuilt, and the preflight found the marker present in 4
files.
- Mutated: objectql 2 failed / 118 passed (exactly the write-door
cells); rest 1 failed / 3 passed (the write cell).
- Restored: the tree is clean, objectql got a full rebuild with DTS, the
marker is absent from 14 files, and 120 and 4 passed.
- **`dispatch-gates --commands`** at `311ce0640` derived 67 commands.
All 67 ran, each exit code captured before any pipe. `--ran` reconciles
them: 67 derived, 67 run, 0 NOT-MEASURED, with a derived zero.
- 66 exit 0.
- `check-empty-changeset.mjs --base origin/main` exits 1, on exactly the
three DELIBERATE CORRECTION names above.
- `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`
(exit 3). After a full `turbo run build`, it exits 0.
- **ESLint, narrowed:** 13 changed `.ts` files, 0 errors and 0 warnings,
counted from `--format json`. The population is `eslint.config.mjs`'s
`**/*.{ts,…}` block (line 971). The config enables no type-aware linting
(its own note, lines 327-328), so no untouched file's verdict can move.
- **`check:driver-conformance`:** base `b28550818` reads 50 covered / 0
DEBT / 0 exempt, and `311ce0640` reads 50 / 0 / 0.
## Acceptance notes
- `driver-mongodb` keeps its own copy of the storage rule
(`mongodb-temporal.ts`). Its `date` arm pads no year and has no number
arm, which #20203 and #20240 name as known. Both doors of this card sit
in the engine in front of it. It was not measured here (no MongoDB in
this container). Carrier: none.
- `time` columns judge no year. This is measured at REST on memory and
SQLite, at head. `where t $gt "+010000-01-01T10:00:00Z"` answers 200
with 3 of 3 rows, and `$lt` answers 0, so the string is compared
verbatim as text. The same instant in 2026 (`10:00:00`) answers 2 / 1.
The predicate reads the string as an instant, while the `time` rule
hands it back unchanged. This is outside the ruling's `date` /
`datetime` scope, so it is reported for the seat to file.
- The write door's `date` arm admits a `Date.parse`-readable string with
no leading `YYYY-MM-DD` inside the range. This is measured at REST on
memory and SQLite, at head. `POST /data/:object` with `d: "2026/07/15"`
answers 201, and the row reads back `"2026/07/15"`, a stored non-day.
The class differs from the year range, and the ruling scoped this card's
write-door refusal to the range, so it is reported for the seat to file.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8113763 commit 3062e50
17 files changed
Lines changed: 1238 additions & 131 deletions
File tree
- .changeset
- packages
- core/src/utils
- drivers
- driver-memory/src
- driver-sql/src
- objectql/src
- validation
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
0 commit comments