Repository navigation
fix(objectql)!: a time field is a zone-less wall clock — a zone-suffixed time of day and an extended-year instant are refused with VALIDATION_FAILED / invalid_time (#20671) - #20721
Conversation
… is refused in its own sentence Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…t this card's surface This reverts commit 691bfab. The write arm stands without it; the spec narrowing is reported as a finding for the spec lane instead. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…the narrowing Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 138 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 d8f2818bba7102d2cfd9156465a1cc3b87e3b533 && git checkout d8f2818bba7102d2cfd9156465a1cc3b87e3b533
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cbaf04c1fd7a595236701bea02280c0f8b34e16e 9b426f8abf8b3618231e6a9e1dd7d27a9b723c81 && git checkout -B drift-repro cbaf04c1fd7a595236701bea02280c0f8b34e16e && git merge --no-ff 9b426f8abf8b3618231e6a9e1dd7d27a9b723c81
node scripts/docs-audit/affected-docs.mjs --json cbaf04c1fd7a595236701bea02280c0f8b34e16e
|
Contract reviewServed-tier: PR #20721 on card #20671. The head was confirmed unmoved at the sha above (branch ① Derived judgmentsEach accept-set and public-surface change the diff implies, judged against triage 5895825766, ADR-0053 D-C1 and core's
The seat answer 5899587971, judged. ADR-0104 D1 on PR body, sentence by sentence where a fact can be checked, the seat-edited first line and H4 bullet included. Shipped prose, two imprecisions that are not defects. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…commits that decided them (objectstack-ai#20729) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the eighth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-analytics/src/**` and nothing else. By the seat's census at the claim (`5899485578`), it is the largest package in the lane that no in-flight work holds. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 7 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as `9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`). That is **76 sites on 76 lines in 22 files, covering 14 numbers**: - 42 census sites (every census site this package has); - 34 sites in test comments, which the census defers. The raw scan found no dead site the gate's grammar cannot see (see Acceptance notes), so there is no third class this time. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **13 distinct shas**. No number in this package has an ADR or ruling record of its own in the repository (a grep of `docs/adr/` for all 14 finds none, and a grep of the rest of `docs/` finds none either), so every anchor is a commit, per ruling C's order. No number was dropped. Only comments changed. Every touched source file keeps its line count (78 lines out, 78 in, over 22 files), so no line citation into these files moves. 2 of those 78 lines hold no dead citation: they are reflow lines, listed under Wordings below. No code token moves (see the guard below). **No citation number is added.** Every tracker number on an added line was already on the line it replaces: `objectstack-ai#10861` (5 lines), `objectstack-ai#12776` (3), `objectstack-ai#10413` (2), `objectstack-ai#16750` (2), and `objectstack-ai#10759`, `objectstack-ai#11152`, `objectstack-ai#5716` and the decision-batch ordinal `objectstack-ai#59` once each. Each tracker number among them resolves. Over the whole diff, added minus removed is 0 or negative for every number, and no number is new to the diff. No PR number is the citation on an added line: the two `PR #N` spellings in scope became their pull request's squash commit, and `objectstack-ai#16750` stays only as the convenience link beside `ed7243d52`, on the line it already stood on. Eight dead sites are left on purpose, all of them test strings (see the list below). One more file: a `patch` changeset for `@objectstack/service-analytics`, because the rewritten docblocks and inline comments ship (see Changeset below). The `AnalyticsResultWithDrill` type and its four sidecar members are not touched: its docblocks carry no dead number (`objectstack-ai#20644`, `objectstack-ai#3214` and `objectstack-ai#1752` all resolve). ## Census: `service-analytics`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/services/service-analytics/`. Each run counts as a reading only because its board frontier equals the newest issue number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | service-analytics sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `cbaf04c1f`, run 2026-09-29T21:41:53Z to 21:45:05Z | enumerated, 186 pages, frontier objectstack-ai#20721 (newest objectstack-ai#20721 before and after), 18,548 numbers | 1,161 | **42** | 42 | 10 | 10 | | after | head `967d73531`, run 21:55:23Z to 21:58:36Z | enumerated, 186 pages, frontier objectstack-ai#20723 (newest objectstack-ai#20723 before and after), 18,550 numbers | 1,119 | **0** | 0 | 0 | 0 | The before count matches the seat's census at the claim and A1 (42 sites): the two comments PR objectstack-ai#20712 rewrote in `analytics-service.ts` did not move it. The whole-repo drop is 42, exactly this diff's census sites. The `resolves` tally is 32,991 in both runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. The after run was taken on `967d73531`; the head `82d2b40b2` adds only the changeset. No run was truncated or discarded: both enumerations read 186 pages at the newest frontier. **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 `namesThisRepository` over every `.ts` file under `service-analytics/src` (162 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when that census judged it on this board anywhere (its `--list` extraction) and did not report it. The 21 numbers the census never saw, because they stand only in test files or strings here, were read one by one on the issues endpoint: 17 answer 200, and `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#16918` and `objectstack-ai#17125` answer 404. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `cbaf04c1f` | 3,514 | **84** | 42 | 34 | 0 | 8 | | after, `967d73531` | 3,438 | **8** | 0 | 0 | 0 | 8 | Its src-comment column equals the census's 42, which is the control on the second instrument. The 3,410 live citations and the 20 cross-repo citations are the same in both readings, and the drop of 76 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 3,598 occurrences and 84 dead before, 3,522 and 8 after; its residue equals the gate's residue site for site, and it sees no dead site beyond the gate. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject, and `git blame` at the base puts every rewritten line in its anchor commit or in a later commit that descends from it (`merge-base --is-ancestor` exit 0 for each pair). | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#11461` | 20/2 | 19/1 | `399ecad58`: a cross-object leaf in one measure's own `filter` (the third producer, lowered onto `aggregations[].filter`) is refused on both ObjectQL doors with `INVALID_FIELD` / 400 naming the measure, folded into the one member view, with insertion order keeping every earlier refusal's message. The last line of its message names `objectstack-ai#11461` as the card it settles. New to the sweep | | `objectstack-ai#17130` | 17/5 | 13/4 | `54b3d1d4a` (PR objectstack-ai#17336): the row-scope resolution refusals carry `READ_SCOPE_COMPILE_FAILED` / 500 through one constructor, so `queryDataset`'s catch re-throws them instead of reading their words, every message byte-unchanged; plus the source-derived wording-collision guard. Named in its diff only (18 added lines carry the tag). New to the sweep | | `objectstack-ai#17124` | 12/8 | 10/2 | `86c505286` (PR objectstack-ai#17593): `explicitDateRangeWindow` is the one reading of `dateRange`'s array arm on all four faces, and an array that is not two string bounds is refused with `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. Named in its diff only (its changeset file is `17124-daterange-array-arm-arity.md`). New to the sweep | | `objectstack-ai#12209` | 10/5 | 10/0 | `017130a09` (PR objectstack-ai#12318): a custom-SQL measure is refused on the ObjectQL aggregate path with `INVALID_FIELD` / 400, keyed on the `EXPRESSION_METRIC_TYPES` partition shared with `NativeSQLStrategy`. Its message records the two failure modes the lines describe (`driver-sql` blaming a `function` key, the in-memory evaluator answering `null` per bucket). Named in its diff only. New to the sweep | | `objectstack-ai#16778` | 5/1 | 4/1 | `357f4992b`: the compile-leg refusal of an aggregate a datetime measure's field type cannot carry, scoped to temporal source fields. The squash commit of the pull request that was `objectstack-ai#16778`; its subject carries the number. New to the sweep | | `objectstack-ai#12940` | 4/2 | 4/0 | `aa16721b6` (PR objectstack-ai#13361): this package's consumer-local `executeAggregate` config mirrors (the plugin options and `AnalyticsServiceConfig`) narrow `aggregations[].method` to `AggregationFunction`, after `objectstack-ai#12776` narrowed the contract. Named in its diff only. New to the sweep | | `objectstack-ai#17015` | 4/2 | 4/0 | `0da638cd9`: the closed `dateRange` preset vocabulary is lowered once and the rest refused, the `[range, range]` fallback is removed from the faces it reached, and the shared conformance kit holds them. The squash commit of the pull request that was `objectstack-ai#17015`. New to the sweep | | `objectstack-ai#16860` | 3/1 | 3/0 | `041d9fdc6`: the object-level read grant is asked at the analytics door, and its bridge to the `security` service resolves an explicit three-way (absent admits; throwing or method-less denies at `error`, finding F3 in its message). The squash commit of the pull request that was `objectstack-ai#16860`. New to the sweep | | `objectstack-ai#12248` | 2/1 | 2/0 | `8425c17cc`: the five ruled engine members, `getDriverForObject?` and `resolveEffectiveDatasource` among them, adopted onto `IDataEngine`, and `getObject` typed. Its subject names it. Stage 5's and the spec stage's anchor | | `objectstack-ai#16685` | 2/2 | 2/0 | `ed7243d52` (PR objectstack-ai#16750): `boolean` / `toggle` accepted for `sum` / `avg` / `min` / `max` in the aggregate × field-type table, holding maintainer ruling `objectstack-ai#11152`. Its subject names it. The spec stage's anchor | | `objectstack-ai#17125` | 2/2 | 2/0 | `5d12b16e7`: the row-scope bridge tells an absent security service from a broken one, so a broken one refuses the query. The squash commit of the pull request that was `objectstack-ai#17125` (404 on the pulls endpoint too). New to the sweep | | `objectstack-ai#16918` | 1/1 | 1/0 | `5d12b16e7`: the same commit. Its changeset's headline names `objectstack-ai#16918` as the card it answers, and its diff writes the line (`admission-bridge-resolution.test.ts:120`) | | `objectstack-ai#6123` | 1/1 | 1/0 | `59d1933f9`: `err.code` lands at `error.code`, not `error.details.code`; the commit that wrote this very line. The `runtime` stage's anchor | | `objectstack-ai#13279` | 1/1 | 1/0 | `6a180e42d`: permission-store read failures fail loud, and the same commit renames `metadata/src/utils/schema-sync-errors.ts` to `packages/types/src/driver-error-classification.ts`, the move the line describes. The anchor of stages 2, 5 and 6, and of the `types`, `rest` and `runtime` stages | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 13), and every one is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13; control leg: stage 1's landing `422db788a` exit 0; the history is complete, `--is-shallow-repository` false, 15,135 commits). Each of the 14 numbers answers 404 on the issues endpoint, read one by one; `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#17015` and `objectstack-ai#17125` answer 404 on the pulls endpoint too. ## Wordings to check - **Bracket tags.** `[#N]` became `[commit SHA]`, as in stage 7; `[objectstack-ai#10861 / objectstack-ai#11461]` and `[objectstack-ai#10861, objectstack-ai#11461]` keep the live `objectstack-ai#10861` beside the new sha. - **The boolean rows, `measure-result-type.ts:115-116` and `aggregate-datetime-measure-refusal.test.ts:65-66`.** 「objectstack-ai#16685 ruled A, landed as objectstack-ai#16750」 and 「objectstack-ai#16685 was ruled A and objectstack-ai#16750 added」 became 「commit ed7243d (objectstack-ai#16750) added those rows」 and 「commit ed7243d (objectstack-ai#16750) added」. 「ruled A」 named an option on the dead card; `ed7243d52`'s message records the decision itself. Line 116 of the first file and line 66 of the second are the 2 reflow lines: each keeps the `objectstack-ai#16750` it already carried. - **PR numbers, `read-scope-resolution-envelope.test.ts:25` and `refusal-wording-collision.test.ts:21`.** 「PR objectstack-ai#17125's refusal」 became 「Commit 5d12b16's refusal」, the pull request's squash commit. - **`read-scope-refusal.ts:29`.** 「objectstack-ai#17130 exists to remove it」 became 「commit 54b3d1d was made to remove it」, the form stage 6 used. - **`refusal-wording-collision.test.ts:49`.** 「the exact move objectstack-ai#17130 forbids」 became 「the exact move commit 54b3d1d ruled out」; its message says the fix is the declaration, not a luckier string. - **`read-scope-resolution-envelope.test.ts:161`.** The verb after the number moved from present to past tense with the sha. - **`measure-expression-both-strategies.test.ts:45` and `:166`.** 「deleting the objectstack-ai#12209 arm in」 became 「deleting the arm commit 017130a added in」, and 「every objectstack-ai#12209 refusal」 became 「every custom-SQL refusal (commit 017130a)」. - **`dataset-executor.ts:609`.** 「objectstack-ai#17015's kit」 became 「commit 0da638c's kit」, the conformance kit that commit built. - **`plugin.ts:116`.** 「and in objectstack-ai#12209:」 became 「and in commit 017130a:」, whose message records the two ways the engine failed. - **`analytics-service.ts:238`.** 「objectstack-ai#13279 moved it there」 became 「commit 6a180e4 moved it there」; that commit's diff is the rename. ## The 8 sites left - **Test strings, 8 sites**, left as stages 1 to 7 left theirs, all `describe` / `it` titles: - `crossobject-conjunct-refusal.test.ts:589` (`objectstack-ai#11461`); - `aggregate-nontemporal-measure-refusal.test.ts:243` (`objectstack-ai#16778`); - `date-range-array-arm-arity.test.ts:213` and `:294` (`objectstack-ai#17124`); - `read-scope-resolution-envelope.test.ts:155`, `:199` and `:226`, and `refusal-wording-collision.test.ts:336` (`objectstack-ai#17130`). - There is no operator string, generated file or quoted ruling carrying a dead number in this package. It has no generated file at all. ## Mechanical guard: no code token moves The guard compares the TypeScript parser's leaf nodes, with comments as trivia and JSDoc nodes never visited, base `cbaf04c1f` against head. Template literals are therefore read in context. It ran over all 22 touched `.ts` files. - Real run: 26,705 base leaf tokens, **0 files with a token change** (exit 0). - Comment control in `plugin.ts` (「refusal buys is in」 to 「refusal earns is in」): 0 files changed, as expected (exit 0). - Positive control, a code token added in `plugin.ts` (`field: a.field,` given `as string`): DIFFER (exit 1). - Positive control, one digit changed inside a kept test title (`date-range-array-arm-arity.test.ts:213`): DIFFER (exit 1). Every mutation went through `scripts/ablation-replace.mjs`, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`ad3dc9fff4d3`, `a606ffbb6ead`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-analytics` (`.changeset/20596-service-analytics-provenance-anchors.md`) is included. Its body is stage 7's, word for word, with the package name changed. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After the build (a cache miss for this package, so `dist` is this head's source), the rewritten comments reach `dist`: `399ecad58` 6 times in each of `dist/index.js`, `index.cjs`, `index.d.ts` and `index.d.cts`; `86c505286` twice in each JS file and once in each declaration file; `54b3d1d4a` once in all four; `aa16721b6` once in each JS file and twice in each declaration file; `017130a09` once in each JS file. Positive controls: the unchanged line 「none of the coverage: a compiled measure's own」, in the same docblock as the shipped rewrite at `objectql-strategy.ts:744`, is found once in each of the four files, and the unchanged line 「back into line. Widening it here again would not be a local matter」 beside the shipped rewrite at `analytics-service.ts:559` once in each declaration file. A never-written negative phrase appears nowhere in `dist`. None of the 14 dead numbers is left anywhere in `dist`. ## Gates (head `82d2b40b2`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` (self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 11 citations across 10 files; 10 resolve and 1 resolves as a pull request (`objectstack-ai#16750`, the convenience link that already stood on its line). - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `82d2b40b2` derived 62 commands: all 56 derived at dispatch, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. Each ran with its exit code captured before any pipe, and all 62 exit 0. `--ran`, fed each command with its exit code, reports 62 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock:** - `pnpm --filter @objectstack/service-analytics test`: 137 files pass and 3,216 tests pass. That is every test file in the package, the 12 touched ones included. - `pnpm --filter @objectstack/service-analytics typecheck` exits 0 (`tsc --noEmit` on `tsconfig.json`). `--listFiles`: the program holds all 162 files under `src/`, the 137 test files and all 22 touched files included. - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 22 touched `.ts` files gives 22 files, 0 errors and 0 warnings. All 22 are in eslint's own population (`isPathIgnored` is false for each; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 23 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#` (objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word 「option」. In this package: - `#N-word`: 8 lines by a plain grep, and 7 once a hyphen before the `#` is excluded too, which is the claim's 7. The eighth is 「pre-objectstack-ai#10413-phase-2」 (`execution-context-bridge.test.ts:223`). The numbers, `objectstack-ai#10413`, `objectstack-ai#5298`, `objectstack-ai#13570` and `objectstack-ai#13640`, all resolve. - `#A/#B`: 29 lines, the claim's 29, over 28 distinct numbers. All resolve; `objectstack-ai#2149`, which the census never judged, was read on its own. - `option #N`: none. So nothing here needed a rewrite beyond the gate, and the raw scan agrees. - **「This card」 phrases are left.** 113 lines in 39 files of this package speak of 「this card」, 「that card」 or 「the card」. They carry no number, neither instrument sees them, and most sit in blocks whose numbers still resolve. Stage 7 rewrote two such lines as lost referents; here none is changed, because the phrase runs through the whole package and rewriting a subset would be arbitrary. - **Prose that names `queryDataset`'s catch, not changed.** Nine comment lines say `queryDataset`'s catch. Since `10c36cc43` that catch sits in the private `answerDataset`, whose docblock calls it the body of `queryDataset`, so the lines still hold at the level of the public method. This is not a dead citation, so it is outside this stage. - **The census instrument did not truncate in this stage.** Both enumerations read 186 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11461` → `399ecad58`; `objectstack-ai#17130` → `54b3d1d4a`; `objectstack-ai#17124` → `86c505286`; `objectstack-ai#12209` → `017130a09`; `objectstack-ai#16778` → `357f4992b`; `objectstack-ai#12940` → `aa16721b6`; `objectstack-ai#17015` → `0da638cd9`; `objectstack-ai#16860` → `041d9fdc6`; `objectstack-ai#17125` and `objectstack-ai#16918` → `5d12b16e7`. - **Base.** The branch is on `main` at `cbaf04c1f`. `main` has since moved four commits (`3711e0b76`, `61455de27`, `6afccda5a`, `671d4c164`). They touch `packages/spec`, `packages/metadata/package.json`, `pnpm-lock.yaml`, docs and changesets, and no file under `service-analytics` or in this diff, so no merge was taken; the merge queue rebuilds on the merged generation. One of them, `671d4c164`, declares the four drill-through sidecars on `AnalyticsResult` in the spec. This diff leaves the local `AnalyticsResultWithDrill` untouched, as the claim requires. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20671
Clause-②: no (narrowing)
The record validator's
timearm now judges a written value by@objectstack/core's one temporal rule,isUninterpretableTemporalComparand('time', value), the rule thetimecomparand door asks since PR #20668. That is how #20525 moved thedate/datetimearm. Atimefield is a zone-less wall clock (triage 5895825766): a time of day with aZor an offset is refused withVALIDATION_FAILED/ 400, field codeinvalid_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 unanchoredhasDatetest is gone.Base
fa0a4b661(this branch's merge base). Head9b426f8ab.Reproduced first, then after
POST /api/v1/data/:objectthen a read-back throughPOST /api/v1/data/:object/query. The process ran inTZ=America/New_York. PostgreSQL 16.13 was a private server atAsia/Shanghai. Memory isRestServeroverInMemoryDriver, from a scratch probe that was not committed. The card's table reproduced on every cell.time"+010000-01-01T10:00:00Z"(the card)DATABASE_ERRORinvalid_time"9999-12-31T23:00:00-02:00"(UTC year 10000)invalid_time"10:00Z"(the card)"10:00Z""10:00Z""10:00:00""10:00+08:00","10:00:00+0800""10:00:00""10:00:00.250Z""10:00:00.250""2026-07-15 10:00Z"(a space and a zone)"10:00:00"invalid_time"10:00","10:00:00"(the controls)"10:00:00""10:00:00.250""10:00:00.250""2026-07-15T10:00:00Z","2026-07-15T18:00:00+08:00","2026-07-15 10:00""10:00:00""07/15/2026 10:00","x2026-07-15T10:00:00Z","{now}", the number36000000invalid_time" "(blank)nullThe 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, thetimearm:readable && !isUninterpretableTemporalComparand(t, value), thedate/datetimearm's line;readableholds the write door to what the comparand door exempts on purpose. A number stays refused as a writtentime(a comparand may be epoch milliseconds), and a{placeholder}stays refused (it is filter vocabulary, judged byclassifyFilterTokenfrom@objectstack/spec/data). A blank is missing before the arm, as before;timeOfDay/hasDatepatterns are deleted;isZonedTimeOfDaychooses the sentence, never the verdict: a time of day plusZ/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, inen/zh-CN/ja-JP/es-ES. It is a rendering variant of the existing wire codeinvalid_time, which does not change. See the scope section for why it is here.content/docs/protocol/objectql/types.mdx: thetimeinput sentence said "with an optional fractional part andZ/offset". It now says no zone, and that an epoch number is refused. The number was already refused at base:36000000answered 400 on all three.PM hypotheses, which held
fa0a4b661core'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'sdist. The arm asks it. The one addition is the write door's type gate above. The predicate answersfalsefor a number, a{placeholder}and a blank, which are comparand exemptions, and the old arm refused the first two as written values.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. Noneeds_decisionis raised on it.invalid_time.fail(code, constraint, messageKey)goes tobuildFieldError, then torenderValidationMessage(messageKey), and that readsBUILTIN_VALIDATION_MESSAGESinpackages/spec. So the prescription has to live there. Details are in the scope section.timekeeps its value, with novalue-shapesreport, as PRs fix(objectql)!: a date string is written in its YYYY-MM-DD form, or refused with VALIDATION_FAILED / invalid_date (#20481) #20524 / 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 did fordate/datetime. Measured:valueShapeViolationhas one caller, the scan (scan-value-shapes.ts:155). The write path does not call it. Its siblingisScannableValueShapeFieldIS on the write path:ObjectQL.objectHasCoveredValueFielddecides from it whether an object reads theadr-0104-value-shapesflag and passesvalueShapeStrictto the validator. Addingtimethere changes notimeverdict, because the arm reads no strictness flag. It does make every object whose only covered field is atimeread the flag, and it makes the boot line announce a warn mode that does not governtime.valueSchemaFor(field, 'stored')", and "the covered classes are exactly the validator's own non-media branch —REFERENCE_VALUE_TYPES… andSTRUCTURED_JSON_TYPES". Coveringtimechanges 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 thetimearm never reads. And the spec'svalueSchemaFor(time)itself admits"10:00Z"(measuredtrue), so the scan could not reuse its own predicate for this.os-dev-reporton record validator: atimefield written "+010000-01-01T10:00:00Z" is stored verbatim (201 on SQLite, 500 on PostgreSQL), and "10:00Z" reads back differently per backend — the write-side twin of #20480 #20671.Scope: two
packages/specedits, one kept and one revertedThe 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, theinvalid_time_zonedkey. The card needs it. Triage rules that a suffix "is refused with a prescription: drop the suffix, or use adatetimefield for an instant". A refusal's sentence can only come from that catalog. Measured on spec'sdist: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-ESeach have 38 keys. The key does not widen a published type or export:BUILTIN_VALIDATION_MESSAGESdoes not change: a record of locale to a record of message key to template;check:api-surfaceanswers "@objectstack/spec public API surface + factory signatures unchanged ✓";FieldErrorCodedoes not change;check-widening-tells --declaration nojudgedvalidation-message.tsagainst 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/specpublishesdist(files[]), and the key ships in 4distfiles, next toinvalid_datetimeas a positive control. So the changeset lists@objectstack/spec: patch.Reverted:
ClockTimeValueSchemainpackages/spec/src/data/field-value.zod.ts. Commit691bfabd6narrowed it to refuse a zone, andb5d95181dreverts it with a normal revert commit. The arm stands without it. With the spec schema left wider, at9b426f8ab:action-params-enforcement.test.ts: 5 / 5;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:
FieldSchemaacceptsField.timewithdefaultValue: '10:00Z';engine.insertthat falls back to that default is refused, 400invalid_time, on a field the caller never sent (measured ona596fad76);validateActionParams, strict under ADR-0104 D2) still admits'10:00Z'for atimeparam (measured[]).The readers of
ClockTimeValueSchemaare all throughvalueSchemaFor:checkLiteralDefaultValue(theFieldSchema.defaultValuegate and the action-paramdefaultValuegate),validateActionParams(runtimeaction-execution.ts:1376), andimport-mapping-target.ts. The last reads only object-shaped schemas, sotimenever reaches it. The objectql scan'sshapeSchemaFornever seestime. Metadata shipped in this repo authors no zonedtimevalue:examples,packages/platform-objects,packages/create-objectstackandskills;examples;timefield declarations inexamplesand 1 inskills, and none carries adefaultValue.The commit
691bfabd6stays 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).engine.validate. Each assertscodeVALIDATION_FAILED,fieldsexactlyslot/invalid_time, and zero driver writes.Dateamong them, and each reaches the driver as written.timeexactly when core refuses it as atimecomparand, 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 withinvalid_timeand 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 newiton the SQLite cell and the live PostgreSQL cell."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). UnderAmerica/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.mjsreplaced the arm'sreadableline with one that admits every string andDate. The anchor went 1 → 0 and the blobeb565fe32fe5→2b0d369b76c9. objectql was rebuilt, andablation-dist-preflightfound the marker in 4 built files.[#20671]itwent red on SQLite and on live PostgreSQL, and every otheritstayed green.git diff HEADis 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[#20671]cells included.Verification at
9b426f8abtypecheckexit 0 for spec, objectql, rest and driver-memory.--listFileslists the new objectql test and the REST file. driver-memory'stsconfig.jsonincludessrc/**/*.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 110 commands. 107 exited 0.check:skill-examplesfirst exited 3 because the client packages had nodist. It was re-run, exit 0, after building them.--ranreads "110 derived famil(ies) accounted for — 107 run, 3 NOT-MEASURED".check:api-surfaceanswered "unchanged ✓", andcheck:docs"226 generated files in sync".check:nul-bytesscanned 9333 files and found no raw control bytes.check:driver-conformancereads 50 covered cells, 0 DEBT.check-adr-0087-registrationreads the changeset as "BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", exit 0.check-changeset-no-majorandcheck-empty-changesetexited 0.pnpm lintis CI's).eslint --no-inline-config --format jsonover the 6 changed.tsfiles: 6 files, 0 errors, 0 warnings.eslint.config.mjs's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}andpackages/**objects cover all 6.--print-configshows noparserOptions.projectorprojectServiceon 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-loadsandcheck:type-check-debtexited 3, PREREQUISITE NOT MET: no whole-workspacedist. The container restarted twice during this run, so a whole-workspace build was not attempted.@objectstack/objectql.has 178 exports and./core52,@objectstack/spec/system400 and@objectstack/spec/data528.check:query-options-erasure: it was killed with the container (exit 137) after its self-test passed, and it was not re-run. CI'sLint & Repo Gatesruns it.Acceptance notes (not filed)
/import: measured after the change on all three backends.10:00Zand10:00+08:00time cells are per-row refusals, from the import's own reader, before this door. They were refused there before this PR too:parseDateCellruns first and never hands this arm a suffix.9999-12-31T23:00:00-02:00is stored as01: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.Dateis 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.defaultValueon atimefield.Generated by Claude Code