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