fix(objectql)!: a temporal string is written on a real calendar day, and a datetime string in an ISO 8601 spelling, or refused with VALIDATION_FAILED / invalid_date (#20525) - #20547
Conversation
…and a datetime string in an ISO 8601 spelling, or refused with VALIDATION_FAILED / invalid_date The record validator's date / datetime arm now refuses a string whose leading YYYY-MM-DD names a day that does not exist (2026-02-30 was stored verbatim as a date and rolled over to March 2 as a datetime), and a datetime string outside the ISO spellings the storage rule reads the same on every host (2026/07/15 10:00 was read in the server process's zone). Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…te and PostgreSQL, and the memory driver's storage of what it admits Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…ith a BREAKING banner Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…al-day-iso Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 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 — 17 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 7f0f9a92ea8362765368d8b117a8828b979fda44 && git checkout 7f0f9a92ea8362765368d8b117a8828b979fda44
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba5927f714af7516105706b36a05cedf34d5fa1b 0999284ab3b36da8c7e9927e20b4ebe834929a90 && git checkout -B drift-repro ba5927f714af7516105706b36a05cedf34d5fa1b && git merge --no-ff 0999284ab3b36da8c7e9927e20b4ebe834929a90
node scripts/docs-audit/affected-docs.mjs --json ba5927f714af7516105706b36a05cedf34d5fa1b |
Contract reviewServed-tier: Inputs read: card #20525 (body and all 4 comments: triage 5880930860, claim 5881460653, os-dev-report 5882243223, seat answer 5882273111), PR #20547 (body, 5-file list, net diff against merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…as written, not as 1900..1999 (objectstack-ai#20550) (objectstack-ai#20591) Fixes objectstack-ai#20550 Clause-②: no `nextUtcCalendarDay` (`packages/spec/src/data/calendar-day.ts`) proves a bare `YYYY-MM-DD` is a real day by building it and reading it back. It built the date with `Date.UTC(y, mo - 1, d)`, and `Date.UTC` reads a year from 0 to 99 as 1900 + year, so `0050-01-01` was built as `1950-01-01`, the round trip failed, and the helper answered `null` for every day of the years 0001..0099. `utcInstantMs` asks the same helper about a bare day, so it answered `null` for them too. A `datetime` `$lte` or `$between` maximum on such a day then skipped whole-day widening (ADR-0053 D-D) and stopped at the day's first instant. The accept set is unchanged: a query on years 0001..0099 now answers what the contract already says (the claim's `Clause-②: no`). ## The change - `packages/spec/src/data/calendar-day.ts`: a private `utcMidnight(year, monthIndex, day)` builds the date as `new Date(0)` plus `setUTCFullYear(year, monthIndex, day)`, which takes the year as written and rolls the month and day over exactly as `Date.UTC` does. `nextUtcCalendarDay` uses it for both the round trip and the next day. The impossible-day refusal is unchanged in kind: `2026-02-30`, `0050-02-30` and `0100-02-29` still roll over and are refused by the round trip. - `utcInstantMs` is byte-identical. Its bare-day arm already delegated the "is this a real day" question to `nextUtcCalendarDay` and then read the instant with `Date.parse` of ISO text, which reads year 0050 correctly; its timestamp arm never touched `Date.UTC`. So the one construction fixes both helpers. No other helper in the file builds a date. - No edit under `packages/core/**` or `packages/drivers/**`: every caller imports these helpers from `@objectstack/spec` (core re-exports them). ## Verification (final head `6a5dce7378`, base `f11b5f20a2`) Premise, measured before editing: 1. **Helper, on the base.** Instrument: `tsx` importing `packages/spec/src/data/calendar-day.ts` at `f11b5f20a2`. `nextUtcCalendarDay('0050-01-01')` = `null`, `utcInstantMs('0050-01-01')` = `null`; the same for `0001-01-01` and `0099-12-31`. Control: `'0100-01-01'` answers `'0100-01-02'` and `-59011459200000`. 2. **Exhaustive, base against head.** Instrument: every string `YYYY-MM-DD` with year 0000..9999, month 00..13 and day 00..32 (4,620,000 strings, 3,652,425 real days), compared with a pure-arithmetic proleptic Gregorian oracle that uses no `Date`. Base: 36,525 mismatches for each helper, exactly every real day of the years 0000..0099. Head: 0 mismatches for either helper. 3. **Over REST, on the base semantics.** Instrument: the new `packages/rest/src/data-query-calendar-day-year-below-100.test.ts` through `POST /api/v1/data/:object/query`, process in `America/New_York`, with `calendar-day.ts`'s construction put back to `Date.UTC` (reverse verification below). SQLite and PostgreSQL 16 answered the same: | `where opened_at` | base (SQLite, PostgreSQL) | head (SQLite, PostgreSQL) | |:--|:--|:--| | `$lte '0050-01-01'` | `y49` | `y49`, `y50`, `y50_last` | | `$between ['0050-01-01', '0050-01-01']` | none | `y50`, `y50_last` | | `$lte '2026-07-15'` (control) | `c26`, `y49`, `y50`, `y50_last`, `y50_next` | the same | | `$between ['2026-07-15', '2026-07-15']` (control) | `c26` | the same | Rows: `y49` = `0049-12-31T10:00Z`, `y50` = `0050-01-01T10:00Z` (the card's row), `y50_last` = `0050-01-01T23:59:59.999Z`, `y50_next` = `0050-01-02T00:00Z`, `c26` = `2026-07-15T14:00Z` (the card's control), `c26_next` = `2026-07-16T00:00Z`. The next day's midnight stays out in every cell. Reverse verification (fix committed first, mutation through `scripts/ablation-replace.mjs`, `trap` restore to the `HEAD` blob): the `utcMidnight` body was replaced by `return new Date(Date.UTC(year, monthIndex, day));`; the anchor fell 1 to 0 and the replacement rose 0 to 1 on disk; `pnpm --filter @objectstack/spec build`; `scripts/ablation-dist-preflight.mjs` found the mutation in 4 built files and the fix's line in none. Then `calendar-day.test.ts`: 3 failed / 7 passed (`0001-01-01: expected null to be '0001-01-02'`); the REST file: 2 failed / 2 passed, the table's base column, identical on both cells. Restore: blob `f03557d5bb` equals `HEAD`, whole-tree `git status --porcelain` empty, a full spec rebuild, the preflight found the fix in 4 built files and the mutation in none, and both files went green again (10/10, 4/4). Tests at `6a5dce7378`: - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: 574 files, 16,883 passed, 1 todo. - `@objectstack/rest`, the new file with the existing temporal suites (`data-temporal-write-real-day-iso`, `data-temporal-year-range`, `data-query-date-year-range`, `data-date-read-year-below-100`, `data-date-write-iso-only`, `data-query-epoch-ms-date-comparand`, `data-query-having-temporal-door`, `aggregation-filter-temporal-storage-rule`, `rest-14078-invalid-date-total-arm`), with a private PostgreSQL 16 server (zone `Asia/Shanghai`) as `OS_TEST_POSTGRES_URL`: 10 files, 89 passed, 5 skipped. The 5 skips are MySQL cells (`OS_TEST_MYSQL_URL` unset). The new file ran on SQLite and PostgreSQL, the two drivers the PR objectstack-ai#20547 harness runs. - `driver-sql` `sql-driver-calendar-day-upper-bound.test.ts` (SQLite and PostgreSQL ran, MySQL not run): 15 passed. `driver-memory`: the six `memory-analytics-date-range-*` suites and `memory-driver-calendar-day-upper-bound.test.ts` (run, not edited): 7 files, 103 passed. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/rest typecheck`: exit 0; `tsc --listFiles` over `packages/rest/tsconfig.test.json` includes the new test file. - Lint, narrowed: `eslint --no-inline-config --format json` over the three touched `.ts` files: 3 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each of them, and `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so the diff cannot move any untouched file's verdict. - Gates from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 83 commands, 81 exit 0. Two were NOT MEASURED, `PREREQUISITE NOT MET` (exit 3), because they need the whole workspace built: `pnpm check:dual-build-cjs-loads` (44 packages without `dist/`) and `pnpm check:type-check-debt` (7 dependencies without a built type entry). CI runs both. Full-repo pin sweep: no test asserts `null` from either helper for a year below 100 (`git grep` for both names applied to a `00NN-` literal: 0 hits), and no suite asserts a `$lte` / `$between` / date-range upper bound on a `datetime` field in those years. So no pin had to change. ## Acceptance notes - **Year 0000.** The new construction also answers for year 0000 (`0000-12-31` is followed by `0001-01-01`), where the base answered `null`. It is not pinned: 0001..9999 is the supported range, and `@objectstack/core`'s `isOutsideTemporalYearRange` refuses year 0000 at the comparand and write doors before either helper is asked. The helper does not keep a second copy of that range. - **Memory driver**, through the engine at head (not REST; `@objectstack/driver-memory` has no binding in `packages/rest`): `$lte '0050-01-01'` answers `y49`, `y50`; the `$between` maximum answers `y50`; the 2026 controls are unchanged. - **Out of scope, reported to the seat and not changed here:** other `Date.UTC` constructions read an author-given year below 100 as 1900 + year, in `packages/core/src/utils/datetime.ts` and `packages/rest/src/import-coerce.ts`. For example, the import door stores a CSV `datetime` cell `0050-01-01 10:00` as `1950-01-01T10:00:00.000Z`. Separately, `nextUtcCalendarDay('9999-12-31')` answers `'10000-01-01'`, so on SQLite a `datetime` `$lte '9999-12-31'` answers no rows. That is unchanged by this PR and measured in the report. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20525
Clause-②: no (narrowing)
The record validator's
date/datetimearm now stores a real calendar day and an ISO instant, or it refuses. A string whose leadingYYYY-MM-DDnames a day that does not exist is refused, for adateand for the day part of adatetime. Adatetimestring outside the ISO 8601 spellings below is refused. Both answerVALIDATION_FAILED/ 400 with the field codeinvalid_date, naming the field, before any driver write. Nothing is rolled over, and nothing is read in the server's zone. This executes triage's ruling on the card (5880930860), Arm 1 and Arm 2 as written.Measured head:
0999284ab. It is the last change commit5083d6742plus a true merge oforigin/main288611e3e(two parents). The merge brought spec,driver-sqlandplatform-objectschanges. It touched no file underpackages/objectql,packages/core,packages/restorpackages/drivers/driver-memory: those four trees are byte-identical between5083d6742and0999284ab.The change
One source file changes:
packages/objectql/src/validation/record-validator.ts, +72 / −2.validateOne. Neither is exported, andpackages/coreis not edited.namesRealCalendarDay(s): the leadingYYYY-MM-DDhas month 01..12 and a day from 01 to that month's length, with February 29 only in a leap year. It is arithmetic, not aDateround trip.ISO_DATETIME_WRITE_FORM: the admitteddatetimegrammar, below.readable && readsAsDay && writtenAsIso && !isOutsideTemporalYearRange(value, t).writtenAsIsoistruefor a non-string.writesAsIsoTemporal(value, t). Both kinds need a real leading day, and adatetimemust also match the grammar, after trimming.Dateand a number go through the arm exactly as before. Thetimearm is not in the diff.The admitted
datetimegrammar (H3)After trimming, a
datetimestring is one of:YYYY-MM-DD, midnight UTC;YYYY-MM-DDTHH:MM[:SS[.fraction]], thenZ, a±HH:MMor±HHMMoffset, or nothing. With nothing, the wall clock is read as UTC (ADR-0074).YYYY-MM-DD HH:MM[:SS[.fraction]], zone-naive only, and read as UTC the same way.On top of that, the leading day is real,
Date.parsereads the string (soT25:00and a+99:99offset stay refused), and the year is 0001..9999.Each admitted spelling was read through core's
temporalStorageFormunder four host zones:UTC,America/New_York,Asia/ShanghaiandPacific/Kiritimati. All 34 probes gave the same instant in every zone. That includes years 0050, 0100 and 0999, fractions of any length,+0800offsets andT24:00(the end of the named day, stored as the next day's00:00Z).Refused by the grammar, with the reason for each borderline case:
"2026-07-15 10:00Z","2026-07-15 10:00:00+08:00"). The storage rule hands such a string toDate.parsewhole, and its non-ISO reading moved"0050-01-01 10:00+01:00"to1950-01-01T09:00:00.000Zin all four zones. TheTspelling of the same value is read correctly.t/z. The storage rule's zone-naive branch matches an upper-caseTonly. A zone-naive"…t10:00"would reachDate.parseand be read in the host zone."2026","2026-07") and an expanded year ("+002026-07-15T10:00:00Z"). These are not spellings the platform writes."2026"was stored as 2026 epoch milliseconds (1970-01-01T00:00:02.026Z). Thedatearm already refuses"+002026-07-15"since record validator: adatefield written as a non-ISO string ("2026/07/15") answers 201 and is stored verbatim as"2026/07/15", a non-day, on memory and SQLite, because thedatearm admits anyDate.parse-readable string #20481.H1: before and after, at the public door
POST /api/v1/data/:object, then a read-back throughPOST /api/v1/data/:object/query. The process ran inTZ=America/New_York(offset 240 on 2026-07-15). PostgreSQL 16 was a private local server atAsia/Shanghai,DateStyleISO, MDY. The base isb2b6a0643. The head column was measured on the fix at5083d6742, and the source trees it reads are the ones at0999284ab.H1 holds as the dispatch wrote it, on every cell.
Arm 1, an impossible day:
date"2026-02-30","2026-02-29","2026-04-31","2026-02-30T10:00:00Z""2026-02-30", …)DATABASE_ERRORinvalid_datedatetime"2026-02-30T10:00:00Z","2026-02-30 10:00""2026-03-02T10:00:00.000Z"invalid_datedatetime"2026-02-30""2026-03-02T00:00:00.000Z"invalid_datedatetime"2026-02-29T10:00:00Z""2026-03-01T10:00:00.000Z"invalid_datedate"2028-02-29"(the leap-day control)"2028-02-29"datetime"2028-02-29T10:00:00Z"(the leap-day control)"2028-02-29T10:00:00.000Z"Arm 2, a non-ISO
datetime:datetime"2026/07/15 10:00","07/15/2026 10:00","15 July 2026 10:00""2026-07-15T14:00:00.000Z", the process zoneinvalid_date"07/08/2026""2026-07-08T04:00:00.000Z", month-first in the process zoneinvalid_date"2026-07-15 10:00 PM""2026-07-16T02:00:00.000Z"invalid_date"2026""1970-01-01T00:00:02.026Z"invalid_date"2026-07""2026-07-01T00:00:00.000Z"invalid_date"Wed, 15 Jul 2026 10:00:00 GMT","2026-07-15t10:00:00z","+002026-07-15T10:00:00Z","2026-07-15 10:00Z""2026-07-15T10:00:00.000Z"invalid_date"2026-07-15 10:00:00+08:00""2026-07-15T02:00:00.000Z"invalid_dateControls:
datetime"2026-07-15 10:00","2026-07-15 10:00:00","2026-07-15T10:00","2026-07-15T10:00:00Z""2026-07-15T10:00:00.000Z", UTC and not the process zonedatetime"2026-07-15T10:00:00+08:00","2026-07-15T10:00:00+0800""2026-07-15T02:00:00.000Z"datetime"2026-07-15T10:00:00.123Z","2026-07-15T24:00:00Z","2026-07-15","0050-01-01","0050-01-01T10:00:00Z"date"2026-07-15","0050-01-01""1784109600000"invalid_dateDate(engine, since JSON carries none)H2: one calendar-day predicate, private to objectql
No reusable core helper exists.
packages/core/src/utils/filter-tokens.tshas a privatedaysInMonth.packages/spec/src/data/calendar-day.ts's exportednextUtcCalendarDayis the one public real-day check. It is aDate.UTCround trip, andDate.UTCreads years 0..99 as 1900..1999. So it answersnullfor"0050-01-01", a supported year since 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, and reusing it would have refused a date the door accepts today. The same holds forutcInstantMs. That is reported below as a finding.The predicate is therefore a private arithmetic helper in the validator. No core export is added, so the claim's
Clause-②: no (narrowing)holds, and only@objectstack/objectqlcarries a changeset. It can be lifted into core as a move if the comparand door later takes the same answer (H5).H4: producer census
Read at objectstack
0999284ab/b2b6a0643and objectuiorigin/main0eb9f36.engine.insert/engine.update, so they are validated.examples/**found 41 ISO temporal literals in 5 files (the positive control). It found 0Date.parse-readable non-ISO literals and 0 impossible-day literals.toLocale*/toUTCString/toDateString/String(new Date(…))feeding a record overexamples/**and non-testpackages/**found no temporal field producer.now()returns aDate, which is admitted as before. No string formatter of a date feeds a record inpackages/formulaorservice-automation.DateTimeFieldsendsfromDateTimeInputValue, which istoISOString(). An impossible day is returned untouched there "because the engine would re-emit it as the rolled day" (objectuinative-date-value.ts). The engine now refuses it, which is the answer that comment wanted.datetimewriters usetoISOString().datetime-localto that sameDateTimeField.create_record/update_recordpass values through. A model-written non-ISOdatetimenow gets the 400 back as the tool error./import(packages/rest/src/import-coerce.tsparseDateCell). Measured atPOST /api/v1/data/:object/import,TZ=America/New_York, no business timezone, on memory, SQLite and PostgreSQL.datecell2026-02-30: at the base, memory and SQLite stored"2026-02-30"and PostgreSQL failed the row with22008. At head it is a per-rowinvalid_dateon all three. That is the intended loud answer (H4), not a producer to fix.datetimecell is turned into ISO text by the importer itself, before this door, so this card does not change it. Three readings there are not ISO-faithful:2026-02-30,2026-02-30 10:00and2026-02-30T10:00:00Zare stored as March 2 (Date.UTC/Date.parseroll the day over);07/15/2026 10:00is stored as2026-07-15T14:00:00.000Z(the process zone);07/08/2026is stored as2026-07-08T04:00:00.000Z(month-first, the process zone).import-coerce.tsis outside this claim's surface, so these are reported, not edited. The zone and locale half is an import-format question, raised for a decision.canonicalUtcDatetime(measured only) is today a one-line delegate to core'stemporalStorageForm. It has no private reading left to diverge. No census write path reaches a driver aroundvalidateRecord.H5: the comparand door keeps its own answer, and diverges
Measured through
POST /api/v1/data/:object/queryat head,TZ=America/New_York. The comparand door (temporal-comparand-door.ts, core'sisUninterpretableTemporalComparand) is not edited.whereplaced_on $eq "2026-02-30"(adate)[](compared as text)DATABASE_ERRORopened_at $eq "2026-02-30T10:00:00Z"2026-03-02T10:00:00.000Zopened_at $eq "2026/07/15 10:00","07/15/2026 10:00"2026-07-15T14:00:00.000ZSo an impossible day is admitted as a comparand on both kinds: a
dateis compared as text or is a 500 on PostgreSQL, and adatetimeis rolled over. A non-ISOdatetimecomparand is read in the host zone. The write door is now strictly narrower. #20481's "adatestring refused as a comparand is refused as a written value too" still holds, and its converse no longer does for an impossible day. This is reported as an out-of-scope finding. Sharing the predicate would change the comparand door's answer for everywhere,filterandhaving, so it is not the costless same-predicate change the dispatch allows.Tests
packages/objectql/src/engine-temporal-write-real-day-iso.test.ts(new, 3 tests, recording driver).datetimespellings and 4 already-refused values, including an epoch number.engine.validate. Each assertscodeVALIDATION_FAILEDandfieldsexactly[field, invalid_date], with zero driver writes.Date. Each reaches the driver unchanged on every door.packages/rest/src/data-temporal-write-real-day-iso.test.ts(new, 3 tests per cell).POSTandPATCHover a realSqlDriver, with the process switched toAmerica/New_Yorkand the switch asserted. SQLite always runs. Live PostgreSQL runs whereOS_TEST_POSTGRES_URLis set and is a named skip otherwise.2026-02-30for both kinds and its four non-ISO spellings each assert 400,VALIDATION_FAILEDand the field code, with no write. The existing row keeps its values."2026-07-15 10:00"as…T10:00:00.000Zand not…T14:00Z.packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts(new, 2 tests). UnderAmerica/New_York, each admitteddatetimespelling and aDateis stored as its UTC instant and found by it. The leap day is stored as itself.Reverse verification. The fix was committed first.
scripts/ablation-replace.mjsremovedwrittenAsIsofrom the verdict line.e51b0aff89c1→fe92054de0c4.git diff HEADis empty.@objectstack/objectqlfromdist).writtenAsIsounused, the DTS build failed with TS6133, and no test ran.writesAsIsoTemporal(value, t) || value.length >= 0(blobe51b0aff89c1→0782d15e00fb), then an objectql rebuild.ablation-dist-preflightfound the marker in 4 built files. The new REST file went 2 failed / 4 passed: the refusal test was red on SQLite and on live PostgreSQL, and both controls were green.Suites:
5083d6742:--project local334 files / 6643 passed.test:repo1 / 5.typecheckexit 0.check:test-typecheckcompiles the new file (--listFileslists it), with the debt unchanged at 40 files / 234 errors / 65 signatures.5083d6742:--project local224 files, 4241 passed / 46 skipped.test:repo1 / 8.typecheckexit 0, and test-typecheck debt is 0.5083d6742: 61 files / 1423 passed.typecheckexit 0, and itstsconfig.jsonprogram lists the new file.datefield written as a non-ISO string ("2026/07/15") answers 201 and is stored verbatim as"2026/07/15", a non-day, on memory and SQLite, because thedatearm admits anyDate.parse-readable string #20481's three, at0999284ab, after the merge and a closure rebuild:Gates at
0999284abdispatch-gates --commands --repo objectstack-ai/objectstack: 65 commands. Each was run with its exit code captured before any pipe, and 63 exited 0.check-adr-0087-registrationreads the changeset as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription), exit 0.check:dual-build-cjs-loads,check:type-check-debt. Both exit 3PREREQUISITE NOT MET, because they need the whole tree built. Only the rest and driver-memory closures are built here. Scoped reading: bothrequireentries of@objectstack/objectqlload from the rebuiltdist,.with 178 exports and./corewith 52.--ranreconciliation: 65 derived, 63 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.eslint --no-inline-config --format jsonover the 4 changed TS files gave 4 files, 0 errors and 0 warnings.--print-configshows noparserOptions.projectorprojectServiceon any of them. Type-aware linting is off, so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Acceptance notes
datefield written as a non-ISO string ("2026/07/15") answers 201 and is stored verbatim as"2026/07/15", a non-day, on memory and SQLite, because thedatearm admits anyDate.parse-readable string #20481.@objectstack/driver-memoryhas no binding inpackages/rest, and a new binding is a census disposition. Memory is covered three ways:RestServeroverInMemoryDriverwith a scratch script that was not committed.invalid_datetimemessage ("must be a valid datetime (ISO-8601)") now states the rule exactly. Thedatemessage is unchanged.timearm (core temporal rule: atimecomparand spelled with an extended-ISO year (+010000-01-01T10:00:00Z) is compared as text on memory and SQLite:$gtanswers 3 of 3 rows,$lt0, where the same wall clock as a 2026 instant answers 2 / 1 #20480),packages/spec,import-coerce.ts(/import: parseDateCell emits adatecell year below 1000 unpadded (0500-01-01→500-01-01), so after PR #20524 a valid ISO date cell is refused per row asinvalid_date, and before it a non-day was stored #20534 holds that file), driver-sql, and the comparand door.driver-mongodbkeeps its own storage copy and is unmeasured here. The refusal sits in the engine in front of it./import'sdatetimerollover and process-zone reading (H4);nextUtcCalendarDay/utcInstantMsrefusing years 0001..0099. Adatetimewhere … $lte "0050-01-01"returns[]on all three backends, while the control$lte "2026-07-15"includes that day's 14:00Z row.Generated by Claude Code