fix(objectql)!: a date string is written in its YYYY-MM-DD form, or refused with VALIDATION_FAILED / invalid_date (#20481) - #20524
Conversation
…— any other spelling is VALIDATION_FAILED / invalid_date The record validator's date arm now asks the date storage rule's own reading of a string (core's isUninterpretableTemporalComparand, the temporal-comparand door's predicate) on top of Date.parse and the year range: a date string is accepted only when it carries a leading YYYY-MM-DD, which temporalStorageForm collapses to that day. Every other spelling (2026/07/15, 07/15/2026, 15 July 2026, 2026-7-15) is refused with invalid_date instead of being stored verbatim on memory and SQLite or read by PostgreSQL's DateStyle. No spelling is canonicalised. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…the engine and at REST on SQLite and PostgreSQL Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…or admits are stored as their day; objectql minor, BREAKING Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…dict per date string Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 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 4ca9d4e4d5563d3f6c488c3ac7e7a3c75e7b389d && git checkout 4ca9d4e4d5563d3f6c488c3ac7e7a3c75e7b389d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 397572ed5da04c14eed7b4bf934a4c353822df6c 4c5740d2d598ce1a6f64b2def69c848a2909e524 && git checkout -B drift-repro 397572ed5da04c14eed7b4bf934a4c353822df6c && git merge --no-ff 4c5740d2d598ce1a6f64b2def69c848a2909e524
node scripts/docs-audit/affected-docs.mjs --json 397572ed5da04c14eed7b4bf934a4c353822df6c |
Contract reviewServed-tier: Inputs read: card #20481 (body and all 4 comments: triage 5875651303, claim 5879527319, os-dev-report 5880394252, seat answer 5880487779), PR #20524 (body, 5-file list, net diff against merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20481
Clause-②: no (narrowing)
A
datefield's write door now stores a day or refuses. Adatestring is accepted only when it carries a leadingYYYY-MM-DD, thedatestorage rule's own reading, whichtemporalStorageFormcollapses to that day. Every other spelling is refused withVALIDATION_FAILED/ 400 (invalid_date) before any driver write. This executes triage's ruling on the card (5875651303): "Direction (triage's call, as the card asks): refuse." No spelling is canonicalised: "07/08/2026is ambiguous between locales, and a guess stores a wrong day silently."Measured head:
4c5740d2d. It is the last change commit9f0d29239plus a true merge oforigin/mainfb194c70e(two parents). The merge brought PR #20517 (packages/rest/src/import-coerce.tsand a test). It touched no file underpackages/objectql,packages/coreorpackages/drivers/driver-memory.The change
One source file changes:
packages/objectql/src/validation/record-validator.ts, thedate/datetimearm, +26 / −3.datestring, the arm also asksisUninterpretableTemporalComparand('date', value)from@objectstack/core. The engine's temporal-comparand door already refuses these same strings onwherewith that predicate. It trims the string and tests for a leadingYYYY-MM-DD, the same readingtemporalStorageFormmakes. So the write door and the comparand door agree on whichdatestrings the rule reads, and no second copy of the leading-day regex is written.Date.parsereadability and the 0001..9999 year range still apply on top.Date, a number, everydatetimeand everytimego through the arm exactly as before.packages/coreis not edited and gains no export, so the claim'sClause-②: no (narrowing)holds and only@objectstack/objectqlcarries a changeset.Before and after, at the public door
POST /api/v1/data/:object, then a read-back. The process ran in America/New_York. PostgreSQL 16.13 was a local server atAsia/Shanghai,DateStyleISO, MDY. Readings are from base0bbe4005eand head.date"2026/07/15","07/15/2026","15 July 2026","2026-7-15","2026.07.15","July 15, 2026""2026-07-15"invalid_date"07/08/2026""2026-07-08"(ISO, DMYreads August 7, measured inpsql)invalid_date"+002026-07-15"DATABASE_ERRORinvalid_date"2026-07-15","2026-07-15T10:00:00Z","2026-07-15 10:00"," 2026-07-15""2026-07-15""20260715","15/07/2026"invalid_dateDateThe dispatch's hypotheses
"2026/07/15"back verbatim. PostgreSQL does not store it verbatim. ItsDATEinput parser reads the spelling by the server'sDateStyle, so the stored day is a property of the server's configuration."07/08/2026"is July 8 underMDYand August 7 underDMY. A spelling it cannot parse ("+002026-07-15") was a 500.readableisDate.parse-readable. These pass today and are refused now:"2026/07/15","07/15/2026","15 July 2026","2026-7-15". These have a leadingYYYY-MM-DD, are accepted and are stored as"2026-07-15"before and after:"2026-07-15T10:00:00Z","2026-07-15 10:00"," 2026-07-15". The last one is accepted because the rule trims."20260715"has noDate.parsereading, so it was refused at the base and still is. The predicate is core's exportedisUninterpretableTemporalComparand(itsdatebranch, privatereadsAsCalendarDayintemporal-comparand.ts), so no new predicate was added.datestring on the shipped composition. The one path that forwards raw text runs only when/importis unreachable. Details are in the census section below.datetimearm. It does not have this defect: no spelling was stored verbatim on any of the three drivers. It has a different one, reported and not edited: a zone-naive non-ISO spelling is read in the server process's zone. See Acceptance notes.Dateand an epoch number keep the behaviour they had. ADateis stored as its UTC day. A number is refused withinvalid_dateon the write door, as it was at the base. Both are pinned as controls.Producer census (H3)
Read at objectstack
0bbe4005eand objectuiorigin/main797a30f.DateField(packages/fields/src/widgets/DateField.tsx): aninputof typedate, andonChangeemitse.target.value, which isYYYY-MM-DDor empty. ISO.plugin-calendar/src/ObjectCalendar.tsxtoStoredDateValue/toMovedDateValue,plugin-gantt/src/ObjectGantt.tsxtoStoredDateValue):toDateInputValueortoISOString().slice(0, 10)for adatefield. ISO./importcell reader (packages/rest/src/import-coerce.tsparseDateCell): it always returnsYYYY-MM-DDfor adatecell. Both the bulk path and the per-row path ofimport-runner.tscallcoerceRowfirst (:775). ISO. Not edited.plugin-grid/src/ImportWizard.tsxlegacyImport→validateRow:561,validateValue:486): it sends the raw cell text, checked only byDate.parse. It runs only when the data source has noimportRecords, or the client has nodata.import(isUnsupportedImport:626). objectui'sdata-objectstackadapter implementsimportRecords(src/index.ts:4430). On this path a non-ISO cell is now a per-row refusal instead of a stored non-day. See open question 1 in the report.examples/: CELdaysAgo(n)/daysFromNow(n)(aDate, normalised toYYYY-MM-DD) and ISO literals. A regex census of non-ISO date literals overexamples/**finds 0, with a control regex for ISO literals finding hits in 6 files.packages/mcp/src/mcp-http-tools.tscreate_record/update_recordforwarddatavalues unchanged, asz.unknown(). They are pass-through, and a model-written non-ISO date now gets the 400 back as the tool error.Tests
packages/objectql/src/engine-date-write-iso-only.test.ts(new, 4 tests, recording driver): 8 refused spellings plus 5 already refused (including{today}and an epoch number), on insert, update, a multi-row update andengine.validate. Each assertscodeVALIDATION_FAILEDandfields[placed_on, invalid_date], with zero driver writes. The accepted leading-day spellings and aDatereach the driver. One test holds both doors to one verdict per string:validatevalidity equalswhereacceptance.packages/rest/src/data-date-write-iso-only.test.ts(new, 3 tests per cell):POSTandPATCHover a realSqlDriver. SQLite always runs. Live PostgreSQL runs whereOS_TEST_POSTGRES_URLis set and is a named skip otherwise. Each refused spelling asserts status 400,codeVALIDATION_FAILEDand the field code, with no write. There is an epoch-number control, and the ISO spellings read back as"2026-07-15".packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts(new, 2 tests): each spelling the door admits, and aDate, is stored as2026-07-15, found by it, and ordered after2026-07-14onInMemoryDriver.Reverse verification:
scripts/ablation-replace.mjsput the base condition back inrecord-validator.ts(anchor 1 → 0, blobb5c6bb81dc72→d501d34ad6bc), and the new file went 3 failed / 1 passed. It passes 4/4 at head. The tool restored the file: blob equals HEAD andgit diff HEADis empty.@objectstack/objectqlfromdist. Against the basedist(readsAsDaycount 0), the new file went 2 failed / 4 passed on SQLite and live PostgreSQL, because"2026/07/15"answered 201. Afterpnpm --filter @objectstack/objectql build(count 2), it passed 6/6.Suites:
9f0d29239, 332 files / 6632 tests passed.test:repo1 / 5.typecheckexit 0;check:test-typecheckcompiles the new file with the debt unchanged at 40 files.9f0d29239, 59 / 1380, andtypecheckexit 0.4c5740d2d,--project local221 files, 4214 passed / 43 skipped.test:repo1 / 8, andtypecheckexit 0.4c5740d2d: 6 / 6.The objectql and driver-memory trees are byte-identical between
9f0d29239and4c5740d2d.Gates at
4c5740d2ddispatch-gates --commands --repo objectstack-ai/objectstack: 65 commands, each run with its exit code captured before any pipe. 63 exit 0.check:dual-build-cjs-loads,check:type-check-debt. Reason: both exit 3PREREQUISITE NOT METand need the whole tree built (lint.ymlbuilds it first); only the rest and driver-memory closures are built here. Scoped reading: bothrequireentries of@objectstack/objectql(.and./core) load from the rebuiltdist.--ranreconciliation: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN.check-changeset-fixed,check:authz-resolver,check:filter-alias-parity,check:object-def-param-keysandcheck:tenant-chokepointeach exit 0.eslint --no-inline-config --format jsonover the 4 changed TS files, which are insideeslint.config.mjs's population: 4 files, 0 errors, 0 warnings.--print-configshows noparserOptions.projectorprojectService, so type-aware linting is off and this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Acceptance notes
@objectstack/driver-memoryhas no binding inpackages/rest, and a new binding is acheck:driver-memory-censusdisposition, not a test's. Memory is covered by the engine pin (the refusal reaches no driver), by the driver-memory pin (admitted spellings are stored as the day), and by the before / after table above, measured throughRestServeroverInMemoryDriverwith a scratch script that was not committed.invalid_datemessage still reads "must be a valid date (ISO-8601)". It lives inpackages/spec(system/validation-message.ts), outside this card's file surface."20260715"(ISO basic) and"+002026-07-15"(ISO extended year) are ISO-8601 spellings refused with that message. Carrier: none.date"2026-02-30"isDate.parse-readable and has a leading day shape. It is still 201 and read back verbatim on memory and SQLite, and a 500 on PostgreSQL. On thedatetimearm, a non-ISO zone-naive spelling is read in the process zone ("2026/07/15 10:00"became14:00Zunder America/New_York, while"2026-07-15 10:00"reads as UTC),"07/08/2026"is read month-first, and"2026-02-30T10:00:00Z"rolls over to2026-03-02T10:00Z.driver-mongodbkeeps its own copy of the storage rule and is unmeasured here. The refusal sits in the engine in front of it.Generated by Claude Code