Repository navigation
fix(core,objectql)!: a temporal comparand is refused exactly when the write door refuses it — a real calendar day, an ISO datetime spelling, and a time instant with a four-digit UTC year - #20668
Conversation
…te door refuses — a real calendar day, and an ISO spelling for a datetime The record validator's two private predicates, `namesRealCalendarDay` and `ISO_DATETIME_WRITE_FORM`, move into `@objectstack/core`'s `temporal-comparand.ts` beside `readsAsCalendarDay`, as a move with no second copy. `isUninterpretableTemporalComparand` now reads a `date` string only on a real leading day, and a `datetime` string only in an ISO 8601 spelling on a real day. The record validator's `date` / `datetime` arm asks that one rule (with `Date.parse` readability kept on top, which also keeps a number refused as a written value), so a string is refused as a comparand exactly when it is refused as a written value. The engine's temporal-comparand door (`where`, a per-aggregation `filter`, `having`) and the analytics raw-SQL decline read the predicate, so each now refuses `"2026-02-30T10:00:00Z"`, `"07/15/2026 10:00"`, `"2026/07/15 10:00"`, `"2026-02-30"` on a date, and a bare integer string, with INVALID_FILTER / 400 naming the field, before any driver read. The door's refusal says why in the class's own words: a misread value answers the wrong rows, it does not compare false for every row. Epoch milliseconds stay a comparand as a number. Measured before, TZ=America/New_York, engine.find over InMemoryDriver, SqlDriver on SQLite, and SqlDriver on PostgreSQL 16 at Asia/Shanghai: the datetime rows matched the rolled-over / host-zone row on all three, and the date row answered 200 [] on memory and SQLite and 500 on PostgreSQL. After: 400 INVALID_FILTER on all three; the 2028-02-29 leap controls and the ISO controls answer the same rows as before. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
… UTC year is refused, in every spelling
A `time` column reads a comparand that is not a bare wall clock as an
instant, by the `datetime` rule, and keeps its UTC time of day only when
that instant spells a four-digit year. Any other it handed back as
written, so the driver compared `+010000-01-01T10:00:00Z` (or its number,
or `9999-12-31T23:00:00-02:00`, year 10000 in UTC) with stored `HH:MM:SS`
text. `isUninterpretableTemporalComparand('time', …)` now asks the
storage rule itself whether it keeps a time of day, for an instant string
the `datetime` reading admits and for a finite number or valid `Date`.
The temporal-comparand door refuses such a comparand with INVALID_FILTER /
400 at `where`, a per-aggregation `filter` and `having`, in the class's
own words. No time of day is read from an extended year. Year 0 spells
four digits and keeps its reading; NaN, Infinity and an Invalid Date stay
unjudged.
The first commit already refused the card's own spelling as a side
effect (it is not one of the ISO forms); this commit closes the class:
the four-digit spelling whose UTC year is 10000, and the number and
`Date` spellings, which the `time` arm never judged.
Measured before, TZ=America/New_York, rows 09:00 / 10:30 / 12:00:
`$gt "+010000-01-01T10:00:00Z"` answered 3 of 3 on memory and SQLite
and 500 on PostgreSQL 16; the number of that instant answered 0 on
memory, 3 on SQLite and 500 on PostgreSQL. After: 400 INVALID_FILTER on
all three. The 2026 control (`$gt` / `$lt "2026-07-15T10:00:00Z"`)
answers 2 / 1 on all three, before and after.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
📓 Docs Drift Check16 anchor(s) derived from 2 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 — 33 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 81a8ee3b4d4aabc72b3eb6cbd00e95f73fc95af5 && git checkout 81a8ee3b4d4aabc72b3eb6cbd00e95f73fc95af5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6bff748bbd484f00906c8d9a58f5a3621a20c2e6 0adb1bf84e39d365124b3ac710470808ee9ce4e7 && git checkout -B drift-repro 6bff748bbd484f00906c8d9a58f5a3621a20c2e6 && git merge --no-ff 0adb1bf84e39d365124b3ac710470808ee9ce4e7
node scripts/docs-audit/affected-docs.mjs --json 6bff748bbd484f00906c8d9a58f5a3621a20c2e6 |
Contract reviewServed-tier: Inputs read: cards #20549 and #20480 (bodies and every comment, the triage rulings, the claims, the os-dev-report and the seat answer 5894035143 included); PR #20668's body, its 15-file list and the net diff of ① Derived judgmentsAccept-set changes the diff implies — each judged right.
Compile faces — one conclusion each, and each checked. All eight faces the dispatch named carry a conclusion in the PR body.
Producer sweep. I searched non-test source on Nits, not failing. The changeset's "Who is affected" paragraph says "a JS ② Semver level
③ Boundary flags
Check-runs on the head, read at 2026-09-29T16:20Z, not waited on: 31 runs — 13 Implemented-by: VERDICT: PASS Generated by Claude Code |
…rc to the commits that decided them (objectstack-ai#20673) Part of objectstack-ai#20594 Clause-②: no ## What changed This is stage 4 of the `domain:cli` lane of the dead-citation sweep: `packages/types/src/**`. Every comment or docblock site in scope that cited a tracker number answering 404 now cites, in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), the commit in this repository's history that decided what the line describes, and says in its own words what that commit decided. PR objectstack-ai#20533 is the method; PR objectstack-ai#20624 (`runtime`), PR objectstack-ai#20632 (`rest`) and PR objectstack-ai#20656 (`cli`) are the landed stages this follows. The card stays open for the form-D stage and the rest of the lane, so this PR says `Part of`. That is **83 comment sites on 83 lines in 17 files, covering 12 numbers**: the census's 52 (all of them) and 31 more in test comments, which the census defers. Each rewritten line cites one of **12 distinct commits**. No ADR or ruling-record file records any of these twelve decisions, so every anchor is a commit. Only comments changed. Every touched file keeps its line count (94 lines out, 94 in, over 17 files), so no line citation into these files moves. Eleven of the 94 lines held no dead site; each is the other half of a sentence that had to change: `thrown-http-error.ts:316-319`, `node.ts:1103`, `:1429`, `:1447`, `:1452`, `:1476`, and `node.test.ts:449`, `:2420` (see "Wordings to check"). **No citation number is added.** Over the 94 line pairs, every tracker number on an added line was already on the line it replaces (per-pair check: 0 added), and no PR number stands on an added line. No code token moves (see the guard below). No site was left: no dead comment site in scope lacked a deciding commit, and no open PR touches `packages/types/src`. One file outside `packages/types/src`: a `patch` changeset for `@objectstack/types`, in PR objectstack-ai#20632's form and level. ## Census: `packages/types`, before and after **Instrument.** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged, run with the fleet token. Its surface is comment prose in `packages/**/src/**/*.ts` with string literals blanked, and it defers `*.test.ts`. The count is its `allocated-but-absent` findings under `packages/types/`. Both runs enumerated the whole board (185 pages), so neither read a truncated board. | reading | tree | board | whole-repo `allocated-but-absent` | types sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `6bff748bbd`, run 2026-09-29T15:31:59Z to 15:41:36Z | enumerated, 185 pages, frontier objectstack-ai#20663, 18,490 numbers | 1,510 | **52** | 52 | 7 | 11 | | after | head `686a4c60cb`, run 2026-09-29T16:04:17Z to 16:12:39Z | enumerated, 185 pages, frontier objectstack-ai#20671, 18,498 numbers | 1,458 | **0** | 0 | 0 | 0 | The before count equals the card's 52 at `f11b5f20a2`: no drift. The whole-repo drop is 52, exactly this diff's 52 sites, and the whole-repo resolving count rises by one (32,882 to 32,883): the live objectstack-ai#12751 that `index.ts:4` now spells so the grammar reads it. Both runs read this worktree, the base and then the base plus this one commit, so no other change entered either count. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `classifyCitation` over every `.ts` file under `packages/types/src` (42 files), against a board from the gate's own `probeBoard`. The lit controls objectstack-ai#20594, objectstack-ai#19123 and objectstack-ai#20656 answered 200 and are on both boards; the dead controls objectstack-ai#11671, objectstack-ai#10514 and objectstack-ai#14828 answered 404 and are on neither. | reading | tree | board | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---|---|---| | before, 15:34Z | `6bff748bbd` | probed, frontier objectstack-ai#20661 | 622 | **101** | 52 | 31 | 1 | 17 | | after, 16:04Z | `686a4c60cb` | probed, frontier objectstack-ai#20668 | 540 | **18** | 0 | 0 | 1 | 17 | Its src-comment column equals the census's 52, site for site (the two site lists are identical), which is the control on the second instrument. The drop of 82 citations is the 83 dead sites removed plus one live number the grammar now reads: `index.ts:4` spelled `[objectstack-ai#11343/objectstack-ai#12751]`, whose second half the grammar skips after a slash, and now reads `[commit c0714eb / objectstack-ai#12751]` like its module doc, so the live objectstack-ai#12751 is judged (resolving src comments 273 to 274). Resolving pull requests (20), cross-repo citations (14) and the other resolving counts are unchanged. A separate scan for slash-joined pairs in `packages/types/src` found six (`objectstack-ai#11343/objectstack-ai#12751`, `objectstack-ai#3878/objectstack-ai#3899`, `objectstack-ai#7525/objectstack-ai#8016`, `objectstack-ai#4728/objectstack-ai#4825`, `objectstack-ai#8621/objectstack-ai#8622`, `objectstack-ai#5352/objectstack-ai#5367`); every second half answers 200, so no dead number hid behind a slash here. ## Per-number table Sites and files are the dead comment sites in scope at the base, test sites counted in brackets. `strings kept` counts string-literal sites, which are tokens and stay as they were. Every anchor was read in its message or its diff, not only its subject: it is the commit that made the change the line describes. | number | comment sites / files | rewritten | strings kept | anchor | |---|---|---|---|---| | `objectstack-ai#8824` | 1/1 (1 test) | 1 | 0 | `8ac232306` | | `objectstack-ai#9934` | 5/4 (2 test) | 5 | 2 | `79c46da90` | | `objectstack-ai#10943` | 10/2 (4 test) | 10 | 2 | `46d34ab7c` | | `objectstack-ai#10944` | 1/1 | 1 | 0 | `e598b1cbc` | | `objectstack-ai#11343` | 3/3 (1 test) | 3 | 1 | `c0714eb5d` | | `objectstack-ai#12281` | 1/1 | 1 | 0 | `0783d7b80` | | `objectstack-ai#13197` | 5/2 (2 test) | 5 | 2 | `56c093c4d` | | `objectstack-ai#13279` | 8/5 (2 test) | 8 | 0 | `6a180e42d` | | `objectstack-ai#13324` | 15/3 (7 test) | 15 | 5 | `4cda78c9b` | | `objectstack-ai#15044` | 8/2 (3 test) | 8 | 1 | `088f761e5` | | `objectstack-ai#15045` | 21/2 (8 test) | 21 | 1 | `288fe9c34` | | `objectstack-ai#16657` | 5/2 (1 test) | 5 | 4 | `5a95b0e93` | | **total** | **83** | **83** | **18** | **12 distinct commits** | Every cited sha matches exactly one object (`git rev-parse --disambiguate`, count 1 for each of the 12), is a commit, has one parent, and is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 12). The checkout is not shallow (`--is-shallow-repository` false); the control leg `f5a9bc2f3` (2026-08-10, older than the oldest anchor, `8ac232306` of 2026-08-15) exits 0 and the negative control (this branch's own `686a4c60cb`, not on `main`) exits 1. **Anchors reused from earlier stages**, so each number carries one anchor across the tree: `79c46da90` for objectstack-ai#9934 (stages 1 and 2, the spec lane), `46d34ab7c` for objectstack-ai#10943, `e598b1cbc` for objectstack-ai#10944 and `288fe9c34` for objectstack-ai#15045 (stage 3), `0783d7b80` for objectstack-ai#12281 (stage 1), `56c093c4d` for objectstack-ai#13197 (stage 2, the spec lane), `6a180e42d` for objectstack-ai#13279 (stages 1 and 2, `plugin-sharing`) and `c0714eb5d` for objectstack-ai#11343 (`plugin-auth`). **New anchors, and how each was found:** - `objectstack-ai#8824` → `8ac232306`: objectstack-ai#8824 is that commit's own PR number (its subject ends `(objectstack-ai#8824)`), so the sha is the object the number named. `error-leak.test.ts:180` read 「PR objectstack-ai#8824 corrected the」 and now reads 「Commit 8ac2323 corrected the」. - `objectstack-ai#13324` → `4cda78c9b`: its subject does not name the number, but its changeset heading does (「require a missing-table error to name the table that was READ (objectstack-ai#13324)」), and its diff adds `readObject` and every `[objectstack-ai#13324]` marker this module carries. It landed in `packages/metadata/src/utils/schema-sync-errors.ts`, the file `6a180e42d` then moved here (a rename at 86 percent similarity). - `objectstack-ai#15044` → `088f761e5`: 「Part of objectstack-ai#15044」, the only commit whose message names the number; it made the objectstack-ai#13330 succeeding leg recognise the package root by the name the declaration promises, and added the `BOUNDARY` pin at `node.test.ts:1863` that `:2175` and `:2419` point at. - `objectstack-ai#16657` → `5a95b0e93`: it added `operatorFacingErrorText`, `DECLARED_DATABASE_FAULT_CODE` and the raw-path fragment, and its message calls itself the fourth prose round on objectstack-ai#16657. ## Wordings to check - **A stale future tense, corrected by its anchor.** `thrown-http-error.ts:315-320` said objectstack-ai#12281 「is a separate card with its own measurement-first step, so nothing here applies it; this function is the shape it will read」. `a81aa9dd5` wrote that on 2026-08-29; `0783d7b80` landed the next day and its message says 「the door now reads `serverFaultProvenance`」. Citing the commit in the future tense would contradict itself, so the six lines now read 「Commit 0783d7b — the prose axis of the same 2026-08-27 ruling — reads the `'declared'` limb of this same function … It landed separately, after its own measurement-first step, so nothing here applies it; this function is the shape it reads rather than a second copy it would have had to grow.」 - **An open question named by a number that had already landed.** `node.ts:1447-1452` called where a relative specifier should resolve from 「an open policy question owned by objectstack-ai#10944」 and ended with 「Answering half of another card's undecided question」. `e598b1cbc` (objectstack-ai#10944's landing) had merged 40 minutes before `46d34ab7c` wrote those lines, and it refuses the relative spelling. The lines now read 「the policy question commit e598b1c settled for `serve` (it refuses a relative `plugins: [...]` entry rather than silently re-basing it)」 and 「Answering half of another change's question」. - **「the card」 once the antecedent became a commit.** `node.ts:1102-1103` 「objectstack-ai#15045 is the card about telling an operator which one was measured」 now reads 「commit 288fe9c is the change that tells an operator which one was measured」. `node.ts:1476` 「the second verification axis the card holds open」 now reads 「the second verification axis that commit left unbuilt」, which is what `288fe9c34`'s message says (「deliberately not built here」). `node.test.ts:449` 「The card's own 4-row matrix」 now reads 「The 4-row matrix behind that commit」. - **Headings that named a defect by its number now say so.** `node.test.ts:1566` reads 「Fixed by commit 088f761: the SUCCEEDING leg recognised the package by the DECLARATION KEY」 and `:1881` reads 「Reworded by commit 288fe9c: the location sub-case REFUSES correctly and EXPLAINED itself wrongly」 (`288fe9c34` changed the wording and kept the refusal). The dash-rule headings trim trailing dashes: `node.ts:1381`, `node.test.ts:1566`. - **A quoted triage.** `node.test.ts:2160` quoted 「objectstack-ai#15045's triage」; it now reads 「quoted from the triage commit 288fe9c landed」. That commit's changeset records the same decision in its own words: the key stays the expectation 「because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD」. `node.test.ts:2053` said objectstack-ai#15045 「asked for this sentence」; it now says `288fe9c34` 「wrote this sentence」, and that commit's own test comment says the card asked for it. - **A defect that proved a point.** `driver-error-classification.callers.test.ts:24-25` said the omission is the shape 「objectstack-ai#13324 existed to close」 and that prose 「is exactly what objectstack-ai#13324 proved insufficient」; it now reads 「the … shape commit 4cda78c closed」 and 「prose is exactly what that commit's defect proved insufficient」. - **「pre-#N」 spellings** (the card's control sites `driver-error-classification.ts:608` and `node.ts:1428`, plus `callers.test.ts:20` and `node.test.ts:594`) now say 「before commit X」. ## Mechanical guard: no code token moves The check compares the TypeScript parser's leaf tokens (TypeScript 6.0.3, JSDoc nodes excluded, comments being trivia) of each touched file at base `6bff748bbd` against the working tree at `686a4c60cb`, over all 17 touched files, and lists EVERY differing token, not only the first. Controls mutate the head text in memory only, so nothing on disk moved for them. - Real run: 31,911 base tokens, token counts equal in every file, **0 differing tokens** (exit 0). - Comment-insertion control (a new line comment in `node.ts`): 0 differing tokens (exit 0). - Code-insertion positive control (a declaration prepended to `node.ts`): the count differs and a difference appears at token 0 (exit 1). - String positive control (one character changed inside the kept `undeclaredMessage` literal at `node.ts:383`): exactly 1 differing token, a `StringLiteral` at token 672 (exit 1). So H2 holds by the token guard. The emitted `dist` is not byte-identical, because the docblocks ship, which is why the changeset is `patch`. Line balance: every touched file is +N/−N and every line count is equal at base and head (17 files). A raw scan of the 18 changed files for control bytes finds none (its positive control, a scratch file holding a U+0001 byte, matches). ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/types` is included, in PR objectstack-ai#20632's form and level: 「Comments only: no error code, refusal text, type, export or runtime behaviour changes.」 Measured on the built package: `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After the build, the rewritten docblocks reach `dist`: `0783d7b80`, `79c46da90`, `5a95b0e93` and `c0714eb5d` are in `dist/index.d.ts` and `index.d.mts`, `4cda78c9b` in all four `index` files, `6a180e42d` in `index.js` and `index.mjs`, and `46d34ab7c` and `288fe9c34` in `dist/node.d.ts` and `node.d.mts`. The positive control, the unchanged sentence 「sanitisation REGIME is the condition, not one of its two outcomes」 of the `0783d7b80` docblock, is in `dist/index.d.ts` beside it; a negative control phrase appears nowhere. Of the twelve dead numbers, only objectstack-ai#10943 remains in `dist`, twice, and both are the kept operator-facing string at `node.ts:383` (see Acceptance notes). ## Gates (head `686a4c60cb`) This host has no `flock`, so `os-verify-lock.sh` ran in its declared unlocked mode. Its disclosure, verbatim, from each locked run at this head: ```text os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter @objectstack/types exec vitest run --project repo --maxWorkers=2 os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 5s · declare it in the PR body · pnpm --filter @objectstack/types exec vitest run --project local --maxWorkers=2 os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter @objectstack/types typecheck os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 28s · declare it in the PR body · pnpm --filter '@objectstack/types...' build os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 46s · declare it in the PR body · pnpm lint os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 93s (1m33s) · declare it in the PR body · pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=4 ``` `origin/main` did not move after the branch was cut: `git merge origin/main` answered 「Already up to date」 at `6bff748bbd`, so the base is the merge base and nothing needed rebuilding. `origin/main` has since moved to `6c11ef9ecb` (PR objectstack-ai#20663: two pages under `content/docs/automation`, read 16:13Z). It touches nothing this diff or its gates read, so the branch was not merged again and every reading here stays at `686a4c60cb`. - **Build:** the dependency closure (`@objectstack/types...`: `spec` then `types`) and then the whole workspace (71 tasks, 71 successful). `check-dts-emitted` finds 2 of 2 declared declaration files. The build left the tree clean. - **Tests:** `--project local`: 22 files, 685 tests pass. `--project repo`: 1 file (`driver-error-classification.callers.test.ts`, touched here), 7 tests pass. 22 + 1 is all 23 test files in the package, so every touched test file ran. - **Typecheck:** `pnpm --filter @objectstack/types typecheck` exits 0. `tsc --listFiles` counts 42 `src` files under `tsconfig.json`, all 23 test files among them, so every touched test file is type-checked. - **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`) exits 0 at `686a4c60cb` (2026-09-29T16:01:23Z to 16:02:09Z). Not narrowed. - **Citation judging:** `node scripts/check-issue-citations.mjs --base origin/main` exits 0: 5 citations judged across 7 files (4 resolve, 1 cross-repo). These are the live numbers that stay on rewritten non-test lines. It defers `*.test.ts`, so the per-pair count over the whole diff covers the rest: 0 numbers added. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `686a4c60cb` derived 61 families. All 61 ran and exit 0, and `--ran` over a record carrying each exit code reads 「61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero). Among them: `check:doc-authoring`, `check:nul-bytes`, `check:issue-citations` (self-test), `check:published-files`, `check:dts-closure`, `check:dual-build-cjs-loads`, `check:type-check-coverage` and `check:type-check-debt`. - **Artifact rosters:** all 36 non-self-test roster rows that run without a pull request exit 0 at `686a4c60cb`, the four whose rosters share a directory with this diff among them (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). The other three, `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`, need a pull request's context; they are run against this PR once it exists and reported on the card. The 18 checker-health-only rows were not run. ## Hypotheses (measured first) - **H0 holds.** The filtered census answers 52 dead sites at `6bff748bbd` (52 lines, 7 files, 11 numbers), equal to the card's count at `f11b5f20a2`: no drift. - **H1 holds, with no exceptions.** After the rewrite the filtered census answers 0 dead sites for `packages/types/`. No site is left for an open PR or an unfound anchor: the claim's read and this stage's read of the open PRs' file lists (15:40:08Z, 7 open PRs) found none touching `packages/types/src` (the Version Packages PR touches only `packages/types/CHANGELOG.md` and `package.json`). A second read before this PR was opened (16:13:18Z, 11 open PRs) found the same. - **H2 holds, by the token guard** above: 0 differing parser leaf tokens over the 17 touched files, with the comment control at 0 and the code and string controls each turning red. ## Acceptance notes - **Form D, not touched here.** 18 dead numbers stand inside string literals: 17 in test titles and test-code strings (8 files, 8 numbers), and one an operator reads. That one is the `undeclaredMessage` note at `node.ts:383`, 「a caller that needs its own resolution passes `{ fallbackImport: (s) => import(s) }`, objectstack-ai#10943)」, printed when the host importer's undeclared fallback fails without a caller base. It is also the only dead number left in `dist`. The comments around it (`node.ts:368`, `:1381`, `:1428`) now cite `46d34ab7c`. Ruling D (no number, the lesson in words) is a string change outside this comment-only stage; the card already carries a form-D stage for the lane (ACCEPT 5888034755), and this string is its author-shown first in `packages/types`. A second one is a remedy an author reads: the `REMEDY` text at `callers.test.ts:281`, which that gate test prints for any call site that omits `readObject` (「Without it the predicate returns the pre-objectstack-ai#13324 WIDE verdict」). - **Outside the scope and the census surface.** `packages/types` outside `src/**` holds one dead citation: `vitest.config.ts:25` cites objectstack-ai#17853 (404), the same number PR objectstack-ai#20624 and PR objectstack-ai#20632 reported in their packages' `vitest.config.ts`. The six other citations outside `src/**` (`CHANGELOG.md` excluded) resolve. It stays for a later stage of this card. ## Deviations - Eleven lines beyond the dead sites are the other half of a rewritten sentence (listed under What changed), and the six lines at `thrown-http-error.ts:315-320` move from the future tense to the present, because the claim they carried stopped being true when `0783d7b80` landed (see Wordings to check). - The anchors were researched in this session, not delegated; every one was checked against its commit's message or diff. - Commit trailers are AGENTS.md's model-free pair (`Claude-Session` plus `Co-authored-by: Claude`), and the pre-push trailer check passed on every push; the harness's attribution reminder asked for a model-named trailer and a different PR footer, which AGENTS.md overrides. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> 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 #20549
Fixes #20480
Clause-②: no (narrowing)
A family PR: two cards, one branch, one changeset, one commit per card.
70b98719cis temporal comparand door admits what the write door now refuses: an impossible day (2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549. The temporal comparand door now refuses what the write door refuses: a calendar day that does not exist, and adatetimestring outside the ISO 8601 spellings the platform writes.0adb1bf84is 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. Atimecomparand whose instant has no four-digit UTC year is refused, in every spelling.Measured head:
0adb1bf84, rebased ontoorigin/main19fc8d6f1. The two commits sincemaintouch no file thatmainmoved.The change
@objectstack/core—packages/core/src/utils/temporal-comparand.tsMoved, not copied.
ISO_DATETIME_WRITE_FORMandnamesRealCalendarDayleaverecord-validator.tsand sit besidereadsAsCalendarDay. Both stay module-private, so there is no new root export.readsAsCalendarDay(thedatereading) now also requires the leading day to exist.readsAsInstant(thedatetimereading, which atimecolumn also uses for an instant) now requires three things:Date.parsereads.The bare-integer-string arm is gone (see H3). Epoch milliseconds as a NUMBER are untouched.
(core temporal rule: a
timecomparand 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) There is a new private helper,keepsTimeOfDay(value). It askstemporalStorageForm(value, 'time')itself whether the rule keeps anHH:MM:SStime of day. Thetimearm now refuses an instant string the rule hands back unchanged. So does a finite number or a validDate, which thetimearm never judged before.0000-…, so it is still read.@objectstack/objectqlvalidation/record-validator.ts(the write door). Thedate/datetimearm is nowreadable && !isUninterpretableTemporalComparand(t, value).readablestays on top. It is the one place the two doors differ, on purpose: a NUMBER is refused as a written value but read as a comparand.timearm is not in the diff.temporal-comparand-door.ts. The verdict is unchanged: core's predicate,INVALID_FILTER/ 400, naming the field, before any read. The refusal text changes only as far as the new classes need, because "compare false for EVERY row" is untrue for them:whose calendar day does not exist;not one of the ISO 8601 spellings;timecomparand 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)an instant whose UTC year falls outside the years 0001 to 9999, so no time of day is read from it.Each has a
whereand ahavingsentence. The remedy now says "on a calendar day that exists" and "epoch milliseconds as a number". The junk class and the year class keep their exact words.Measured, before and after
Through
engine.findover InMemoryDriver, SqlDriver on SQLite, and SqlDriver on a live PostgreSQL 16.13. The process ran underTZ=America/New_York, and the server atAsia/Shanghai. Base iscd901d7a5; after is0adb1bf84. Rows arer12026-03-02T10:00Z / 09:00,r22026-07-15T14:00Z / 10:30, andr32028-02-29T10:00Z / 12:00.$eq "2026-02-30T10:00:00Z"[r1](rolled over) /[r1]/[r1]INVALID_FILTER$eq "07/15/2026 10:00","2026/07/15 10:00"[r2](process zone) /[r2]/[r2]$eq "2026-02-30"[]/[]/ 500DATABASE_ERROR$gt "+010000-01-01T10:00:00Z"(the #20480 card)$ltthe same[]/[]/ 500$gt "9999-12-31T23:00:00-02:00"(UTC year 10000)[]/[]/ 500$gtthe number orDateof+010000-01-01T10:00Z[]/ 3 of 3 / 500$gt "07/15/2026 10:00"[](14:00 UTC, the process zone)$gt "2026"$gt 1769940000000(a number)2028-02-29on date and datetime; ISOZ,+08:00and zone-naive"2026-07-15 14:00"; time$gt/$lt "2026-07-15T10:00:00Z"; time$gt "10:00"[r3],[r3],[r2]×3,[r2,r3]/[r1],[r2,r3]After commit 1 alone, the #20480 card's literal row already read 400 on all three, because it is not an ISO spelling. The UTC-year-10000 ISO spelling and the number and
Datespellings still answered as at base. The second commit closes those (see H1).PM hypotheses — which held
readsAsInstantto the ISO form refuses the card's+010000-…spelling as a side effect (measured after commit 1). It does not close the class:9999-12-31T23:00:00-02:00is an ISO spelling whose UTC year is 10000, and the number andDatenever reached the string arm. Sotimeneeds its own arm, which is commit 2.export *fromtemporal-comparand.tsexposes exactly the three symbols it did before.Clause-②staysno (narrowing).readable) and read as a comparand. This predates this PR, is scoped by the An unparseable date comparand on a datetime filter is passed through and compares false — HTTP 200, zero rows, no diagnostic — while an unknown{placeholder}is correctly rejected 400 (17.0.0 GA) #8690 ruling (strings only), and is now asserted inengine-temporal-comparand-door.test.ts."2026"as two seconds after 1970, and matched every row on all three backends. Zone 1's direction ("the comparand door refuses what the write door refuses") and the claim's "datetime: only the ISO 8601 spelling the write door admits" therefore cover it, and it is refused. The two pins are flipped, with load-bearing assertions (below). Because earlier cards pinned it, this is raised in the report as an open question rather than decided silently.datestring with a real leading day and textDate.parsecannot read after it (2026-07-15T25:00) is refused by the write door. It is read by the comparand door as its day, the 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 shape. This predates this PR and is outside both cards' classes, so it is left as is (Acceptance notes).service-analytics/src/comparand-shape.tsis not edited. ItsjudgeTemporalLiteralscalls core's predicate, so the raw-SQL decline moves with it. The whole suite is green on0adb1bf84: 135 files, 3171 tests, underTZ=America/New_York. No pin flipped.namesRealCalendarDaystays private, so there is still nothing forpackages/rest/src/import-coerce.tsto read in place of its own copy.Compile faces — one conclusion each
The door is the engine's single filter collection point, in front of every driver. The pins show zero driver reads on every refusal: a recording driver in objectql, and the REST door over SQLite and PostgreSQL.
driver-sqlapplyFilterCondition, withdriver-sqlite-wasmand turso LOCAL: already compliant, behind the door. Measured: the refusal happens before any read on SQLite and PostgreSQL. The driver's deliberate pass-through (temporalFilterValue('t','at','not-a-date')) is unchanged.RemoteTransport.buildWhereSQL: already compliant, behind the same door. Not measured, because no turso server was available.compileScopedFilterToSql(RLS read side): explicitly out of scope. It compiles the platform's injected RLS predicate, which the door never judges by design (the door's docblock: "an injected read filter is the platform's own").lowerAnalyticsWhere: changed by inheritance. A comparand core now refuses is declined off the raw-SQL strategy to the ObjectQL strategy, whoseengine.aggregatepasses the door. The suite is green, as in H4.formulamatchesFilterCondition: explicitly out of scope. It evaluates authored RLScheckpredicates and formula conditions, not a caller'swhere. Its own2026-02-30text-fallback pin (matches-filter.test.ts:180) is unchanged.applyHaving/matchesHaving: changed.assertHavingTemporalComparandsInterpretableruns the same predicate. It is pinned inengine-temporal-comparand-door.test.ts(temporal comparand door admits what the write door now refuses: an impossible day (2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549 and 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 #20480havingrows) andengine-aggregate-having-temporal-door.test.ts(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 rows, plus the parity table computed from the predicate).filter: changed, and pinned in the same two files and inengine-aggregate-temporal-storage-rule.test.ts.driver-memorycheckCondition: behind the door. Measured: the engine over InMemoryDriver refuses every row above. The driver's admitted-controls half is pinned.driver-mongodbtranslateFieldOperators: behind the door, not measured, because no MongoDB was available.Pins
#20549 (commit 1):
coretemporal-comparand.test.ts, a new describe:Date.parse-readable (or a bare integer) as the control;T25:00and+99:99are refused;timeinstant half is covered;{today}, an epoch-ms number, aDate) are kept.objectqlengine-temporal-comparand-door.test.ts, a new describe:$eq/$gt/$inmember →INVALID_FILTER/ 400 naming the field, with zero reads, and not the junk class's words;it;havingpositions;engine.insertrefuses it withinvalid_date;restdata-temporal-write-real-day-iso.test.ts, beside the write door's twin rows:POST /api/v1/data/:object/queryanswers 400INVALID_FILTERfor the card's values with no read, and the leap and ISO controls find their rows, on SQLite and on live PostgreSQL.sql-driver-20264-temporal-year-range.test.tsgains a leap row and anitover 7 ISO spellings, on the dialect matrix CI'sTemporal Conformance (live PG + MySQL)job runs;memory-20525-temporal-write-real-day-iso.test.tsgains comparand controls underAmerica/New_York.#20480 (commit 2):
core: a new describe covers the card's spelling, UTC year 10000 and year -1, numbers andDates, and the Date-range extremes, all refused. The 2026 control and year 0 are read. An agreement pin: refused exactly whentemporalStorageForm(v, 'time') === v.objectqlengine-temporal-comparand-door.test.ts: five spellings ×$gt/$lt/$eqatwhere, plus the per-aggregation filter andhaving, with zero reads, beside the 2026 control and year 0.engine-aggregate-having-temporal-door.test.ts: two refused rows, and two 2026 controls onmax(time).rest: atimefield on SQLite and live PostgreSQL. The card's instant, the UTC-10000 ISO spelling and the number answer 400, with no read. The 2026 instant, the offset and the number answer 2 / 1.sql-driver-time-live-dialects.test.tsgains the 2026 control in five spellings, on live PostgreSQL and MySQL (CI's temporal job);memory-temporal-storage-form.test.tsgains four 2026 control rows.Flipped pins, one round (sweep ①). Each keeps a load-bearing assertion of the new semantics:
coretemporal-comparand.test.ts: the 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 control'1769940000000'(string) moves to the number1769940000000, read. The string is refused in the temporal comparand door admits what the write door now refuses: an impossible day (2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549 describe. The coretemporalStorageForm: thedatearm leaves a year outside 1000..9999 unpadded — over REST the epoch-ms number for 0999-06-15 counts$gt0 /$lt7 on InMemoryDriver and SQLite (correct 6 / 0); its ISO string counts 6 / 0 #20240 "leaves the time rule alone" test becomes a per-value verdict: year 0 and in-range are read; years 10000 / -1 and the Date extremes are refused, with the rule's own output asserted.objectqlengine-aggregate-temporal-storage-rule.test.ts: the family row "an epoch-ms string$gton a datetime counts 3" is nowINVALID_FILTER/ 400 at both positions, for"1769940000000"and"2026". The number of the same instant still counts 3.objectqlengine-temporal-year-range.test.ts: "a time field judges no year" becomes year 0 read (three spellings, three reads) and years 10000 / -1 refused (five spellings), with no further read.objectqlengine-date-year-range-door.test.ts: the time half becomes year 0 read (two reads) and 10000 / -1 refused.objectqlengine-aggregate-having-temporal-door.test.ts: "the number for 10000-01-01 on max(time) — not judged on time" moves from the unchanged table to the refused table (INVALID_FILTER/ 400, no read, and thewheretwin agrees).The repo-wide sweep found no other same-semantic pin, re-run on
0adb1bf84. It covered quoted 4–14 digit strings under a filter operator on a temporal field, slashed or worded datetime comparands, and extended-year comparands ontimefields, over every*.test.ts. Truly illegal shapes keep their refusal assertions verbatim.Verification, on
0adb1bf84Every suite below ran under
TZ=America/New_York. PostgreSQL cells ran against a live PostgreSQL 16.13 atAsia/Shanghai.pnpm --filter @objectstack/core test: 57 files / 1536 tests passed.@objectstack/objectql, whole suite: 336 files / 6674 tests passed.@objectstack/rest, whole suite with live PostgreSQL: 228 files, 4422 passed / 22 skipped. The changed file ran verbosely, and both thesqliteandlive postgrescells executed every temporal comparand door admits what the write door now refuses: an impossible day (2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549 and 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 #20480it.@objectstack/driver-memorytest: 62 files / 1436 passed.@objectstack/driver-sqltest with live PostgreSQL: 208 files passed / 3 skipped, 4023 tests passed / 95 skipped. The two changed files ran verbosely: the SQLite and live-postgres cells ran; MySQL is a named skip.@objectstack/service-analytics: 135 files / 3171 passed.typecheckpassed for all five: core, objectql, rest, driver-memory and driver-sql (the test layers included, with theirtest-typecheck-debt.jsonledgers unchanged).pnpm check:driver-conformance, before (19fc8d6f1) and after (0adb1bf84): identical, 50 covered cells, 0 DEBT, 0 exempt, dialect axis 10 of 10.node scripts/pm/dispatch-gates.mjs --commandson the final head derived 67 commands. All 67 ran with exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (PREREQUISITE NOT MET, no workspacedist/). They were re-run with exit 0 afterturbo run build --filter='./packages/*' --filter='./packages/*/*'.--ranover the recorded exit codes: "67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED (a DERIVED zero)".check-changeset-fixed,check:authz-resolver,check:filter-alias-parity,check:object-def-param-keysandcheck:tenant-chokepoint.pnpm lintis CI's).eslint --no-inline-config --format jsonover the 14 changed.tsfiles: 14 files, 0 errors, 0 warnings.eslint.config.mjs's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}/packages/**objects cover all 14.parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file.scripts/ablation-replace.mjsandscripts/ablation-dist-preflight.mjs, with atraprestore. Each restore was proven: blob == HEAD, and a cleangit status --porcelain.2026-02-30) is rolled over as a datetime comparand and is a 500 on PostgreSQL as a date comparand, and a non-ISO datetime comparand is read in the host zone #20549). ThereadsAsInstantISO / real-day guard was deleted: anchor 1 → 0, and the marker was absent frompackages/core/dist. Core's predicate suite went 3 failed / 23, and the objectql door suite went 2 failed / 14 (the refusal and the aggregation/havingtests). The corpus agreement pin stayed green, correctly, because both doors moved together. Restored and rebuilt, the marker is present in 2 dist files, and 23/23 and 14/14 pass.timecomparand 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). Thetimearm's non-string verdict was replaced withreturn false: the marker was present in dist before, and absent after. Core went 3 failed / 23; four objectql files went 4 failed / 82. Restored and rebuilt, the marker is present, and 82/82 pass.NOT MEASURED:
Temporal Conformance (live PG + MySQL)job, insql-driver-time-live-dialects.test.tsandsql-driver-20264-temporal-year-range.test.ts.packages/restran locally, but no CI job provisions PostgreSQL for that package. CI's PostgreSQL coverage of this card is the driver-sql half above.Acceptance notes (not filed)
datecomparand with a real leading day and unreadable trailing text ("2026-07-15T25:00") is read as its day. The write door refuses the same string throughDate.parse. This predates this PR and is outside both cards' classes.timeyear class's wording is chosen by core'sisOutsideTemporalYearRangeon the instant. For a year-0 instant in a non-ISO spelling on atimecolumn (refused for its spelling), the message would name the year class instead. The verdict is right; the edge is theoretical.datetime.ts,having-filter.ts,matches-filter.ts) has not landed, and no file here overlaps it.Generated by Claude Code