fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) - #20469
Conversation
…9, at the comparand door and the write door (#20264) WIP: the rule and its two doors; pins follow. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… engine doors, flipping the year-0 pins (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…public door and each dialect's edges (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… out-of-range number is refused on datetime too (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…lause corrections to the 20203, 20240 and 20263 notes it falsifies (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check15 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 — 33 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 d02002e9ef679d19200241c1503c5d03ba2e6e82 && git checkout d02002e9ef679d19200241c1503c5d03ba2e6e82
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b810ddb6f1635fdf58a901aa082f5de015bb80c8 b559a5d0effc161b2730cf17aaa7caa9dc5088cf && git checkout -B drift-repro b810ddb6f1635fdf58a901aa082f5de015bb80c8 && git merge --no-ff b559a5d0effc161b2730cf17aaa7caa9dc5088cf
node scripts/docs-audit/affected-docs.mjs --json b810ddb6f1635fdf58a901aa082f5de015bb80c8 |
…mporal-year-range
Contract reviewServed-tier: Inputs: card #20264 (body and all 5 comments), PR #20469 (body, 17-file list, net diff against the merge base dc0ab6a), the check-runs on the head, and the rulings the card cites on #20280 (5859414357) and #20240 (5857781537, 5858389239). Read-only git and REST GETs only; nothing built, run or re-run. ① Derived judgmentsSource moves in four files (core
② Semver level
③ Boundary flags
Must-changes (both one-clause edits to Implemented-by: VERDICT: FAIL |
…oot export; state the 7/0/0 answer it gave, and except the date-string class whose refusal words moved (#20264) Patch round 1 of the at-tier review on the PR: two false sentences and the Clause-② reading. No code or test change. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…mporal-year-range
Contract reviewServed-tier: Delta record over the FAIL 5874841530 at 311ce06. Inputs added: the amended claim 5874849531 and the patch-round-1 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20264
Clause-②: yes (narrowing)
A
dateordatetimevalue now names a year from 0001 to 9999, or it is refused:INVALID_FILTER/ 400 as a comparand onwhere, a per-aggregationfilterandhaving, andVALIDATION_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 bothdateanddatetime." Year 0000 joins the refused range, and thedatearm's padding covers 0001..0999. One range function in@objectstack/coreanswers both doors. No driver source is edited.Stop valve (claim 5872518067): it fired. On a local MySQL 8.0.46, a
datetimein years 0001..0099 is still stored right and read back a century late through mysql2's instant parser. That cell is returned asneeds_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.mdonly; no code or test changed.Clause-②is nowyes (narrowing), because@objectstack/coregains the root exportisOutsideTemporalYearRange. The levels, the BREAKING banner, the ADR-0087 marker and the FROM → TO line are unchanged.datetimewherebound in year 10000 answered 7/0/0 for$gt/$lt/$eq. The right answer is 0/7/0.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 onwhere, the per-aggregationfilterandhaving, but now in the year-class words.b559a5d0eis that commit (f16ff84ac) plus a merge oforigin/mainb810ddb6f. Every other file of this PR is blob-identical to311ce0640.What changed
packages/core/src/utils/temporal-storage-form.tsisOutsideTemporalYearRange(value, kind), the one range. The year is the one the kind's rule reads:datetime: the UTC year of the instantcanonicalUtcDatetimereads. That function now shares one privateinstantMsreader with the range, so the two cannot drift.date: a string's leadingYYYY-MM-DDyear. Otherwise, the UTC year of the instant the value names.time: never judged.datearm 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 thedatetimespelling of any instant is unchanged.packages/core/src/utils/temporal-comparand.ts:isUninterpretableTemporalComparandasks the range for adateordatetimenumber,Dateor readable string.#20240's privateisOutsideCalendarDayYears(0..9999,dateonly) is removed.timeis 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-aggregationfilterandhavinginherit the range through the one predicate. The door asks core'sisOutsideTemporalYearRangewhich class a hit is, and never re-derives the range.packages/objectql/src/validation/record-validator.ts, thedate/datetimearm only: a readable value outside the range failsinvalid_date, with the same code, constraint and message key as any other invalid date. This covers insert, update, a multi-row update andengine.validate. The number arm (PR feat(objectql,spec)!: enforce a field's declaredprecision(total digits) at the write seam —max_precision(#19992) #20423) is not touched.Measured: base
b28550818vs headf2d96c96cScratch 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), withTZ=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.wheredatetime,$gt/$lt/$eqDate, ISOINVALID_FILTERfilter$gt/having$gtonmin(datetime)INVALID_FILTERwheredatetimeINVALID_FILTERwheredateDate, ISO, bare0000-06-15INVALID_FILTERdate+010000-01-01T00:00:00.000Z/-000001-…VALIDATION_FAILEDdate/datetimeVALIDATION_FAILED0001-01-01,9999-12-31T23:59:59.999Z, 2026 controlH1 held: the card's table reproduces on
origin/mainin every cell. PR #20261 (#20240) and #20263 had already moved only thedate10000 / −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,judgeFilterand service-analytics' decline) and by the record validator. Each caller of the storage rule:resolveNowDefault/normalizeExpressionDefault) runs beforevalidateRecordon insert, so a defaulted year outside the range is refused.SqlDriver.formatInputandmemory-temporal.tsreadtemporalStorageFormand 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.tskeeps 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 aDateyear 1..999, no number arm ondate), and is noted below, not changed.H3. The write door is
validateRecord'sdate/datetimearm, 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:
temporal-comparand.test.tsIN_RANGEthe first millisecond of year 0;temporal-storage-form.test.tspadding cases0000-06-15and0000-01-01, now0-06-15and0-01-01;engine-date-year-range-door.test.tsIN_RANGE0000-01-01.These pins asserted a
datetimenumber,Dateor extended-year string as read, and are flipped too:leaves the datetime and time rules alone;leaves the datetime and time fields alone;havingUNCHANGEDan extended-year ISO on min(datetime);data-query-date-year-range.test.ts's datetime control. It now reads a 2026 instant.Stop valve: MySQL
datetimein 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:00Zreads back as2001-01-01T00:00Z,0001-03-04T10:00Zas2004-01-03,0050-…as1950-…,0069-…as1969-…,0070-…as1970-…, and0099-…as1999-….0100,0101,0500,0999and1000read back as written.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-reporton #20264. #20280 remains open for itsdatetimehalf, per ruling 5859414357.DELIBERATE CORRECTION: three pending release notes
Check Changesetwill 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: theUnchangedclause "everydatetimeandtimecell, 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 "temporal values outside the years a four-digit text or a backend holds: adatetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264 … narrows that to 0001..9999"..changeset/20263-having-temporal-comparand-door.md: theUnchangedclause "an extended-year instant on adatetimecolumn, which that rule reads" gains "until temporal values outside the years a four-digit text or a backend holds: adatetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264 … refuses adatetimeyear outside 0001..9999".The claim's file surface names
.changeset/20264-*.mdonly. These three are an in-place addition, declared here and in the report.Tests and gates, measured at
311ce0640311ce0640is the merge oforigin/maindc0ab6a2einto this branch, and it carries PR #20423's record-validator number arm. These readings are its own.test:repo3 / 48.test:repo1 / 5.test:repo1 / 8.TZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1, live PostgreSQL 16.13 (Asia/Shanghai) and live MySQL 8.0.46 (+08:00).--listFiles.dateonly, 0..9999). The mutation went in throughscripts/ablation-replace.mjs: anchor 1 to 0, blob7801894eto8a7dc4b4. Core was rebuilt, and the dist preflight found the marker present in 2 built files.datetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264 cell: thedatetimeyear class, year 0, or the write door. Everydate10000 / −1 cell and every control stayed green.HEAD,git status --porcelainis empty, and after a rebuild the preflight finds the marker absent from 14 files. core 97, objectql 67 and rest 23 passed.record-validator.ts. Anchor 1 to 0, bloba7fd6b04tocfbeeebc. objectql was rebuilt, and the preflight found the marker present in 4 files.dispatch-gates --commandsat311ce0640derived 67 commands. All 67 ran, each exit code captured before any pipe.--ranreconciles them: 67 derived, 67 run, 0 NOT-MEASURED, with a derived zero.check-empty-changeset.mjs --base origin/mainexits 1, on exactly the three DELIBERATE CORRECTION names above.check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3). After a fullturbo run build, it exits 0..tsfiles, 0 errors and 0 warnings, counted from--format json. The population iseslint.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: baseb28550818reads 50 covered / 0 DEBT / 0 exempt, and311ce0640reads 50 / 0 / 0.Acceptance notes
driver-mongodbkeeps its own copy of the storage rule (mongodb-temporal.ts). Itsdatearm pads no year and has no number arm, which 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 and 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 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.timecolumns 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$ltanswers 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 thetimerule hands it back unchanged. This is outside the ruling'sdate/datetimescope, so it is reported for the seat to file.datearm admits aDate.parse-readable string with no leadingYYYY-MM-DDinside the range. This is measured at REST on memory and SQLite, at head.POST /data/:objectwithd: "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