Repository navigation
fix(objectql)!: a no-operator object where a scalar field's value belongs is refused INVALID_FILTER / 400 on every driver (#20546) - #20744
Conversation
… under a scalar column (#20546) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…egation filter and having Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ver SqlDriver (SQLite, live PostgreSQL/MySQL) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…e label Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 825d793287536190223a86bb4b2d8ea5f7560520 && git checkout 825d793287536190223a86bb4b2d8ea5f7560520
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01e78dceeffb28477bcdbcab26f951b4cbef78ec b50627aca98d596a70d5f60c165ddca239ebdadf && git checkout -B drift-repro 01e78dceeffb28477bcdbcab26f951b4cbef78ec && git merge --no-ff b50627aca98d596a70d5f60c165ddca239ebdadf
node scripts/docs-audit/affected-docs.mjs --json 01e78dceeffb28477bcdbcab26f951b4cbef78ec
|
Contract reviewServed-tier: Inputs: card #20546 (body; triage 5882227960; claim 5901567907; dev report 5902224595), PR #20744 (body, the 8-file list, ① Derived judgmentsThe narrowing, cell by cell — what is refused now that was accepted, and where.
One walk, not a second traversal. RIGHT.
Public surface. None moves. Refusal envelope and words. Check-runs on Shipped prose, sentence by sentence. Changeset: the title, the ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36656292008 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…SON or undeclared id column is refused INVALID_FILTER / 400 on every driver (objectstack-ai#20745) (objectstack-ai#20781) Fixes objectstack-ai#20745 Clause-②: no (narrowing) ## What this changes A plain object with no `$`-operator key beneath a **relation** field (the nested-relation form), a **structured-JSON** field (a whole-value match), or the **platform-provisioned `id` column** that the declared field map omits is now refused with `INVALID_FILTER` / 400, in the engine's words, before any driver is asked. It holds on every driver, at the three positions the engine judges: `where` (object form and `FilterArray` sugar; `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter`), `aggregations[i].filter`, and `having`. **Landing site: the door PR objectstack-ai#20744 opened for scalars, extended. There is no second door and no second traversal.** The no-operator-object arm of the number-comparand door's walk (`walkCondition`) already asked one question per field key. It now classifies the column into one of three kinds, each with its own words: - `packages/objectql/src/no-operator-object-door.ts`: `noOperatorObjectColumnKind` gives `scalar` (unchanged: `SCALAR_FILTER_HEAD_TYPES` plus `MULTI_OPTION_TYPES`), `relation` (spec's `REFERENCE_VALUE_TYPES`: `lookup`, `master_detail`, `user`, `tree`), `json` (spec's `STRUCTURED_JSON_TYPES`), or `null` (never judged). `provisionedNoOperatorObjectColumn` covers `id` / `created_at` / `updated_at` when the declared map omits them. The three word builders live here too. ⛔ Nothing in it walks a filter. - `number-comparand-declared-type-door.ts`: the per-key facts carry the judged column instead of a scalar type. The walk, its positions and its boundaries are unchanged. - `engine.ts` / `having-filter.ts`: comments only. - `packages/spec/src/data/filter.zod.ts`: prose only. `FilterCondition`'s form 4 now states that the engine refuses it and names the route. The `QueryFilter` example stops teaching it. The two "Nested relation" type comments point at the refusal. ⛔ The type and the schema are not narrowed. - `content/docs/kernel/contracts/data-engine.mdx`: the `// Nested relation filter` example is replaced by the route that works (query the related object, then `$in` on its ids), with one paragraph on the refusal and on multi-valued lookups. **The words put the verdict and the route first.** The REST door truncates a 4xx message at 500 characters (`CLIENT_MESSAGE_MAX` in `packages/rest/src/error-response.ts`). The first draft of these words, and the objectstack-ai#20546 scalar words, put the route past that bound, so a REST caller never saw it. Every kind now reads: position, then verdict, then `The filter was NOT applied.`, then the route, then the reasoning. The REST pins assert the route in the response body. The objectstack-ai#20546 scalar words were rewritten in the same shape because this change made their middle sentence false: it said a nested-relation condition is something "only a relation field … can carry". Examples of the words, as `engine.find` throws them: ```text find('rp_ledger'): filter on 'owner' puts an object with no operator key (keys "region") at where.owner, beneath the declared lookup field 'owner' — the nested-relation form, which the engine does not serve. The filter was NOT applied. Filter the related object 'rp_owner' first, then match 'owner' against the ids it returns: { "owner": { "$in": [ID, …] } }. An object with no "$" operator is filter structure, not a value: 'owner' stores the related record's id, no driver follows it into the related object, and an empty answer would read exactly like a real one. find('rp_ledger'): filter on 'owners' puts an object with no operator key (keys "region") at where.owners, beneath the declared lookup field 'owners' — the nested-relation form, which the engine does not serve. The filter was NOT applied. Filter the related object 'rp_owner' first, then match 'owners' against the ids it returns: { "owners": { "$contains": ID } } for one id, an $or of those for several. … find('rp_ledger'): filter on 'meta' puts an object with no operator key (keys "a") at where.meta, as the value of the declared json field 'meta' — a whole-value match, which the engine does not serve. The filter was NOT applied. Test the whole value's presence with { "meta": { "$null": false } }, or store the part you filter on in a field of its own and filter that field. … find('rp_ledger'): filter on 'id' puts an object with no operator key (keys "a") at where.id, where a value of the platform-provisioned text column 'id' belongs. An object with no "$" operator is filter structure, not a value. The filter was NOT applied. Compare 'id' with a value ({ "id": VALUE }) or an operator ({ "id": { "$eq": VALUE } }). … ``` ## Before, measured on `origin/main` `a51920f5fb` The readings come through `POST /api/v1/data/:object/query` (the real `RestServer` route over `ObjectStackProtocolImplementation` and `ObjectQL`) on InMemoryDriver, SqlDriver on SQLite (better-sqlite3), and SqlDriver on a live PostgreSQL 16.13 started for this run. Three rows: owner `u1` (region NA) on `d1` and `d3`; `meta` `{a:1}` / `{a:2}` / `{b:1}`. | filter | InMemoryDriver | SQLite | PostgreSQL 16 | |:--|:--|:--|:--| | `where` `{ owner: { region: 'NA' } }` (lookup, the card) | 200, **no rows** (`d1`, `d3` meant) | 400 `INVALID_FILTER`, the driver's words ("cannot be bound as a SQL parameter") | same as SQLite | | the same under `master_detail`, a `multiple: true` lookup, `user`, `tree` | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` `{ meta: { a: 1 } }` (json, the card) | 200, `d1` (deep equality) | 400, the driver's words | 400, the driver's words | | `where` `{ ship_to: { city: 'Paris' } }` (address), `{ spec: { k: 1 } }` (composite) | 200, `d1`, `d3` (deep equality) | 400 | 400 | | `where` `{ id: { a: 1 } }` (the card) | 200, no rows | 400 | 400 | | `where` `{ owner: {} }`, `{ meta: {} }` | 400, the driver's zero-operator words | 400, the driver's words | same | | `aggregations[1].filter` `{ owner: { region: 'NA' } }` | count 0 | count 0 | count 0 | | `aggregations[1].filter` `{ meta: { a: 1 } }` | count 1 | count 1 | count 1 | | `having` `{ owner: { region: 'NA' } }` over `groupBy: ['owner']` | no group | no group | no group | | `having` `{ meta: { a: 1 } }` over `groupBy: ['meta']` | one (wrong) group | no group | **500 `DATABASE_ERROR`** (from the json groupBy itself, see the notes) | | route `{ owner: { $in: ['u1'] } }`; same under `master_detail`, `user` | `d1`, `d3` | `d1`, `d3` | `d1`, `d3` | | route `{ parent: { $in: ['d1'] } }` (tree) | `d2`, `d3` | `d2`, `d3` | `d2`, `d3` | | `{ owners: { $in: ['u1'] } }` (multiple lookup) | `d1`, `d3` | **400**, the driver's JSON-column words | 400 | | route `{ owners: { $contains: 'u1' } }`, and `$or` of `$contains` | `d1`, `d3` | `d1`, `d3` | `d1`, `d3` | | dotted `{ 'owner.region': 'NA' }` | 400 `INVALID_FIELD` (the dotted verdict) | same | same | | `{ meta: { $contains: 'a' } }` | 400 `INVALID_FILTER` (the text-operator door: a JSON value is never a string) | same | same | | `{ meta: { $null: false } }`, `{ meta: { $exists: true } }` | all 3 rows | all 3 rows | all 3 rows | | control `{ photo: { url: 'x' } }` (image) | 200, no rows | 400, the driver's words | same | ## After, the same run on this branch Every refused row above answers `400 INVALID_FILTER` in the engine's words, on all three drivers, at the path the object sits at (`where.owner`, `where.$not.owner`, `aggregations[1].filter.owner`, `having.owner`, …). No read of the object runs. The routes (`$in`, `$contains`, `$null`), the dotted verdict, the `$contains`-on-json refusal and the image control answer exactly as before. ## Hypotheses (zone 2): which held - **H1: held, with two refinements.** The door classifies by declared type, and relation and JSON became judged kinds of the same classification, with their own words. (a) The classes are the spec's closed sets: `REFERENCE_VALUE_TYPES` brings in `user` and `tree` beside `lookup` / `master_detail`, and `STRUCTURED_JSON_TYPES` brings in `address`, `composite`, `repeater`, `record`, `location` and `vector` beside `json`. See the scope note below. (b) **Left unjudged:** file and media types (the objectstack-ai#8371 carve-out: a legacy stored value is an inline object that memory can still match) and `formula` (refused one door earlier, `INVALID_FIELD`). Triage's text covers neither. - **H2: held, in the "not served" direction.** A dotted relation path `'owner.region'` is served by no driver: it answers `400 INVALID_FIELD` (the objectstack-ai#8371 dotted verdict) on all three. So the refusal names only the related-object query plus ids. **One refinement:** `$in` on ids does not work for a **multi-valued** lookup on SQL, where the driver refuses `$in` on its JSON column. So the words name `$contains` per id there (measured `d1`, `d3` on all three). - **H3: held.** All three positions take the new kinds through the same walk. At `having`, relation and JSON columns **do** appear: a groupBy of the field, or a `min` / `max` of it, carries that field's type (`aggregatedRowColumnTypes`). A nested-relation `having` kept no group on every driver before. `having` has no field declaration to read, so its relation words name "the related object" and the `$in` spelling. - **H4: held; the nested arm is not separable in `FilterCondition` without narrowing another form.** Its index signature is `any | FieldOperators | FilterCondition`, which TypeScript collapses to `any`. So removing the `FilterCondition` member is a no-op, not a separation. At the schema, the nested-relation form and a JSON object comparand are the same shape (a plain object with no `$` key beneath a key), and only the column's declared type tells them apart. The generic `Filter` type's nested arm (a recursive `Filter` over an object-typed property's own type) **is** a separate union member. Removing it would narrow `Filter` for every object-typed property, so it is a `Clause-②: yes (narrowing)` change for its own card. ⛔ Neither is separated here. ## Where this departs from the order or the ruling (named, not silently chosen) - **`{ id: { a: 1 } }`.** Triage says the `id` row "answers the door's existing unknown-field verdict on every driver". The **Pins** ruling says every row of the card's table answers the same `INVALID_FILTER`. On `origin/main` the door has no unknown-field verdict that refuses. Its verdict for an undeclared key is tolerance (the engine's registry-less rule, pinned by `GUARD an UNKNOWN field …`), which leaves `id` to the drivers: memory 200 with no rows, SQL 400. Both readings cannot hold at once. `id` is not an unknown field by the engine's own definitions: `find` / `findOne` add it to their known set, the write gate admits it (`PLATFORM_PROVISIONED_COLUMNS`), and so do the REST ingress (`resolveQueryFields`) and the per-aggregation reference names. So this PR judges the three platform-provisioned columns by the type they store, and only when the declared map omits them. That keeps `id`, `created_at` and `updated_at` in the scalar words, keeps every other undeclared key tolerated, and makes the Pins row true. Reported to the PM as an open question. - **Scope of the JSON kind.** Triage names `json`. `address` and `composite` were measured with the identical split (memory deep-equal rows, SQL 400). The spec publishes one class for them, so the arm judges the class, not one member of it. This is the bounded in-place fix: it is the same defect class, the same mechanical classification as the card, and the same file under this claim, and it adds no new gate family. - **Triage's `$contains` example for a JSON field.** It is not a route: the text-operator door refuses `$contains` over a JSON value on every driver (measured above). The JSON words name `$null` and a stored field instead. - **The per-aggregation `filter` with a JSON object.** It was the one cell that already answered alike on every driver (count 1, the engine's own deep equality). It is refused now, so that one filter has one answer at every position. The changeset names it. - **InMemoryDriver cells.** The memory cell of each refusal is `engine-nested-object-door.test.ts`'s recording driver by construction: the arm answers before a driver is resolved. The memory readings of the **routes** were measured (the table above) but are not pinned in a new suite: `check:driver-memory-census` refuses a new test consumer of the in-memory driver without a maintainer ruling. It caught a first draft that put one in `packages/runtime`, which was dropped. ## Tests The final HEAD is `ee17b18fde`, a merge of `origin/main` `eead9dcf40` into the branch. - `pnpm --filter @objectstack/objectql test` on `ee17b18fde`: **339 files / 6719 passed**. - `pnpm --filter @objectstack/rest test` on `ee17b18fde`: **231 files / 4469 passed / 71 skipped**. - `pnpm --filter @objectstack/spec test` on `ee17b18fde`: **577 files / 17007 passed / 1 todo**. - `test:repo` on `e08fd6883f`: spec 45 files / 794, objectql 1 / 5, rest 1 / 8. - `typecheck` for objectql, rest and spec on `ee17b18fde`: exit 0, including each `check:test-typecheck` with its ledger held. - `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date. - New pin `packages/objectql/src/engine-nested-object-door.test.ts` (15 tests). It covers every relation type (single and multiple, with the route per multiplicity and the related object's name), every structured-JSON type, the provisioned `id`, `{}`, every verb and the judge, `$and` / `$or` / `$not` and sugar, the per-aggregation filter, `having` (a lookup groupBy, a `max` of a master-detail, a json groupBy), the three REST doors into `findData`, the controls (the routes, a file field, an unknown key), and the classification GUARDs over every `FieldType`. - New pin `packages/rest/src/data-nested-object-door.test.ts`. SQLite always runs; PostgreSQL and MySQL run where `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set. It covers every row of the card's table, and the **route asserted inside the REST body** (so it must land inside the 500-character bound). It also covers the per-aggregation filter and `having`, the routes answering the rows the nested form meant (`$in` on the related object's ids gives `d1`, `d3`; `$contains` on a multiple lookup gives `d1`, `d3`; tree gives `d2`, `d3`), and the file control. 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. - Fixture triage for the removed "accepted" branch. The objectstack-ai#20546 pins (`engine-no-operator-object-door.test.ts`, `data-no-operator-object-door.test.ts`) keep a file field as their only control. Two name-gate controls pinned "the nested-relation form still passes the doors": `protocol-explicit-filter-field-gate.test.ts` (objectstack-ai#7534) and `query-expression-conformance.test.ts` (objectstack-ai#8371). They now pin what they were for: the answer is the engine's `INVALID_FILTER` in the nested-relation words, never the name gate's `INVALID_FIELD`. - Downstream consumers (`...@objectstack/objectql` direction), on `e08fd6883f`: - `@objectstack/metadata-protocol`: 190 files passed, 3 skipped / 2792 passed, 19 skipped. - `@objectstack/service-analytics`: 140 / 3266 passed. Its nested-relation `where` is flattened to cube members before any engine call, and it passed unchanged. - `@objectstack/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` in WRAP mode, trap-restored. The walk's gate `if (facts.column !== null && isNoOperatorObject(value)) {` was narrowed back to the objectstack-ai#20546 behaviour with `facts.column.kind === 'scalar' && facts.column.provisioned !== true && facts.column.type !== '__ablated_20745__'`. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob `ea19d1959255` → `3134e87031b1`. Then objectql was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - Predicted direction: red. Observed: red. - objectql pins: **13 failed / 236 passed**. Every new refusal case failed, and so did the two rewritten name-gate controls. Every control, GUARD and objectstack-ai#20546 scalar pin stayed green. - rest pins: **4 failed / 12 passed / 8 skipped**. The `where` and aggregate refusals failed on SQLite and live PostgreSQL. The routes, the controls and the objectstack-ai#20546 file stayed green. - Restore leg: blob equals HEAD (`ea19d1959255`), `git diff HEAD` is empty, and whole-tree `git status --porcelain` is empty. After a rebuild, the `--absent` preflight found the marker absent from all 14 built files. Then both pin sets were green again (249 passed; 16 passed + 8 skipped). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `ee17b18fde` (after `git fetch`, so not stale) derived 110 commands. All 110 were run on `ee17b18fde`. `--ran` reconciles them: **110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN**, and all exit 0. Among them are these gates: - `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` (904 pinned), `check:where-matcher` (440 matchers, 0 silently wrong), `check:driver-memory-census`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:type-check-debt`, `check:query-options-erasure`. - `check:dual-build-cjs-loads` (105 entry points, 66 packages). - spec's `check:api-surface` / `check:docs` / `check:authorable-surface` / `check:skill-examples`. Lint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the 11 changed `.ts` files at `ee17b18fde` found **11 files, 0 errors, 0 warnings**. Three facts make this narrowing a measurement: - The checked population comes from eslint's own config: `isPathIgnored` answers `false` for all 11. - The file count comes from the JSON output: 11 results. - Untouched files cannot change verdict: `parserOptions.project` and `projectService` are `null` for every file, so type-aware linting is not enabled. ## Changesets - `.changeset/20745-nested-object-door.md`: `@objectstack/objectql` `minor`, a BREAKING banner, `Clause-②: no (narrowing)`, and the ADR-0087 marker `not-required (no-migration-prescription)`, as the objectstack-ai#20546 changeset has. It states what an author sees now and the route that works, per kind, with the table. It says it supersedes the "Unchanged" paragraph of the pending scalar-field entry (`20546-no-operator-object-on-scalar`) for relation and structured-JSON fields. - `.changeset/20745-nested-relation-prose.md`: `@objectstack/spec` `patch` for the shipped JSDoc. - No export or published type changes: the door module is internal, and `@objectstack/objectql`'s root and `./core` exports are unchanged. `check:api-surface` is green. ## Acceptance notes - **Out of scope, reported to the PM, not filed:** - The **published skill** `skills/objectstack-query` teaches the nested-relation form as working: `SKILL.md` "Nested Relation Filters", the "Filter parent by child conditions" row and the `search` paragraph, and `rules/filters.md` "Nested Relation Filters". It was already untrue before this change (memory answered no rows, SQL 400). It is a governed Tier H surface outside this claim's file surface, so it is not touched here. - A **`groupBy` of a `json` field** answers 500 `DATABASE_ERROR` on PostgreSQL. On InMemoryDriver it merges every row into one group (`n: 3`), and on SQLite it gives one group per serialized value. - File and media fields keep the objectstack-ai#8371 carve-out and stay unjudged. `{ photo: { url: 'x' } }` still answers memory 200 with no rows and SQL 400. - At `having` there is no field declaration, so a relation column's words say "the related object" and give the `$in` spelling. A `having` over a multi-valued relation groupBy would get that single-valued spelling. - `referenceTargetOf` names the related object in the relation words. For a field whose `reference` carrier never went through the schema's parse (a non-string), it throws its own `TypeError` instead of the refusal. Parse refuses that shape at the contract door. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20546
Clause-②: no (narrowing)
What this changes
A plain object with no
$-operator key where a scalar field's value belongs, for examplewhere: { amount: { a: 1 } }on anumberfield, is now refused withINVALID_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 andFilterArraysugar, onfind/findOne/count/aggregate/update/deleteand the judge-onlyjudgeFilter),aggregations[i].filter, andhaving.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 #20545's walk (
walkConditioninnumber-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:aggregatedRowColumnTypesreads each aggregated column's type off the query.aggregatedRowColumnClassesis now derived from it, so the class and the type are one reading of the query. Thehavingarm needs the type because thetextclass lumps ajsonorlookupgroupBy in with a real text column.engine.ts: thehavingcall passes the types; the other hunks are comments. PR fix(objectql,service-automation,runtime): the card's named warnings and endpoint hints state each decision in words instead of a tracker number #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 #8371 dotted-head verdict reads the same set) with or withoutmultiple: true, plusMULTI_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 #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/mainfbec216e2dThrough
engine.find/engine.aggregateandPOST /api/v1/data/:object/query(both doors answered alike). Three rows (amount5 / 12 / 30;owneru1 / 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:where{ amount: { a: 1 } }(number, the card)INVALID_FILTER, the driver's words ("cannot be bound")where{ title: { a: 1 } }(text)wheresingle select, boolean, date, autonumber,multiple: trueselect,multiselect,tagswhere{ $not: { amount: { a: 1 } } }where{ $or: [{ amount: { a: 1 } }, { amount: 30 }] }wheresugar[['amount', '=', { a: 1 }]]where{ amount: {} }aggregations[1].filter{ amount: { a: 1 } },{ title: { a: 1 } },{ amount: {} }having{ total: { a: 1 } }(asum),{ title: { a: 1 } }(a groupBy),{ total: {} }where{ owner: { region: 'NA' } }(lookup;master_detailand a multiple lookup alike)where{ meta: { a: 1 } }(json)where{ amount: { $gt: { $field: 'cap' } } }After, the same run on this branch
Every non-control row above answers
400 INVALID_FILTERin 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$fieldreference, the undeclared key and theidkey. Example of the words:Hypotheses (zone 2), which held
lowerWhereFilterArrayis the seam, andnarrowNumberComparandsis 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 itswhereresolver already returned every declared field's type. The text door and the temporal door each walk too, but neither runs athavingwith 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.whereanswered per driver, andaggregations[i].filterandhavinganswered a silent empty on every driver. Each is pinned.multiple: trueselect,multiselectandtagssplit exactly as a scalar field does (memory 200 no rows, SQL 400). That includes{ tags: { 0: 'x' } }, the spelling the [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #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).git diff fbec216e2d HEAD -- packages/driversis empty). The SQL driver's ownINVALID_FILTERstays 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
b50627aca9or 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-typecheckOK, 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).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/$notpaths, sugar, the three REST doors intofindData, the per-aggregation filter,having(sum, groupBy, max of a date, a month bucket), the accepted side at all three positions, aMapand the classification GUARD over everyFieldType.packages/rest/src/data-no-operator-object-door.test.ts: SQLite always, PostgreSQL and MySQL whereOS_TEST_POSTGRES_URL/OS_TEST_MYSQL_URLare set.whererefusals, 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).@objectstack/rest, so the live cells run only locally.@objectstack/objectql):service-analytics137 files / 3216 passed;plugin-security147 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 anchorif (facts.scalarType !== null && isNoOperatorObject(value)) {was replaced byif (facts.scalarType === '__ablated_20546__' && …) {. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob16151b29f6c1→0e8acf882100. Then objectql was rebuilt andablation-dist-preflightreported 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: thewhereand aggregate refusals failed on SQLite and live PostgreSQL, and the controls stayed green. Restore leg: blob equals HEAD (16151b29f6c1),git diff HEADempty, the whole-treegit status --porcelainempty, rebuilt,--absentpreflight (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 --commandsatb50627aca9derived 65 commands. All were run onb50627aca9, and--ranreconciles them: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN. 63 exit 0, includingcheck: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-coverageandcheck:query-options-erasure.check:dual-build-cjs-loadsandcheck: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'sLint & Repo Gatesbuilds that closure.fbec216e2d, a detached comparison worktree) and after (onb50627aca9):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.pnpm exec eslint --no-inline-config --format jsonover the 7 changed.tsfiles, atb50627aca9, found 7 files, 0 errors, 0 warnings. The checked population comes from eslint's own config:calculateConfigForFileanswersisPathIgnored=falsefor all 7. The file count comes from the JSON output (7 results). Untouched files cannot change verdict:parserOptions.projectandprojectServicearenullfor 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/objectqlminor, a BREAKING banner,Clause-②: no (narrowing)and the ADR-0087 markernot-required (no-migration-prescription), following the #20501 / #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 onInMemoryDriver, 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./coreexports are unchanged.Acceptance notes
{ owner: { region: 'NA' } }on alookupgives memory 200 with no rows and SQL 400.{ meta: { a: 1 } }on ajsonfield gives memory 200 with 1 row and SQL 400. So does the undeclaredidkey ({ id: { a: 1 } }: memory 200 no rows, SQL 400), because the registry's declared map carries noid. Spec'sFilterConditiondeclares the nested-relation form, but no data-path driver serves it. [finding] a plain object with no$key as a scalar field's filter value answers 200 with no rows on the memory driver andINVALID_FILTER400 on SQLite and PostgreSQL #20546 is not the card for that.where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #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.where, a{}under a judged column is now answered in the engine's words instead of each driver's{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 words, with the sameINVALID_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 answersnull, and its docblock says so.Generated by Claude Code