Repository navigation
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 |
…xed time of day and an extended-year instant are refused with VALIDATION_FAILED / invalid_time (objectstack-ai#20671) (objectstack-ai#20721) Fixes objectstack-ai#20671 Clause-②: no (narrowing) The record validator's `time` arm now judges a written value by `@objectstack/core`'s one temporal rule, `isUninterpretableTemporalComparand('time', value)`, the rule the `time` comparand door asks since PR objectstack-ai#20668. That is how objectstack-ai#20525 moved the `date` / `datetime` arm. A `time` field is a zone-less wall clock (triage 5895825766): a time of day with a `Z` or an offset is refused with `VALIDATION_FAILED` / 400, field code `invalid_time`, and a sentence that says what to do. An extended-year instant is refused too. Nothing reaches a driver, so it is never a 500. The unanchored `hasDate` test is gone. Base `fa0a4b661` (this branch's merge base). Head `9b426f8ab`. ## Reproduced first, then after `POST /api/v1/data/:object` then a read-back through `POST /api/v1/data/:object/query`. The process ran in `TZ=America/New_York`. PostgreSQL 16.13 was a private server at `Asia/Shanghai`. Memory is `RestServer` over `InMemoryDriver`, from a scratch probe that was not committed. The card's table reproduced on every cell. | written to a `time` | memory, base | SQLite, base | PostgreSQL, base | head, all three | |:--|:--|:--|:--|:--| | `"+010000-01-01T10:00:00Z"` (the card) | 201, read back verbatim | 201, verbatim | 500 `DATABASE_ERROR` | 400 `invalid_time` | | `"9999-12-31T23:00:00-02:00"` (UTC year 10000) | 201, verbatim | 201, verbatim | 500 | 400 `invalid_time` | | `"10:00Z"` (the card) | 201, `"10:00Z"` | 201, `"10:00Z"` | 201, `"10:00:00"` | 400, the zone sentence | | `"10:00+08:00"`, `"10:00:00+0800"` | 201, verbatim | 201, verbatim | 201, `"10:00:00"` | 400, the zone sentence | | `"10:00:00.250Z"` | 201, verbatim | 201, verbatim | 201, `"10:00:00.250"` | 400, the zone sentence | | `"2026-07-15 10:00Z"` (a space and a zone) | 201, `"10:00:00"` | the same | the same | 400 `invalid_time` | | `"10:00"`, `"10:00:00"` (the controls) | 201, `"10:00:00"` | the same | the same | unchanged | | `"10:00:00.250"` | 201, `"10:00:00.250"` | the same | the same | unchanged | | `"2026-07-15T10:00:00Z"`, `"2026-07-15T18:00:00+08:00"`, `"2026-07-15 10:00"` | 201, `"10:00:00"` | the same | the same | unchanged | | `"07/15/2026 10:00"`, `"x2026-07-15T10:00:00Z"`, `"{now}"`, the number `36000000` | 400 `invalid_time` | the same | the same | unchanged | | `" "` (blank) | 201, `null` | the same | the same | unchanged | The zone sentence, in English: "Slot is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant". Every other refusal keeps the existing "must be a valid time (HH:MM or HH:MM:SS)". ## The change - `packages/objectql/src/validation/record-validator.ts`, the `time` arm: - the verdict is `readable && !isUninterpretableTemporalComparand(t, value)`, the `date` / `datetime` arm's line; - `readable` holds the write door to what the comparand door exempts on purpose. A number stays refused as a written `time` (a comparand may be epoch milliseconds), and a `{placeholder}` stays refused (it is filter vocabulary, judged by `classifyFilterToken` from `@objectstack/spec/data`). A blank is missing before the arm, as before; - the private `timeOfDay` / `hasDate` patterns are deleted; - a private `isZonedTimeOfDay` chooses the sentence, never the verdict: a time of day plus `Z` / `z` / an offset whose wall-clock half core's rule reads. So `"25:00Z"` gets the plain sentence. - `packages/spec/src/system/validation-message.ts`: one message key, `invalid_time_zoned`, in `en` / `zh-CN` / `ja-JP` / `es-ES`. It is a rendering variant of the existing wire code `invalid_time`, which does not change. See the scope section for why it is here. - `content/docs/protocol/objectql/types.mdx`: the `time` input sentence said "with an optional fractional part and `Z`/offset". It now says no zone, and that an epoch number is refused. The number was already refused at base: `36000000` answered 400 on all three. ## PM hypotheses, which held - **H1 held.** At `fa0a4b661` core's predicate refuses `"10:00Z"`, `"10:00+08:00"`, `"+010000-01-01T10:00:00Z"` and `"9999-12-31T23:00:00-02:00"`, measured on core's `dist`. The arm asks it. The one addition is the write door's type gate above. The predicate answers `false` for a number, a `{placeholder}` and a blank, which are comparand exemptions, and the old arm refused the first two as written values. - **H2 held.** A full ISO instant with a four-digit year is admitted and stores its UTC time of day (ADR-0053 D-C1: "A `Date` / epoch-ms / full-timestamp value folds to its UTC time-of-day"). It is pinned as a control on the engine, on REST over SQLite and PostgreSQL, and on the memory driver. No `needs_decision` is raised on it. - **H3 held.** The wire code stays `invalid_time`. `fail(code, constraint, messageKey)` goes to `buildFieldError`, then to `renderValidationMessage(messageKey)`, and that reads `BUILTIN_VALIDATION_MESSAGES` in `packages/spec`. So the prescription has to live there. Details are in the scope section. - **H4: not a clean reuse. The seat answered it in-seat as A (5899587971, by ADR-0104 D1): a row already stored with a zone-suffixed `time` keeps its value, with no `value-shapes` report, as PRs objectstack-ai#20524 / objectstack-ai#20547 did for `date` / `datetime`.** Measured: - (a) `valueShapeViolation` has one caller, the scan (`scan-value-shapes.ts:155`). The write path does not call it. Its sibling `isScannableValueShapeField` IS on the write path: `ObjectQL.objectHasCoveredValueField` decides from it whether an object reads the `adr-0104-value-shapes` flag and passes `valueShapeStrict` to the validator. Adding `time` there changes no `time` verdict, because the arm reads no strictness flag. It does make every object whose only covered field is a `time` read the flag, and it makes the boot line announce a warn mode that does not govern `time`. - (b) ADR-0104 D1 defines what a passed flag means: "no stored value of the covered classes fails `valueSchemaFor(field, 'stored')`", and "the covered classes are exactly the validator's own non-media branch — `REFERENCE_VALUE_TYPES` … and `STRUCTURED_JSON_TYPES`". Covering `time` changes that fact. Every deployment that already holds the flag, including every fresh datastore that attests it at creation, would never re-run the scan, so its rows would not be reported. The findings would also block a gate whose strictness the `time` arm never reads. And the spec's `valueSchemaFor(time)` itself admits `"10:00Z"` (measured `true`), so the scan could not reuse its own predicate for this. - Nothing is rewritten, as triage requires. The options and the four-axis analysis are in the `os-dev-report` on objectstack-ai#20671. - **H5 held.** No driver changes. A refused value never reaches a driver: the recording-driver pin shows zero writes, and the REST pin counts zero writes. ## Scope: two `packages/spec` edits, one kept and one reverted The claim's file surface did not name `packages/spec`. Both edits are explained here, as the claim asks for a breach. **Kept: `packages/spec/src/system/validation-message.ts`, the `invalid_time_zoned` key.** The card needs it. Triage rules that a suffix "is refused with a prescription: drop the suffix, or use a `datetime` field for an instant". A refusal's sentence can only come from that catalog. Measured on spec's `dist`: `renderValidationMessage({ messageKey: 'invalid_time_zoned_absent_probe', label: 'Slot' })` renders `"Slot (invalid_time_zoned_absent_probe)"`, the resolution order's step 4, a coding-error fallback. With the key it renders the sentence above, and in zh-CN it renders "时段是不带时区的时刻:…". The spec test "every locale defines every message key" makes all four locales required, and it passes: `en` / `zh-CN` / `ja-JP` / `es-ES` each have 38 keys. The key does not widen a published type or export: - the declared type of `BUILTIN_VALIDATION_MESSAGES` does not change: a record of locale to a record of message key to template; - no export is added: `check:api-surface` answers "@objectstack/spec public API surface + factory signatures unchanged ✓"; - `FieldErrorCode` does not change; - `check-widening-tells --declaration no` judged `validation-message.ts` against its declared surface and found no widening tell. What a deployment gains is one more translation key it may override, `validation.field.invalid_time_zoned`. `@objectstack/spec` publishes `dist` (`files[]`), and the key ships in 4 `dist` files, next to `invalid_datetime` as a positive control. So the changeset lists `@objectstack/spec: patch`. **Reverted: `ClockTimeValueSchema` in `packages/spec/src/data/field-value.zod.ts`.** Commit `691bfabd6` narrowed it to refuse a zone, and `b5d95181d` reverts it with a normal revert commit. The arm stands without it. With the spec schema left wider, at `9b426f8ab`: - spec: 575 files, 16960 tests; - objectql: 336 files, 6679 tests; - rest, with live PostgreSQL: 228 files, 4420 passed / 22 skipped; - driver-memory: 63 files, 1451 tests; - runtime `action-params-enforcement.test.ts`: 5 / 5; - dogfood `field-zoo-value-shape.test.ts`: 45 / 45. All passed. No parity pin or gate reds on the difference. What the wider schema leaves open is reported to the seat as a finding rather than fixed here: - `FieldSchema` accepts `Field.time` with `defaultValue: '10:00Z'`; - with this PR, each `engine.insert` that falls back to that default is refused, 400 `invalid_time`, on a field the caller never sent (measured on `a596fad76`); - the action-param door (`validateActionParams`, strict under ADR-0104 D2) still admits `'10:00Z'` for a `time` param (measured `[]`). The readers of `ClockTimeValueSchema` are all through `valueSchemaFor`: `checkLiteralDefaultValue` (the `FieldSchema.defaultValue` gate and the action-param `defaultValue` gate), `validateActionParams` (runtime `action-execution.ts:1376`), and `import-mapping-target.ts`. The last reads only object-shaped schemas, so `time` never reaches it. The objectql scan's `shapeSchemaFor` never sees `time`. Metadata shipped in this repo authors no zoned `time` value: - 0 zoned time-of-day literals in `examples`, `packages/platform-objects`, `packages/create-objectstack` and `skills`; - positive control: 6 plain wall-clock literals in `examples`; - the population is 4 `time` field declarations in `examples` and 1 in `skills`, and none carries a `defaultValue`. The commit `691bfabd6` stays on this branch as a ready reference for the spec seat, with its pins. ## Tests - `packages/objectql/src/engine-time-write-zone-less.test.ts` (new, 5 tests, recording driver). - 9 zoned, 7 unread-instant and 9 already-refused values, each on insert, update and a multi-row update, and through `engine.validate`. Each asserts `code` `VALIDATION_FAILED`, `fields` exactly `slot` / `invalid_time`, and zero driver writes. - The sentence is asserted by the catalog key the refusal renders: the zone key for the 9, the plain key for the rest. The words themselves are not pinned. - The positive control has 12 values, a `Date` among them, and each reaches the driver as written. - A one-rule corpus pin: a string is refused as a written `time` exactly when core refuses it as a `time` comparand, except `{now}`. The number is asserted as the other write-only refusal. - `packages/objectql/src/validation/record-validator.test.ts`, one pin flipped. `'14:30:00Z'` and `'08:15:00+02:00'` were pinned as accepted; they are now refused with `invalid_time` and the zone sentence. That keeps a load-bearing assertion of the new rule. - `packages/rest/src/data-temporal-write-real-day-iso.test.ts`, a new `it` on the SQLite cell and the live PostgreSQL cell. - The card's values are 400 on create and on PATCH, with no write. - `"10:00"`, `"10:00:00"`, `"10:00:00.250"` and the two full instants read back identically. - `packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts` (new, 2 tests). Under `America/New_York`, each spelling the door admits is stored as its wall clock and found by it. **Reverse verification.** The fix was committed first. `scripts/ablation-replace.mjs` replaced the arm's `readable` line with one that admits every string and `Date`. The anchor went 1 → 0 and the blob `eb565fe32fe5` → `2b0d369b76c9`. objectql was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - objectql, the 2 files: 11 failed / 106 passed. The new file's 4 refusal tests went red and its positive control stayed green. The flipped pins went red too. - REST: 2 failed / 10 passed. The `[objectstack-ai#20671]` `it` went red on SQLite and on live PostgreSQL, and every other `it` stayed green. - Restore leg: blob == HEAD and `git diff HEAD` is empty. After a rebuild, the preflight found the marker absent from all 14 built files and the tree clean. objectql went 117 / 117 and REST 12 / 12, both `[objectstack-ai#20671]` cells included. ## Verification at `9b426f8ab` - The suite counts in the scope section above. - `typecheck` exit 0 for spec, objectql, rest and driver-memory. - The test-typecheck ledgers held: spec 53 files / 251 errors, objectql 40 / 234, rest 0. - `--listFiles` lists the new objectql test and the REST file. driver-memory's `tsconfig.json` includes `src/**/*`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands. 107 exited 0. - `check:skill-examples` first exited 3 because the client packages had no `dist`. It was re-run, exit 0, after building them. - `--ran` reads "110 derived famil(ies) accounted for — 107 run, 3 NOT-MEASURED". - `check:api-surface` answered "unchanged ✓", and `check:docs` "226 generated files in sync". - `check:nul-bytes` scanned 9333 files and found no raw control bytes. `check:driver-conformance` reads 50 covered cells, 0 DEBT. - `check-adr-0087-registration` reads the changeset as "BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", exit 0. `check-changeset-no-major` and `check-empty-changeset` exited 0. - Lint, narrowed and declared (the repo-wide `pnpm lint` is CI's). `eslint --no-inline-config --format json` over the 6 changed `.ts` files: 6 files, 0 errors, 0 warnings. - Population: `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `packages/**` objects cover all 6. - Invariance: `--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. **NOT MEASURED:** - `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3, PREREQUISITE NOT MET: no whole-workspace `dist`. The container restarted twice during this run, so a whole-workspace build was not attempted. - Scoped reading: the CJS entries load: `@objectstack/objectql` `.` has 178 exports and `./core` 52, `@objectstack/spec/system` 400 and `@objectstack/spec/data` 528. - The four changed packages typecheck, as above. - `check:query-options-erasure`: it was killed with the container (exit 137) after its self-test passed, and it was not re-run. CI's `Lint & Repo Gates` runs it. - MySQL, turso and MongoDB: not provisioned. The refusal sits in the engine, in front of every driver. ## Acceptance notes (not filed) - **`/import`**: measured after the change on all three backends. - `10:00Z` and `10:00+08:00` time cells are per-row refusals, from the import's own reader, before this door. They were refused there before this PR too: `parseDateCell` runs first and never hands this arm a suffix. - An offset-bearing instant cell `9999-12-31T23:00:00-02:00` is stored as `01:00:00`, its UTC clock, while the write door refuses the same string. The import converts before the door, both answers can be defended, and this PR leaves it as it was. - An Invalid `Date` is still admitted by all three temporal arms, as before. Only an engine caller can send one (JSON cannot carry one), so no public door reaches it. - The changeset's "Who is affected" states the narrowing, including a zone-suffixed literal `defaultValue` on a `time` field. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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