Repository navigation
fix(spec)!: a boolean, a Date or an array compared against a number field is refused like a non-numeric string (#20502) - #20545
Conversation
…nd an array against a numeric field (wip) Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…mber-comparand-non-string
… judges A flag operator's boolean ($null / $exists / $empty) is never handed to the verdict; the widened verdict refuses a boolean, so the table derives the row's answer from the position first. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…ring forms Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…mber-comparand-non-string
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 4757b433e7745bf7d6dee3c184cce564fa11761a && git checkout 4757b433e7745bf7d6dee3c184cce564fa11761a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 288611e3e58f6d8af50b889abcdde3e413e0eaa0 829106fd061463f52c1c56b91883690d390d078b && git checkout -B drift-repro 288611e3e58f6d8af50b889abcdde3e413e0eaa0 && git merge --no-ff 829106fd061463f52c1c56b91883690d390d078b
node scripts/docs-audit/affected-docs.mjs --json 288611e3e58f6d8af50b889abcdde3e413e0eaa0
|
Contract reviewServed-tier: Read: card #20502 (body, triage grade 5877498426, claim 5878166283, os-dev-report 5882074829), card #20510 (body and triage 5879512564), PR #20545 (body, comments, file list, the 8-file net diff against the merge-base), the source at the head that the diff touches or calls ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
… commits that decided them (stage 4) (objectstack-ai#20548) Part of objectstack-ai#20234 Clause-②: no ## What changed This is stage 4 of the staged sweep: the `data/` remainder. It covers the six `packages/spec/src/data/` files stage 3 (PR objectstack-ai#20533, landed `03b19d9cfd`) left out because an open PR held them, and nothing else. They are `object.zod.ts`, `filter-logic-conformance.ts`, `object.form.ts`, `data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts`. Later stages cover the other areas, so this PR says `Part of`. The census below measured all six. Three of them carry comment or docblock sites that cite a tracker number answering 404. `data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts` carry none, so they are not in the diff. Every such site has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123). That is **19 sites on 19 lines in 3 files, covering 9 numbers**. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and it says in its own words what that commit decided. Where a PR number was already on the line (`PR objectstack-ai#13529`), it stays beside the commit as the link. No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/` records the decision behind any of the 9 numbers: a search for each number, with and without `#`, finds nothing there. So every anchor is a commit: **9 distinct shas**. Stage 3 had already read these commits and recorded them in PR objectstack-ai#20533's body. They were not copied from there. Each one was re-read against the current line it anchors: its own message or diff names the number it replaces, and it made the change the line describes. `object.zod.ts` and `filter-logic-conformance.ts` moved on `main` after stage 3 read them (PRs objectstack-ai#20521 and objectstack-ai#20523). Each site was therefore re-read at this base, `03b19d9cfd`. Only comments changed. Every source file keeps its line count (20 lines out, 20 in, over 3 files), so no line citation into these files moves. One of the 20 lines held no dead citation: `filter-logic-conformance.ts:249`, the first half of a sentence reflowed onto `:250`. No code token moves (see the guard below). **No tracker number is added.** Every tracker number on an added line was already in the hunk it replaces. `PR objectstack-ai#13529` stands on three added lines, and on the three removed lines of the same hunks. It is the link beside commit `9dac1ae01`, which stage 3 recorded the same way. No reference page under `content/docs/references/` moved: none of the rewritten docblocks projects into one (`check:docs` at the head: `226 generated files in sync`). The PR adds one `patch` changeset for `@objectstack/spec` (see Changeset below). ## Census: the six files, before and after **Instrument.** This is the instrument of stages 1 to 3. It sends REST `GET /repos/objectstack-ai/objectstack/issues/N` without following redirects, for every distinct number cited in `packages/spec/src/data`. The population is: - the citation gate's own exported `CITATION_RE` and `NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`, `objectstack-ai/objectstack`, `framework`, `pre-` or `post-`; - widened case-insensitively to `Pre-`, `POST-` and `Framework`, as in stage 3; - N of 100 or more, excluding `summon` heads. Each site is classified by the TypeScript parser as a line comment, a docblock, a block comment or a string. Two cross-checks close the population. First, a raw `#N` count in each of the six files equals the census rows plus the cross-repo rows in five files. In the other two it is one higher, and the extra is a second number after a slash inside a string (`objectstack-ai#5322/objectstack-ai#5134` in a `note`, `objectstack-ai#6262/objectstack-ai#6433` in a test title). Both answer 200. Second, no spelled citation (`issue N`, `PR N`, `card N`) occurs in any of the six. **Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at the start, after every 100 numbers and at the end: 24 of 24 lit (200) and 24 of 24 dead (404) over 8 checkpoints in the base run, and 21 of 21 lit and 21 of 21 dead over 7 checkpoints in the head run. | reading | tree | numbers probed | 200 | 404 | 301 or other | dead sites, all of `data/` | dead sites, the six files | lines | files | numbers | |---|---|---|---|---|---|---|---|---|---|---| | before | base `03b19d9cfd`, probed 2026-09-29T01:11:59Z to 01:15:49Z | 601 | 572 | 29 | 0 | **77** | 19 | 19 | 3 | 9 | | after | head `53c9070dfd`, probed 2026-09-29T01:25:55Z to 01:29:35Z | 597 | 572 | 25 | 0 | **58** | 0 | 0 | 0 | 0 | The head probe found no number newly dead since the base probe: the same 572 numbers answer 200. The base reading of 77 equals stage 3's after reading at `96fd49caa2`. **Per file.** Cited sites here are every in-repo citation the population reads, live or dead. | file | cited sites (base) | dead sites before | by class | dead sites after | |---|---|---|---|---| | `object.zod.ts` | 120 | 15 | 8 docblock, 7 line comment | 0 | | `filter-logic-conformance.ts` | 97 | 3 | 2 docblock, 1 line comment | 0 | | `object.form.ts` | 31 | 1 | 1 line comment | 0 | | `data-engine.zod.ts` | 48 | 0 | | 0 | | `data-engine.test.ts` | 29 | 0 | | 0 | | `hook.form.ts` | 0 | 0 | | 0 | None of the 19 sites is a string, so this stage leaves no string token behind. ## Per-number table | number | sites / lines | anchor: what it decided | |---|---|---| | `objectstack-ai#8772` | 4 / 4, `object.zod.ts:2718`, `:2731`, `:2744`, `:2910` | `75b7c240a`: Direction 2 of the 2026-08-16 maintainer ruling. `ObjectSchema.create()` forces `required: true` on a `master_detail` reference under `controlled_by_parent` and refuses an explicit `required: false`. Raw parse stays tolerant, and runtime tolerance is the ruling's other half. Its changeset records the measurement that only the security gate closed that shape while the declaration surface accepted it (`:2731`). ADR-0055 stays cited beside it. It is the same anchor stage 3 gave `object.test.ts` | | `objectstack-ai#10165` | 2 / 2, `object.zod.ts:818`, `:1036` | `801296050`: `ttl.onlyWhen` with the canonical null predicate (maintainer ruling 2026-08-20, option A). One shared `onlyWhen` union, and both of `retention.onlyWhen`'s conflicts mirrored. Its diff wrote both `[objectstack-ai#10165]` blocks | | `objectstack-ai#10347` | 3 / 3, `object.zod.ts:1006`, `:1042`, `:1049` | `530c1df65`: the Archiver honours a declared `ttl`. It selects by the ttl cutoff on `ttl.field` when `ttl` is declared, and by `created_at` / `archive.after` otherwise (maintainer ruling 2026-08-20) | | `objectstack-ai#10527` | 1 / 1, `object.zod.ts:1005` | `5649efbf9`: refuses a diverging retention + ttl + archive triple at parse time. Its diff wrote this very paragraph | | `objectstack-ai#11195` | 1 / 1, `object.zod.ts:1791` | `b37231883`: `UserActionsConfigSchema` adopts `group` / `hideFields` / `rowColor` (the "last three" the line names) | | `objectstack-ai#11408` | 1 / 1, `object.zod.ts:2189` | `f11fc61c5`: declares `editMode` on the object document (maintainer ruling 2026-08-24, the `objectstack-ai#10144` declare-or-rule-out family, which stays cited) | | `objectstack-ai#13608` | 3 / 3, `object.zod.ts:2317`, `:2354`, `:2366` | `fc9ba76a5`: `publicSharing.eligibility` is held at redemption, not only at mint, fail-closed, with the undifferentiated `null` refusal. Its changeset heads with objectstack-ai#13608. It is the same anchor stage 1 gave `contracts/share-link-service.ts` | | `objectstack-ai#13195` | 3 / 3, `filter-logic-conformance.ts:190`, `:250`, `:525` | `9dac1ae01`, PR objectstack-ai#13529's squash commit, which stays as the link: `$exists` means has-a-value on driver-memory's live mingo path, its analytics face and driver-mongodb's `translateFilter` (the "last three key-presence exits") | | `objectstack-ai#12868` | 1 / 1, `object.form.ts:256` | `c459da6bc`: narrows the per-option `default` key out of the form-view options vocabulary, which offered a key nothing on that surface read. Commit `e808890958`, which wrote this line, names objectstack-ai#12868 as the same offer-vs-door class | The shas were checked at the base and again at `origin/main` `288611e3e5`. Every one matches exactly one commit (`git rev-parse --disambiguate`, count 1). Every one is an ancestor (`git merge-base --is-ancestor`, exit 0 for 9 of 9). The control leg `e9584681a4` also exits 0, and the repository is not shallow. For each commit, a grep of its own message or diff finds the number it replaces. Seven of the nine name it in the message. `fc9ba76a5` names it in its diff (20 lines, including its changeset heading), and so does `c459da6bc` (8 lines, including its changeset heading). Wordings to check, each true of its commit: - `object.zod.ts:2731` now reads 「closes that shape, and commit 75b7c24 records that the declaration and the enforcement disagree」. The measurement was the card's. The commit's changeset records it: "only the security gate closed that shape while the declaration surface accepted it". - `object.zod.ts:2189` reads 「Declared here by commit f11fc61's maintainer ruling」, and `:2744` reads 「the other half of commit 75b7c24's ruling」. This is stage 3's wording for the same relation (`object.test.ts`, 「the other half of commit 75b7c24's ruling」): the commit that landed the ruling and quotes it. - `object.zod.ts:1049` reads 「That is the whole of what [commit 530c1df] changed here」. Commit `52db1d1f2a` wrote the paragraph. `530c1df65` is the change it describes. ## Mechanical guard: no code token moves The check compares leaf tokens with comments stripped, base `03b19d9cfd` against head `53c9070dfd`. It uses the TypeScript parser's leaf tokens (TypeScript from the head's lockfile), so template literals are scanned in context, and it excludes JSDoc nodes. It ran over all 3 touched `.ts` files. It is the stage-3 instrument, unchanged. - Real run: 13,624 base tokens (object.zod.ts 8,774, object.form.ts 3,226, filter-logic-conformance.ts 1,624), **0 files with a token change** (exit 0). - Comment-insertion control (`object.form.ts`): 0 files changed, as expected (exit 0). - Positive control (a declaration inserted into `object.zod.ts`): 1 file reads DIFFER at token 1629 (exit 1). - Positive control (one digit changed inside the `objectstack-ai#5322/objectstack-ai#5134` `note` string in `filter-logic-conformance.ts`): 1 file reads DIFFER at token 889 (exit 1). Line balance: `object.zod.ts` +15 / -15, `filter-logic-conformance.ts` +4 / -4, `object.form.ts` +1 / -1. Line counts are equal at base and head: 3,240, 621 and 751. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/spec` is included. It says only that the provenance comments were re-anchored. `Clause-②: no`: no export, key, value or type moves (the guard above). Measured on the head's built package: `object.zod.ts` is `src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten comments also reach `dist`: - `9dac1ae01` appears in `dist/data/index.d.ts` (the `filter-logic-conformance.ts` docblock) and in 4 bundled `.js` files; - `fc9ba76a5`, `f11fc61c5` and `b37231883` each appear in 22 bundled `.js` files, and `c459da6bc` in 12; - the positive control, the pre-existing `object.zod.ts` sentence 「Fail-CLOSED at both points」, appears in 11 bundled `.js` files. ## Gates (head `53c9070dfd`) - **Citation judging pass, run as CI runs it:** `pnpm check:issue-citations && node scripts/check-issue-citations.mjs` exits 0. The self-test passes 73 cases in 7 batteries. The live run judged 6 citations across 3 files: 3 resolve (`objectstack-ai#9138` twice, `objectstack-ai#11410`) and 3 resolve as a pull request (`objectstack-ai#13529`, the link). - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at the head derived 79 families, and all 79 exit 0. `--ran` reports 79 run, 0 NOT MEASURED, 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` ran first, under the shared verify lock: 71 of 71 tasks, VERDICT command-exit 0. So no gate met an unbuilt prerequisite. - `pnpm --filter @objectstack/spec run check:generated`: under the lock against that build, `All 15 generated artifacts are up to date`, VERDICT command-exit 0. - **Tests and typecheck:** - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data` under the lock: Test Files 107 passed (107), Tests 3527 passed, 1 todo (3528), VERDICT command-exit 0. It covers every test in `data/`, among them `object.test.ts`, which reads these schemas. - The 13 spec suites outside `src/data` that read the touched files' source text or pin their line numbers, under the lock: Test Files 13 passed (13), Tests 544 passed (544). They are stage 3's 12 (`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`, `src/api/api-entry-graph.pin.test.ts`, `src/contracts/scoped-context.test.ts`, `src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`, `src/system/constants/platform-object-names.test.ts`, `src/type-alias-convention.pin.test.ts`, `src/ui/dashboard.test.ts`) plus `src/shared/union-author-message-pins.test.ts`, which pins `data/object.zod.ts:855`. - `pnpm --filter @objectstack/spec typecheck` under the lock exits 0, including `check:test-typecheck` (53 files, 251 errors, 138 pinned signatures held). - **Lint, as a proven narrowing at the head:** `eslint --no-inline-config --format json` over the 3 touched `.ts` files gives 3 files, 0 errors and 0 warnings. All 3 are in eslint's own population (`isPathIgnored` is false for each). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, which its own line 328 states), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **Base.** The branch forked from `03b19d9cfd`, stage 3's landing. `origin/main` then moved two commits (`05077d4c26`, PR objectstack-ai#20532, and `288611e3e5`, PR objectstack-ai#20536), and neither touches `data/`. `dispatch-gates` flagged its derivation as stale because `scripts/regen-artifacts.mjs` had moved, so `origin/main` was merged in (`53c9070dfd`, a clean merge with no driver-deferred path) before the gates ran. The PR's delta against `origin/main` is exactly its 4 files. `origin/main` has since moved two more commits: `7e36a3cd7c` (PR objectstack-ai#20531) and `ba5927f714` (PR objectstack-ai#20460). Neither touches `data/` or anything the gate derivation reads, and a re-derivation prints the same 79 commands. A no-driver `merge-tree` of the head onto `ba5927f714`, from a bare shared clone, exits 0. So there is no second merge. - **Open PRs, re-read at 2026-09-29T02:01Z:** 9 open PRs, and none touches any of the six files. The `data/` files open PRs touch are objectstack-ai#20458's `analytics*` files, objectstack-ai#20504's `driver/turso.*`, and objectstack-ai#20545's `filter-number-comparand-declared-type.*`, which is disjoint. Since the claim, PR objectstack-ai#20460 has landed (`ba5927f714`) without touching `filter-subtree-provenance.ts`. That file's 3 dead sites are outside this claim's fence, so they are left for a later stage. - **The rung.** Two anchored changes also have ADR-0087 entries in `packages/spec/src/migrations`: `cbp-master-detail-required-forced` for objectstack-ai#8772, and `form-view-option-default-retired` for objectstack-ai#12868. The second entry's own header names commit `c459da6bc`. This PR takes the commit rung, as stages 1 to 3 did. The D3 id is the more durable in-repo record, if the ruling's first rung is later read to include those entries. - **What stays in `data/` after this stage: 58 dead sites.** - **12 comment sites in files other open work still holds.** `analytics.zod.ts`, `analytics-strictness-batchd.test.ts` and `analytics-date-range-two-bound-window.test.ts` hold 5 (objectstack-ai#20300, PR objectstack-ai#20458). `driver/turso.zod.ts` and `driver/turso.test.ts` hold 4 (objectstack-ai#20437, PR objectstack-ai#20504). `filter-subtree-provenance.ts` holds 3. It was held by objectstack-ai#20367 and is now free (see above). - **3 comment sites stage 3 left on purpose.** They are the test-read `[objectstack-ai#6259]` marker at `api-derivation.ts:163`, the test comment at `api-derivation.test.ts:232` that names it, and `field.zod.ts:370`, whose `objectstack-ai#6111` is objectui's number. - **43 string sites**, left as tokens: 41 test strings (2 of them in the held analytics and turso test files) and the 2 exported `AGGREGATION_CASES` note strings in `aggregation-conformance.ts` (`:398`, `:407`, objectstack-ai#11065), which objectstack-ai#20489's claim holds. - **Outside `data/`,** the card's other remaining items are unchanged: the migrations and ui areas, the `liveness/**` notes, the `why` strings, the `PROVENANCE_WAIVERS` reason, and `rest-server.zod.ts`. - **The citation gate's reach.** It defers `packages/**/*.test.ts`. No test file is touched here, so all 3 touched files are in its judging population. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…L only at `where` (objectstack-ai#20577) Fixes objectstack-ai#20510 Clause-②: no ## What changed Two false phrases in the number-comparand refusal, at two positions: 1. **`having` said "a declared number field".** The column at `having` (`total`, an aggregation alias or a groupBy projection) has no declared `FieldType` of its own — the door hands the verdict a synthesized `{ type: 'number' }` for whatever the engine classes numeric (`aggregatedRowColumnClasses`). It now says "a numeric aggregated column". 2. **The `not-a-number` / `boolean` / `date` clauses named PostgreSQL's server error at every position.** Only `where` (both spellings) ever reaches a live driver bind; `having` and the per-aggregation `filter` are evaluated by the engine itself, in JS, before any row is read, on every driver alike — no driver ever sees the comparand there. The clause now appears only where the driver actually binds it. **Measured, and the card's own claim corrected:** the per-aggregation `filter`'s column is a real declared field, not an aggregated one — that position narrows the object's RAW source rows *before* aggregation runs, against the object's declared field map (confirmed by both the door's own header and the existing pin at `aggregations[1].filter.f_number.$gt`, which already reads `'f_number' compares a declared number field`). So only its PostgreSQL clause moves; its "declared … field" wording is unchanged. ### Before / after | position | column | before | after | |:--|:--|:--|:--| | `where` | declared field | `… compares a declared number field against "abc" …, which is not a number: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error).` | unchanged, byte for byte | | per-aggregation `filter` | declared field | `… compares a declared number field against "abc" …: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error).` | `… compares a declared number field against "abc" …: it has no numeric reading.` | | `having` | aggregated column | `… compares a declared number field against "abc" at having.total.$gt, which is not a number: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error).` | `… compares a numeric aggregated column against "abc" at having.total.$gt, which is not a number: it has no numeric reading.` | The accept set is unchanged — no refusal added or removed, words only. ### Mechanism `NumberComparandRefusalSite` (`@objectstack/spec`) gains two optional fields, both defaulting to the pre-existing `where` behaviour so an existing site (or one built before this change) renders identically: - `aggregated?: boolean` — the column is an aggregated-row column, not a declared field. Only `having` sets it. - `boundByDriver?: boolean` — this position reaches a live driver bind. Only `where` sets it `true`; default is `true` for backward compatibility. The engine door (`@objectstack/objectql`) passes both at its three call sites — it already knew which position it was building a refusal for; no second rule, and no driver-level change. ## Acceptance notes - Out of scope, noted only: a `{ $field }`-adjacent structural filter shape (`where { amount: { "a": 1 } }`) was already flagged by PR objectstack-ai#20545's at-tier review as a class (a) finding for the filing seat to pick up; this PR does not touch it. - `packages/rest`'s `data-number-comparand-door.test.ts` asserts no phrase at `having` / the per-aggregation `filter` (only status/path), and its `where` phrase pin is unaffected — left untouched, per the claimed file surface. ## Tests (all green, fresh builds) - `@objectstack/spec` build, then `vitest run filter-number-comparand-declared-type.test.ts` — **47/47 passed**, including new cases for `aggregated` / `boundByDriver` and a byte-for-byte pin that the pre-existing `where` message is unchanged; `pnpm typecheck` — clean (no new errors). - `@objectstack/objectql` build, then `vitest run engine-number-comparand-declared-type-door.test.ts engine-aggregate-having-temporal-door.test.ts` — **85/85 passed**; `pnpm typecheck` — clean. - `@objectstack/rest` build, then `vitest run data-number-comparand-door.test.ts` — **4 passed, 8 skipped** (PostgreSQL/MySQL cells skipped as before; no code change was needed there). - `check:api-surface`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:lean-entry-closure` — all green against the fresh build (the widened `NumberComparandRefusalSite` type doesn't move the name-level api-surface snapshot). - Derived gate sweep (`dispatch-gates.mjs`, 86 commands): all green except 3 that need the WHOLE workspace built (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:type-check-debt --re-measure`) — out of this diff's local scope, left to CI. Full command list and exit codes are in the report comment on the issue. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ Co-authored-by: Claude <noreply@anthropic.com>
…ongs is refused INVALID_FILTER / 400 on every driver (objectstack-ai#20546) (objectstack-ai#20744) Fixes objectstack-ai#20546 Clause-②: no (narrowing) ## What this changes A plain object with no `$`-operator key where a scalar field's value belongs, for example `where: { amount: { a: 1 } }` on a `number` field, is now refused with `INVALID_FILTER` / 400. The refusal names the field, its declared type, the object's keys (never its values) and the path. It runs before any driver is resolved, on every driver, at the three positions the engine judges: `where` (object form and `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter`), `aggregations[i].filter`, and `having`. **Landing site: the number-comparand door's walk, as a second arm. It adds no second traversal.** Triage said: "If the same walk is the natural site, it lands serially after that PR, in the same walk. ⛔ No second traversal of the filter." PR objectstack-ai#20545's walk (`walkCondition` in `number-comparand-declared-type-door.ts`) is the only filter walk the engine runs at all three positions with each column's declaration in hand. It already stood on the exact branch: a field spec with no `$` key, which it stepped past (`return kept(spec)`). It now asks one question per field key before the number arm runs: - `packages/objectql/src/no-operator-object-door.ts` (new) holds the arm's classification (`holdsScalarValues`), its structure test (`isNoOperatorObject`) and its words. ⛔ Nothing in it walks a filter. - `number-comparand-declared-type-door.ts`: the walk's per-key resolver now supplies two facts, the number arm's meta and the column's scalar-valued type. The first refusal the walk meets is either arm's. - `having-filter.ts`: `aggregatedRowColumnTypes` reads each aggregated column's type off the query. `aggregatedRowColumnClasses` is now derived from it, so the class and the type are one reading of the query. The `having` arm needs the type because the `text` class lumps a `json` or `lookup` groupBy in with a real text column. - `engine.ts`: the `having` call passes the types; the other hunks are comments. PR objectstack-ai#20738's warning-text region is untouched. **Which columns are judged (H3): a closed definition from spec's classes.** `SCALAR_FILTER_HEAD_TYPES` (spec's published "stores one scalar value" set, derived from the ADR-0104 value classes; the objectstack-ai#8371 dotted-head verdict reads the same set) with or without `multiple: true`, plus `MULTI_OPTION_TYPES`. The accepted side is never judged: relation types (`lookup`, `master_detail`, `user`, `tree`, single or multiple), structured-JSON types, file and media types (the objectstack-ai#8371 carve-out: a legacy stored value is an inline object), `formula` (refused one door earlier, `INVALID_FIELD`), undeclared keys, and unknown types. ## Before, measured on `origin/main` `fbec216e2d` Through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query` (both doors answered alike). Three rows (`amount` 5 / 12 / 30; `owner` u1 / u2 / u1 with u1 in region NA; `meta` `{a:1}` / `{a:2}` / `{b:1}`). InMemoryDriver, SqlDriver on SQLite (better-sqlite3), SqlDriver on a live PostgreSQL 16.13: | position · filter | InMemoryDriver | SQLite | PostgreSQL 16 | |:--|:--|:--|:--| | `where` `{ amount: { a: 1 } }` (number, the card) | 200, no rows | 400 `INVALID_FILTER`, the driver's words ("cannot be bound") | same as SQLite | | `where` `{ title: { a: 1 } }` (text) | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` single select, boolean, date, autonumber, `multiple: true` select, `multiselect`, `tags` | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` `{ $not: { amount: { a: 1 } } }` | 200, **every row** | 400 ("not one this driver evaluates") | same | | `where` `{ $or: [{ amount: { a: 1 } }, { amount: 30 }] }` | 200, 1 row | 400 | 400 | | `where` sugar `[['amount', '=', { a: 1 }]]` | 200, no rows | 400 | 400 | | `where` `{ amount: {} }` | 400, the objectstack-ai#5240 words | 400, the objectstack-ai#5240 words | same | | `aggregations[1].filter` `{ amount: { a: 1 } }`, `{ title: { a: 1 } }`, `{ amount: {} }` | 200, count 0 | 200, count 0 | 200, count 0 | | `having` `{ total: { a: 1 } }` (a `sum`), `{ title: { a: 1 } }` (a groupBy), `{ total: {} }` | 200, no group | 200, no group | 200, no group | | control: `where` `{ owner: { region: 'NA' } }` (lookup; `master_detail` and a multiple lookup alike) | 200, no rows | 400, the driver's words | same | | control: `where` `{ meta: { a: 1 } }` (json) | 200, 1 row (deep equality) | 400, the driver's words | same | | control: `where` `{ amount: { $gt: { $field: 'cap' } } }` | 200 | 200, 2 rows | 200, 2 rows | ## After, the same run on this branch Every non-control row above answers `400 INVALID_FILTER` in the engine's words on all three drivers, at the path the object sits at (`where.amount`, `where.$not.amount`, `where.$or[0].amount`, `aggregations[1].filter.amount`, `having.total`). No read of the object runs. Every control answers exactly as before: the lookup, master-detail, multiple-lookup and JSON filters reach the driver as written, and so do the file field, the `$field` reference, the undeclared key and the `id` key. Example of the words: ```text find('rp_ledger_20546'): filter on 'amount' puts an object with no operator key (keys "a") at where.amount, where a value of the declared number field 'amount' belongs. An object with no "$" operator is filter structure, not a value: beneath a field it is a nested-relation condition, which only a relation field (lookup, master-detail, user, tree) can carry, or a whole-value match, which only a JSON-bearing field can hold. A number column holds scalar values — one, or a list of them — so no record can match an object there, and an empty answer would read exactly like a real one. The filter was NOT applied. Compare 'amount' with a value ({ "amount": VALUE }) or an operator ({ "amount": { "$eq": VALUE } }). ``` ## Hypotheses (zone 2), which held - **H1: held, with one refinement.** `lowerWhereFilterArray` is the seam, and `narrowNumberComparands` is called there on both branches (the object branch and the lowered array branch). The number door's walk was number-specific only at its per-field gate (`numberComparandFieldVerdict(meta) !== 'judged'`), and its `where` resolver already returned every declared field's type. The text door and the temporal door each walk too, but neither runs at `having` with a column declaration, so neither covers every position. The number door's walk is the one walk that does. The arm rides it, and no traversal was added. - **H2: held, and all three positions are reached.** Measured above: `where` answered per driver, and `aggregations[i].filter` and `having` answered a silent empty on every driver. Each is pinned. - **H3: refined.** The closed definition is above. Multi-value fields were measured on their own: a `multiple: true` select, `multiselect` and `tags` split exactly as a scalar field does (memory 200 no rows, SQL 400). That includes `{ tags: { 0: 'x' } }`, the spelling the objectstack-ai#8371 multi-value carve-out exists for: the nested-object form does not reach an array member on InMemoryDriver. So they are judged. A multiple lookup stays on the relation side. A `{ $field }` reference carries a `$` key, so it is never this arm's (measured: served 2 rows on SQL, as before). - **H4: held.** No driver file changes (`git diff fbec216 HEAD -- packages/drivers` is empty). The SQL driver's own `INVALID_FILTER` stays as defence in depth for driver-direct callers and for the columns this arm does not judge (the lookup and JSON controls above still meet it). ## Tests All from `b50627aca9` or from a commit whose non-test source is byte-identical to it (the last two commits touch only the changeset). - `pnpm --filter @objectstack/objectql test`: **338 files / 6704 tests passed**. `test:repo`: 1 file / 5 passed. - `pnpm --filter @objectstack/objectql typecheck`: exit 0 (`check:test-typecheck` OK, the debt ledger held). - `pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test`: **229 files / 4391 passed / 63 skipped** (the live-dialect cells, no URL set). - New pin `packages/objectql/src/engine-no-operator-object-door.test.ts` (17 tests). It uses a recording driver, which is InMemoryDriver's cell by construction because the arm answers before a driver is resolved. It covers every scalar class, `{}`, every verb and the judge, `$and` / `$or` / `$not` paths, sugar, the three REST doors into `findData`, the per-aggregation filter, `having` (sum, groupBy, max of a date, a month bucket), the accepted side at all three positions, a `Map` and the classification GUARD over every `FieldType`. - New pin `packages/rest/src/data-no-operator-object-door.test.ts`: SQLite always, PostgreSQL and MySQL where `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set. `where` refusals, the per-aggregation filter, `having`, and the **two controls** (a lookup nested-relation filter and a JSON object comparand: the driver is asked, and the answer is never the arm's). Local run with a live PostgreSQL 16.13: **8 passed (sqlite 4, live postgres 4) / 4 skipped (mysql, no URL)**.⚠️ As with the sibling door suites, no CI job sets these URLs for `@objectstack/rest`, so the live cells run only locally. - Consumer suites (downstream of `@objectstack/objectql`): `service-analytics` 137 files / 3216 passed; `plugin-security` 147 files / 3202 passed / 23 skipped. The other downstream consumers are declared to CI. **Reverse verification (ablation), from the committed fix.** It ran through `scripts/ablation-replace.mjs` (WRAP mode, trap-restored). The anchor `if (facts.scalarType !== null && isNoOperatorObject(value)) {` was replaced by `if (facts.scalarType === '__ablated_20546__' && …) {`. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob `16151b29f6c1` → `0e8acf882100`. Then objectql was rebuilt and `ablation-dist-preflight` reported the marker present in 4 built files. Predicted direction: red. Observed: red. The objectql pin went **10 failed / 7 passed**: every refusal case failed, and every control and GUARD stayed green. The rest pin went **4 failed / 4 passed / 4 skipped**: the `where` and aggregate refusals failed on SQLite and live PostgreSQL, and the controls stayed green. Restore leg: blob equals HEAD (`16151b29f6c1`), `git diff HEAD` empty, the whole-tree `git status --porcelain` empty, rebuilt, `--absent` preflight (marker absent from all 14 built files), then both pins green again (17 / 17; 8 passed + 4 skipped). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `b50627aca9` derived 65 commands. All were run on `b50627aca9`, and `--ran` reconciles them: **65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN**. 63 exit 0, including `check:adr-0087-registration --base origin/main` (`not-required (no-migration-prescription)` accepted), `check:changeset-no-major`, `check:empty-changeset`, `check:doc-authoring`, `check:nul-bytes`, `check:engine-double-contract`, `check:where-matcher`, `check:driver-memory-census`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` and `check:query-options-erasure`. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: each exits 3 (PREREQUISITE NOT MET) because it reads the built closure of every package, and this box built only the objectql/rest closure. CI's `Lint & Repo Gates` builds that closure. - Driver-related families read before (on `fbec216e2d`, a detached comparison worktree) and after (on `b50627aca9`): - `check:where-matcher`: 440 matchers, 440 correct or loudly refusing, before and after. - `check:driver-memory-census`: 12 bindings / 2 ruled consumers, before and after. - `check:engine-double-contract`: pinned rows 825 → 825 and discovered files 953 → 953. Test files went 4231 → 4233 and production files 2997 → 2998, which are the two new tests and the new module. No new fake engine. - Lint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the 7 changed `.ts` files, at `b50627aca9`, found **7 files, 0 errors, 0 warnings**. The checked population comes from eslint's own config: `calculateConfigForFile` answers `isPathIgnored=false` for all 7. The file count comes from the JSON output (7 results). Untouched files cannot change verdict: `parserOptions.project` and `projectService` are `null` for every file, so type-aware linting is not enabled and this diff cannot move any untouched file's lint result. ## Changeset `.changeset/20546-no-operator-object-on-scalar.md`: `@objectstack/objectql` `minor`, a BREAKING banner, `Clause-②: no (narrowing)` and the ADR-0087 marker `not-required (no-migration-prescription)`, following the objectstack-ai#20501 / objectstack-ai#20545 precedent. Its "Who is affected" section names a caller that sends the shape to the in-memory driver: a test suite, a local or embedded deployment on `InMemoryDriver`, or a flow or hook calling the engine in-process. No export or published type changes: the door modules are internal, and `@objectstack/objectql`'s root and `./core` exports are unchanged. ## Acceptance notes - **Out of scope, reported to the PM, not filed:** the two controls still answer two ways, because this card's direction keeps them accepted. `{ owner: { region: 'NA' } }` on a `lookup` gives memory 200 with no rows and SQL 400. `{ meta: { a: 1 } }` on a `json` field gives memory 200 with 1 row and SQL 400. So does the undeclared `id` key (`{ id: { a: 1 } }`: memory 200 no rows, SQL 400), because the registry's declared map carries no `id`. Spec's `FilterCondition` declares the nested-relation form, but no data-path driver serves it. objectstack-ai#20546 is not the card for that. - File and media fields keep the objectstack-ai#8371 carve-out and stay unjudged. `{ photo: { url: 'x' } }` answered memory 200 with no rows (on fresh rows) and SQL 400. A legacy inline value could still match on memory. - At `where`, a `{}` under a judged column is now answered in the engine's words instead of each driver's objectstack-ai#5240 words, with the same `INVALID_FILTER` / 400 envelope. Under a column this arm does not judge, `{}` keeps the drivers' refusal. - `findNonNumericComparand` (internal, tests only) still answers the number arm alone. When the walk's first refusal is the new arm's, it answers `null`, and its docblock says so. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ainst a boolean field is refused like a non-boolean string (objectstack-ai#21382) (objectstack-ai#21404) Fixes objectstack-ai#21382 Clause-②: yes (narrowing) ## What this changes One verdict, widened, as triage directed on the card (grade `5949762416`, inheriting the number door's direction `5877498426`): the published boolean-comparand verdict now refuses any comparand against a declared `boolean` / `toggle` field (or a `formula` returning `boolean`) that is outside its accepted set. The engine's boolean arm consumes that verdict and nothing else; it carries no second rule. This is the boolean twin of PR objectstack-ai#20545 (`b05743433b`) on the number door, and it follows that PR's shape and words where the two contracts have the same parts. - **The verdict**, `packages/spec/src/data/filter-boolean-comparand-declared-type.ts`. `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0` (`2`, `-1`, `0.5`, `NaN`), a `Date` and an array, at a scalar slot or as a list member. The string rule is byte-for-byte what PR objectstack-ai#21372 published. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"`, `"0"`; `null` is still the null test. - **A `bigint` is read as the number it names**: `1n` / `0n` narrow like `1` / `0`, and any other `bigint` is refused as a number. See *The bigint reading* below for why this is the only reading that gives one answer at every position. - **What still passes**: a boolean, `null`, a `{ $field }` reference, and every value outside the comparand-type door's accepted set (`undefined`, a plain object, a `Map`). That door already refuses those on every field, in its own words (see *Objects* below). - Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` / `NonBooleanComparandForm`. The refusal's `form` widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`. The site's `value` was already `unknown`; it now carries a non-string. `booleanComparandDoorVerdict`'s signature is unchanged (relevant to objectstack-ai#21376, which will consume it). - Three refusal clauses. None names a backend, because PostgreSQL's server error was measured at `where` only. The engine evaluates the per-aggregation `filter` and `having` itself, and this contract has no driver-bound site flag. A `Date` renders as `Date(ISO)` and a non-finite number by name, not as JSON (`NaN` would otherwise print as `null`, the null test). - The derived case table gains a `value` group: `-1` at `$ne` on every judged field, and `2` and a `Date` at every judged position of `f_boolean`. An array sits at every judged position except the equality slots, where the comparand-shape door refuses one first. Beside them are four passing rows. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals. `freshComparand` copies a `Date` and an array per filter, as the number table does. No `bigint` row is in either table, so every filter a suite builds survives `JSON.stringify`. - **The arm**, `packages/objectql/src/boolean-comparand-declared-type-door.ts`: comments only. The walk already routed every comparand to the verdict and carried the refused value as written. `number-comparand-declared-type-door.ts` (the shared walk) is untouched. - **Tests**: the spec contract suite, the objectql arm suite, and a new REST three-dialect cell `packages/rest/src/data-boolean-comparand-door.test.ts`, beside `data-number-comparand-door.test.ts`. In that cell SQLite always runs, and PostgreSQL / MySQL are named skips without their URL. - **Generated**: `packages/spec/api-surface/data.json` and `export-origins/data.json`, three added lines each, regenerated by `gen:api-surface` / `gen:export-origins` after `check:generated` named exactly those two. - **Changesets**, one per published package moved, both BREAKING `minor` with `!`, a `Clause-②: yes (narrowing)` line and one ADR-0087 marker (`not-required (no-migration-prescription)`): `.changeset/21382-spec-boolean-comparand-non-string.md` and `.changeset/21382-objectql-boolean-comparand-non-string.md`. ## Clause-②, measured `node scripts/pm/check-widening-tells.mjs` on `git diff 69a12a0...HEAD`: - `--declaration no`: exit 4, three T3 tells. These are the three new rows in `packages/spec/api-surface/data.json` (lines 469, 505, 507). - `--declaration yes`: exit 0. So this PR declares `Clause-②: yes (narrowing)`. It adds an export listing row, and what it changes in behaviour is a narrowing. No T1 / T2 / T4 tell fired. ## Before and after A declared `boolean` field, two rows (`rt` true, `rf` false), through `engine.find` / `engine.aggregate`. The probe is a scratch script against freshly built dists, not a committed test. A `where` cell reads implicit, `$eq`, `$ne`, and a `$in` member beside `false`. Before: base `69a12a0952`. After: this branch, spec and objectql source as at `196afd75cd`. PostgreSQL ran on a private PostgreSQL 16.14 cluster in this container, started for the run and stopped after. | position | comparand | before: memory · SQLite · PostgreSQL 16 | after, all three | |:--|:--|:--|:--| | `where` | `2`, `-1`, `0.5`, a `Date` (the card) | no row, no row, both, the false row · the same · **500 `DATABASE_ERROR` at every slot** | 400 `INVALID_FILTER` at every slot | | per-aggregation `filter` | the same | count 0, 0, 2, 1 on all three | 400 | | `having` on a groupBy of the field | the same | no group, no group, both, the false group on all three | 400 | | `where` | an array `[true]` as a `$in` member (the card) | **the false row (200)** · a driver 400 · a driver 400 | 400, in the contract's words | | per-aggregation `filter` / `having` | the same | count 1 / the false group on all three: the member silently dropped | 400 | | all three | `[true]` at implicit / `$eq` / `$ne` | 400 (the comparand-shape door) | the same | | `where` | `2n` (in-process) | no row · no row · 500 | 400 | | `where` | `1n` (in-process) | **no row · the true row · the true row** (`$ne 1n`: both rows on memory) | the true row on all three | | all three | `{ "a": 1 }` | 400 (the comparand-type door) | the same, same words | | all three | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same | ## Premise check, and the dispatch's hypotheses - **H1 holds.** On `69a12a0952`, `readBooleanComparand` returned `null` for every non-string except `1` / `0`, the verdict answered `passes`, and the reading rows read `unread(2)` / `unread(-1)`. The card's table reproduces on memory, SQLite and PostgreSQL (rows above). The number contract's non-string branch was the template. - **H2 holds: no walk change.** `judgeBooleanComparand` already passed `value: comparand` and `form: verdict.form` straight through, so widening the verdict reaches `where` (both spellings), the per-aggregation `filter` and `having` with no code change in objectql. The shared walk file is not in the diff. - **H3**: the after-column is re-measured on memory, SQLite and PostgreSQL 16.14 (above). MySQL is NOT MEASURED, because no MySQL server is available in this container. The REST cell's MySQL leg is a named skip. - **H4 holds.** A `formula` returning `boolean` is still refused one door earlier, by the unmaterializable-field door, with `INVALID_FIELD` / 400. The arm suite's NAMED DIVERGENCE test drives every formula row, including the new `value` row on `f_formula_boolean`. ### The bigint reading The ruling does not name `bigint`. Measured on the base, `1n` against the boolean field gave different answers per driver at `where`: no row on InMemoryDriver, the true row on SQLite and PostgreSQL. `2n` gave PostgreSQL's 500. That is the card's defect class, through an in-process caller. The comparand-type door rewrites a `bigint` to its number. It runs AFTER this door on the object spelling of `where` and on a per-aggregation `filter`, and BEFORE it on the `FilterArray` spelling and at `having`. So: - passing a `bigint` leaves the divergence; - refusing every `bigint` would refuse `1n` on one spelling and narrow it on the other; - reading it as the number it names gives one answer at every position. This is not a widening. A `bigint` passed the verdict before, so it was accepted, and now it is either narrowed correctly or refused. The arm suite pins it on both spellings and at all three positions. ### Objects: served by the comparand-type door, deliberately Triage's list names objects. On the base, an object comparand against the boolean field is already refused with `INVALID_FILTER` / 400 by the comparand-type door, on all three drivers and at all three positions (table above). The widened verdict passes such a value rather than refusing it a second time. The reason is the one PR objectstack-ai#20545 measured: the engine runs the two doors in a different order per position, so a second refusal would answer one mistake with two sets of words. The arm suite pins it: for a plain object, `undefined` and a `Map`, the engine's message equals the comparand-type door's own message. ## Tests (at `196afd75cd`, the final head) Each heavy run went through `scripts/pm/os-verify-lock.sh` with exit codes from the lock's `VERDICT command-exit` lines. - **`@objectstack/spec`**: `filter-boolean-comparand-declared-type.test.ts` 34/34. `--project local`, run as two `--shard` halves to fit the container's foreground cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599 files in all, 0 failed). `typecheck` exit 0; `check:test-typecheck` holds its ledger (52 files / 246 errors, none added). - **`@objectstack/objectql`**: `engine-boolean-comparand-declared-type-door.test.ts` 34/34, and `engine-number-comparand-declared-type-door.test.ts` still green (its boolean-arm partition reads the widened verdict). `--project local` as two shards: 182 files / 3583 passed, and 182 files / 3777 passed. `typecheck` exit 0 (ledger 40 / 234, none added). - **`@objectstack/rest`**: `data-boolean-comparand-door.test.ts` with `OS_TEST_POSTGRES_URL` set: 6 passed, 3 skipped (the MySQL leg). The SQLite and PostgreSQL legs both ran. `--project local` (no live URL): 255 files, 4808 passed, 322 skipped. `typecheck` exit 0 (test layer 0 / 0). - **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's): 1. Population, from `eslint.config.mjs` itself: all 5 touched lintable files answer `isPathIgnored` false. The other 4 changed files are JSON / Markdown, outside its globs. 2. `eslint --no-inline-config --format json` over them: 5 files, 0 errors, 0 warnings. 3. The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `196afd75cd` derives 90 families; all 90 ran with exit 0, and `--ran` reconciles 90 derived / 90 run / 0 NOT-MEASURED. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET, because the workspace was not fully built. It was re-run after a full `turbo run build`, and exited 0. ## Ablation (reverse verification) From the committed head `196afd75cd`, with `scripts/ablation-replace.mjs` in wrap mode. The mutation lives only while the tool's trap is armed. It removes the widening at its one routing line in the verdict: `if (form === null) return { verdict: 'passes' };` gains `|| (globalThis as Record[string, unknown]).ABLATION_21382 === undefined` (angle brackets spelled as square brackets here), so every non-string passes again. The string rule is untouched. - **Mutated leg**: anchor 1 to 0, blob `41a24373` to `064410ff`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found the marker in 4 built files (exit 0). Results: - spec suite: **3 failed** / 31 passed (the non-string verdict, the bigint reading, the `value` group); - objectql arm suite: **5 failed** / 29 passed (the GUARD's form set, and the `where`, per-aggregation `filter`, `having` and bigint tests); - REST cell: **4 failed** / 2 passed / 3 skipped (the refusal tests on both the SQLite and PostgreSQL legs). - The control tests stayed green on every suite. The direction is the expected one: red. - **Restore leg**: blob back to `41a24373` equal to HEAD, `git diff HEAD` empty, and `git status --porcelain` empty for the whole tree. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 230 built files (exit 0). Results: spec 34/34, objectql 34/34, REST 6 passed / 3 skipped. ## Acceptance notes - `comparandPreview` is a private copy in the boolean module. The number module's copy is private, and PR objectstack-ai#21390 holds that file, so this PR does not touch it. Moving both copies into `filter-comparand-refusal-text.ts` is a consolidation for whichever PR next touches both. No carrier is named. - Two comments outside this claim's file surface still describe the string half only: `engine.ts` around the `narrowNumberComparands` call ("any other string is refused"), and the shared walk's `[objectstack-ai#21333]` header section. Both are incomplete, not false. No carrier is named. - The REST cell's live legs are red-capable and un-run in CI (no job provisions `OS_TEST_POSTGRES_URL` for `packages/rest`), as with the number cell. This PR carries their local PostgreSQL run. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20502
Clause-②: no (narrowing)
What this changes
One verdict, widened, as triage directed on the card: the published number-comparand contract now refuses any comparand compared against a declared numeric field that is neither a number nor a string the numeric grammar admits. The engine door that consumes it is unchanged in behaviour and carries no second rule.
packages/spec/src/data/filter-number-comparand-declared-type.ts.numberComparandDoorVerdict(field, comparand)answersdoor-refusal(INVALID_FILTER/ 400) for a boolean, aDateand an array at a judged position, beside the non-numeric string it already refused. The string rule is byte-for-byte what PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414 published. What still passes: a number, abigint(the comparand-type door narrows it one call later),null(the null test), and every value outside the comparand-type door's accepted set (undefined, a plain object, a{ $field }reference, aMap), which that door already refuses on every field in its own words (see Objects below).NON_NUMERIC_VALUE_FORMS(boolean,date,array) and the typesNonNumericValueForm/NonNumericComparandForm. The refusal'sformand the refusal site'svaluewiden to carry a non-string.Daterenders asDate(ISO)so it does not read as the quoted string the grammar refuses.valuegroup:falseat$gton every judged field,trueand aDateat every judged position off_number, an array at every judged position except the equality slots (implicit,$eq,$ne, where the comparand-shape door refuses an array one door earlier), and four passing rows (the null test twice, a boolean on abooleanfield, aDateon adatetimefield).filterAtnow gives each filter its own copy of aDateor an array.unjudgedrows carry the flag operators' booleans ($null: true,$exists: false,$empty: true), which the door never hands to the verdict. The table now asks whether the slot is judged before consulting the verdict, so those rows staypasses. The engine never refused them; only the table's label would have been wrong.packages/objectql/src/number-comparand-declared-type-door.ts: the refusal site carriesvalue: comparandinstead ofcomparand as string, plus comments. No rule was added or changed; the compiled walk is the same.packages/spec/api-surface/data.jsonandexport-origins/data.json, three added lines each, regenerated bygen:api-surface/gen:export-originsaftercheck:generatednamed exactly those two..changeset/20502-number-comparand-non-string.md,@objectstack/specand@objectstack/objectqlminor, BREAKING banner, a FROM → TO line, the ADR-0087 dispositionnot-required (no-migration-prescription).Before and after
A declared
numberfield, rows 5, 12 and 30. Measured throughengine.find/engine.aggregateandPOST /api/v1/data/:object/query(the two doors agree), onInMemoryDriver,SqlDriveron SQLite andSqlDriveron PostgreSQL 16. Before: base4a1df19656. After: this branch at0dc69019da.where$gt true(the card)DATABASE_ERRORINVALID_FILTERwhere$gt falsewhere$eq true/ implicittruewhere$ne truewhere$inmembertruewhere$between [true, 20]where(in-process)$gt/$eq/ implicit / a$inmember, aDatewhere$gt [1],$lt [100]where$inmember[1]filter$gt true/$gt [1]filter$gtaDatehavingonsum(amount)$gt true/$gt [1]havingonsum(amount)$gtaDate$gt 10(the numeric control)where$gt {"a":1}, a$inmember{"a":1}where$eq nullThe per-aggregation
filterandhavingrows are the engine's own evaluator on every driver, which is why they agree across drivers before the change: JS coercion readtrueand[1]as 1.Premise check, and the dispatch's hypotheses
4a1df19656on all three drivers (rows above), through REST andengine.find. PostgreSQL ran on a private PostgreSQL 16 cluster in this container (the same major as theTemporal Conformance (live PG + MySQL)job'spostgres:16service), started for this run and stopped after it.judgeComparandcallsnumberComparandDoorVerdictand turns the answer into an outcome); the one assumption it carried was the type castcomparand as string, erased at compile time. Widening the verdict alone reacheswhere(both spellings), the per-aggregationfilterandhaving: the door file's diff is that cast plus comments, and the after-state above is uniform.git grepoverexamples/**andpackages/**for a filter comparing with a boolean, aDateor an array (filter rules,FilterArraytriples,$opliterals), then each named field's declared type: every non-test hit names a boolean field (active,is_active,archived, ...) or a date field, or sits in driver-internal code. The one number-field hit,amount: { $gt: new Date(0) }indriver-sql'ssql-driver-cross-field-reference.test.ts, callsSqlDriverdirectly, beneath the engine door, and stays green (driver-direct callers keep native binding, as the changeset says). No fixture needed triage.Objects: served by the comparand-type door, deliberately
Triage's list names objects. Measured on the base, an object comparand against the number field is already refused with
INVALID_FILTER/ 400, naming the path, on all three drivers and at all three positions, by the comparand-type door (normalizeFilterComparandTypes), and at the REST ingress withVALIDATION_FAILED. The widened verdict passes such a value instead of refusing it a second time, for one measured reason: the engine runs the two doors in a different order per position (this door first on the object spelling ofwhereand on a per-aggregationfilter; the comparand-type door first on theFilterArrayspelling and onhaving). A second refusal here would answer one mistake with two sets of words depending on where it was written, and would change the comparand-type door's pinned words for every numeric field. The engine suite pins it: for a plain object,undefinedand aMap, the engine's message equals the comparand-type door's own message, on both spellings.Tests (at
829106fd06, the final head, unless noted)Every package run below is at
829106fd06(this branch merged withorigin/main31d281d3b2throughscripts/pm/os-regen-merge.sh, whole workspace rebuilt: 72/72). Each run went throughscripts/pm/os-verify-lock.sh; exit codes are the lock'sVERDICT command-exitlines.@objectstack/spec:filter-number-comparand-declared-type.test.ts41/41.--project local573 files, 16842 passed, 1 todo.--project repo: 39 files, 701 passed.@objectstack/objectql:engine-number-comparand-declared-type-door.test.ts34/34.--project local332 files, 6641 passed.--project repo1 file, 5 passed.@objectstack/rest, withOS_TEST_POSTGRES_URLset:data-number-comparand-door.test.ts8 passed, 4 skipped (the MySQL cell); the SQLite and PostgreSQL cells both ran.--project local222 files, 4256 passed, 22 skipped.--project repo1 file, 8 passed.@objectstack/driver-sql, the whole suite underTZ=America/New_Yorkagainst the live server set toAsia/Shanghai(theTemporal Conformancejob's provisioning): 206 files, 3997 passed, 93 skipped (3 files skipped).@objectstack/driver-memory: 59 files, 1419 passed.pnpm --filter @objectstack/spec typecheck,@objectstack/objectqland@objectstack/rest: exit 0, andcheck:test-typecheckholds each ledger with no debt added (spec 53 files / 251 errors, objectql 40 / 234, rest 0 / 0).pnpm lintis CI's): ① population fromeslint.config.mjsitself: all 5 touched lintable files answerisPathIgnoredfalse; the other 3 changed files are JSON / Markdown, outside its globs. ②eslint --no-inline-config --format jsonover them: 5 files, 0 errors, 0 warnings. ③ The config never enables type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move any untouched file's verdict.0dc69019da(this change before the later case-table fix, which changes test data only) and is re-pinned at the final head by the REST suite's SQLite and PostgreSQL cells and the objectql suite.Ablation (reverse verification)
From the committed head
829106fd06, withscripts/ablation-replace.mjsin wrap mode (the mutation lives only while the tool's trap is armed). The mutation removes the widening at its one routing line in the verdict:if (form === null) return { verdict: 'passes' };becomes the same line with|| globalThis.ABLATION_20502 === undefinedadded to its condition (spelled with a cast to a string-keyed record so the DTS pass type-checks it), so every non-string comparand passes again. The string rule is untouched.fdb42a2a→55919fda. Afterpnpm --filter @objectstack/spec build,ablation-dist-preflightfound the marker in 4 built files (exit 0). Result: the spec suite had 5 failed, 36 passed; the objectql door suite 4 failed, 30 passed; the REST door suite 2 failed, 6 passed, 4 skipped, one failure each for the SQLite and PostgreSQL cells. Every failure is a new non-string pin, or the objectql guard that counts the refused forms. Every string pin and every numeric control stayed green.fdb42a2a(the HEAD blob),git diff HEADis empty andgit status --porcelainis clean. After a rebuild,ablation-dist-preflight --absentfound the marker absent from all 224 built files and the tree clean. The three suites passed again: 41/41, 34/34, and 8 passed with 4 skipped.|| 'ABLATION_20502'. The suites went red the same way, butablation-dist-preflightanswered exit 1 because the marker was absent fromdist/: spec's tsup config runs withtreeshake: true, and the bundler folded the constant condition away together with its marker. The mutation was therefore not proven to have reached the built artifact, so that reading is discarded. The redo above uses aglobalThisproperty read, which the bundler cannot fold because the read could have side effects. I checked that it survives an esbuild bundle even withminifySyntax, and the preflight found it indist/.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat829106fd06derives 88 commands (the 86 derived before the merge, pluscheck:generatedandcheck:pm-widening-tells, which the merged gate map added). All 88 ran, and each exit code was captured before any pipe: 88 exited 0. That includescheck:dual-build-cjs-loadsandcheck:type-check-debt, which answered exit 3 (PREREQUISITE NOT MET) before the whole workspace was built. Reconciliation withdispatch-gates --ran(recorded ascommand :: exit N): 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN, and that zero is derived from the recorded codes.check:adr-0087-registrationreads the changeset as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).check-changeset-no-major: nomajorbump.Acceptance notes
InMemoryDriver. Its cell is pinned by construction in the objectql door suite: the door answers before any driver is resolved, and the suite's recording driver asserts no read. Its live before/after rows above come from a scratch run against the built packages. A permanent real-
InMemoryDrivercell would need a new dependency edge (@objectstack/restor@objectstack/objectqlon@objectstack/driver-memory), which this change did not add.PostgreSQL in CI. No CI job provisions
OS_TEST_POSTGRES_URLfor@objectstack/rest, so the REST suite's PostgreSQL cell is red-capable and un-run in CI (the predecessor PR recorded the same). It ran locally here, against the private cluster.MySQL: NOT MEASURED. No MySQL server in this container; the REST suite's MySQL cell is a named skip.
havingwords. A numeric aggregated column has no declared type; the door hands the verdictnumber, so the words read "compares a declared number field" forhaving.total.$gt. Inherited from the predecessor, unchanged.The flag operators.
$null/$exists/$emptytake a boolean and are never judged; pinned in both the spec suite and, through the table, the engine suite.Out of scope, measured and handed to the seat, not filed here: a plain object with no
$key in a scalar field's value position,where { amount: { "a": 1 } }. This is filter STRUCTURE (a nested-relation / deep-equality condition), which no field-aware door descends into. On the base it answers 200 with no rows on InMemoryDriver and a driver 400 on SQLite and PostgreSQL, throughPOST /api/v1/data/:object/queryandengine.find. It is not specific to numeric fields, and this change leaves it as it was.The save-time door, one family over. No lint or save rule reads the number verdict, so a stored filter comparing a number field with a string, and now also with a boolean or an array, saves clean and is refused at query time. The predecessor's report already carries that family. This change widens its population, and the census above found no in-repo producer of the new shapes.
Generated by Claude Code