fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) - #20469
Conversation
…9, at the comparand door and the write door (#20264) WIP: the rule and its two doors; pins follow. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… engine doors, flipping the year-0 pins (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…public door and each dialect's edges (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… out-of-range number is refused on datetime too (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…lause corrections to the 20203, 20240 and 20263 notes it falsifies (#20264) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check15 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 d02002e9ef679d19200241c1503c5d03ba2e6e82 && git checkout d02002e9ef679d19200241c1503c5d03ba2e6e82
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b810ddb6f1635fdf58a901aa082f5de015bb80c8 b559a5d0effc161b2730cf17aaa7caa9dc5088cf && git checkout -B drift-repro b810ddb6f1635fdf58a901aa082f5de015bb80c8 && git merge --no-ff b559a5d0effc161b2730cf17aaa7caa9dc5088cf
node scripts/docs-audit/affected-docs.mjs --json b810ddb6f1635fdf58a901aa082f5de015bb80c8 |
…mporal-year-range
Contract reviewServed-tier: Inputs: card #20264 (body and all 5 comments), PR #20469 (body, 17-file list, net diff against the merge base dc0ab6a), the check-runs on the head, and the rulings the card cites on #20280 (5859414357) and #20240 (5857781537, 5858389239). Read-only git and REST GETs only; nothing built, run or re-run. ① Derived judgmentsSource moves in four files (core
② Semver level
③ Boundary flags
Must-changes (both one-clause edits to Implemented-by: VERDICT: FAIL |
…oot export; state the 7/0/0 answer it gave, and except the date-string class whose refusal words moved (#20264) Patch round 1 of the at-tier review on the PR: two false sentences and the Clause-② reading. No code or test change. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…mporal-year-range
Contract reviewServed-tier: Delta record over the FAIL 5874841530 at 311ce06. Inputs added: the amended claim 5874849531 and the patch-round-1 ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…write seam (objectstack-ai#20386) (objectstack-ai#20482) Fixes objectstack-ai#20386 Clause-②: no (narrowing) A `progress` field's declared `min` / `max` are now **enforced at the objectql write seam**, per triage `5865053231` (ENFORCE, no decision card). A `progress` write outside a declared bound is refused with `400 VALIDATION_FAILED` and the `number` field's own field codes, `max_value` / `min_value`. `scale` and `precision` stay unread on `progress`. Measured head: **`af9e5101a`**. ## What changes - **`packages/objectql/src/validation/record-validator.ts`, the number arm.** The `if (t === 'progress') return null;` early return moves from above the `min` / `max` checks to directly below them, and above `scale` / `precision`. The objectstack-ai#20308 docblock that deferred this now says why the bounds bind and why the return stays above `scale` / `precision`: each of those keys' own `.describe()` names a type set `progress` is not in. - **The file header's `min` / `max` line** now lists `progress`. It named the five types that were enforced, so leaving it would have made it false. This is one line outside "the number arm and the objectstack-ai#20308 docblock" (declared below as a deviation). It is line 31, far from the date line PR objectstack-ai#20469 edits (line 55 on `main`). - **Tests.** `record-validator.blank-typed-value.test.ts` pinned the old boundary (`progress` `max: 100` accepting `150.5`). It now pins what stays true: `summary` reads no bound or `scale`, and `progress` reads no `scale`. One test name in `record-validator.precision.test.ts` said `progress`'s "bounds the numeric branch never reads", and it is reworded. Its assertion is unchanged. - **`.changeset/20386-progress-min-max-enforced.md`**: `@objectstack/objectql` `minor`, **BREAKING** banner, the `Clause-②` line, a before → after line and the ADR-0087 disposition (below). ## Measured premises (dispatch zone 2) - **H1: `progress` bounds are skipped on `origin/main`. Held.** Reproduced on `dc0ab6a2e` through the real `RestServer` `POST /api/v1/data/:object` handler, over a real `ObjectQL` engine on the SQLite `SqlDriver` and on the memory driver. This was a scratch harness, not committed, because `check:driver-memory-census` refuses a new driver-memory consumer. | field | value | SQLite before | memory before | both after (`5f5bc7580`) | |:--|:--|:--|:--|:--| | `progress`, `max: 100` | `150` | 201, stored `150` (real) | 201, stored `150` | 400 `VALIDATION_FAILED` / `max_value` `{ max: 100 }`, no row | | `progress`, `min: 0` | `-5` | 201, stored `-5` (real) | 201, stored `-5` | 400 `VALIDATION_FAILED` / `min_value` `{ min: 0 }`, no row | | `number`, `max: 100` (control) | `150` | 400 / `max_value`, no row | the same | unchanged | | `number`, `min: 0` (control) | `-5` | 400 / `min_value`, no row | the same | unchanged | | `progress` in bounds / on each bound | `50`, `0`, `100` | 201 | 201 | 201, stored unchanged | - **H2: `scale` / `precision` after the move. Held, with a measured boundary.** With the return placed below the bounds, `scale` and `precision` are still not enforced on `progress`: `33.5` under `scale: 0` gets 201, and `99.5` under `precision: 2` gets 201, on SQLite and memory. Deleting the return outright would start both. Measured by ablation (below): four pins turn red with `max_scale` / `max_precision`. So the placement is load-bearing, and it is pinned from both sides. - **H3: producers. Zero writes outside a declared bound.** - objectui `SliderField`, the `progress` editor (`FieldEditWidget.tsx:87` `progress: SliderField`), is byte-identical at the pinned `.objectui-sha` `dd3f7e1be3` and at objectui HEAD `b8e0941`. It passes `min = field.min ?? 0` and `max = field.max ?? 100` to `@radix-ui/react-slider` (`^1.4.7`). That component's `updateValues` does `clamp(snapToStep, [min, max])` before every `onValueChange` (read in the 1.4.7 tarball). So it cannot emit a value outside a declared bound. - Example apps, on `dc0ab6a2e`: 2 `progress` fields declare bounds, `showcase_task.progress` and the field zoo's `f_progress`, both `min: 0, max: 100`. Their writers are 12 seed rows, the `showcase_mark_done` action (`progress: 100`) and the dogfood field-zoo matrix (`60`). All of them are in bounds. ## Pins - `packages/objectql/src/validation/record-validator.progress-bounds.test.ts` (new, 14 tests): - the triage pins (`150` gets `max_value` `{ max: 100 }`, `-5` gets `min_value` `{ min: 0 }`, and in-bounds values plus both inclusive bounds are accepted); - envelope equality with the `number` refusal; - one bound declared alone, and no invented 0..100 bound when none is declared; - update mode, string-carried values, and an omitted field that is never re-read; - ⛔ `scale` / `precision` unread on `progress`, while the same declaration on `slider` refuses; - every engine write door through a stub driver: insert of one row and of an array, `insertMany` partial success, update by id and by predicate, the `validate` dry run, and a control showing that in-bounds values arrive as the same number. - `packages/rest/src/rest-data-progress-bounds.test.ts` (new, 6 tests), on the real `RestServer` routes over SQLite, reading the physical column past every read coercion: - POST, batch create, PATCH, batch update and updateMany refuse `150` / `-5` with the `number` field's envelope and write or change nothing; - controls: in-bounds values and both bounds are stored unchanged, and `33.5` writes under `scale: 0, precision: 2`. ## Verification Heavy runs went through `scripts/pm/os-verify-lock.sh`. All readings are at `af9e5101a` unless stated otherwise. - **Build.** `pnpm turbo run build --filter='@objectstack/rest...' --concurrency=2`: 25/25, VERDICT command-exit 0. It was re-run after each merge of `main` (last at `5c4148234`). The objectql source has not changed since. - **objectql.** - Validator and door suites (`progress-bounds`, `blank-typed-value`, `precision`, `number-value`, `record-validator`, `engine-number-value-door`, `engine-blank-typed-value-door`): 7 files, 333/333. - Full `--project local`: 328 files, 6081/6081 (at `6a029a923`, before the second merge, which brought only spec and driver-sql commits). - `typecheck` (tsc, scripts, and `check:test-typecheck`, whose `tsconfig.test.json` includes `src/**/*`): exit 0. - **rest.** `rest-data-progress-bounds`, `rest-data-number-value`, `rest-data-blank-typed-value` and `import-integration`: 4 files, 100/100. `typecheck` including `check:test-typecheck`: exit 0. - **Reverse verification.** - **REST pin, with a build.** Against the `dist/` built from base `dc0ab6a2e`, the REST pin read **5 failed / 1 passed**. Each failure was `expected 201 to be 400` or a batch row reporting success. The one green is the in-bounds control. The `dist/index.js` number arm was read directly before and after: the return sat above the bounds, then below `max`. After `pnpm --filter @objectstack/objectql build` at `5f5bc7580` the pin reads 6/6. - **Ablation 1, fix committed first (`3b7b55406`).** `scripts/ablation-replace.mjs` re-planted `if (t === 'progress') return null;` above the bounds: anchor 1 → 0, blob `9ede5b5b` → `d396c53a`. Result: the new objectql file reads **10 failed / 4 passed**. The 4 greens are the controls: in-bounds values, `scale` unread, `precision` unread, and the engine in-bounds control. Restored: blob `9ede5b5b` equals HEAD, and `git diff HEAD` is empty. The subject resolves through a relative import to `src/`, so no rebuild was involved. Direction: red. - **Ablation 2, the H2 reading.** The same tool deleted the return: blob `9ede5b5b` → `d683b83e`. **4 failed**: the two `progress-bounds` pins for `scale` / `precision`, the `blank-typed-value` `scale` pin, and the `precision` test that excludes `progress`. Restored the same way. Direction: red. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `af9e5101a` derived 64 commands. **62 exit 0**. 2 are **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `check:dual-build-cjs-loads` and `check:type-check-debt` both need a whole-repo build. `--ran` reconciliation: 64 derived, 62 run, 2 NOT-MEASURED (derived from exit 3), 0 UNRUN, exit 0. - First pass at `5c4148234`: `check:error-code-casing` exit 1 on four bare `{ code: 'max_value' }` style assertions. They are now field-addressed (`af9e5101a`), and the gate reads 0. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` on the 5 changed `.ts` files: 5 files, 0 errors, 0 warnings. - Population: `--print-config` applies the config's rules to each file (6 rules on the validator, 5 on the REST test). - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. - The repo-wide `pnpm lint` is declared to CI. - **Not run locally, declared to CI:** the rest of the rest suite, runtime and dogfood, and the 6 path-matched families that take a value from the workflow. ## ADR-0087 disposition: which precedent, and why `not-required (no-migration-prescription)`, following **PR objectstack-ai#20423**, not objectstack-ai#7501: - objectstack-ai#20423 is the closer precedent: the same arm, the same kind of change (a declared numeric bound starting to bind at the write seam, a narrowing of the write accept set with no authored key moving), and a gate-era marker. - objectstack-ai#7501's changeset (`number-scale-enforced-by-rejection.md`, `951476719`) declared no BREAKING banner, so `check:adr-0087-registration` never asked it for a marker. It carries none, and there is nothing to copy. One conflict with the dispatch order's wording is worth stating. The seat's dispatch order asks for "a FROM → TO line" (claim 5873443045 itself names only `.changeset/20386-*.md`; seat edit after review 5875022310). Measured with the gate's own exported `findMigrationPrescription`: a line opening with the literal `FROM → TO` label is read as a **migration prescription** (branch `from-to-label`), and that refuses `no-migration-prescription`. The only category left would then be `registered`, which would need a new ledger entry in `packages/spec`. That is out of scope for this card and wrong on the facts, since nothing authorable moves. So the changeset carries the mapping as **"What a caller sees, before → after"** (`201`, stored as sent → `400 VALIDATION_FAILED` + `max_value` / `min_value`, nothing stored), plus the one-line fix. The detector reads that as no prescription, and `check:adr-0087-registration` passes. ## Acceptance notes - **`scale` / `precision` declared on a `progress` field parse, and nothing reads them at the write seam.** - Their `.describe()` texts name the types they bind on, and `precision`'s says "Not read on any other field type". - The metadata designer offers neither on `progress`: `ObjectFieldInspector` `isNumeric` covers only `number`, `currency` and `percent`. - No example declares them. Noted, not filed. - **objectui `SliderField`'s undeclared-bound defaults** (`min ?? 0`, `max ?? 100`) are narrower than the server, which enforces nothing undeclared. - There is one degenerate shape, not measured in a browser: a `progress` field declaring `min` above 100 and no `max`. The slider then clamps into `[min, 100]`, so it would emit `100`, which is now refused. - No field declares that shape. Noted, not filed. Holder: none. - **`docs/qa/platform-checklist/areas/records-forms.json`**, item `records-forms.field-type-constraints`: its steps do not reach `progress` bounds (triage said so). This belongs to the next checklist-author sweep. Holder: none. - **PR objectstack-ai#20469** (objectstack-ai#20264) edits the header's date line and the date / datetime arm of the same file. The hunks are far apart and there is no textual overlap. The later lander merges `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… field at the engine's filter door, and narrow a numeric one (objectstack-ai#20351) (objectstack-ai#20501) Fixes objectstack-ai#20351 Clause-②: no (narrowing) ## What this adds Lane (2) of the two-lane route objectstack-ai#20336 took on objectstack-ai#15661's precedent: the engine door that consults the contract PR objectstack-ai#20414 published in `@objectstack/spec/data` (`filter-number-comparand-declared-type.ts`). The contract half is untouched; `packages/spec` is not in this diff. - **The door**, `packages/objectql/src/number-comparand-declared-type-door.ts`, beside the text-operator and temporal doors. For each comparand at a judged position on a declared numeric field it asks `numberComparandDoorVerdict` and routes the answer: - `door-refusal`: throws `INVALID_FILTER` / 400 (the existing `invalidFilterError` envelope) in the contract's words, `numberComparandRefusalMessage`, before any driver is resolved; - `narrows`: rewrites the numeric string to its number, copy-on-write (the caller's filter is never edited, and a filter with nothing to narrow comes back by reference); - `passes` / `deferred`: leaves it alone. The door reads no string itself. The grammar, the judged types (`NUMERIC_VALUE_TYPES` by identity), the judged operators (`NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` / `NUMBER_COMPARAND_DOOR_LIST_OPERATORS`) and the words are all the spec's. - **Its calls in `engine.ts`, at the collection point only**, fifth after the temporal door in the same order everywhere: - `lowerWhereFilterArray`, object form (before `normalizeFilterComparandTypes`) and array form (on the lowered condition). So `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter` (`judgeWhereAdmission` calls the same function) all inherit it; - each per-aggregation `filter`, rooted at `aggregations[i].filter`, against the object's declared fields; - `having`, after the temporal `having` door, over the columns `aggregatedRowColumnClasses` classes `numeric` (`count` / `sum` / `avg`, and a groupBy or `min` / `max` of a numeric field). The `judgeWhereAdmission` docblock's pipeline list names the new door (comment only). - **A changeset**, `.changeset/20351-number-comparand-door.md`: `@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`, a FROM → TO line, and the ADR-0087 disposition `not-required (no-migration-prescription)` in the form PR objectstack-ai#20469 and PR objectstack-ai#20370 used. `@objectstack/objectql`'s root exports are unchanged: the door module is not re-exported from `index.ts` or `core.ts`, like its two siblings. ## What it does to the card's three answers Measured through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query`, three rows (5, 12, 30), on InMemoryDriver, SqlDriver on SQLite and SqlDriver on a local PostgreSQL 16.13 server: | position | comparand on a `number` field | base `3062e5001`: memory · SQLite · PostgreSQL | this branch, all three | |:--|:--|:--|:--| | `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"` | 200 no rows · 200 no rows · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` | | `where` | `$ne "abc"` | every row · every row · 500 | 400 | | `where` | `$eq ""` | no rows · no rows · 500 | 400 | | `where`, REST | `$gt "{current_user_id}"` (resolved to the user's id) | no rows · no rows · 500 | 400 | | per-aggregation `filter` | `$gt "abc"` / `$ne "abc"` | count 0 / count 3, on all three | 400 | | `having` on `sum(amount)` | `$gt "abc"` / `$ne "abc"` | no group / every group, on all three | 400 | | `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1 row on all three | | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | The last-but-one row is the narrowing's point: InMemoryDriver compared `"12"` as a string and matched nothing. ## Premise check, and the order's hypotheses - **H1 holds, reproduced at `3062e5001`** (the table above). SqlDriver's server-side log line on PostgreSQL reads `(22P02) … invalid input syntax for type numeric: "abc"`. - **H2: the collection point is where the order says**, and the new door sits after the temporal door at each call. `judgeFilter` passes through it: `judgeWhereAdmission` calls `lowerWhereFilterArray` (pinned: `judgeFilter` answers `INVALID_FILTER` / 400 for `"abc"` and `{ ok: true }` for `"12"`). **RLS / sharing / tenant predicates do NOT pass through it at runtime.** The middleware chain composes them onto the AST after this seam, and `plugin-security`'s `judgeCompiledComparands` runs only the two field-agnostic faces (`rls-compiler.ts`, the `[objectstack-ai#20212]` block). A policy predicate reaches this door at authoring instead: `validateRlsPredicateEnforceability` asks the engine's `judgeFilter` when the host hands the rule a judge. - **H3 holds.** The verdict is `numberComparandDoorVerdict` over `NUMBER_COMPARAND_DOOR_JUDGED_TYPES` with the scalar and list operators, and the words are `numberComparandRefusalMessage`. A numeric string is **narrowed** to its number (the verdict's `narrows`, as the contract review's judgment 7 asks). The pins assert the rewritten filter the driver receives, not only the 400s. - **H4: MySQL is NOT MEASURED.** No MySQL server is available in this container. The REST suite carries a MySQL cell, a named skip without `OS_TEST_MYSQL_URL`. - **H5: neither consults the same verdict everywhere.** - `service-analytics`: the ObjectQL strategy sends the caller's `where` into `engine.aggregate` and asks `judgeFilter` about the read scope (`assertReadScopeAdmittedByEngine`), so both inherit the door. The **NativeSQL strategy's decline** (`NativeSQLStrategy.canHandle`) declines a cross-field reference and an uninterpretable temporal comparand, but does not consult the number verdict. So a raw-SQL deployment compiles `amount > 'abc'` itself (read at source, not measured). - **The metadata save door:** RLS `using` is judged through `judgeFilter`, as above. No lint rule reads `numberComparandDoorVerdict` (`git grep` over `packages/lint/src` finds zero hits), so a stored view or report filter comparing a number field with a non-numeric string saves clean and is refused at query time. Both are reported as findings below and are not edited here. ## The staged `$empty` row: pinned at the door alone `NUMBER_COMPARAND_DOOR_CASES` carries PR objectstack-ai#20442's `unjudged` `$empty` row. The engine suite partitions it out of the end-to-end drive and pins it at the door alone: `findNonNumericComparand` answers `null`, and `narrowNumberComparands` returns the same reference. A partition guard asserts the table is split exactly. So the row can neither turn this suite red for a reason that is not the door's, nor vanish unnoticed. The contract's `formula` rows are partitioned the same way the text door's suite does it: they are pinned in the direction they answer (`INVALID_FIELD` / 400 from the objectstack-ai#8296 materializable door, one door earlier). The door's own walk is pinned to judge `f_formula_number` by its `returnType`. ## Tests (at `09da7a4cc`, the merged head, unless noted) - **New: `packages/objectql/src/engine-number-comparand-declared-type-door.test.ts`, 29 tests.** It drives the contract's case table through a real `ObjectQL` and a recording driver, per the contract header: - of the table's 137 cases, 51 refusals (the 52nd is the `f_formula_number` row), asserting `code` + `status` + `httpStatus`, every `mustMention` substring, and no driver read. All 8 refusal forms and every judged position are covered, both ways; - 23 `narrows` cases, asserting the driver receives `c.expectedFilter()` and the caller's filter is untouched; - 57 `passes` cases, reaching the driver unchanged; - the formula (5) and `$empty` (1) partitions above. Beside the table: - every verb (read and write, no read and no write on refusal); - `FilterArray` sugar, both refused and narrowed; - `$and` / `$or` / `$not`; - a placeholder refused unresolved; - `judgeFilter`; - the per-aggregation `filter`, refused at its path, with numeric strings counting what their numbers count; - `having` on `count` / `sum` / a numeric `min`, refused, narrowed, and a placeholder on `count`; - the four `findData` doors (`where` object, `$filter`, filter AST, implicit query parameter), both ways; - the registry-less, unknown-key, by-reference and unrecognised-combinator guards. - **New: `packages/rest/src/data-number-comparand-door.test.ts`.** It runs `POST /api/v1/data/:object/query` and `engine.find` / `engine.aggregate` over SqlDriver, with a cell per dialect: - `where`: 9 refused spellings; - the per-aggregation `filter`; - `having` on `sum` and `max(currency)`, on the native and the rows path; - numeric-string controls, equal to their numbers at all three positions. The SQLite cell always runs. The PostgreSQL cell ran against the local server: 3/3 passed at `09da7a4cc`.⚠️ **No CI job provisions `OS_TEST_POSTGRES_URL` for `@objectstack/rest`.** The `Temporal Conformance (live PG + MySQL)` job runs `driver-sql`'s suite, `metadata-protocol`'s `live-*` files and one `runtime` file, and a `driver-sql`-only pin cannot reach an engine door. So the live cells are red-capable and un-run in CI; the local run above is their measurement. - **Re-pinned, test side only.** Four existing pins asserted the old silent answer for a string on a numeric column: - `engine-aggregate-having-temporal-door.test.ts`: the three "a string on sum / count / avg keeps no group" rows move to a refusal pin in the number door's words; - `engine-aggregate-positions.test.ts`: the "unknown token on count" row moves to a text column, which neither field-aware door judges, and the count-column case is pinned in the new suite; - `rest-aggregate-numeric-having.test.ts`: three rows move from `KEPT` to a `REFUSED` table, SQLite and PostgreSQL both run locally; - `data-query-having-temporal-door.test.ts`: "a string on sum" becomes a number control plus a refusal pin. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 330 files, 6118 tests passed. `--project repo`: 1 file, 5 passed. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 219 files, 3930 passed, 40 skipped. `--project repo`: 1 file, 8 passed. - The live PostgreSQL run of the two PostgreSQL-capable REST files: 30 passed (15 live-postgres), 15 skipped (MySQL). - `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/rest typecheck`: exit 0. `check:test-typecheck` is OK for both, with no debt added (objectql 40 files / 234 errors held; rest 0 / 0). ## Ablation (reverse verification) The mutation is in the door's walk, which every position routes through: `if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue;` → `if (meta || 'ABLATION_20351') continue;`. It is made with `scripts/ablation-replace.mjs`: anchor 1 → 0, and blob `aa4a3247` → `56a5bdf6`. - **Mutated leg:** after `pnpm --filter @objectstack/objectql build`, `ablation-dist-preflight` found the marker in 4 built files. The objectql door suite went **20 failed / 8 passed**; the 8 are the guards and partitions that do not depend on the door firing. The REST door suite went **6 failed / 3 skipped**. The SQLite cell answered `200` with `records: []`, the PostgreSQL cell `500 DATABASE_ERROR`, and the per-aggregation `$in ["5","30"]` counted 0 instead of 2: the card's defect, back. - **Restore leg:** the blob is back to `aa4a3247` = HEAD and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 14 built files and the tree clean. Both suites passed again (28/28 and 6 + 3 skipped at that commit, `872d7708b`). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at **`09da7a4cc`** derives 65 commands, the same list as at the first merged head. All 65 ran with each exit code recorded before any pipe: - 63 exited 0 on the first pass; - `check:dual-build-cjs-loads` and `check:type-check-debt` answered exit 3 (PREREQUISITE NOT MET) until the whole workspace was built (`turbo run build --filter=!@objectstack/docs`, 72/72), then exited 0. `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The branch merged `origin/main` twice with true merge commits, no rebase and no force-push; the last merge base is `45f428d8f`. ## Acceptance notes - **`having` words.** A numeric aggregated column has no declared `FieldType`, so the door hands the verdict `number` (the member of the numeric class the column holds). The spec's words then read "compares a declared number field against … at having.total.$gt". The `not-a-number` clause ("backends answer it differently (PostgreSQL with a server error)") is the `where` fact: `having` is evaluated by the engine on every driver, and there it kept no group, or every group under `$ne`. The words are the contract's, and the path names the position. - **Out of the contract, measured, unchanged:** a boolean or a `Date` compared against a number field is not judged (the contract judges strings). `$gt true`: no rows on memory, every row on SQLite, 500 on PostgreSQL. A `Date`: no rows · no rows · 500. Both hold on the base and on this branch. Handed to the seat below. - **Not measured:** MySQL (no server in this container); `driver-mongodb` (the door sits in front of it); the NativeSQL analytics path (read at source). - **Line budget:** n/a (no `skills/**` path in the diff). ## Out of scope, handed to the seat (not filed by this dev) 1. **Class (a), reach measured at REST.** A boolean or a `Date` comparand against a number field answers `500 DATABASE_ERROR` on PostgreSQL. It is `POST /api/v1/data/:object/query` with `where: { amount: { $gt: true } }` against a `number` field, on a local PostgreSQL 16 server, on the base and on this branch. The contract review of PR objectstack-ai#20414 said to file this only if it answered 500; it does. Dedupe words: `boolean comparand number field postgres 500` · `Date comparand numeric column database_error` · `non-string comparand declared number type`. 2. **Carrier: none. Noted, not filed (read at source, reach not measured).** `NativeSQLStrategy.canHandle` does not consult the number verdict, so a raw-SQL analytics deployment does not fall through to this door. Dedupe words: `native sql decline number comparand` · `analytics raw sql non-numeric string`. 3. **Carrier: none. Noted, not filed (read at source, no named producer).** No authoring rule reads `numberComparandDoorVerdict`, so a stored view or report filter with a non-numeric string on a number field saves clean and is refused at query time. Dedupe words: `stored view filter non-numeric number field lint` · `authoring number comparand verdict`. 4. **Carrier: none. Noted, not filed.** The runtime RLS compile (`judgeCompiledComparands`) does not consult the number verdict. The authoring judge does, when present. Dedupe words: `rls compiled predicate number comparand` · `policy using string against number field`. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20264
Clause-②: yes (narrowing)
A
dateordatetimevalue now names a year from 0001 to 9999, or it is refused:INVALID_FILTER/ 400 as a comparand onwhere, a per-aggregationfilterandhaving, andVALIDATION_FAILED/ 400 (invalid_date) as a written value. This is triage's ruling on the card (5858474998): "The supported year range is 0001..9999 for bothdateanddatetime." Year 0000 joins the refused range, and thedatearm's padding covers 0001..0999. One range function in@objectstack/coreanswers both doors. No driver source is edited.Stop valve (claim 5872518067): it fired. On a local MySQL 8.0.46, a
datetimein years 0001..0099 is still stored right and read back a century late through mysql2's instant parser. That cell is returned asneeds_decision(see the section below). Everything else lands here.Patch round 1 (at-tier review 5874841530, amended claim 5874849531) touches
.changeset/20264-temporal-year-range.mdonly; no code or test changed.Clause-②is nowyes (narrowing), because@objectstack/coregains the root exportisOutsideTemporalYearRange. The levels, the BREAKING banner, the ADR-0087 marker and the FROM → TO line are unchanged.datetimewherebound in year 10000 answered 7/0/0 for$gt/$lt/$eq. The right answer is 0/7/0.date-column string whose instant names a year outside 0001..9999 (+010000-01-01T00:00:00.000Z,-000001-…, an out-of-range epoch-millisecond string) is refused with the same code and status onwhere, the per-aggregationfilterandhaving, but now in the year-class words.b559a5d0eis that commit (f16ff84ac) plus a merge oforigin/mainb810ddb6f. Every other file of this PR is blob-identical to311ce0640.What changed
packages/core/src/utils/temporal-storage-form.tsisOutsideTemporalYearRange(value, kind), the one range. The year is the one the kind's rule reads:datetime: the UTC year of the instantcanonicalUtcDatetimereads. That function now shares one privateinstantMsreader with the range, so the two cannot drift.date: a string's leadingYYYY-MM-DDyear. Otherwise, the UTC year of the instant the value names.time: never judged.datearm pads years 0001..0999 only. Year 0 keeps its unpadded spelling (0-06-15), like every other year outside the range. The rule stays total, and thedatetimespelling of any instant is unchanged.packages/core/src/utils/temporal-comparand.ts:isUninterpretableTemporalComparandasks the range for adateordatetimenumber,Dateor readable string.#20240's privateisOutsideCalendarDayYears(0..9999,dateonly) is removed.timeis untouched.packages/objectql/src/temporal-comparand-door.ts: the year-class refusal now covers both kinds, in words that name 0001 to 9999.where, the per-aggregationfilterandhavinginherit the range through the one predicate. The door asks core'sisOutsideTemporalYearRangewhich class a hit is, and never re-derives the range.packages/objectql/src/validation/record-validator.ts, thedate/datetimearm only: a readable value outside the range failsinvalid_date, with the same code, constraint and message key as any other invalid date. This covers insert, update, a multi-row update andengine.validate. The number arm (PR feat(objectql,spec)!: enforce a field's declaredprecision(total digits) at the write seam —max_precision(#19992) #20423) is not touched.Measured: base
b28550818vs headf2d96c96cScratch harness, not committed. Drivers: InMemoryDriver, and SqlDriver on SQLite, on a local PostgreSQL 16.13 (server
Asia/Shanghai) and on a local MySQL 8.0.46 (+08:00), withTZ=America/New_York. Doors: the engine and REST (POST /data/:object/query,POST /data/:object). Data: seven 2026 rows. The harness compares 236 cells: 160 identical, 76 moved. Every moved cell went from a misorder, a 500 or a stored non-day to a 400. No in-range cell and no control moved.wheredatetime,$gt/$lt/$eqDate, ISOINVALID_FILTERfilter$gt/having$gtonmin(datetime)INVALID_FILTERwheredatetimeINVALID_FILTERwheredateDate, ISO, bare0000-06-15INVALID_FILTERdate+010000-01-01T00:00:00.000Z/-000001-…VALIDATION_FAILEDdate/datetimeVALIDATION_FAILED0001-01-01,9999-12-31T23:59:59.999Z, 2026 controlH1 held: the card's table reproduces on
origin/mainin every cell. PR #20261 (#20240) and #20263 had already moved only thedate10000 / −1 cells, and those are unchanged.The PM's hypotheses
H2. The one place is core's
isOutsideTemporalYearRange. It is called by the predicate (and through it by the three comparand positions,judgeFilterand service-analytics' decline) and by the record validator. Each caller of the storage rule:resolveNowDefault/normalizeExpressionDefault) runs beforevalidateRecordon insert, so a defaulted year outside the range is refused.SqlDriver.formatInputandmemory-temporal.tsreadtemporalStorageFormand still see an out-of-range year, but only on a direct driver call that bypasses the engine. Both doors sit in front of them. Their source is not edited.mongodb-temporal.tskeeps its own copy (storageDatetimeValue/storageDateValue). This card needs no edit there, because both doors are engine-level. The copy's drift is pre-existing (no four-digit padding for aDateyear 1..999, no number arm ondate), and is noted below, not changed.H3. The write door is
validateRecord'sdate/datetimearm, reached from the engine and REST create, PATCH and the multi-row update. It is refused there through the same range.H4. These pins asserted year 0000 as accepted. Each is flipped as the ruling says:
temporal-comparand.test.tsIN_RANGEthe first millisecond of year 0;temporal-storage-form.test.tspadding cases0000-06-15and0000-01-01, now0-06-15and0-01-01;engine-date-year-range-door.test.tsIN_RANGE0000-01-01.These pins asserted a
datetimenumber,Dateor extended-year string as read, and are flipped too:leaves the datetime and time rules alone;leaves the datetime and time fields alone;havingUNCHANGEDan extended-year ISO on min(datetime);data-query-date-year-range.test.ts's datetime control. It now reads a 2026 instant.Stop valve: MySQL
datetimein 0001..0099 (needs_decision)The cell was measured live on MySQL 8.0.46, through REST create then query, at base and at head (identical):
0001-01-01T00:00Zreads back as2001-01-01T00:00Z,0001-03-04T10:00Zas2004-01-03,0050-…as1950-…,0069-…as1969-…,0070-…as1970-…, and0099-…as1999-….0100,0101,0500,0999and1000read back as written.CAST(… AS CHAR)) is right in every case.The mysql2 read parser is not touched here (ADR-0053 D-F2). The two options are in the
os-dev-reporton #20264. #20280 remains open for itsdatetimehalf, per ruling 5859414357.DELIBERATE CORRECTION: three pending release notes
Check Changesetwill be red on these three names by design. Each file gets one clause, correcting a sentence this change makes false in the same release. Do NOT restore them from base..changeset/20240-date-year-four-digits.md: theUnchangedclause "everydatetimeandtimecell, the same numbers included" gains the 0001..9999 narrowing..changeset/20203-epoch-ms-date-comparand.md: the parenthetical "refuses one whose year falls outside 0..9999" gains "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 … narrows that to 0001..9999"..changeset/20263-having-temporal-comparand-door.md: theUnchangedclause "an extended-year instant on adatetimecolumn, which that rule reads" gains "until 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 … refuses adatetimeyear outside 0001..9999".The claim's file surface names
.changeset/20264-*.mdonly. These three are an in-place addition, declared here and in the report.Tests and gates, measured at
311ce0640311ce0640is the merge oforigin/maindc0ab6a2einto this branch, and it carries PR #20423's record-validator number arm. These readings are its own.test:repo3 / 48.test:repo1 / 5.test:repo1 / 8.TZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1, live PostgreSQL 16.13 (Asia/Shanghai) and live MySQL 8.0.46 (+08:00).--listFiles.dateonly, 0..9999). The mutation went in throughscripts/ablation-replace.mjs: anchor 1 to 0, blob7801894eto8a7dc4b4. Core was rebuilt, and the dist preflight found the marker present in 2 built files.datetimecomparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; adatein year 0000 500s on PostgreSQL; adatewrite stores+010000-…verbatim #20264 cell: thedatetimeyear class, year 0, or the write door. Everydate10000 / −1 cell and every control stayed green.HEAD,git status --porcelainis empty, and after a rebuild the preflight finds the marker absent from 14 files. core 97, objectql 67 and rest 23 passed.record-validator.ts. Anchor 1 to 0, bloba7fd6b04tocfbeeebc. objectql was rebuilt, and the preflight found the marker present in 4 files.dispatch-gates --commandsat311ce0640derived 67 commands. All 67 ran, each exit code captured before any pipe.--ranreconciles them: 67 derived, 67 run, 0 NOT-MEASURED, with a derived zero.check-empty-changeset.mjs --base origin/mainexits 1, on exactly the three DELIBERATE CORRECTION names above.check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3). After a fullturbo run build, it exits 0..tsfiles, 0 errors and 0 warnings, counted from--format json. The population iseslint.config.mjs's**/*.{ts,…}block (line 971). The config enables no type-aware linting (its own note, lines 327-328), so no untouched file's verdict can move.check:driver-conformance: baseb28550818reads 50 covered / 0 DEBT / 0 exempt, and311ce0640reads 50 / 0 / 0.Acceptance notes
driver-mongodbkeeps its own copy of the storage rule (mongodb-temporal.ts). Itsdatearm pads no year and has no number arm, which driver-sql + driver-memory: an epoch-millisecond NUMBER against adatefield is read by neither driver's storage rule —where: { placed_on: { $gt: 1769940000000 } }returns 6 of 6 rows on SqlDriver and 0 on InMemoryDriver over REST #20203 and 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 name as known. Both doors of this card sit in the engine in front of it. It was not measured here (no MongoDB in this container). Carrier: none.timecolumns judge no year. This is measured at REST on memory and SQLite, at head.where t $gt "+010000-01-01T10:00:00Z"answers 200 with 3 of 3 rows, and$ltanswers 0, so the string is compared verbatim as text. The same instant in 2026 (10:00:00) answers 2 / 1. The predicate reads the string as an instant, while thetimerule hands it back unchanged. This is outside the ruling'sdate/datetimescope, so it is reported for the seat to file.datearm admits aDate.parse-readable string with no leadingYYYY-MM-DDinside the range. This is measured at REST on memory and SQLite, at head.POST /data/:objectwithd: "2026/07/15"answers 201, and the row reads back"2026/07/15", a stored non-day. The class differs from the year range, and the ruling scoped this card's write-door refusal to the range, so it is reported for the seat to file.Generated by Claude Code