Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20240-date-year-spelling
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20240-date-year-spelling

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20240
Clause-②: no (narrowing)

A number or Date compared against a date field 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 no YYYY-MM-DD form, and it is now refused INVALID_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 a Date or a number is padded to four digits (0999-06-15, not 999-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: on date, a finite number or a valid Date whose UTC year is below 0 or above 9999 is uninterpretable. That includes a finite number past the Date range (±8.64e15). NaN, ±Infinity and an Invalid Date name no year, so they stay unjudged. The datetime and time branches 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 on where (every verb, both spellings) and on a per-aggregation filter. 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's value is widened from string to unknown. It is internal and not exported from the package.
  • Tests and .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.ts said: "Non-string comparands. A number is epoch milliseconds, a Date is an instant, null is a null test. The refusal scopes to strings by ruling." Every scope sentence on #8690, read in full:

  • Triage 5294745926: "Refusing a non-interpretable bare string on a temporal target is the queued work."
  • Maintainer ruling 5299879288, its only scope clause: "The empty-string cell stays its own card — B and C scope to non-empty strings and must not decide it in passing".
  • PR fix(objectql): refuse an uninterpretable temporal filter comparand at the engine door (#8690) #8808's body lists "Non-string comparands untouched — a number is epoch milliseconds, a Date is 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 date year 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 → head

The fixture has seven rows on a date field (six 2026 days plus 0999-06-15), live on InMemoryDriver, SqlDriver on better-sqlite3, and PostgreSQL 16.13. The process zone is America/New_York and the server zone Asia/Shanghai. Each cell went through engine.find / engine.aggregate and through POST /api/v1/data/:object/query with a JSON round-tripped body. The two doors agree on all 288 comparable cells at base and at head. Over REST a Date is its ISO string (all 144 REST Date cells equal their ISO twins). Cells are $gt / $lt / $eq, given as memory · SQLite · PostgreSQL.

comparand position base head
number for 0999-06-15 (-30627504000000), and its Date (engine) where 0/7/0 · 0/7/0 · 6/0/1 6/0/1 on all three
the same per-aggregation filter 0/7/0 on all three 6/0/1 on all three
the same having on max(date) none / all four groups / none, on all three c1,c2,c3 / none / c4, on all three
ISO 0999-06-15T00:00:00.000Z all three positions 6/0/1, and c1,c2,c3 / none / c4 unchanged
number for 10000-01-01 (253402300800000), and its Date where 6/1/0 · 6/1/0 · 0/7/0 INVALID_FILTER / 400, no read
the same per-aggregation filter 6/1/0 on all three INVALID_FILTER / 400, no read
number for -1-01-01 (-62198755200000), and its Date where 7/0/0 · 7/0/0 · DATABASE_ERROR 500 INVALID_FILTER / 400, no read
the same per-aggregation filter 7/0/0 on all three INVALID_FILTER / 400, no read
ISO +010000-01-01T00:00:00.000Z / -000001-01-01T00:00:00.000Z where, per-aggregation filter INVALID_FILTER / 400 unchanged
control, number / Date / ISO for 2026-02-01T10:00Z all three positions 1/4/2, and c2 / c1,c4 / c3 unchanged
the same numbers on a datetime field where memory, SQLite 7/0/0 on every year; PostgreSQL 500 for 10000 and -1 unchanged (see Out-of-scope 2)

"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 Date on the date field: 0999 at where, the per-aggregation filter and having, and 10000 / -1 at where and the per-aggregation filter. Zero moved among ISO strings, the 2026 control, the datetime control and having out-of-range.

H2, the PostgreSQL raw number: not reproduced at base

At 0d3ec47137 (and at the card's 615c468746, whose query path is byte-identical: git diff --stat on 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 → temporalStorageForm binds the rule's "-1-01-01", and the server refuses it with 22007 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 what select '-62198755200000'::date answers. That is the pre-#20224 path, where toDateOnly returned a number unchanged. At head the refusal precedes the driver, with zero reads.

H4's having leg: falsified, having never reaches the door

having takes its own entry doors in engine.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, on max(date):

  • having: { last_placed: { $lt: 'not-a-date' } } keeps every group (200), and its where twin is refused 400;
  • the ISO string +010000-01-01T00:00:00.000Z under $gt keeps every group, and its where twin is refused 400.

So a year-outside-range number or Date on having still compares as written: the number for 10000-01-01 under $gt keeps c1, c2, c3 at head, as at base. Wiring the door into having would 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 reach having, and its 0999 cells are now right.

H5, no collateral

  • The rule and the predicate: 63 shapes × 3 kinds (189 cells), base → head. 23 moved, all date × a number or Date: the 0..999 years now pad, and the predicate turns true outside 0..9999, the Date-range edges and past-range numbers included. Every datetime and time cell is byte-identical, and so is every string, null, bigint, boxed Number, boolean, object and array cell.
  • End to end, a second fixture (date, datetime and time fields; rows in 1000, 2026, 9999 and 0999): 3099 cells over where, the per-aggregation filter and having on all three fields, three drivers, both doors, and the read paths (find, max / min per group, a groupBy key). 72 moved, all a finite number past the Date range (±9e15) on the date field at where or the per-aggregation filter, 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 every datetime and time cell, year 0, 'not-a-date', a wall clock and every read-path cell.
  • Write paths: 156 cells, 14 moved, all a year-0..999 number or Date on memory or SQLite, now stored padded (0999-06-15, 0000-06-15). That covers driver.create / driver.update, plus engine.insert of a Date, 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 default DateStyle (ISO, MDY) it stored the unpadded 9-03-04 as 2004-09-03 and refused 99-03-04 (22008). That was measured under ablation A (below) and with select '9-03-04'::date, and it is pinned by the 0009 / 0099 cells. MySQL 8.0, measured at the driver door in round 2, stored 99-03-04 as 1999-03-04. All three dialects now store the day. The engine and REST write doors still refuse a number on date (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 the datetime rule (lookupMember(...)?.type === 'time' ? 'datetime' : null), so the new date branch is unreachable from it.

Tests

New pins:

  • packages/core/src/utils/temporal-storage-form.test.ts: years 0000..0999 pad for the number, its Date and its ISO string; the spellings sort chronologically; out-of-range years keep their spelling.
  • packages/core/src/utils/temporal-comparand.test.ts (new): the date year 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 spell YYYY-MM-DD.
  • packages/objectql/src/engine-date-year-range-door.test.ts: code AND status plus zero driver reads, with its positive control (years 0000, 0999, 2026, 9999 reach the driver) in the same it(). Also covered: every comparand position and both spellings, all six verbs, the per-aggregation filter, and the datetime / time controls.
  • packages/drivers/driver-memory/src/memory-20240-date-year-spelling.test.ts and packages/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, its Date and 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, with where, the per-aggregation filter and having for 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 at 2f219baa71:

  • @objectstack/core: 58 files / 1522 passed.
  • @objectstack/driver-memory: 57 / 1374 passed.
  • @objectstack/objectql: 320 / 5777 passed.
  • @objectstack/driver-sql with live PostgreSQL (TZ=America/New_York, server Asia/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), at e83b248a39, the whole driver-sql suite ran against both live servers: PostgreSQL 16.13 and a private MySQL 8.0.46 (server zone +08:00), with OS_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.json for core, objectql and rest; tsconfig.json for 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 and ablation-dist-preflight passed (present on the mutate leg, --absent on the restore leg), because objectql and rest resolve @objectstack/core through its dist. The direction was predicted before each run.

  • A, padding off. The whole line const yyyy = y >= 0 ? String(y).padStart(4, '0') : String(y); became const yyyy = String(y).padStart(0, '0');; anchor 1 → 0, blob 123c85a117 → 685087daf5. The contract review's literal padStart(4, '0') → padStart(0, '0') substitution gives d803edfcb6. Both ids reproduce from the source, and both unpad a non-negative year the same way, so the counts agree:

    • core 8 failed / 82 passed;
    • memory 9 failed / 3 passed;
    • driver-sql 12 failed / 12 passed / 1 skipped;
    • REST 3 failed / 5 passed;
    • objectql 6 passed.

    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 unpadded 9-03-04 as 2004-09-03, a different day, silently, and refuses 99-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;; blob 912a0609ab → f150d2d782):

    • core 3 failed / 87 passed;
    • objectql 4 failed / 2 passed (the datetime/time control and the unjudged NaN stay green);
    • REST 2 failed / 6 passed;
    • memory 12 passed and driver-sql 24 passed, as predicted, since the drivers are not the door.
  • Restore legs: after both, the blob equals HEAD, git diff HEAD is empty and git status --porcelain is clean. Core was rebuilt and the preflight --absent exited 0. Pins: core 90, objectql 6, memory 12, driver-sql 24 + 1 skipped, REST 8, all passed.

Gates

dispatch-gates --commands --repo objectstack-ai/objectstack at 2f219baa71 derives 65 commands (the dispatch clue's 50, plus the changeset families and more). Every one ran at 2f219baa71: 62 exit 0, 1 exit 1, 2 exit 3. --ran with 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-loads and pnpm check:type-check-debt: NOT MEASURED, PREREQUISITE NOT MET. Both read every package's built output, and CI builds the whole ./packages/* before them.
  • Also run, all exit 0: the six roster gates whose roster sits under a path of mine (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:object-def-param-keys, check:tenant-chokepoint), and check-issue-citations --base e46218674b (11 citations, all resolve). check-adr-0087-registration reads the not-required (no-migration-prescription) disposition, and check-changeset-no-major finds no major.
  • Lint is a declared narrowing: eslint --no-inline-config --format json on the 9 changed .ts files at 2f219baa71 reports 9 files, 0 errors / 0 warnings / 0 fatal, and --print-config resolves a config for each. The two .md files resolve none, so they are outside eslint's population. eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService, no TypeChecked preset), 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 with expected '1909-03-04' to be '0009-03-04' on the write-path assertion only; the SQLite and PostgreSQL cells passed, and so did MySQL's where cells, which read ids and not dates. The cause is the read, not the write:

  • mysql2 3.23.1, lib/packets/packet.js parseDate, rebuilds a DATE as new Date(y, m - 1, d), or as new Date(Date.UTC(y, m - 1, d)) under timezone: 'Z', which driver-sql sets (sql-driver.ts, the timezone: 'Z' connection override). Both constructors map a year 0..99 to 1900..1999.
  • On a private MySQL 8.0.46 (server zone +08:00, the CI setting), the bound '0009-03-04' is stored as 0009-03-04 (CAST(... AS CHAR) and DATE_FORMAT agree). mysql2 hands back 1909-03-04 for it, 1999-03-04 for 0099-03-04 and 1900-06-15 for 0000-06-15, while year 999 survives.
  • The failure reproduced locally before the fix, byte for byte (1 failed / 23 passed).

The fix is in the test only; there is no driver src change and no skip. The write-path cell now reads the STORED text through a raw per-dialect cast (::text on PostgreSQL, cast(... as char) on MySQL, cast(... as text) on SQLite) on every dialect, the pattern sql-driver-12380-json-roundtrip.test.ts uses. It adds the year-99 write, the one MySQL stored wrong at base, and checks $eq on 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 reads 9-03-04 and 999-06-15 literally, so its other cells stay green. Observed exactly that. Restored: blob equals HEAD, git diff HEAD empty, porcelain clean.

MySQL at the driver door, base e46218674b (a detached worktree) against head, create and update alike:

written base: stored / read back head: stored / read back
number or Date for 0009-03-04 0009-03-04 / 1909-03-04 0009-03-04 / 1909-03-04
number or Date for 0099-03-04 1999-03-04 / 1999-03-04 0099-03-04 / 1999-03-04
bare day '0099-03-04' 0099-03-04 / 1999-03-04 unchanged
number or bare day for 0999-06-15 0999-06-15 / 999-06-15 0999-06-15 / 0999-06-15
number or bare day for 0000-06-15 0000-06-15 / 1900-06-15 unchanged
number for 2026-02-01T10:00Z 2026-02-01 / 2026-02-01 unchanged

$eq found 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-04 as 2004-09-03) or refused (99-03-04, 22008) a shorter one, while MySQL stored 99-03-04 as 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 and check-issue-citations. Result: 63 exit 0; check-empty-changeset exit 1 on the same DELIBERATE CORRECTION; check:dual-build-cjs-loads and check:type-check-debt exit 3 (PREREQUISITE NOT MET). --ran against the full derivation at e83b248a39 reads 65 derived, 63 run, 2 NOT MEASURED, 0 unrun. The six families this round's paths do not reach carry their 2f219baa71 records. 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

Acceptance notes

Out-of-scope findings (reported for the seat; nothing filed from here)

  1. class (a) · reach: public door, a measured wrong answer. having on a temporal aggregated column does not reach the temporal-comparand door for any comparand. POST /api/v1/data/:object/query with having: { last_placed: { $lt: 'not-a-date' } } over max(placed_on) answers 200 and keeps every group on memory, SQLite and PostgreSQL, while the where twin answers INVALID_FILTER / 400. The number for 10000-01-01 under $gt keeps three groups where a refusal is due. Seam: runtime packages/objectql/src/engine.ts (aggregate, the having entry 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) #8690 where, objectql + REST: the per-aggregation filter still lacks four of where's doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key answer 200 with every count 0 #20148 per-aggregation filter; having missing). Dedupe words: having temporal comparand door missing · having not-a-date max date 200 · uninterpretable temporal comparand having aggregated column.
  2. class (a) · reach: public door, a measured wrong answer. The datetime rule 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, Date and ISO form alike, and PostgreSQL answers 500 (22009 / 22007) for 10000 and -1. The ISO string passes the datetime door, because Date.parse reads extended years. Seam: runtime packages/core/src/utils/temporal-storage-form.ts canonicalUtcDatetime and temporal-comparand.ts readsAsInstant → 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.
  3. class (a) · reach: public door, a measured wrong answer. PostgreSQL has no year 0000. A date comparand in year 0 answers DATABASE_ERROR 500 (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 spelled 0-06-15, also 22008). A direct driver write of year 0 is refused 22008 too. Triage's pad range (0..999) includes year 0, so this PR pads it, and whether year 0 is in the date range 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.
  4. class (a) · reach: public door, measured. The REST create door accepts an extended-year ISO string on a date field and stores it verbatim. POST /api/v1/data/:object with placed_on: '+010000-01-01T00:00:00.000Z' answers 201 and stores that text (not a YYYY-MM-DD day) on memory and SQLite, and DATABASE_ERROR 500 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/:object with placed_on: '0009-03-04' answers 201 and the server stores 0009-03-04, but POST /api/v1/data/:object/query returns placed_on: '1909-03-04'; '0099-03-04' returns '1999-03-04', year 0 returns 1900, and find / findOne at the driver door agree. A datetime written as '0009-03-04T10:00:00.000Z' is stored as 0009-03-04 10:00:00.000 and returned as '2004-09-03T10:00:00.000Z'. Measured at the head's REST door, and at the driver door at base e46218674b and head, with identical read-back: the write went in as a bare string, which is unchanged base → head. Cause, for DATE: mysql2 parseDate rebuilds the column with Date.UTC(y, m - 1, d) under driver-sql's timezone: 'Z', and Date.UTC maps years 0..99 to 1900..1999. The datetime read was not traced. Seam: runtime packages/drivers/driver-sql/src/sql-driver.ts (the mysql2 connection options and the read presentation) → mysql2 parseDate / 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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a9fb83ef06a079a938c97d1ea5e364705e1bc13b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b8823a2fae47e9271c37008a096ea405b1ff045a — the merge of head e83b248a39ee8da5b98b20a41ac8c254332d0291 into base a9fb83ef06a079a938c97d1ea5e364705e1bc13b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…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>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2f219baa719988680c45ddd6f96ccdcc77a10e23

Scope: PR #20261 (card #20240), 11 files, +951/−26, vs merge base e46218674b. Measured in two detached worktrees under <scratchpad>/pr-20261/rev/ (head 2f219baa71, base e46218674b), full closure rebuilt with turbo build --force (25/25 tasks each). main merged at e46218674b brought 7 commits touching none of the 11 files (intersection empty). Drivers: InMemoryDriver, SqlDriver/better-sqlite3, SqlDriver/PostgreSQL 16.13 (private role+db, DB zone Asia/Shanghai, process TZ=America/New_York); doors engine.find/aggregate and POST /api/v1/data/:object/query (JSON round-tripped) via a real RestServer; populated (7 rows) and empty objects. 6623 end-to-end cells + 291 rule/predicate cells per tree. Current origin/main (a9fb83ef06) differs from e46218674b only in spec save-door, rest/import-*, core/artifact-packages*: none on the query path, so base = current main for every cell below.

① Derived judgments

  • The rows — CORRECT. Head: 0999 (number / Date / ISO) answers 6/0/1 at where, 6/0/1 at the per-aggregation filter, c1,c2,c3 / none / c4 at having(max date), on all three drivers and both doors. Base: number/Date at where 0/7/0 · 0/7/0 · 6/0/1; filter 0/7/0 on all three; having none / c1–c4 / none on all three; ISO already right. Years 10000 and −1 (number and Date): head INVALID_FILTER/400 with 0 driver reads at where on find/findOne/count/aggregate/update/delete and the array spelling, and at the per-aggregation filter, all drivers, both doors, populated and empty; base 10000 where 6/1/0 · 6/1/0 · 0/7/0, −1 where 7/0/0 · 7/0/0 · DATABASE_ERROR 500; filter 6/1/0 and 7/0/0 on all three. having for 10000/−1 keeps its base answer (180 cells, 0 moved): number/Date 10000 → c1,c2,c3 / c4 / none; −1 → c1–c4 / none / none; ISO → c1–c4 / none / none. 2026 control 1/4/2 and c2 / c1,c4 / c3 everywhere, unchanged. engine = REST on 990/990 comparable cells at base and head. 498 of 6623 cells moved, every one classified: 216 out-of-range refusals at where/filter, 42 verb refusals, 12 judgeFilter, 208 year-0999 padding cells (rows, second fixture, verbs), 20 write cells (below). No unexplained movement.
  • H2 — CONFIRMED. At base PostgreSQL received the rule's "-1-01-01" (29 statements, SQLSTATE 22007 invalid input syntax for type date); zero binds of the raw -62198755200000. The card's 22009 text is the pre-fix(core): read an epoch-ms number on a date field as its UTC calendar day, so driver-memory, SQLite and PostgreSQL give one answer #20224 path.
  • Stop valve — did not trip; dev's reading upheld. Read 5299879288 in full: the only scope sentence is "The empty-string cell stays its own card — B and C scope to non-empty strings and must not decide it in passing". Option B is worded "refuse the uninterpretable temporal comparand"; no MAINTAINER sentence makes "non-string comparands are never refused" a rule. Triage 5294745926 ("Refusing a non-interpretable bare string … is the queued work") is a scope-of-work sentence by the triage seat, not a ruling. PR fix(objectql): refuse an uninterpretable temporal filter comparand at the engine door (#8690) #8808's "Non-string comparands untouched — a number is epoch milliseconds, a Date is an instant" sits under the body's own "Scope boundaries, each by ruling" heading; the ruling contains no such clause, so it is the PR's scope statement over-attributed to the ruling, exactly as the dev says.
  • One rule, no collateral — CONFIRMED. Rule+predicate sweep (99 inputs × 3 kinds = 291): 28 moved, all date × finite number/Date (12 padded 0..999; 16 predicate → true for year <0, >9999, the ±8.64e15 edges and past-range numbers); datetime 97/0 moved, time 97/0, date × string/null/NaN/±Infinity/Invalid Date/bigint/boxed/boolean/object/array 41/0. End to end: years 1000/2026/9999 on date/datetime/time in number/Date/ISO/bare-day at where/filter/having (2916 cells) 0 moved; every datetime cell (1926) and time cell (1296) 0 moved; every string comparand (1350) 0 moved; read-path presentation 0 moved. Write paths: driver create/update of a year-0..999 number/Date now stores the padded day on memory and SQLite (18 cells), engine/REST write doors unchanged (VALIDATION_FAILED/400 for a number; engine.insert of a Date accepted and now padded); out-of-range writes unchanged (10000-01-01, -1-01-01 stored on memory/SQLite; PG 22007/22008/22009 as before). One extra movement the dev's prose denies: PostgreSQL driver.create/update of the year-0009 number moved from 2004-09-03 (base, PG's ISO, MDY misread of 9-03-04) to 0009-03-04 (head); 99-03-04 is refused 22008 at base (ablation log). Judged a correct movement, wrongly described (see ② and the must-change list).
  • NativeSQLStrategy.canHandle — does not move (8/8 cells identical): an out-of-range number or Date on a date-typed dataset dimension keeps the raw-SQL path at base and head (rawSql=1 aggregate=0); only not-a-date declines. Measured, matches the dev's code reading. IObjectQLEngine.judgeFilter — now refuses (measured, the dev had only read it): base ok → head refused INVALID_FILTER/400 for 10000/−1 in number, Date and ISO; 0999 and 2026 stay ok.
  • Pins discriminate — REPRODUCED. A (padding off, anchor 1→0, core rebuilt, no inlined copy in objectql dist): core 8 failed/82, memory 9/3, driver-sql 12 failed/12 passed/1 skipped (live PG ran; PG log shows 22008 on "99-03-04"), REST 3/5, objectql 6 passed. B (? false : false, blob 912a0609ab→f150d2d782, matches the dev): core 3/87, objectql 4/2, REST 2/6, memory 12 passed, driver-sql 24 passed/1 skipped. Restore by git restore --source=HEAD --staged --worktree: blobs back to 123c85a117 / 912a0609ab, git diff HEAD empty, git status --porcelain empty, core rebuilt; restored pins core 90, memory 12, driver-sql 24+1 skipped, REST 8, objectql 6, all green.
  • Export surface — no widening. isUninterpretableTemporalComparand, temporalStorageForm, temporalComparandKind were already exported from @objectstack/core's index (base and head dist/index.d.ts export lists identical for them); the new isOutsideCalendarDayYears is module-private; temporal-comparand-door.ts is not re-exported from @objectstack/objectql's index, so UninterpretableTemporalComparand.value: string → unknown is package-internal.
  • Prose — every sentence TRUE at head except one. .changeset/20240-*: the rows table, the rule/predicate/door sentences, the verb list, judgeFilter, "Unchanged" list (datetime/time, strings, 1000..9999, NaN/±Infinity/Invalid Date, read path, having not at the door, analytics decline, mongodb copy) all measured TRUE. FALSE: "PostgreSQL already stored the day." — at base PG stored 2004-09-03 for the year-0009 number and refused 99-03-04 (22008); true only for three-digit years. The PR body's H5 write-path sentence ("14 moved, all … on memory or SQLite … PostgreSQL stored the day already") is FALSE for the same cell, while the body's own Reverse-verification paragraph records the misread. The 20203-* DELIBERATE CORRECTION: git diff --numstat 1/1; old "… every Date and every string on a date field, and every datetime and time reading." → new adds "(core temporalStorageForm: the date arm leaves a year outside 1000..9999 unpadded — over REST the epoch-ms number for 0999-06-15 counts $gt 0 / $lt 7 on InMemoryDriver and SQLite (correct 6 / 0); its ISO string counts 6 / 0 #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)"; TRUE at head (sweep: past-range numbers predicate true, rule unchanged), every other line byte-identical — confirmed as written. 20176-*, 20148-* (both), 20212-* and the five non-temporal notes main brought: no sentence made FALSE (20176's/20148's "Not changed" lists are scoped to their own before/after, the reading the fix(core): read an epoch-ms number on a date field as its UTC calendar day, so driver-memory, SQLite and PostgreSQL give one answer #20224 review applied; 20212 is RLS). Rewritten module note in temporal-comparand.ts and the door's new refusal words: TRUE at head (quotes match the sources; JSON.stringify(NaN)/Invalid Date → null; the refusal names field, path, Date <iso> preview, and the 0000..9999 range).
  • Scope and merge. packages/spec, every driver src/ non-test file, engine.ts, having-filter.ts, service-analytics: untouched (grep over the 11 paths, zero hits). git merge-tree --write-tree --name-only in a driver-free bare probe against origin/main a9fb83ef06: one tree hash, no conflicted path.
  • Gates (head tree, current PR body as --event): check-changeset-no-major --base e46218674b --event event.json exit 0: "✓ This diff introduces no major bump. ✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and it grades a package whose packages/**/src/** it moves at minor or above … @objectstack/core: minor · @objectstack/objectql: minor … direction arm: narrowing — a BREAKING change; during the launch window it ships minor". check-adr-0087-registration --base e46218674b exit 0: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/20240-date-year-four-digits.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)".
  • CI at head (34 check runs, all completed; read last): 29 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke), 2 failure. Required contexts: Lint & Repo Gates success, TypeScript Type Check success, Test Core success, Dogfood Regression Gate success, Build Core success, Governed Surface Queue Guard success, Temporal Conformance (live PG + MySQL) FAILURE. Check Changeset red by design on exactly .changeset/20203-epoch-ms-date-comparand.md: "present on the merge base and CHANGED by this PR -- this is somebody else's release note … DELIBERATE CORRECTION -- … do NOT restore it -- say so on the PR and get it confirmed". The other failure is NOT by design: FAIL src/sql-driver-20240-date-year-spelling.test.ts > live cell budget (60000 ms) > [#20240] a date field's year is four digits — live mysql > the write path stores the four-digit year — create and update, a number and a Date — AssertionError: expected '1909-03-04' to be '0009-03-04' at line 116 (Test Files 1 failed | 201 passed (202), Tests 1 failed | 4643 passed | 1 skipped). It is the PR's own new pin in the one cell the dev could not run locally ("live mysql NOT RUN"); not a merge-base signature.

② Semver level

  • Clause-②: no (narrowing) is correct: a request that answered 200 with wrong rows (or 500) now answers 400; no key, export or operator widens.
  • Changeset levels: @objectstack/core minor, @objectstack/objectql minor (launch-window convention for a narrowing, as 20148/20212), @objectstack/driver-sql and @objectstack/driver-memory patch (behaviour reached through the shared rule, as 20203) — consistent with the declaration and accepted by the level axis. ADR-0087 disposition not-required (no-migration-prescription) is the right category (query-time refusal of a comparand value; no authorable key or stored shape moves) and matches the 20148/20212 precedent; gate exit 0.
  • One release-note sentence is factually wrong ("PostgreSQL already stored the day.") — a prose correction, not a level change.

③ Boundary flags

  • open_questions: none declared.
  • having leg not delivered — accepted: measured at base and head on all three drivers, having reaches no temporal door for any comparand (not-a-date $lt keeps every group while where twin is 400); its 0999 cells are corrected by the padding; out-of-range having cells unchanged (180/180). Correctly left to the filing gate (finding 1).
  • Refusal scope includes past-Date-range numbers — measured (predicate true for 9e15, MAX_SAFE_INTEGER, ±8.64e15±1; rule hands them back unchanged); consistent with "cannot spell YYYY-MM-DD". Accepted.
  • Year 0000 padded but PostgreSQL has no year 0 — measured 22008 at base and head in every spelling, bare day included; correctly deferred to the class-closure card (finding 3).
  • driver-mongodb copy: NOT MEASURED (no mongod), acceptance note, carrier none — stands. judgeFilter note: now measured (above), no longer a flag.
  • New flag from CI: MySQL (via mysql2) presents a date in years 0..99 as 1900+yy on read (0009-03-04 → 1909-03-04); NOT MEASURED locally (no MySQL), measured by the required job. Needs the dev's disposition (skip-with-reason in the MySQL cell + card, or a read-path fix) before landing.

Out-of-scope findings re-measured at base for the seat's filing (not verdict items):

  1. having { last_placed: { $lt: 'not-a-date' } } over max(placed_on): 200 keeping c1–c4 on memory, SQLite, PostgreSQL (engine and REST); where twin INVALID_FILTER/400. '+010000-01-01T00:00:00.000Z' under $gt: keeps c1–c4 at having, where twin 400. Number 10000 $gt at having keeps c1,c2,c3 (base = head). Reproduces.
  2. where on datetime (opened_at) with 10000-01-01 / −1-01-01 in number, Date, ISO: memory and SQLite 7/0/0 for both years (instant's answer 0/7/0 and 7/0/0); PostgreSQL DATABASE_ERROR 500 for both (22009 on +010000-…, 22007 on the negative spelling). Base = head. Reproduces.
  3. date comparand in year 0000 (number Date.parse('0000-06-15Z'), Date, ISO 0000-06-15T00:00:00.000Z, bare 0000-06-15): PostgreSQL 500 (22008 date/time field value out of range) in all four spellings at where, base (number spelled 0-06-15) and head (0000-06-15); memory and SQLite 7/0/0. Direct PG driver.create of year 0: 22008. Reproduces.
  4. REST POST /api/v1/data/:object with placed_on: '+010000-01-01T00:00:00.000Z' on a date field: 201 on memory and SQLite with the text stored verbatim (read back "+010000-01-01T00:00:00.000Z"), PostgreSQL 500 DATABASE_ERROR (22009). Base = head. Reproduces.
  5. (new, from CI) MySQL date read for years 0..99 presents 1900+yy (1909-03-04 for a stored 0009-03-04); class (a) on the read path, not reproducible here.

Implemented-by: claude/issue-20240-date-year-spelling
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: FAIL

Must-change:

  1. Make the required Temporal Conformance (live PG + MySQL) context green at the new head: the MySQL cell of sql-driver-20240-date-year-spelling.test.ts ("the write path stores the four-digit year", line 116, year-0009 update read back 1909-03-04) must either be scoped out of the MySQL dialect with the reason named in the file and the read-path defect filed as its own card (finding 5), or the MySQL read path fixed — then re-read the job at the new head. The Check Changeset red stays, by design.
  2. Correct the release note: .changeset/20240-date-year-four-digits.md "PostgreSQL already stored the day." is false for years 0..99 (base PG stored 2004-09-03 for the year-0009 number; 99-03-04 refused 22008). Say that PostgreSQL stored a three-digit year's day and misread or refused shorter ones, as the PR body's reverse-verification paragraph already records; align the PR body's H5 write-path sentence ("14 moved, all … on memory or SQLite") with the measured PG year-0009 cells.

Everything else — the rows, zero-read refusals, one-rule/no-collateral, the stop-valve reading, the ablations, the export surface, the 20203-* correction, the gates — passes as measured; a prose-plus-CI patch round should re-enter as PASS.

…year at base, and MySQL's read-path cells

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e83b248a39ee8da5b98b20a41ac8c254332d0291

Scope: (delta of 5857959126) PR #20261 (card #20240), previous record FAIL at 2f219baa71 with two must-changes. git diff --name-only 2f219baa71 e83b248a39 = exactly .changeset/20240-date-year-four-digits.md and packages/drivers/driver-sql/src/sql-driver-20240-date-year-spelling.test.ts (+44/−10; commits 677aaeb40b test, e83b248a39 changeset, both single-parent). The other 9 files are blob-identical (temporal-storage-form.ts 123c85a117, temporal-comparand.ts 912a0609ab, 20203-* 872a33edd4), so every code judgment of 5857959126 carries forward. main was not re-merged: merge-base with origin/main (4d7e740d3b) is still e46218674b; the 4 commits main gained since base touch none of the 11 PR files; git merge-tree --write-tree --name-only in a driver-free bare probe → tree 08027d228e, no conflicted path. Measured in detached worktrees rev2/head (e83b248a39) and rev2/base (e46218674b), closures rebuilt with turbo build --force; SQLite (better-sqlite3), live PostgreSQL 16.13 (private role/db, DateStyle ISO, MDY), private MySQL 8.0.46 on :33206 with time_zone='+08:00' (CI's setting), mysql2 3.23.1, process TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1.

① Derived judgments

  • Must-change 1 — MET. The pin at head: 36 passed on sqlite + live postgres + live mysql, reporter "all 3 dialects were exercised". CI: Temporal Conformance (live PG + MySQL) job 108667159843 success, step "Run driver-sql suite against both live servers" success. The round-1 red reproduces byte for byte with the OLD test file (blob 1a5688a8ec) run at head on 3 dialects: 1 failed | 35 passed, AssertionError: expected '1909-03-04' to be '0009-03-04' in the live-mysql write-path cell (the dev's "1 failed / 23 passed" is the sqlite+mysql count; consistent).
  • MySQL stores the day — CONFIRMED. CAST(d AS CHAR), DATE_FORMAT and YEAR() agree: 0009-03-04/9, 0099-03-04/99, 0999-06-15/999, 0000-06-15/0 (sql_mode STRICT_TRANS_TABLES, NO_ZERO_DATE). Unpadded literals (the base's spelling): '9-03-04'→0009-03-04, '99-03-04'→**1999-03-04**, '999-06-15'→0999-06-15, '0-06-15'→0000-06-15. PostgreSQL: '9-03-04'::date→2004-09-03, '99-03-04'→22008, '0000-06-15'→22008.
  • mysql2 read fold — CONFIRMED from source and live. lib/packets/packet.js parseDate: !timezone||'local'→new Date(y, m-1, d); 'Z'→new Date(Date.UTC(y, m-1, d)); any other zone→string constructor with leftPad(4, y). driver-sql reaches 'Z' on a URL connection too (withConnectBound moves the URL into {uri, connectTimeout}, then withUtcSession adds timezone: 'Z'). Measured under 'Z': stored 0009-03-04→1909-03-04T00:00Z, 0099→1999, 0000→1900-06-15, 0999 survives; default/'local' fold the same (plus the −05:00 offset); '+00:00' does NOT fold; dateStrings:true shows the wire text is right. DATETIME (parseDateTime → new Date('0009-03-04 10:00:00.000Z'), V8's non-ISO fallback): 2004-09-03T10:00Z; 0099→1999-03-04T10:00Z; 0000→2000-06-15T10:00Z; 0999 survives.
  • Rewritten cell — HONEST. It asserts the STORED text by raw cast on every dialect (::text / cast(… as char) / cast(… as text), the sql-driver-12380-json-roundtrip.test.ts pattern, verified), keeps a driver read of the 0999 row (pins the head's padded MySQL read; base read 999-06-15), and $eq for 0009 and 0099. It still pins what the PR changes: ablation A reds the write-path cell on all three dialects. It masks no PR-caused regression: MySQL's read of a stored 0009-03-04 is 1909-03-04 at base and at head (driver door and REST, both trees). Coverage change: the driver-path read of a year below 100 is no longer asserted on SQLite/PG either (it was at 2f219baa71); that read is right at base and head on both, so nothing this PR moves is unpinned. No MySQL read-path cell is worse at head than at base: 0009/0099/0000 read identically (1909/1999/1900); 0999 moved 999-06-15→0999-06-15 (a correction); 0099's STORED text moved 1999-03-04→0099-03-04 (a correction); 2026 unchanged; $eq found every written row at both trees (base's number-vs-bare-day 0099 split, head unified). The dev's Round-2 table: every cell matches.
  • Ablation A re-run at head — REPRODUCED EXACTLY. Anchor String(y).padStart(4, '0') 1→0 in core temporal-storage-form.ts, blob 123c85a117→d803edfcb6 (driver-sql's vitest aliases @objectstack/core to core/src, no rebuild owed): 14 failed | 22 passed — sqlite 9 ($in/$between, 0009 $gt, 0099 $lt, 0999 $eq/$gt/$gte/$lt/$ne, write path), live postgres 3 (0009 $gt, 0099 $lt, write path), live mysql 2 (0099 $lt, write path). Restore git restore --source=HEAD --staged --worktree: blob 123c85a117, git diff HEAD empty, porcelain empty, 36 passed.
  • Must-change 2 and all new prose — no FALSE sentence. Changeset: "PostgreSQL stored the unpadded 9-03-04 as 2004-09-03 and refused 99-03-04 (22008)" TRUE; "MySQL 8.0 stored 99-03-04 as 1999-03-04" TRUE; "All three dialects now store the day" TRUE; the re-scoped "every read-path presentation on those three" TRUE (carried, 0 moved); "a stored year from 100 to 999 now reads back padded (0999-06-15, where it read 999-06-15)" TRUE (measured base→head); "a stored year below 100 still reads back a century late (0009-03-04 as 1909-03-04, mysql2's Date.UTC …), which this change does not touch" TRUE. The connective "already stored a three-digit year's day, but not a shorter one" is TRUE as qualified by its colon but over-broad for MySQL's one-digit and zero years (base stored the year-9 number as 0009-03-04, measured) — see ③. Test header: "MySQL 8.0 read 9-03-04 as year 9 but stored 99-03-04 as 1999-03-04" TRUE; the storedText / parseDate / timezone: 'Z' / "unchanged by this card and reported beside it" sentences TRUE. PR body H5: "PostgreSQL already stored a three-digit year's day … but not a shorter one: … 2004-09-03 … refused 99-03-04 (22008) … MySQL 8.0, measured at the driver door in round 2, stored 99-03-04 as 1999-03-04. All three dialects now store the day." TRUE. "## Round 2": the CI-failure description (write path only; where cells read ids) TRUE; the mysql2 mechanism and "Both constructors map a year 0..99 to 1900..1999" TRUE; the +08:00 stored/read-back sentence TRUE; "reproduced locally … byte for byte" TRUE (its count is the 2-dialect run); "test only, no driver src change, no skip" TRUE; raw-cast/12380 pattern, year-99 write, $eq, 0999 driver read, "36 passed" TRUE; the ablation split TRUE; the 6-row table TRUE; "$eq found every row it wrote" TRUE; the Must-change-2 paragraph matches the diff; job 108661118941 is indeed the round-1 Temporal Conformance failure. Whole-suite claim (202 files / 4644 passed / 1 skipped): CI step success is the authority; locally 202 files, 198 green and 4 red only on my PG zone (Etc/UTC: "the live postgres server runs at UTC"), the 4 re-run at Asia/Shanghai passed 4/4 (212 tests), all 3 dialects exercised.
  • 20203-* DELIBERATE CORRECTION — unchanged since 2f219baa71 (blob 872a33edd4 at both; base 840404d228), one-clause diff; the clause is TRUE at this head (rule/predicate files blob-identical; head driver door stores 0009/0099/0999/0000 padded on MySQL; the refusal sweep carries). Confirmed as written.
  • Finding 5 re-measured at base e46218674b, REST over SqlDriver/mysql2 (for the seat's filing): POST /api/v1/data/:object with placed_on: '0009-03-04' → 201, stored 0009-03-04, …/query returns "1909-03-04"; '0099-03-04' → stored 0099-03-04, returns "1999-03-04"; '0000-06-15' → stored 0000-06-15, returns "1900-06-15"; '0999-06-15' → stored 0999-06-15, returns "999-06-15" at base ("0999-06-15" at head); datetime '0009-03-04T10:00:00.000Z' → 201, stored 0009-03-04 10:00:00.000, returned "2004-09-03T10:00:00.000Z" (0099→"1999-03-04T10:00:00.000Z", 0000→"2000-06-15T10:00:00.000Z"). Head identical for every year below 100. where placed_on $eq '0009-03-04' finds the row (presented 1909-03-04); $eq '1909-03-04' finds none; $lt '0100-01-01' finds the three. Reproduces; base = head.
  • Gates (head tree, current PR body as --event): check-changeset-no-major --base e46218674b --event event.json exit 0: "✓ This diff introduces no major bump. ✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and it grades a package whose packages/**/src/** it moves at minor or above … @objectstack/core: minor · @objectstack/objectql: minor … driver-sql: patch · driver-memory: patch … direction arm: narrowing — a BREAKING change; during the launch window it ships minor". check-adr-0087-registration --base e46218674b exit 0: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/20240-date-year-four-digits.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)". Also eslint on the changed test 1 file 0/0/0; driver-sql typecheck exit 0, file in the program.
  • CI at e83b248a39 (41 check runs, all completed; read last): 34 success, 5 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke, and the edited-run Auto Label / Check PR Size), 2 failure, both Check Changeset (108666926460 on the push run, 108669680680 on the edited run), both by design on exactly .changeset/20203-epoch-ms-date-comparand.md: "present on the merge base and CHANGED by this PR -- this is somebody else's release note … DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed" (and "✓ No empty-frontmatter changeset introduced by this diff (2 declaring changeset(s) added)"). No other failure. Required contexts all success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Governed Surface Queue Guard, and Temporal Conformance (live PG + MySQL) success.

② Semver level

  • Unchanged from 5857959126 (prose and test only): Clause-②: no (narrowing) correct; @objectstack/core / @objectstack/objectql minor (launch-window narrowing), @objectstack/driver-sql / @objectstack/driver-memory patch; ADR-0087 not-required (no-migration-prescription); both gates exit 0 with the current body. The 5857959126 prose defect is corrected.

③ Boundary flags

  • 5857959126's new flag (MySQL fold needs the dev's disposition) — disposed: the cell asserts stored text on every dialect, the header names the defect and its mechanism, the PR body carries it as finding 5 for the seat's filing; no skip, no driver change. Accepted.
  • Changeset precision: "but not a shorter one" over-reaches for MySQL years 0..9 (base stored 9-03-04 as 0009-03-04, measured); every named cell is true and the fix sentence is true. Accepted as a precision note; tighten if the branch is pushed again.
  • Pin coverage: the driver-path read of a year below 100 is no longer asserted on SQLite/PG (right at base and head) — not a PR-changed behaviour. Accepted.
  • Finding 5 (driver-sql MySQL read path: mysql2 parseDate / parseDateTime under timezone: 'Z'; years below 100 on date, and on datetime too, presented in the wrong century; base = head) — file as its own card; remedy hints measured: dateStrings: true hands back the exact stored text, and a '+00:00' zone takes mysql2's padding string-constructor arm for DATE (not for DATETIME).
  • All other flags of 5857959126 (having leg, past-range numbers, year 0000 on PG, mongodb copy, judgeFilter) stand unchanged.

Implemented-by: claude/issue-20240-date-year-spelling
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants