Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20525-temporal-write-real-day-iso
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20525-temporal-write-real-day-iso

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20525

Clause-②: no (narrowing)

The record validator's date / datetime arm now stores a real calendar day and an ISO instant, or it refuses. A string whose leading YYYY-MM-DD names a day that does not exist is refused, for a date and for the day part of a datetime. A datetime string outside the ISO 8601 spellings below is refused. Both answer VALIDATION_FAILED / 400 with the field code invalid_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 commit 5083d6742 plus a true merge of origin/main 288611e3e (two parents). The merge brought spec, driver-sql and platform-objects changes. It touched no file under packages/objectql, packages/core, packages/rest or packages/drivers/driver-memory: those four trees are byte-identical between 5083d6742 and 0999284ab.

The change

One source file changes: packages/objectql/src/validation/record-validator.ts, +72 / −2.

  • Two private helpers sit beside validateOne. Neither is exported, and packages/core is not edited.
    • namesRealCalendarDay(s): the leading YYYY-MM-DD has 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 a Date round trip.
    • ISO_DATETIME_WRITE_FORM: the admitted datetime grammar, below.
  • The arm's verdict line gains one conjunct: readable && readsAsDay && writtenAsIso && !isOutsideTemporalYearRange(value, t).
    • writtenAsIso is true for a non-string.
    • For a string it is writesAsIsoTemporal(value, t). Both kinds need a real leading day, and a datetime must also match the grammar, after trimming.
  • A Date and a number go through the arm exactly as before. The time arm is not in the diff.

The admitted datetime grammar (H3)

After trimming, a datetime string is one of:

  • YYYY-MM-DD, midnight UTC;
  • YYYY-MM-DDTHH:MM[:SS[.fraction]], then Z, a ±HH:MM or ±HHMM offset, 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.parse reads the string (so T25:00 and a +99:99 offset stay refused), and the year is 0001..9999.

Each admitted spelling was read through core's temporalStorageForm under four host zones: UTC, America/New_York, Asia/Shanghai and Pacific/Kiritimati. All 34 probes gave the same instant in every zone. That includes years 0050, 0100 and 0999, fractions of any length, +0800 offsets and T24:00 (the end of the named day, stored as the next day's 00:00Z).

Refused by the grammar, with the reason for each borderline case:

H1: before and after, at the public door

POST /api/v1/data/:object, then a read-back through POST /api/v1/data/:object/query. The process ran in TZ=America/New_York (offset 240 on 2026-07-15). PostgreSQL 16 was a private local server at Asia/Shanghai, DateStyle ISO, MDY. The base is b2b6a0643. The head column was measured on the fix at 5083d6742, and the source trees it reads are the ones at 0999284ab.

H1 holds as the dispatch wrote it, on every cell.

Arm 1, an impossible day:

written memory, base SQLite, base PostgreSQL, base head, all three
date "2026-02-30", "2026-02-29", "2026-04-31", "2026-02-30T10:00:00Z" 201, read back as written ("2026-02-30", …) the same 500 DATABASE_ERROR 400 invalid_date
datetime "2026-02-30T10:00:00Z", "2026-02-30 10:00" 201, "2026-03-02T10:00:00.000Z" the same the same 400 invalid_date
datetime "2026-02-30" 201, "2026-03-02T00:00:00.000Z" the same the same 400 invalid_date
datetime "2026-02-29T10:00:00Z" 201, "2026-03-01T10:00:00.000Z" the same the same 400 invalid_date
date "2028-02-29" (the leap-day control) 201, "2028-02-29" the same the same unchanged
datetime "2028-02-29T10:00:00Z" (the leap-day control) 201, "2028-02-29T10:00:00.000Z" the same the same unchanged

Arm 2, a non-ISO datetime:

written to a datetime memory, base SQLite, base PostgreSQL, base head, all three
"2026/07/15 10:00", "07/15/2026 10:00", "15 July 2026 10:00" 201, "2026-07-15T14:00:00.000Z", the process zone the same the same 400 invalid_date
"07/08/2026" 201, "2026-07-08T04:00:00.000Z", month-first in the process zone the same the same 400 invalid_date
"2026-07-15 10:00 PM" 201, "2026-07-16T02:00:00.000Z" the same the same 400 invalid_date
"2026" 201, "1970-01-01T00:00:02.026Z" the same the same 400 invalid_date
"2026-07" 201, "2026-07-01T00:00:00.000Z" the same the same 400 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" 201, "2026-07-15T10:00:00.000Z" the same the same 400 invalid_date
"2026-07-15 10:00:00+08:00" 201, "2026-07-15T02:00:00.000Z" the same the same 400 invalid_date

Controls:

written memory, base SQLite, base PostgreSQL, base head, all three
datetime "2026-07-15 10:00", "2026-07-15 10:00:00", "2026-07-15T10:00", "2026-07-15T10:00:00Z" 201, "2026-07-15T10:00:00.000Z", UTC and not the process zone the same the same unchanged
datetime "2026-07-15T10:00:00+08:00", "2026-07-15T10:00:00+0800" 201, "2026-07-15T02:00:00.000Z" the same the same unchanged
datetime "2026-07-15T10:00:00.123Z", "2026-07-15T24:00:00Z", "2026-07-15", "0050-01-01", "0050-01-01T10:00:00Z" 201, the same instant the same the same unchanged
date "2026-07-15", "0050-01-01" 201, as written the same the same unchanged
an epoch-millisecond number, and the string "1784109600000" 400 invalid_date 400 400 unchanged
a Date (engine, since JSON carries none) accepted accepted accepted unchanged

H2: one calendar-day predicate, private to objectql

No reusable core helper exists.

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/objectql carries 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 / b2b6a0643 and objectui origin/main 0eb9f36.

  • Seeds and examples. Seed loaders write through engine.insert / engine.update, so they are validated.
    • A literal census over the 256 tracked files under examples/** found 41 ISO temporal literals in 5 files (the positive control). It found 0 Date.parse-readable non-ISO literals and 0 impossible-day literals.
    • A grep for toLocale* / toUTCString / toDateString / String(new Date(…)) feeding a record over examples/** and non-test packages/** found no temporal field producer.
  • Flows and formulas. CEL now() returns a Date, which is admitted as before. No string formatter of a date feeds a record in packages/formula or service-automation.
  • objectui writers. All are ISO.
    • DateTimeField sends fromDateTimeInputValue, which is toISOString(). An impossible day is returned untouched there "because the engine would re-emit it as the rolled day" (objectui native-date-value.ts). The engine now refuses it, which is the answer that comment wanted.
    • The calendar and gantt datetime writers use toISOString().
    • The action and bulk param widgets map datetime-local to that same DateTimeField.
  • AI / MCP. create_record / update_record pass values through. A model-written non-ISO datetime now gets the 400 back as the tool error.
  • /import (packages/rest/src/import-coerce.ts parseDateCell). Measured at POST /api/v1/data/:object/import, TZ=America/New_York, no business timezone, on memory, SQLite and PostgreSQL.
    • A date cell 2026-02-30: at the base, memory and SQLite stored "2026-02-30" and PostgreSQL failed the row with 22008. At head it is a per-row invalid_date on all three. That is the intended loud answer (H4), not a producer to fix.
    • A datetime cell 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:00 and 2026-02-30T10:00:00Z are stored as March 2 (Date.UTC / Date.parse roll the day over);
      • 07/15/2026 10:00 is stored as 2026-07-15T14:00:00.000Z (the process zone);
      • 07/08/2026 is stored as 2026-07-08T04:00:00.000Z (month-first, the process zone).
    • import-coerce.ts is 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.
  • driver-sql's canonicalUtcDatetime (measured only) is today a one-line delegate to core's temporalStorageForm. It has no private reading left to diverge. No census write path reaches a driver around validateRecord.

H5: the comparand door keeps its own answer, and diverges

Measured through POST /api/v1/data/:object/query at head, TZ=America/New_York. The comparand door (temporal-comparand-door.ts, core's isUninterpretableTemporalComparand) is not edited.

where memory SQLite PostgreSQL
placed_on $eq "2026-02-30" (a date) admitted, 200, [] (compared as text) the same 500 DATABASE_ERROR
opened_at $eq "2026-02-30T10:00:00Z" admitted and rolled over: matches the row stored at 2026-03-02T10:00:00.000Z the same the same
opened_at $eq "2026/07/15 10:00", "07/15/2026 10:00" admitted, read in the process zone: matches the row at 2026-07-15T14:00:00.000Z the same the same

So an impossible day is admitted as a comparand on both kinds: a date is compared as text or is a 500 on PostgreSQL, and a datetime is rolled over. A non-ISO datetime comparand is read in the host zone. The write door is now strictly narrower. #20481's "a date string 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 every where, filter and having, 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).
    • It covers 9 impossible-day values across both kinds, 12 non-ISO datetime spellings and 4 already-refused values, including an epoch number.
    • Each is tried on insert, update, a multi-row update and engine.validate. Each asserts code VALIDATION_FAILED and fields exactly [field, invalid_date], with zero driver writes.
    • The positive control has 21 values: the leap days, each grammar form, a leading blank, year 0050 and a 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).
    • It sends POST and PATCH over a real SqlDriver, with the process switched to America/New_York and the switch asserted. SQLite always runs. Live PostgreSQL runs where OS_TEST_POSTGRES_URL is set and is a named skip otherwise.
    • The card's 2026-02-30 for both kinds and its four non-ISO spellings each assert 400, VALIDATION_FAILED and the field code, with no write. The existing row keeps its values.
    • There is an epoch-number control. The leap days and the ISO spellings read back unchanged in UTC, "2026-07-15 10:00" as …T10:00:00.000Z and not …T14:00Z.
  • packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts (new, 2 tests). Under America/New_York, each admitted datetime spelling and a Date is stored as its UTC instant and found by it. The leap day is stored as itself.

Reverse verification. The fix was committed first.

  • objectql (src): scripts/ablation-replace.mjs removed writtenAsIso from the verdict line.
    • The mutation landed: anchor 1 → 0, blob e51b0aff89c1 → fe92054de0c4.
    • The new engine file went 2 failed / 1 passed. The positive control stays green, as it should.
    • The tool restored the file: blob equals HEAD, and git diff HEAD is empty.
  • REST (reads @objectstack/objectql from dist).
    • The first attempt was a no-run. Deleting the conjunct left writtenAsIso unused, the DTS build failed with TS6133, and no test ran.
    • Re-anchored: writesAsIsoTemporal(value, t) || value.length >= 0 (blob e51b0aff89c1 → 0782d15e00fb), then an objectql rebuild. ablation-dist-preflight found 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.
    • Restore leg: the source was back to HEAD, and objectql was rebuilt. The preflight found the ablation marker absent from all 14 built files, the tree clean, and the fix marker present in 4. The REST file then passed 6/6.

Suites:

Gates at 0999284ab

  • dispatch-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-registration reads the changeset as [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription), exit 0.
  • NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt. Both exit 3 PREREQUISITE NOT MET, because they need the whole tree built. Only the rest and driver-memory closures are built here. Scoped reading: both require entries of @objectstack/objectql load from the rebuilt dist, . with 178 exports and ./core with 52.
  • --ran reconciliation: 65 derived, 63 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.
  • Narrowed lint: eslint --no-inline-config --format json over the 4 changed TS files gave 4 files, 0 errors and 0 warnings. --print-config shows no parserOptions.project or projectService on any of them. Type-aware linting is off, so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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
  • 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 — 17 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 ba5927f714af7516105706b36a05cedf34d5fa1b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7f0f9a92ea8362765368d8b117a8828b979fda44 — the merge of head 0999284ab3b36da8c7e9927e20b4ebe834929a90 into base ba5927f714af7516105706b36a05cedf34d5fa1b, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0999284ab3b36da8c7e9927e20b4ebe834929a90
Local-runs: none

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 288611e3e: +593 / −2), the check-runs on the head (read twice, 02:06Z and 02:15Z), the precedent PR #20524 (body, its review 5881202199, the #20481 thread), objectui origin/main a33cf7e (newer than the PR's 0eb9f36; every cited writer re-read there), and scripts/pm/record-recognisers.mjs for the shape. Read-only git and REST GETs only; nothing built, run or re-run.

① Derived judgments

  1. The one accept-set change, and it is the ruling's. packages/objectql/src/validation/record-validator.ts date / datetime arm, +72 / −2: the verdict line is now readable && readsAsDay && writtenAsIso && !isOutsideTemporalYearRange(value, t) (line 1316), with writtenAsIso = typeof value !== 'string' || writesAsIsoTemporal(value, t). writesAsIsoTemporal trims, requires ISO_DATETIME_WRITE_FORM for a datetime, then namesRealCalendarDay for both kinds; on a miss the arm returns the existing fail('invalid_date', …), naming the field, before any driver call. RIGHT against triage 5880930860 Arm 1 (a real leading day, for date and for the day part of a datetime, refused and never rolled over) and Arm 2 (ISO 8601 only, zone-naive read as UTC, every other spelling refused, no host zone, no locale guess).
  2. namesRealCalendarDay is exact. /^(\d{4})-(\d{2})-(\d{2})/ on the trimmed string; month 1..12; day ≥ 1; leap year y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) (the proleptic Gregorian rule, the calendar Date.parse itself uses); February 28 / 29, 30 for months 4, 6, 9, 11, 31 otherwise. Exact for every month and every year 0001..9999 (year 0 and beyond are refused by the range conjunct regardless). It is applied to a date (whose leading day readsAsDay already guarantees) and to a datetime (whose leading day the grammar guarantees), and it judges the day the string NAMES on its wall clock, which is what Arm 1 says: 2026-02-28T20:00:00-08:00 names February 28, a real day, and is stored as March 1 04:00Z, which is an offset conversion, not a rollover. Arithmetic only, no Date round trip, so 0050-01-01 is not misread as 1950. RIGHT.
  3. ISO_DATETIME_WRITE_FORM is exactly the PR's stated grammar. ^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?| \d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?$ reads one to one as: a bare day; T HH:MM[:SS[.fraction]] then Z, ±HH:MM, ±HHMM or nothing; a space, HH:MM[:SS[.fraction]], no zone. Neither admitted nor claimed: an hour-only offset (+08) and an hour-only time. On top of it readable (Date.parse) still refuses T25:00, T24:30 and a +99:99 offset, and the year range still applies. RIGHT.
  4. The four refused ISO-adjacent spellings, each judged against "an ISO 8601 string is admitted".
    • Space plus zone (2026-07-15 10:00Z, 2026-07-15 10:00:00+08:00). ISO 8601 spells the separator T; the space is RFC 3339's note-level allowance, not an ISO 8601 spelling. From source: the storage rule's naive branch in packages/core/src/utils/temporal-storage-form.ts instantMs matches ^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$, so a space form WITH a zone matches nothing and goes to Date.parse whole, that is to V8's non-ISO parser, whose two-digit-year reading is what turns the dev's measured 0050-01-01 10:00+01:00 into 1950; years 0001..0099 are in the supported range (temporal values outside the years a four-digit text or a backend holds: a datetime comparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; a date in year 0000 500s on PostgreSQL; a date write stores +010000-… verbatim #20264). Within the ruling. Qualification, stated in the changeset: for years ≥ 0100 these were stored correctly at the base, so this is a narrowing, and the changeset says "write it with a T". RIGHT.
    • Lower-case t / z. ISO 8601 spells T and Z upper case (lower case is RFC 3339's allowance). From source: the naive branch is [ T], so a zone-naive 2026-07-15t10:00 reaches Date.parse whole and is read in the host zone, which is the Arm 2 defect itself; the zone-explicit …t…z was read correctly at the base. One grammar with upper-case letters refuses both; within the ruling, and the changeset names "2026-07-15t10:00:00z" (lower case) as refused. RIGHT.
    • Reduced precision (2026, 2026-07). Neither carries a leading YYYY-MM-DD, so Arm 1's "anything else is refused" reaches them directly. 2026 was stored WRONG: instantMs reads a bare integer string as epoch milliseconds (/^-?\d+$/, verified), so "2026" became 1970-01-01T00:00:02.026Z; 2026-07 was read as July 1. RIGHT.
    • Expanded year (+002026-07-15T10:00:00Z). No leading YYYY-MM-DD, so Arm 1 again; the date arm has refused +002026-07-15 since record validator: a date field 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 the date arm admits any Date.parse-readable string #20481 (engine-date-write-iso-only.test.ts:59). RIGHT.
      No refusal narrows beyond the ruling; the two that remove a correctly stored spelling (space plus zone, and lower case with an explicit zone, years ≥ 0100) are stated in the changeset before → after with the one-line fix.
  5. T24:00 is admitted as end of day, and that is right. The grammar admits 24:00; readable admits it because the ECMAScript Date Time String Format permits 24:00:00 only as the end of the named day (V8 refuses T24:30, so that is refused by readable); the named day is real; the stored instant is the next day's 00:00:00.000Z, an ISO-defined alias of the same instant, not a rollover of a day that does not exist. 9999-12-31T24:00:00Z names year 10000 and the range refuses it. The stored instant is the dev's reading and the ES rule, not run here. RIGHT.
  6. A Date and an epoch number keep today's answer. A Date is not a string (writtenAsIso true, readable true); a number was never readable and stays refused; the bare-integer string "1784109600000" is not Date.parse-readable and stays refused. The engine pin and the REST pin carry both controls. RIGHT.
  7. The time arm and the comparand door are untouched. The source hunks are the header comment, the two helpers plus writesAsIsoTemporal beside validateOne, and the arm's verdict line; git diff merge-base..head over packages/objectql/src/temporal-comparand-door.ts, packages/objectql/src/index.ts, packages/core, packages/spec, packages/drivers/driver-sql and packages/rest/src/import-coerce.ts is empty. The invalid_date code and both messages (validation-message.ts:103 / :104) are unchanged. RIGHT.
  8. Every write door is behind the change. validateRecord runs in engine.ts at 11888 (the dry-run validate), 12661 (insert; insertMany delegates to insert, 12939), 13933 (update by id) and 14244 (the multi-row update). REST create / PATCH / bulk, /import after coerceRow, MCP create_record / update_record, flows and the seed loader all reach the engine through those methods. RIGHT.
  9. Existing pins, seeds, examples and fixtures: nothing shipped writes a now-refused spelling. Grepped at the head over examples/** and packages/** (non-Markdown): space-plus-zone datetimes, 19 hits, all comments, spec rationale prose, or k.raw(timestamptz '…') SQL literals in a driver-sql test that bypasses the validator; lower-case t / z, 1 hit, the new engine pin; expanded years, every hit a year outside 0001..9999 already refused by the range, or +002026-07-15 in the record validator: a date field 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 the date arm admits any Date.parse-readable string #20481 refused lists; reduced-precision temporal values, 0; slash dates outside tests only in comments and the settings locale-format labels (never written to a temporal field); toLocale* / toUTCString / toDateString producers in examples and non-test package sources, 0. Tests carrying month-name, RFC 2822 or slash datetimes: 7 hits, all comments, the record validator: a date field 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 the date arm admits any Date.parse-readable string #20481 refused lists, or parseDateCell calls (the importer, before the door). The shipped seed writes f_datetime: '2026-06-17T14:30:00Z' (examples/app-showcase/src/data/seed/index.ts:335), the external fixture writes YYYY-MM-DD days, app-todo writes toISOString() (task.hook.ts:79) or toISOString().slice(0, 10). Docs: content/docs/data-modeling/validation-rules.mdx:535 already states datetime as "ISO 8601 datetime, UTC" and content/docs/api/error-catalog.mdx:207 stays true; no page states the old lenient accept-set. Test Core (1/6)..(6/6), Lint & Repo Gates and Temporal Conformance concluded success on the head. No FAIL item.
  10. The pins, read in full.
    • packages/objectql/src/engine-temporal-write-real-day-iso.test.ts (3 tests): a recording driver; 9 impossible-day values across both kinds, 12 non-ISO datetime spellings and 4 already-refused values (an epoch number among them), each on insert, update and the multi-row update with code VALIDATION_FAILED and fields exactly [field, invalid_date], then writes.length === 0; the dry-run validate on every value; a 21-value positive control that includes the leap-day controls 2028-02-29 and 2028-02-29T10:00:00Z, every grammar form, a leading blank, year 0050 and a Date, each asserted to reach the driver as written. Leap-day and ISO controls present. RIGHT.
    • packages/rest/src/data-temporal-write-real-day-iso.test.ts (3 tests per cell): SQLite always; live PostgreSQL via describe.skipIf(!config) with a label naming OS_TEST_POSTGRES_URL; the process switched to America/New_York and asserted; the card's 2026-02-30 for both kinds and its four spellings on POST and PATCH, each 400 / VALIDATION_FAILED / exact field code, a zero write delta on the object, o1 keeps both values, no refused row; an epoch control; a positive control that reads back both leap days, +08:00 as 02:00Z, and the zone-naive space and T forms as 10:00:00.000Z, on create and on PATCH. RIGHT. CI: ci.yml's Temporal Conformance (live PG + MySQL) job runs the driver-sql suite, the non-SQL backends (core, formula, driver-memory, driver-mongodb, service-analytics) under the skewed zone, metadata-protocol's live files and runtime's cascade file; it does not run packages/rest. So the PG cell is a named skip in CI, exactly as on record validator: a date field 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 the date arm admits any Date.parse-readable string #20481 and as the file's header says; the dev's 12 / 12 live-PG reading is local and unverified here.
    • packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts (2 tests): under TZ=America/New_York (asserted), the leap day, UTC, +08:00, naive T, naive space and a Date each read back as their UTC instant and are found by $eq and by a range; the leap date is stored as itself. RIGHT. Not pinned against a real driver: the ±HHMM offset (engine pin only, recording driver); the dev's H1 reading of it is consistent with V8's parser, noted, not a defect.
    • No existing assertion is removed or weakened: the diff adds three files and edits no existing test.
  11. Producer census (H4), verified. Seeds: SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts:920, :952, :984, :1530) writes through engine.insertMany / engine.insert / engine.update, so seed rows are validated. objectui at origin/main a33cf7e: fromDateTimeInputValue (packages/core/src/utils/native-date-value.ts:141) returns toISOString() or the impossible-day string untouched, with the comment "the engine would re-emit it as the rolled day", which this PR turns into the 400 that comment wanted; DateTimeField.tsx:91 and GridField.tsx:595 use it; calendar ObjectCalendar.tsx:343 and gantt ObjectGantt.tsx:419 write toISOString() for a datetime and toDateInputValue for a date; no other raw datetime-local writer exists in packages/*/src or apps/*/src. /import: parseDateCell at the head (packages/rest/src/import-coerce.ts:352) emits a datetime cell as toISOString() on all three branches (the Date.UTC fast path, the wall-clock path, the new Date(s) fallback), so a datetime cell reaches the door already ISO and this PR changes nothing there; a date cell is emitted ${y}-MM-DD with the day checked only against 31, so 2026-02-30 reaches the door and is now a per-row invalid_date. The file is not in the diff. TRUE.
  12. Merge facts. 0999284ab has two parents, 5083d6742 and 288611e3e (verified). git diff --stat 5083d6742 0999284ab over packages/objectql, packages/core, packages/rest and packages/drivers/driver-memory is empty; the merge brought 184 files, in packages/spec, packages/platform-objects and packages/drivers/driver-sql. TRUE.
  13. Sentence census, PR body and changeset. Every sentence about the diff, the helpers, the grammar, the refusal path, the unchanged set (a Date, a number, the time arm, the comparand door, the year range, spec, core, driver-sql, import-coerce.ts), the census sites, the pins and the merge is TRUE against the code. Qualified: "The invalid_datetime message … now states the rule exactly" is TRUE as a statement of fit, not of a change (validation-message.ts:104 is unchanged). "Read at … objectui origin/main 0eb9f36": main is now a33cf7e; re-verified there. The H1, H5 and /import tables, the four-zone probe of 34 spellings, the ablation readings, the suite counts, dispatch-gates 63 / 65 and the narrowed lint are the dev's own readings, not reproducible read-only; they are consistent with the code and with 31 / 31 green check-runs and are recorded as unverified here, not FALSE. H2's "no reusable core helper" is consistent with finding [2] (nextUtcCalendarDay is a Date.UTC round trip that refuses 0001..0099) and the dev's account of filter-tokens.ts's private daysInMonth was not re-read.

② Semver level

  • Changeset .changeset/20525-temporal-write-real-day-iso.md: "@objectstack/objectql": minor and nothing else, a BREAKING banner, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription). The PR body and claim 5881460653 carry the same Clause-②: no (narrowing) line. Consistent.
  • no is right. No core export: packages/core is not in the diff, packages/objectql/src/index.ts is untouched, and ISO_DATETIME_WRITE_FORM, namesRealCalendarDay and writesAsIsoTemporal are module-private (const / function, no export). No new key, no new union member, no accept-set widening anywhere: the diff removes strings from a write accept-set. (narrowing) is right and is BREAKING; minor is right under the launch-window rule (scripts/check-changeset-no-major.mjs:1502: "a declared narrowing is a BREAKING change; during the launch window it ships minor"), the breaking-ness carried by the banner and the ADR-0087 disposition. Check Changeset and Governed Surface Queue Guard concluded success on the head.
  • The narrowing is stated as what a caller sees, before → after, for each arm. Arm 1: date "2026-02-30" (201 verbatim on memory and SQLite, 500 on PostgreSQL → 400 invalid_date) and datetime "2026-02-30T10:00:00Z" (201, March 2 → 400). Arm 2: the process-zone spellings, "07/08/2026" month-first, "2026" as epoch milliseconds → 400. The four ISO-adjacent spellings are named in the refused list: "2026", "2026-07", "2026-07-15t10:00:00z" (lower case), "+002026-07-15T10:00:00Z" and "a space-separated time carrying a zone, "2026-07-15 10:00:00+08:00" (write it with a T)". The one-line fix, "Who is affected", the /import note and the "Unchanged" list (both leap days, each ISO spelling with its stored instant, a Date, the refused number, the year range, every time value, every comparand) are present. A caller sees before and after.
  • ADR-0087 no-migration-prescription is the honest arm. "Send an ISO spelling" re-spells a value; no authorable key, spelling or stored shape of metadata moves; packages/spec is untouched; a stored row keeps whatever it holds; the package publishes; no registry id covers a write-door value check. The dev's check-adr-0087-registration reading (exit 0) is consistent.
  • Only @objectstack/objectql moves and only it carries a changeset. Right.

③ Boundary flags

  1. Deviation 1 — no REST-on-memory cell. Answered: covered. The REST pin imports only @objectstack/driver-sql; packages/rest has no @objectstack/driver-memory binding and a new one is a driver-memory-census disposition, the same fact 5881202199 verified. The refusal precedes every driver, so a REST-over-memory cell would re-measure the engine pin. The ruling's three backends are held by the engine pin (driver-agnostic, zero writes, the card's spellings), the driver-memory pin (storage under America/New_York) and REST on SQLite, with PG when provisioned. No needs_decision.
  2. Deviation 2 — a private PG cluster outside the scratchpad. Process hygiene; reported stopped and deleted; the diff carries no artefact. Accepted.
  3. Deviation 3 — the first dist ablation was a no-run (TS6133), re-anchored and repeated. Honestly recorded; this record does not rest on the ablation readings, it rests on the committed pins' assertions, read here, and the head's check-runs. Accepted.
  4. Deviation 4 — the harness's model-named trailer not followed. AGENTS.md's trailer pair governs the repo; the branch and card guards concluded success. Accepted.
  5. Deviation 5 — scratch measurement scripts never committed. The diff is the 5 files listed, so the H1 and H5 tables stay the dev's readings. Accepted.
  6. Deviation 6 — no core line in the changeset. Consistent with the diff: no core export was added. Accepted.
  7. Open question 1 — the seat's answer 5882273111 routes it to /import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534, and that does not block this PR. Verified: import-coerce.ts is not in the diff and /import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534 holds the file (the claim's own ⛔ line). A datetime cell reaches the door already ISO, so this PR changes nothing on /import's datetime readings; a date cell naming a day that does not exist becomes a per-row invalid_date, which is Arm 1's loud answer ("never rolled over"), not a producer to fix; the zone / locale half is an import-format question the claim itself routes to a decision. Agreed: not a block. Note for triage on /import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534: with this PR the per-row refusal of such a date cell is the write door's invalid_date, not the importer's import_invalid_date, because parseDateCell's fast and wall-clock paths check the day only against 31.
  8. Out-of-scope finding [0] — the comparand door now admits what the write door refuses. Verified from source: temporal-comparand-door.ts and core's temporal-comparand.ts are not in the diff, readsAsCalendarDay is a leading-shape test, and the write door is now strictly narrower; record validator: a date field 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 the date arm admits any Date.parse-readable string #20481's "a date string refused as a comparand is refused as a written value too" still holds. Outside this card's ruling, which names the write door only. The seat says "filed bare, card named in the seat's next note"; no card number is on the thread yet. Escalated, not a block: the seat names or files that card before this PR's landing record.
  9. Finding [1] — /import's rollover and process-zone readings. Folded into /import: parseDateCell emits a date cell year below 1000 unpadded (0500-01-01 → 500-01-01), so after PR #20524 a valid ISO date cell is refused per row as invalid_date, and before it a non-day was stored #20534 by the seat answer, with the rollover half noted as not a choice under Arm 1. Agreed.
  10. Finding [2] — spec's nextUtcCalendarDay / utcInstantMs refuse years 0001..0099. Consistent with packages/spec/src/data/calendar-day.ts being a Date.UTC round trip and with the dev's $lte "0050-01-01" reading; outside this claim (packages/spec is ⛔). The seat says it is filed bare for the spec lane; no card number is on the thread. Escalated the same way, not a block.
  11. Finding [3] — the four refused ISO-adjacent spellings. Judged in ① item 4: each within the ruling, the two narrowings stated in the changeset with the fix. Acceptance note, carrier none: agreed.
  12. Docs drift comment — no hand-written page names an anchor; validation-rules.mdx:535 and error-catalog.mdx:207 stay true. Nothing owed.
  13. Check-runs on 0999284ab3b36da8c7e9927e20b4ebe834929a90, re-read 2026-09-29T02:15:46Z (the last step): 34 check-runs, all completed — 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), each path- or opt-in-gated), 0 failure, 0 in progress. At the first read (02:06Z) ten were in progress (Test Core (1/6)..(6/6), Lint & Repo Gates, Type Check · workspace, Dogfood Regression Gate (2/3) and (3/3)); every one concluded success. Green by name: Check Changeset, Lint & Repo Gates, Test Core (1/6)..(6/6) and its roll-up, Type Check · source gates / consumer gates / debt ledger / workspace and TypeScript Type Check, Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (1/3)..(3/3) and its roll-up, Dogfood Verify CLI, Governed Surface Queue Guard, Auto Label, Check PR Size, Check Documentation Links, Flag docs affected by code changes, No other open PR may claim the same issue / the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, Build Core, filter. Combined commit status success (Vercel). The PR head is unchanged, mergeable_state: clean, still a draft.

Implemented-by: claude/issue-20525-temporal-write-real-day-iso
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 02:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 92ea760 Sep 29, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20525-temporal-write-real-day-iso branch September 29, 2026 02:45
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
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/l tests tooling

Projects

None yet

2 participants