Skip to content

Commit 3062e50

Browse files
fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) (#20469)
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

‎.changeset/20203-epoch-ms-date-comparand.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ The rule is shared by the drivers' write and read paths too:
2828
- `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before.
2929
- A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there.
3030

31-
Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
31+
Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included; #20264, in the same release, narrows that to 0001..9999, so year 0 is refused rather than padded), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.

‎.changeset/20240-date-year-four-digits.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ What changes:
3535

3636
**Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year.
3737

38-
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
38+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included (#20264, in the same release, then narrows the range to 0001..9999 on `date` and `datetime` alike: year 0 is refused too, and so is a `datetime` number, `Date` or string outside it, and the padding covers 0001..0999); every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 read back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch and #20280, in the same release, corrects by reading a MySQL `DATE` as its text. `having` reaches the same door in the same release (#20263), so a number or `Date` outside 0..9999 is refused there too. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.

‎.changeset/20263-having-temporal-comparand-door.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ The refusal follows the `where` door's words. It names the `having` path, the co
3838

3939
**Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a `datetime` column with an ISO-8601 instant, a bare day or epoch milliseconds, either one with a relative-date placeholder the resolver knows (`{30_days_ago}`, `{current_month_start}`; `having` resolves them from the same release, #20334), and a `time` column with an `HH:MM` or `HH:MM:SS` wall clock.
4040

41-
**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words.
41+
**Unchanged**, measured identical before and after on the three drivers, both paths and both doors: every `where` and per-aggregation `filter` answer; every `having` on a temporal column whose comparand the rule reads (a `YYYY-MM-DD` day, an ISO instant, an epoch-millisecond number or string, an in-range `Date`, a zone-naive instant, a wall clock, an extended-year instant on a `datetime` column, which that rule reads, until #20264, in the same release, refuses a `datetime` year outside 0001..9999 through the same predicate); `{today}`-style placeholders, known or not; the empty and the whitespace-only string; `null`, `$exists`, `$in` / `$nin`, `$between`, `$not` / `$or` / `$and` and `{ $field }` references; `$contains` and `$startsWith`; every `count` / `sum` / `avg` column, a string comparand included; a `month` bucket; and every existing `having` refusal, in its words.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(core,objectql)!: a `date` or `datetime` value 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 as a written value (#20264)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a temporal VALUE at the query door and the write door: no authorable key, spelling or stored shape moves, `packages/spec` is untouched, and a stored row keeps the form it has. What is refused is a day or an instant whose year falls outside 0001..9999, and which in-range year the caller meant is not something a ledger entry can decide. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a comparand or a written value (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what a `date` or `datetime` field accepts, as a filter comparand and as a written value. A value whose year falls outside 0001..9999 used to answer 200 with the wrong rows, 201 with a non-day stored, or a 500 on PostgreSQL; it now answers 400. It ships as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
FROM a `date` or `datetime` value in year 0, before it, or after 9999 (`"+010000-01-01T00:00:00.000Z"`, `"-000001-…"`, `"0000-06-15"`, or the epoch-millisecond number or `Date` of such an instant) → TO `INVALID_FILTER` / 400 as a comparand and `VALIDATION_FAILED` / 400 (`invalid_date`) as a written value. The fix is one line: write a year from 0001 to 9999.
15+
16+
Measured through `engine.find` / `engine.aggregate` / `engine.insert` and `POST /data/:object/query` / `POST /data/:object` (the two doors agree), over seven 2026 rows, `$gt` / `$lt` / `$eq`:
17+
18+
| position | value | before: memory · SQLite · PostgreSQL 16 | now, on all three |
19+
|:--|:--|:--|:--|
20+
| `where` on a `datetime` | year 10000 or −1: a number, `Date` or ISO string | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
21+
| per-aggregation `filter`, `having` on `min` of a `datetime` | the same, `$gt` | 7 rows, every group, on all three | `INVALID_FILTER` / 400 |
22+
| `where` on a `datetime` | year 0, every spelling | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 |
23+
| `where` on a `date` | year 0: a number, `Date`, ISO string or bare `0000-06-15` | 7/0/0 · 7/0/0 · 500 | `INVALID_FILTER` / 400 |
24+
| create a `date` | `"+010000-01-01T00:00:00.000Z"` | 201, read back verbatim (not a day) · the same · 500 | `VALIDATION_FAILED` / 400 |
25+
| create a `date` or a `datetime` | year 0, year −1, year 10000 | 201 · 201 · 500 | `VALIDATION_FAILED` / 400 |
26+
27+
MySQL 8.0 answered the year-10000 and year-−1 cells with a 500 and the year-0 cells like SQLite. A `datetime` in year 10000 spells `+010000-…`, which sorts below every four-digit year as text (its `where` answer was 7/0/0 for `$gt` / `$lt` / `$eq`, where the right answer is 0/7/0); PostgreSQL's `DATE` and `timestamptz` have no year 0 (`22008`). Year 0 was answered right on memory and SQLite and a 500 on PostgreSQL; it is refused everywhere now, one answer on every driver. Each refused query or write now reaches no driver.
28+
29+
What changes:
30+
31+
- `@objectstack/core` exports `isOutsideTemporalYearRange(value, kind)`, the one range both doors ask. The year is the one the kind's storage rule reads: a `datetime`'s UTC year, a `date` string's leading `YYYY-MM-DD` year (otherwise the UTC year of the instant it names), never a `time`'s.
32+
- `isUninterpretableTemporalComparand` is true for a `date` or `datetime` number, `Date` or readable string whose year falls outside 0001..9999; before, it judged only a `date` number or `Date`, against 0..9999. The engine's temporal-comparand door refuses such a comparand on `where` (every verb, both spellings), in a per-aggregation `filter` and on `having`, before any read, in words that name the year range. `IObjectQLEngine.judgeFilter` and `service-analytics`' raw-SQL decline read the same predicate.
33+
- The record validator's `date` / `datetime` arm refuses a value outside the range on insert, update, a multi-row update and `engine.validate`, with the field's `invalid_date` code and its existing message.
34+
- `temporalStorageForm(value, 'date')` pads a `Date`'s or a number's year to four digits for 0001..0999 only; year 0 keeps its unpadded spelling (`0-06-15`) like every other year outside the range. Only a direct driver write, which bypasses both doors, reaches that arm with year 0.
35+
36+
**Who is affected.** A caller that filters on or writes a `date` or `datetime` in year 0, before it, or after 9999. No writer that stores or queries such a year has been measured; the reach is the public query and write doors.
37+
38+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every year from 0001 to 9999 (the edges 0001-01-01 and 9999-12-31T23:59:59.999Z included) and every 2026 control; every `time` cell; every string the rules could not read before, refused in its existing words, except 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), refused with the same code and status on `where`, the per-aggregation `filter` and `having` but now in the year-class words; `NaN`, ±Infinity and an Invalid Date, which name no year; the `datetime` storage rule's own spelling of any instant on the write and read paths. On MySQL 8.0, a `datetime` in years 0001..0099 is still stored right and read back a century late through mysql2's instant parser (`0009-03-04T10:00Z` as `2004-09-03T10:00Z`), which ADR-0053 D-F2 keeps and this change does not touch; from year 0100 up it reads back as written. `driver-mongodb` keeps its own copy of the storage rule and is not changed; both doors sit in the engine, in front of it.

0 commit comments

Comments
 (0)