Repository navigation
fix(core,objectql)!: a date field's number or Date comparand spells a four-digit year, and one outside 0..9999 is refused INVALID_FILTER / 400 (#20240) - #20261
Conversation
…e a number or Date whose UTC year falls outside 0..9999 on a date field WIP: source change only; pins follow. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…e out-of-range refusal on memory, SQLite, PostgreSQL, engine and REST Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…refusal; one-clause correction to the pending 20203 note Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… every dialect — PostgreSQL read the unpadded 9-03-04 as 2004-09-03 Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check7 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 32 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 b8823a2fae47e9271c37008a096ea405b1ff045a && git checkout b8823a2fae47e9271c37008a096ea405b1ff045a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a9fb83ef06a079a938c97d1ea5e364705e1bc13b e83b248a39ee8da5b98b20a41ac8c254332d0291 && git checkout -B drift-repro a9fb83ef06a079a938c97d1ea5e364705e1bc13b && git merge --no-ff e83b248a39ee8da5b98b20a41ac8c254332d0291
node scripts/docs-audit/affected-docs.mjs --json a9fb83ef06a079a938c97d1ea5e364705e1bc13b |
…ql2 reads a year below 100 back a century late The live MySQL cell read year 9 back through the driver as 1909-03-04: mysql2's parseDate rebuilds a DATE with Date.UTC(y, m - 1, d), which maps years 0..99 to 1900..1999. The server stored 0009-03-04. The write-path cell now reads the stored text by a raw per-dialect cast on every dialect, and adds the year-99 write. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: PR #20261 (card #20240), 11 files, +951/−26, vs merge base ① Derived judgments
② Semver level
③ Boundary flags
Out-of-scope findings re-measured at base for the seat's filing (not verdict items):
Implemented-by: VERDICT: FAIL Must-change:
Everything else — the rows, zero-read refusals, one-rule/no-collateral, the stop-valve reading, the ablations, the export surface, the |
…year at base, and MySQL's read-path cells Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Scope: (delta of 5857959126) PR #20261 (card #20240), previous record FAIL at ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ar below 100 no longer comes back a century late (objectstack-ai#20306) Part of objectstack-ai#20280 — the `date` half. The `datetime` half stays open on the card: it needs a decision about ADR-0053 D-F2 (see "Open: the datetime half" below), so merging this must leave objectstack-ai#20280 open. Clause-②: no ## What was wrong On MySQL, driver-sql took mysql2's JS `Date` for a `DATE` column. mysql2 3.23.1 `Packet#parseDate` rebuilds it as `new Date(Date.UTC(y, m - 1, d))` under the driver's `timezone: 'Z'` pin (`new Date(y, m - 1, d)` under `'local'`), and both constructors read a year from 0 to 99 as 1900 + year. The write was right and the read was wrong: `where placed_on $eq '0009-03-04'` found the row and presented `1909-03-04`. ## What changed - `packages/drivers/driver-sql/src/sql-driver.ts`: a new `withMysqlCalendarDayAsText`, chained in `withConnectBound` beside `withUtcSession` and `withPostgresCalendarDayAsText`, sets `dateStrings: ['DATE']` on a MySQL connection (`mysql` / `mysql2`, object or URL connection) unless the host set `dateStrings` itself. A function-valued connection is left alone, as `withUtcSession` leaves it. - The read doors already present a `date` through `toDateOnly`, which is `@objectstack/core`'s `temporalStorageForm`. With the wire text arriving, that rule's string arm hands back the stored `YYYY-MM-DD`: the same path PostgreSQL's `date` takes since its calendar-day parser. No presenter changed and no copy of the rule was added. - Docblocks of `withPostgresCalendarDayAsText` and `toDateOnly` updated: a `date` now reaches the read doors as text on every dialect, MySQL included. ## Measured (live MySQL 8.0.46, server `time_zone='+08:00'`, process `TZ=America/New_York`, mysql2 3.23.1) Records written through `POST /api/v1/data/:object`, read through `driver.find` / `findOne`, `engine.find` / `findOne`, `POST …/query` and `GET …/:id` (all six agree in every cell). Base `89f87f2344`, head this branch. | year | kind | stored (`CAST(… AS CHAR)`) | presented at base | presented at head | |:--|:--|:--|:--|:--| | 0009 | date | `0009-03-04` | `1909-03-04` | `0009-03-04` | | 0099 | date | `0099-03-04` | `1999-03-04` | `0099-03-04` | | 0999 | date | `0999-06-15` | `0999-06-15` | `0999-06-15` | | 0000 | date | `0000-06-15` | `1900-06-15` | `0000-06-15` | | 1000 | date | `1000-01-01` | `1000-01-01` | `1000-01-01` | | 2026 | date | `2026-03-04` | `2026-03-04` | `2026-03-04` | | 9999 | date | `9999-12-31` | `9999-12-31` | `9999-12-31` | | 0009 | datetime | `0009-03-04 10:00:00.000` | `2004-09-03T10:00:00.000Z` | unchanged | | 0099 | datetime | `0099-03-04 10:00:00.000` | `1999-03-04T10:00:00.000Z` | unchanged | | 0000 | datetime | `0000-06-15 10:00:00.000` | `2000-06-15T10:00:00.000Z` | unchanged | | 0999 / 1000 / 2026 / 9999 | datetime | stored as written | the stored instant | unchanged | A `groupBy` key on the `date` field, `distinct`, and `min` moved the same way (base `1900-06-15` / `1909-03-04` / `1999-03-04`; head `0000-06-15` / `0009-03-04` / `0099-03-04`). `$eq` and `$gt` found the same rows at base and head on both kinds. Year 0000 is recorded, not decided: it is inside what this head stores and reads, and the range decision on objectstack-ai#20264 refuses it once that lands. ## Remedy choice (the two measured on PR objectstack-ai#20261's review) Measured on a mysql2 connection per remedy, same rows: | remedy | `DATE` 0009 / 0099 / 0000 | `DATETIME` 0009 / 0099 / 0000 | zero `DATE` / `DATETIME` | |:--|:--|:--|:--| | `timezone: 'Z'` (base) | 1909 / 1999 / 1900 as a `Date` | 2004-09-03 / 1999 / 2000 as a `Date` | `1899-11-30` / Invalid `Date` | | `timezone: '+00:00'` | right, as a `Date` | still folded | Invalid `Date` / Invalid `Date` | | `dateStrings: ['DATE', 'DATETIME']` | right, as text | right, as text | text / text | - `dateStrings` for `DATE` is taken. It is the narrower change: it touches only `DATE` columns and no write, and it is the shape PostgreSQL already reads a day in. `'+00:00'` would also move the zone every bound `Date` and every `DATETIME` is rendered in, and fixes nothing more. - `dateStrings` for `DATETIME` is NOT taken, because ADR-0053 D-F2 (accepted; anchored in `scripts/adr-anchors/packages__drivers__driver-sql__src__sql-driver.ts.json`) says the mysql2 parser keeps materialising an instant as a `Date`, folded to text only at the driver's read doors. Text at the client parser for an instant is the ADR's "B1-full", an option not taken, and three landed pins encode it (`sql-driver-13973-canonical-iso-read-door.test.ts` §C, `sql-driver-13567-audit-stamp-materialisation.test.ts` §B3, `sql-driver-14078-invalid-date-materialisation.test.ts` §B2). Reversing that is an ADR decision, not a changeset. ## Open: the datetime half A MySQL `DATETIME` in years 0001..0099 still reads a century late. No read-door repair exists (the fold is not invertible: `2004-09-03` may be a real stored day), so any fix is at the client parser. The options and their measured costs are in the report on the card; the tests here pin the current `datetime` reading as OBSERVED so a decision that moves it has to move them. ## Collateral (MySQL only) - Every read of a year from 1000 to 9999, on `date`, `datetime`, `time`, a `TIMESTAMP` column and `null`, is byte-identical base to head on `find`, `findOne`, `count`, `aggregate` (`min`, `max`, `groupBy`), `distinct` and a write-then-read (265 cells compared; the 25 that moved are exactly the year 0 / 9 / 99 `date` cells above, their `groupBy` / `distinct` / `min` keys, the raw `execute()` `DATE`, and the new connection option). - A raw `execute()` read, and a `DATE` column read under a field not declared `date`, now receive `YYYY-MM-DD` text where they received a `Date` (measured: `Date(2026-03-04T00:00:00.000Z)` became `"2026-03-04"`). A `DATETIME` there is still a `Date`. PostgreSQL has answered a `date` this way since its calendar-day parser. - A zero day (`0000-00-00`, only storable with `NO_ZERO_DATE` off) presents as that text, where mysql2 invented `1899-11-30`. The zero `DATETIME` pin of objectstack-ai#14078 is untouched (still an Invalid `Date`). - SQLite and PostgreSQL: `withMysqlCalendarDayAsText` returns their config object unchanged (pinned), and the whole driver-sql suite is green on both. ## Tests - New `packages/drivers/driver-sql/src/sql-driver-20280-mysql-date-read.test.ts`: the connection option (URL and object, host-set `dateStrings` kept, other dialects untouched, `DATETIME` / `TIMESTAMP` not in the list), then per dialect cell of the live matrix: stored text by raw cast, `find` / `findOne`, `groupBy` / `distinct` / `min` / `max`, `$eq` / `$gt`, the raw wire (`date` text everywhere, `datetime` a `Date` on live cells), a create / update / read round trip of years 42 and 1, and the `datetime` reading as observed. - New `packages/rest/src/data-date-read-year-below-100.test.ts`: the same read through `engine.find` / `findOne` / `aggregate` / `count` and `POST /api/v1/data/:object`, `POST …/query`, `GET …/:id`, on a SQLite cell (every runner) and a live MySQL cell (`OS_TEST_MYSQL_URL`; a named skip otherwise, a failure under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`). No CI job runs `packages/rest` against MySQL today, so that cell runs only where a server is provisioned; the CI-run pin of the same read is the driver-sql file under `Temporal Conformance (live PG + MySQL)`. - Corrected pins that described the old read: `sql-driver-connect-bound.test.ts` (the exact MySQL URL config gains `dateStrings`), `sql-driver-11389-date-tz-skew.test.ts` (two comments, plus the option asserted), `sql-driver-20240-date-year-spelling.test.ts` (header no longer says MySQL reads a year below 100 a century late; the write-path cell now also reads the year-9 row back through the driver). Runs (live MySQL 8.0.46 on a private server at `+08:00`, live PostgreSQL 16.13 at `Asia/Shanghai`, `TZ=America/New_York`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1`): - whole `@objectstack/driver-sql` suite at `e9d1a26529`: 203 files, 4669 passed, 1 skipped, "all 3 dialects were exercised"; the one test file changed after it (`sql-driver-20280-mysql-date-read.test.ts`, query options typed) re-run at `93af6580d2`: 25 passed on 3 dialects. - REST query files (`data-query-date-year-range`, `data-query-epoch-ms-date-comparand`, the new file) at `93af6580d2`: 3 files, 36 passed, SQLite and live MySQL cells. - `pnpm --filter @objectstack/driver-sql typecheck` exit 0 (the new test is in the `tsc` program, `--listFiles` count 1); `pnpm --filter @objectstack/rest typecheck` exit 0 (test layer: 0 files / 0 errors held). - eslint `--no-inline-config` on the 6 changed TypeScript files: 6 files, 0 errors, 0 warnings. That narrowing is complete: `eslint.config.mjs` lints `**/*.{ts,…}` and enables no type-aware rule, so this diff cannot move a verdict on an untouched file. Ablation (committed first; restore proved by blob equal to `HEAD` and a clean `git status --porcelain`): removing the `withMysqlCalendarDayAsText` call from `withConnectBound` - driver-sql, 4 files on 3 dialects: 9 failed / 107 passed. Every red is a MySQL cell or a config pin; every SQLite and PostgreSQL cell stayed green. - REST, with driver-sql's `dist/` rebuilt around the mutation (`ablation-dist-preflight` proved the call absent from `dist/` in the mutate leg, present again after the restore rebuild): 4 failed / 6 passed, the four being every `date` cell of the live MySQL cell. ## Changesets - `.changeset/20280-mysql-date-read-text.md`: `@objectstack/driver-sql` patch, `Clause-②: no`. - DELIBERATE CORRECTION of the pending `.changeset/20240-date-year-four-digits.md`, one clause. It said a stored MySQL year below 100 "still reads back a century late … which this change does not touch", which this PR makes false for the release both notes ship in. It now reads "read back a century late … which this change does not touch and objectstack-ai#20280, in the same release, corrects by reading a MySQL `DATE` as its text." `check-empty-changeset` is red on that file by design, and asks for exactly this: please confirm the correction. ## Acceptance notes - `check:dual-build-cjs-loads` printed PREREQUISITE NOT MET (it reads every package's `dist/`; this worktree built only the driver-sql and REST closures): NOT MEASURED here, CI measures it. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…9, refused at the comparand door and the write door (objectstack-ai#20264) (objectstack-ai#20469) Fixes objectstack-ai#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. `objectstack-ai#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 objectstack-ai#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 objectstack-ai#20261 (objectstack-ai#20240) and objectstack-ai#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 objectstack-ai#20264. objectstack-ai#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 "objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#20203 and objectstack-ai#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>
Fixes #20240
Clause-②: no (narrowing)
A number or
Datecompared against adatefield now spells its year with four digits, the spelling its ISO string already had. One whose UTC calendar day falls in a year below 0 or above 9999 has noYYYY-MM-DDform, and it is now refusedINVALID_FILTER/ 400 at the temporal-comparand door, as its ISO string already was. Both edits are in the one rule and its one predicate, in@objectstack/core. No driver source changes.What changed
packages/core/src/utils/temporal-storage-form.ts,canonicalCalendarDay: a non-negative year of aDateor a number is padded to four digits (0999-06-15, not999-06-15). A year below 0 or above 9999 keeps the spelling it had (10000-01-01,-1-01-01). The rule is shared with the write and read paths, and no ordered form is invented.packages/core/src/utils/temporal-comparand.ts,isUninterpretableTemporalComparand: ondate, a finite number or a validDatewhose UTC year is below 0 or above 9999 is uninterpretable. That includes a finite number past theDaterange (±8.64e15).NaN, ±Infinity and an Invalid Date name no year, so they stay unjudged. Thedatetimeandtimebranches are untouched. The module note about non-string comparands was rewritten so it stays true (the stop valve, below).packages/objectql/src/temporal-comparand-door.ts: the door already calls that predicate onwhere(every verb, both spellings) and on a per-aggregationfilter. It now words the new class in its own sentence, because such a comparand answers the wrong rows or a database error, not "compare false for every row". The result'svalueis widened fromstringtounknown. It is internal and not exported from the package..changeset/20240-date-year-four-digits.md(minor: core and objectql;patch: driver-sql and driver-memory). There is one one-clause DELIBERATE CORRECTION to.changeset/20203-epoch-ms-date-comparand.md(see Deviations).Stop valve: the ruling scoped #8690's own change; it is not a standing rule
temporal-comparand.tssaid: "Non-string comparands. A number is epoch milliseconds, aDateis an instant,nullis a null test. The refusal scopes to strings by ruling." Every scope sentence on #8690, read in full:Dateis an instant" under "Scope boundaries, each by ruling". The ruling itself contains no clause about non-strings.Reading: the ruling scoped #8690's own change (strings, the card's defect, and the empty-string boundary). The door's own reason for leaving non-strings alone was a fact ("both are read correctly today"). That fact is false for a
dateyear outside 0..9999, which this card measured. The valve does not trip, so triage's refuse direction applies. The module note now says exactly this.The rows, base
0d3ec47137→ headThe fixture has seven rows on a
datefield (six 2026 days plus0999-06-15), live on InMemoryDriver, SqlDriver on better-sqlite3, and PostgreSQL 16.13. The process zone isAmerica/New_Yorkand the server zoneAsia/Shanghai. Each cell went throughengine.find/engine.aggregateand throughPOST /api/v1/data/:object/querywith a JSON round-tripped body. The two doors agree on all 288 comparable cells at base and at head. Over REST aDateis its ISO string (all 144 RESTDatecells equal their ISO twins). Cells are$gt/$lt/$eq, given as memory · SQLite · PostgreSQL.-30627504000000), and itsDate(engine)wherefilterhavingonmax(date)0999-06-15T00:00:00.000Z253402300800000), and itsDatewhereINVALID_FILTER/ 400, no readfilterINVALID_FILTER/ 400, no read-62198755200000), and itsDatewhereDATABASE_ERROR500INVALID_FILTER/ 400, no readfilterINVALID_FILTER/ 400, no read+010000-01-01T00:00:00.000Z/-000001-01-01T00:00:00.000Zwhere, per-aggregationfilterINVALID_FILTER/ 400Date/ ISO for 2026-02-01T10:00Zdatetimefieldwhere"No read" was measured with a counter on the object's
find/findOne/count/aggregate: zero reads through the engine and REST, on all three drivers.Moved cells, classified mechanically: 180 of 867. Every one is a number or
Dateon thedatefield: 0999 atwhere, the per-aggregationfilterandhaving, and 10000 / -1 atwhereand the per-aggregationfilter. Zero moved among ISO strings, the 2026 control, thedatetimecontrol andhavingout-of-range.H2, the PostgreSQL raw number: not reproduced at base
At
0d3ec47137(and at the card's615c468746, whose query path is byte-identical:git diff --staton core, both drivers, objectql, rest and metadata-protocol sources shows only four plugin-contract / plugin-loader files), no layer hands PostgreSQL the raw number for year -1.SqlDriver.coerceFilterValue→toDateOnly→temporalStorageFormbinds the rule's"-1-01-01", and the server refuses it with22007 invalid input syntax for type date: "-1-01-01". That was captured from knex's query-error bindings on every one of the 9 statements. The card's message,22009 time zone displacement out of range: "-62198755200000", is exactly whatselect '-62198755200000'::dateanswers. That is the pre-#20224 path, wheretoDateOnlyreturned a number unchanged. At head the refusal precedes the driver, with zero reads.H4's
havingleg: falsified,havingnever reaches the doorhavingtakes its own entry doors inengine.aggregate(assertHavingIsFilterCondition,assertListComparandShapes,normalizeFilterComparandTypes,assertHavingIsEvaluable). None of them is the temporal-comparand door, for any comparand. Measured at base on all three drivers, through both doors, onmax(date):having: { last_placed: { $lt: 'not-a-date' } }keeps every group (200), and itswheretwin is refused 400;+010000-01-01T00:00:00.000Zunder$gtkeeps every group, and itswheretwin is refused 400.So a year-outside-range number or
Dateonhavingstill compares as written: the number for 10000-01-01 under$gtkeeps c1, c2, c3 at head, as at base. Wiring the door intohavingwould also newly refuse the strings above. That is a second narrowing on a file (engine.ts/having-filter.ts) outside the claimed surface, so it is reported (Out-of-scope 1) and not done. The padding does reachhaving, and its 0999 cells are now right.H5, no collateral
date× a number orDate: the 0..999 years now pad, and the predicate turns true outside 0..9999, the Date-range edges and past-range numbers included. Everydatetimeandtimecell is byte-identical, and so is every string,null, bigint, boxed Number, boolean, object and array cell.date,datetimeandtimefields; rows in 1000, 2026, 9999 and 0999): 3099 cells overwhere, the per-aggregationfilterandhavingon all three fields, three drivers, both doors, and the read paths (find,max/minper group, agroupBykey). 72 moved, all a finite number past theDaterange (±9e15) on thedatefield atwhereor the per-aggregationfilter, now 400. Before, memory answered 0/0/0, SQLite 7/0/0 and PostgreSQL 500. Every cell for years 1000..9999 (number,Date, ISO, bare day) is byte-identical, as is everydatetimeandtimecell, year 0,'not-a-date', a wall clock and every read-path cell.Dateon memory or SQLite, now stored padded (0999-06-15,0000-06-15). That coversdriver.create/driver.update, plusengine.insertof aDate, which the engine accepts. PostgreSQL already stored a three-digit year's day (this sweep's short years were 0000 and 0999), but not a shorter one: under its defaultDateStyle(ISO, MDY) it stored the unpadded9-03-04as 2004-09-03 and refused99-03-04(22008). That was measured under ablation A (below) and withselect '9-03-04'::date, and it is pinned by the 0009 / 0099 cells. MySQL 8.0, measured at the driver door in round 2, stored99-03-04as 1999-03-04. All three dialects now store the day. The engine and REST write doors still refuse a number ondate(VALIDATION_FAILED/ 400), unchanged, and every out-of-range write cell is unchanged.service-analytics' raw-SQL decline (NativeSQLStrategy.canHandle, not edited): 0 of 7 moved. It reads a time dimension with thedatetimerule (lookupMember(...)?.type === 'time' ? 'datetime' : null), so the newdatebranch is unreachable from it.Tests
New pins:
packages/core/src/utils/temporal-storage-form.test.ts: years 0000..0999 pad for the number, itsDateand its ISO string; the spellings sort chronologically; out-of-range years keep their spelling.packages/core/src/utils/temporal-comparand.test.ts(new): thedateyear class, the datetime / time controls,NaN/ Infinity / Invalid Date unjudged, and an invariant that for a finite number the predicate refuses exactly when the rule cannot spellYYYY-MM-DD.packages/objectql/src/engine-date-year-range-door.test.ts:codeANDstatusplus zero driver reads, with its positive control (years 0000, 0999, 2026, 9999 reach the driver) in the sameit(). Also covered: every comparand position and both spellings, all six verbs, the per-aggregationfilter, and the datetime / time controls.packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.tsandpackages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts(the live-dialect matrix): years 0009, 0099 and 0999 answer one count for the number, itsDateand its ISO string, plus the 2026 control,$in/$between, and the create / update write path.packages/rest/src/data-query-date-year-range.test.ts: engine and REST over SqlDriver, withwhere, the per-aggregationfilterandhavingfor 0999 and 2026; 10000 and -1 refused with no read of the object; a datetime control.Whole suites, after merging
main(e46218674b) and rebuilding the closure of rest, driver-memory and driver-sql at2f219baa71:@objectstack/core: 58 files / 1522 passed.@objectstack/driver-memory: 57 / 1374 passed.@objectstack/objectql: 320 / 5777 passed.@objectstack/driver-sqlwith live PostgreSQL (TZ=America/New_York, serverAsia/Shanghai): 199 files passed, 3 skipped / 3888 passed, 88 skipped; the reporter says sqlite RAN, live postgres RAN, live mysql NOT RUN. In round 2 (below), ate83b248a39, the whole driver-sql suite ran against both live servers: PostgreSQL 16.13 and a private MySQL 8.0.46 (server zone+08:00), withOS_EXPECT_LIVE_DIALECT_MATRIX=1. Result: 202 files / 4644 passed, 1 skipped; the reporter says all 3 dialects were exercised.@objectstack/rest(every query-route test): 203 / 3592 passed, 1 skipped.Typecheck at
2f219baa71: core, objectql, driver-memory, driver-sql and rest each exit 0. Every new test file is in a compiled program (--listFiles:tsconfig.test.jsonfor core, objectql and rest;tsconfig.jsonfor the two drivers). The test-typecheck ledgers held: core 4, objectql 234 errors / 65 signatures, rest 0.Reverse verification
Both ablations ran after the fix was committed, through
ablation-replace(wrap mode, restore trap on the absolute path). Core was rebuilt in every leg andablation-dist-preflightpassed (present on the mutate leg,--absenton the restore leg), because objectql and rest resolve@objectstack/corethrough itsdist. The direction was predicted before each run.A, padding off. The whole line
const yyyy = y >= 0 ? String(y).padStart(4, '0') : String(y);becameconst yyyy = String(y).padStart(0, '0');; anchor 1 → 0, blob123c85a117→685087daf5. The contract review's literalpadStart(4, '0')→padStart(0, '0')substitution givesd803edfcb6. Both ids reproduce from the source, and both unpad a non-negative year the same way, so the counts agree:Predicted red, observed red, with one more red than first predicted. On the first run PostgreSQL went red on the write path, which I had predicted green. Its default
DateStyle(ISO, MDY) reads the unpadded9-03-04as 2004-09-03, a different day, silently, and refuses99-03-04(22008). The padded forms read right. So I added 0009 and 0099 cells to both driver pins and re-ran; the counts above are that run. The base defect is in this card's class and the padding closes it.B, refusal off (
return isOutsideCalendarDayYears(value);→return isOutsideCalendarDayYears(value) ? false : false;; blob912a0609ab→f150d2d782):NaNstay green);Restore legs: after both, the blob equals HEAD,
git diff HEADis empty andgit status --porcelainis clean. Core was rebuilt and the preflight--absentexited 0. Pins: core 90, objectql 6, memory 12, driver-sql 24 + 1 skipped, REST 8, all passed.Gates
dispatch-gates --commands --repo objectstack-ai/objectstackat2f219baa71derives 65 commands (the dispatch clue's 50, plus the changeset families and more). Every one ran at2f219baa71: 62 exit 0, 1 exit 1, 2 exit 3.--ranwith the recorded exit codes reads: 65 derived, 63 run, 2 NOT MEASURED (derived from exit 3), 0 unrun.check-empty-changeset --base origin/main: exit 1, on purpose. It names exactly.changeset/20203-epoch-ms-date-comparand.md, which is the DELIBERATE CORRECTION class. Please confirm it here; it must not be restored from the base.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt: NOT MEASURED,PREREQUISITE NOT MET. Both read every package's built output, and CI builds the whole./packages/*before them.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:object-def-param-keys,check:tenant-chokepoint), andcheck-issue-citations --base e46218674b(11 citations, all resolve).check-adr-0087-registrationreads thenot-required (no-migration-prescription)disposition, andcheck-changeset-no-majorfinds nomajor.eslint --no-inline-config --format jsonon the 9 changed.tsfiles at2f219baa71reports 9 files, 0 errors / 0 warnings / 0 fatal, and--print-configresolves a config for each. The two.mdfiles resolve none, so they are outside eslint's population.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService, noTypeCheckedpreset), so no untouched file's verdict can move.Round 2: contract review 5857959126
Must-change 1: the live MySQL cell (CI job 108661118941,
Temporal Conformance). It failed withexpected '1909-03-04' to be '0009-03-04'on the write-path assertion only; the SQLite and PostgreSQL cells passed, and so did MySQL'swherecells, which read ids and not dates. The cause is the read, not the write:lib/packets/packet.jsparseDate, rebuilds aDATEasnew Date(y, m - 1, d), or asnew Date(Date.UTC(y, m - 1, d))undertimezone: 'Z', which driver-sql sets (sql-driver.ts, thetimezone: 'Z'connection override). Both constructors map a year 0..99 to 1900..1999.+08:00, the CI setting), the bound'0009-03-04'is stored as0009-03-04(CAST(... AS CHAR)andDATE_FORMATagree). mysql2 hands back 1909-03-04 for it, 1999-03-04 for0099-03-04and 1900-06-15 for0000-06-15, while year 999 survives.The fix is in the test only; there is no driver
srcchange and no skip. The write-path cell now reads the STORED text through a raw per-dialect cast (::texton PostgreSQL,cast(... as char)on MySQL,cast(... as text)on SQLite) on every dialect, the patternsql-driver-12380-json-roundtrip.test.tsuses. It adds the year-99 write, the one MySQL stored wrong at base, and checks$eqon both short-year days. The year-999 row still reads back through the driver too. Result: 36 passed across SQLite, PostgreSQL and MySQL.Reverse verification of the new cell, with the fix committed and ablation A re-run through
ablation-replace(driver-sql reads core from source, so no rebuild was owed). Direction predicted before the run: 14 failed / 22 passed, split SQLite 9, PostgreSQL 3 (0009, 0099, the write path) and MySQL 2 (0099$lt, the write path). MySQL reads9-03-04and999-06-15literally, so its other cells stay green. Observed exactly that. Restored: blob equals HEAD,git diff HEADempty, porcelain clean.MySQL at the driver door, base
e46218674b(a detached worktree) against head, create and update alike:Datefor 0009-03-040009-03-04/1909-03-040009-03-04/1909-03-04Datefor 0099-03-041999-03-04/1999-03-040099-03-04/1999-03-04'0099-03-04'0099-03-04/1999-03-040999-06-15/999-06-150999-06-15/0999-06-150000-06-15/1900-06-152026-02-01/2026-02-01$eqfound every row it wrote, at base and at head.Must-change 2, the prose. The changeset's "PostgreSQL already stored the day." now says what was measured: PostgreSQL and MySQL stored a three-digit year's day, and PostgreSQL misread (
9-03-04as 2004-09-03) or refused (99-03-04,22008) a shorter one, while MySQL stored99-03-04as 1999-03-04. Its "Unchanged … every read-path presentation" is now scoped to the three dialects it was measured on, and it names the MySQL read cells above. The H5 write-path sentence in this body is aligned the same way.Gates for this round, at
e83b248a39: the 59 commands derived for the two changed paths ran, plus the six roster gates andcheck-issue-citations. Result: 63 exit 0;check-empty-changesetexit 1 on the same DELIBERATE CORRECTION;check:dual-build-cjs-loadsandcheck:type-check-debtexit 3 (PREREQUISITE NOT MET).--ranagainst the full derivation ate83b248a39reads 65 derived, 63 run, 2 NOT MEASURED, 0 unrun. The six families this round's paths do not reach carry their2f219baa71records. Also clean: eslint on the changed test (0 / 0) and driver-sql typecheck (exit 0, file in the program). The core pins passed (90).Deviations
.changeset/20203-epoch-ms-date-comparand.md, a one-clause DELIBERATE CORRECTION. Its "Not changed" list named "everyDate… on adatefield" and "a number outside theDaterange", and this card moves both in the same release. It now reads "… everyDateand every string on adatefield (coretemporalStorageForm: thedatearm leaves a year outside 1000..9999 unpadded — over REST the epoch-ms number for 0999-06-15 counts$gt0 /$lt7 on InMemoryDriver and SQLite (correct 6 / 0); its ISO string counts 6 / 0 #20240, in the same release, then pads aDate's or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past theDaterange included), and everydatetimeandtimereading." Nothing else in the note changed..changeset/20176-*and.changeset/20212-*were read and left alone: 20176's "Not changed" list is scoped before and after its own change (the reading fix(core): read an epoch-ms number on adatefield as its UTC calendar day, so driver-memory, SQLite and PostgreSQL give one answer #20224's accepted review applied to that sentence's "everywhereanswer"), and 20212 is RLS.havingleg is not delivered (above).havingnever reached the door, and wiring it in is outside the claimed surface and a second narrowing.datefield as its UTC calendar day, so driver-memory, SQLite and PostgreSQL give one answer #20224 / fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where #20202.packages/resthas no driver-memory dependency, and a live PostgreSQL is reachable only through driver-sql's testkit (its isolation test forbids reading the env var elsewhere). So memory and PostgreSQL are pinned at their driver doors, and their engine and REST cells are the measured table above.mainwas merged once (e46218674b) before opening, per AGENTS.md §10. The whole suites, typecheck and gate union above were re-run on the merged head.mainhas moved again since (443b2f4fdc), and the queue rebuilds on it.Acceptance notes
driver-mongodb'sstorageDateValue(mongodb-temporal.ts) is its own copy of thedaterule. It spells aDate's year unpadded (${y}-${m}-${d}), as core did before this PR, and returns a number unchanged (driver-sql + driver-memory: an epoch-millisecond NUMBER against adatefield is read by neither driver's storage rule —where: { placed_on: { $gt: 1769940000000 } }returns 6 of 6 rows on SqlDriver and 0 on InMemoryDriver over REST #20203's note). After this PR it disagrees with core on both, as a code reading only: nomongodis reachable here, so it is NOT MEASURED. Carrier none, beside the same note on PR fix(objectql,core): a per-aggregation filter and having read a temporal comparand by the column's storage rule — one rule in core, shared with both drivers' where #20202 and PR fix(core): read an epoch-ms number on adatefield as its UTC calendar day, so driver-memory, SQLite and PostgreSQL give one answer #20224.IObjectQLEngine.judgeFilterruns the samewhereadmission, so the analytics read-scope judgement (security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995) now refuses a scope carrying such a number orDateon adatefield. This is a code reading; it was not measured.10000-01-01/-1-01-01for an out-of-rangeDateon the write path, soengine.insertof such aDatestill stores that text on memory and SQLite, unchanged.Out-of-scope findings (reported for the seat; nothing filed from here)
havingon a temporal aggregated column does not reach the temporal-comparand door for any comparand.POST /api/v1/data/:object/querywithhaving: { last_placed: { $lt: 'not-a-date' } }overmax(placed_on)answers 200 and keeps every group on memory, SQLite and PostgreSQL, while thewheretwin answersINVALID_FILTER/ 400. The number for 10000-01-01 under$gtkeeps three groups where a refusal is due. Seam: runtimepackages/objectql/src/engine.ts(aggregate, thehavingentry doors) →having-filter.ts. Family: the temporal-comparand door's positions (An unparseable date comparand on a datetime filter is passed through and compares false — HTTP 200, zero rows, no diagnostic — while an unknown{placeholder}is correctly rejected 400 (17.0.0 GA) #8690where, objectql + REST: the per-aggregationfilterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with every count 0 #20148 per-aggregationfilter;havingmissing). Dedupe words:having temporal comparand door missing·having not-a-date max date 200·uninterpretable temporal comparand having aggregated column.datetimerule spells a year outside 0..9999 in extended ISO (+010000-01-01T00:00:00.000Z,-000001-01-01T00:00:00.000Z), which sorts as no instant does. On memory and SQLite,where: { opened_at: { $gt / $lt / $eq: X } }for 10000-01-01 answers 7 / 0 / 0 (the instant's answer is 0 / 7 / 0), in number,Dateand ISO form alike, and PostgreSQL answers 500 (22009/22007) for 10000 and -1. The ISO string passes thedatetimedoor, becauseDate.parsereads extended years. Seam: runtimepackages/core/src/utils/temporal-storage-form.tscanonicalUtcDatetimeandtemporal-comparand.tsreadsAsInstant→ both drivers. Family: this card's (a year outside the four-digit range), on the arm the claim keeps byte-identical. Dedupe words:datetime comparand year 10000 extended ISO text order·+010000 datetime filter postgres 22009·datetime storage form year outside 0 9999.datecomparand in year 0 answersDATABASE_ERROR500 (22008) on PostgreSQL in every spelling, bare day'0000-06-15'included, while memory and SQLite answer 7 / 0 / 0. That is at base and at head (at base the number spelled0-06-15, also22008). A direct driver write of year 0 is refused22008too. Triage's pad range (0..999) includes year 0, so this PR pads it, and whether year 0 is in thedaterange is not decided here. Family: this card's. Dedupe words:postgres date year 0000 out of range 22008·date comparand year zero postgres 500·ISO year 0000 1 BC postgres date.datefield and stores it verbatim.POST /api/v1/data/:objectwithplaced_on: '+010000-01-01T00:00:00.000Z'answers 201 and stores that text (not aYYYY-MM-DDday) on memory and SQLite, andDATABASE_ERROR500 on PostgreSQL; this is unchanged by this PR. Family: this card's, on the write door. Dedupe words:date field write extended year ISO stored verbatim·create date +010000 stored not YYYY-MM-DD·date write door year 10000 postgres 500.Findings 2, 3 and 4 are one family with this card (a temporal value outside the years a fixed-width text or a backend can hold), at three more positions, so they are named for one closing card rather than three.
5. class (a) · reach: public door, a measured wrong answer. On MySQL, a stored date in a year below 100 reads back a century late.
POST /api/v1/data/:objectwithplaced_on: '0009-03-04'answers 201 and the server stores0009-03-04, butPOST /api/v1/data/:object/queryreturnsplaced_on: '1909-03-04';'0099-03-04'returns'1999-03-04', year 0 returns 1900, andfind/findOneat the driver door agree. Adatetimewritten as'0009-03-04T10:00:00.000Z'is stored as0009-03-04 10:00:00.000and returned as'2004-09-03T10:00:00.000Z'. Measured at the head's REST door, and at the driver door at basee46218674band head, with identical read-back: the write went in as a bare string, which is unchanged base → head. Cause, for DATE: mysql2parseDaterebuilds the column withDate.UTC(y, m - 1, d)under driver-sql'stimezone: 'Z', andDate.UTCmaps years 0..99 to 1900..1999. Thedatetimeread was not traced. Seam: runtimepackages/drivers/driver-sql/src/sql-driver.ts(the mysql2 connection options and the read presentation) → mysql2parseDate/parseDateTime. Family: driver-sql's MySQL read path for a year below 100; separate from findings 2 to 4. Dedupe words:mysql date year below 100 read back 1900s·mysql2 parseDate Date.UTC year 0009 1909·mysql datetime year 9 presented 2004.Generated by Claude Code