Repository navigation
fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face - #19882
Conversation
…and-shape face
The comparand-shape face gains its equality-slot arm: an array as an
implicit-equality comparand ({ field: [...] }, what the equality spellings
lower an array to) or under $eq is refused with the face's INVALID_FILTER /
400 envelope, naming $in and $contains as the remedies. $ne and the other
scalar operators are not judged. Adds the conformance rows, re-judges the two
fixtures that pinned the old accept set, and registers the ADR-0087 semantic
entry filter-equality-array-comparand-refused.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…rray before translateFilter Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…arrowing Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…w refuses before dispatch The engine-double dispatch tables carried three rows whose where.id is an array. The shared comparand-shape face now refuses that input at the engine lowering seam before the dispatch runs, so the real engine answered them with the face's refusal and the objectql harness went red. The rows are retired with a note; the predicates are unchanged. Also adapts driver-memory's vocabulary probe helper, which fed an array through every AST spelling, and records the analytics FilterArray reading in the migration entry. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
…spatch row Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 ab43d380fe50afa502d822cd20b574496976abbd && git checkout ab43d380fe50afa502d822cd20b574496976abbd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1f89ba0d704a797225ab8cf306e4cdec89edae8e 438d385af3913f136ebb71f031da4a48ce0066c1 && git checkout -B drift-repro 1f89ba0d704a797225ab8cf306e4cdec89edae8e && git merge --no-ff 438d385af3913f136ebb71f031da4a48ce0066c1
node scripts/docs-audit/affected-docs.mjs --json 1f89ba0d704a797225ab8cf306e4cdec89edae8e
|
Contract reviewServed-tier: 57/57 Isolated at-tier reviewer subagent, run by the Inputs read: card #19757 body plus all 6 comments (ruling 5793368540 included); PR #19882 body, ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL — one blocking item: the face-by-face compile-surface declaration required by Generated by Claude Code |
Contract reviewServed-tier: 68/68 Isolated at-tier reviewer subagent, run by the Re-review on the same head after record ① Derived judgments
② Semver levelUnchanged and confirmed. ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…$eq instead of binding it (objectstack-ai#19994) Fixes objectstack-ai#19975 Clause-②: no (narrowing) ## What this changes `compileScopedFilterToSql` (`packages/services/service-analytics/src/read-scope-sql.ts`) lowers a row-level read scope into the SQL that the analytics NativeSQL path executes and the `/analytics/sql` echo prints. It already refused a list in the implicit equality slot (`{ f: [...] }`) with `READ_SCOPE_COMPILE_FAILED` / 500. The explicit spelling, `{ f: { $eq: [...] } }`, compiled to an equality with the whole list bound as one parameter, which left the meaning of the predicate to the executing database. A new gate, `assertNoListInEqualitySlot`, refuses that spelling in `compileField`, at any depth under `$and` / `$or` / `$not`, in this module's own envelope. It runs before the member gates, so a list is reported as a list and not by one of its members. This applies ruling 乙 (objectstack-ai#19757, record `5793368540`: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to a compiler that never reaches the shared face. ## Declaration **BREAKING**: this narrows what `compileScopedFilterToSql`, exported from `@objectstack/service-analytics`, accepts. A read scope carrying `{ f: { $eq: [...] } }` compiled before this change and is refused after it. The remedy is `{ f: { $in: [...] } }`. The changeset ships the narrowing as `minor` under the launch-window convention for accept-set narrowings, with a `!` on its headline, the `Clause-②: no (narrowing)` line and an ADR-0087 `not-required (no-migration-prescription)` disposition: no authorable key, spelling or stored shape moves, and an authored policy never emits this spelling. ## Measured first The measurement was recorded on this branch as `c647adb6cc`, before any source change. This is an abstract summary; the tests are the pins. - **Authoring door.** The published RLS policy schema and the RLS authoring lint's decision procedure both admit an equality predicate whose comparand is a list, whether a list literal or a membership variable. - **Lowering.** That predicate lowers to the implicit spelling. The CEL lowering emits `$eq` only around a `{ $field }` reference, so no authored policy produces a list under `$eq`. The tenant layer, the sharing read filter and the controlled-by-parent filter do not emit `$eq` at all. - **This compiler.** The implicit spelling reaches it through the security service's read filter and is refused (500). A list under `$eq` reaches it only from a host-supplied `getReadScope` or from a direct caller of the export, and it compiled. - **Engines.** On the NativeSQL execute path the bound list got four different answers depending on the engine: a driver error, zero rows, rows the scope never named, and every row when negated. Measured on better-sqlite3 and sql.js through the drivers, and on a local PostgreSQL 16 through `driver-sql` and through a plain `pg` pool. MySQL is NOT MEASURED: there is no server in the container. ## Deliberate choices - **500, not the shared face's 400.** The objectstack-ai#5367 ruling, re-affirmed as objectstack-ai#7598 Q2 = A and recorded in this module's header, keeps every refusal of this compiler at `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld. The scope is a policy the caller cannot author, and a 4xx would echo it back to them. The card's 400 belongs at the policy's authoring door, which is not this file. - **The module's own wording, not a call into the shared face.** `assertListComparandShapes` throws `INVALID_FILTER` / 400. It also judges more than the equality slot: list-operator shapes, null members, null ordering comparands and `$between` bounds. Calling it here would change other refusals of this compiler, and each of those has its own ruling on this door. The new sentence follows this module's bare-array refusal, so both spellings of the one condition read the same way in the operator's log. - **`$ne` with a list is not judged.** Ruling 乙 names equality only. `$ne` falls under ruling A of objectstack-ai#19886 and is handled on that card. ## Compile surfaces (a list in the equality slot) | surface | verdict | |:--|:--| | `compileScopedFilterToSql` (service-analytics read scope) | **changed.** A list under `$eq` is refused. The implicit list was already refused and is now pinned at every depth. | | `assertListComparandShapes` (spec shared face) | **already compliant.** This is ruling 乙's own face (objectstack-ai#19882, landed). Measured: `INVALID_FILTER` / 400 for the implicit list, for `$eq`, and under `$not`. | | `matchesFilterCondition` (formula) | **already compliant.** Measured: `INVALID_FILTER` / 400 for the same three shapes (objectstack-ai#19886 stage 2a). | | `applyFilterCondition` (driver-sql) | **already compliant.** It refuses with 400 at the driver and behind the engine's shared-face seam (table in the objectstack-ai#19882 changeset; not re-measured here). | | `buildWhereSQL` (driver-turso RemoteTransport) | **already compliant.** 400 according to the compile-face table in the objectstack-ai#19886 stage-2a report; not re-measured here. | | `checkCondition` (driver-memory) | **already compliant.** 400 at every depth (table in the objectstack-ai#19882 changeset). | | `translateFieldOperators` (driver-mongodb) | **out of scope.** The driver answers with MongoDB array equality. Platform doors reach it only through the shared face, which refuses (the declared scope of objectstack-ai#19882). | | `lowerAnalyticsWhere` (analytics caller `where`) | **out of scope.** This is the caller-authored filter door (the `INVALID_FILTER` / 400 family), not a read scope. Its object-form `$eq` list cell still reads `accept` in the frozen comparand matrix. The claim records objectstack-ai#19888 against this file. | | `applyHaving` / `matchesHaving` (objectql HAVING) | **out of scope.** A caller-authored filter applied after aggregation, not a read scope. Its answers are recorded in the objectstack-ai#19886 stage-2a report. | The analytics ObjectQL execute route never calls this compiler. It hands the scope to `engine.aggregate`, and the engine's shared-face seam refuses the list with `INVALID_FILTER` / 400 (measured). See the acceptance notes. ## Tests and evidence (head `feb810c3d4`, after merging `origin/main` at `276d96dd23`) - New `src/__tests__/read-scope-eq-array-refusal.test.ts`, 29 tests: - `$eq` lists at every depth, including negation, and beside another operator in either key order; - list-before-member precedence (`[undefined]`, `[{ $field }]`); - the implicit list at every depth; - seven neighbouring shapes that must compile unchanged; - the NativeSQL execute face and the echo face over a real sql.js engine. Both refuse, and no statement reaches the engine. The prescribed `$in` serves exactly the rows it names. - `read-scope-refusal-envelope.test.ts`: inventory row ⑯ added, and the ratchet moves to 16 rows over 14 sites. - `comparand-door-single-source.test.ts`: the frozen matrix's read-scope `$eq` array cell changes from `accept` to the refusal, with a note. It pinned exactly the bind this PR removes. - `pnpm --filter @objectstack/service-analytics test`: 116 files and 2484 tests passed. `typecheck` exited 0, and `tsc --listFiles` includes all three touched test files. - Ablations. Each was run from the committed fix. The mutation went through `scripts/ablation-replace.mjs`, and each restore was proven by the blob hash matching HEAD. - **A:** removing the gate call turned 18 tests red. These include every `$eq` pin, both real-engine faces (zero rows served, and every row served under the negation), and inventory ⑯. - **B:** making the bare-array arm bind turned 11 tests red. - Gates. `dispatch-gates` derives the same 61 at `feb810c3d4` as at `11c11c7dc3`, and all 61 were re-run on `feb810c3d4`: 59 exited 0. Two are NOT MEASURED because their prerequisite was not met (`check:dual-build-cjs-loads` and `check:type-check-debt` need the whole workspace built, and CI builds it). Among the 59: `check-adr-0087-registration --base origin/main` (1 declared-breaking changeset, carrying its disposition), `check-changeset-no-major --base origin/main`, `check:changeset-gate-self-tests` and `check-issue-citations` in its board-probing mode, all exit 0. - Lint, narrowed to the change. `eslint --no-inline-config --format json` over the four touched TypeScript files: 4 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each of them. `eslint.config.mjs` never enables type-aware linting (its own note, near line 326), so this diff cannot change the verdict on any untouched file. ## Acceptance notes - **Authoring door, implicit spelling.** The authoring-door half of the card for the implicit spelling is work in the objectstack-ai#19886 lane: draft PR objectstack-ai#19947 refuses `==` against a list at the CEL lowering and in the lint. objectstack-ai#19975 needs nothing more from it. - **Adjacent finding, filed by the seat, not addressed here.** The analytics ObjectQL execute route answers a read-scope list with the engine's `INVALID_FILTER` / 400, not this compiler's 500. - **Premise correction.** PR objectstack-ai#19882, ruling 乙's shared face, merged at 2026-09-24T14:44Z, before this branch was cut from `ae7a35a63b`. The dispatch described it as in flight; it was not. --------- Co-authored-by: Claude <noreply@anthropic.com>
… the equality slot instead of reading it as `IN` (objectstack-ai#19888) (objectstack-ai#20008) Fixes objectstack-ai#19888 Clause-②: no (narrowing) ## What this changes The analytics `where` door (`lowerAnalyticsWhere` in `packages/services/service-analytics/src/strategies/filter-normalizer.ts`) now refuses a list in the equality slot of an object-form filter, `{ f: [...] }` and `{ f: { $eq: [...] } }`, with `INVALID_FILTER` / 400. This applies ruling 乙 of objectstack-ai#19757 (record `5793368540`: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to the one analytics spelling that never reached the shared face. The new gate, `assertNoListInEqualitySlot`, walks the object-form condition the way the shared face does: `$and` / `$or`, `$not` and field entries, from the same `where` path seed. It hands each equality-slot list to `assertListComparandShapes` (`@objectstack/spec/data`) as a one-entry node. That node holds nothing but the list, so only the face's equality arm can fire, and the refusal is the face's own: its envelope, wording, path and `$in` prescription. The `FilterArray` spelling of the same condition was already refused inside `parseFilterAST`. Both spellings now produce the same bytes, and a test pins this. Every filter source of this door passes through the gate: the caller `where`, a dataset's scope `filter` and a measure's `filter`. That holds on the NativeSQL execute path, the `/analytics/sql` echo and the ObjectQL engine path. The draft-data preview (`preview-evaluator.ts`) calls the same exported gate, so a drafted chart refuses what the published chart refuses. The `in` reading is deleted from `fieldLeaves`. So are the two arms that existed only because of it: the bare-array member sweep in `assertDefinedComparands` and the bare-array guard in `nullGuardForFieldSpec`. Two details: - **Nested relations.** A nested-relation object (`{ acct: { region: [...] } }`) is descended too. The shared face leaves such an object alone, because a driver reads it as a deep-equality comparand or another object's condition. This compiler flattens it to the dotted member `acct.region`, and the list is in that member's equality slot. - **Precedence.** The list is diagnosed before any member gate. `{ d: [1, undefined] }` gets the list refusal, not the undefined-comparand one. The shared face and `read-scope-sql.ts` use the same order. `$ne` with a list is not judged, because ruling 乙 names equality only. The list operators keep their lists, including `$in: []` and `$nin: []`. Every scalar compiles as before, including `null`. ## Measured first (recorded on this branch as `cca9717240`, before any source change) **In-repo stored filters.** An AST scan (TypeScript compiler API) covered every git-tracked `.ts` / `.js` / `.json` file, plus the fenced ts/js/json blocks of md/mdx files. It used four detectors: - D1: `$eq` with an array literal. - D2: a field entry with an array literal under a filter-bearing key (`filter`, `filters`, `where`, `runtimeFilter`, `relatedListFilter`, `having` and five more). The object is walked as a FilterCondition, descending `$and` / `$or` / `$not` and nested relations. - D3: a FilterArray triple on `=` / `==` / `equals` / `eq` carrying an array. - D4: a filter rule `{ field, operator: EQUALITY_SPELLING, value: [...] }`. The positive controls ran on the same walkers: - a synthetic fixture with one case per detector (4/4 hits, in both TS and JSON); - a count of `$in` array literals (C1); - a count of the filter-bearing objects walked (C2). | scope | files | C1 | C2 | hits | |:--|--:|--:|--:|:--| | `examples/**` | 228 (3 tsconfig JSONC unparsed) | 1 (matches the text grep) | 91 | **0** | | `packages/**` | 6910 | 522 | 4636 | 5 in non-test files, **all false positives**: 2 realtime-subscription `eventTypes` lists, 3 MongoDB aggregation-expression `$eq: [a, b]` operands. 65 in tests, all deliberate refusal fixtures or pins. | | md/mdx fenced blocks (content, skills, docs, examples, packages, .changeset) | 1545 files, 3162 blocks | 12 | 176 | 3, **all false positives** (a `nin` rule in a design note; a plugin-permission `filter`) | A text grep for a `$eq` list outside `packages/` and `examples/` found 7 hits, all in changesets that describe the refusal itself. The control (`$in` list) found 140. **No in-repo artefact carries the shape, so nothing needed converting.** Deployed `sys_metadata` rows: NOT MEASURED (there is no deployment data here). **The analytics faces at base**, over a real sql.js engine. The rows are d1 `won`, d2 `lost`, d3 `open`, d4 NULL, d5 the text `'won,lost'`: | `where` | native execute / echo | ObjectQL engine path | draft preview | |:--|:--|:--|:--| | `{ stage: ['won', 'lost'] }` | `stage IN (?, ?)`, rows d1, d2 | the engine received `{ stage: { $in: [...] } }`, so the engine's own shared-face check never saw the list | string-compared the row against `'won,lost'`: d5 | | `{ stage: { $eq: ['won', 'lost'] } }` | `stage = ?` bound to `'won'`: d1, and `'lost'` was dropped without a word | `{ stage: 'won' }` | d5 | | `{ stage: { $eq: [] } }` | **no WHERE at all: every row** | `{}` | no row | | `{ stage: [] }` | the FALSE constant | | | | `['stage', '=' / 'equals' / '==' / 'eq', [...]]` | refused `INVALID_FILTER` / 400 | refused | no row (the preview does not lower an array) | | control `{ stage: { $in: ['won', 'lost'] } }` | d1, d2 | d1, d2 | d1, d2 | After this change, every object-form cell above is refused with `INVALID_FILTER` / 400, carrying the face's message (re-measured at `896e1e70f3`). ## Compile surfaces (a list in the equality slot) | surface | verdict | |:--|:--| | `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree` (analytics caller `where`, dataset scope `filter`, measure `filter`; NativeSQL execute, `/analytics/sql` echo, ObjectQL engine path) | **changed.** Both spellings are refused with `INVALID_FILTER` / 400 at any depth, measured over sql.js before and after. | | `evaluateAnalyticsQueryOverRows` / `matchesWhere` (analytics draft-data preview) | **changed**, as a bounded in-place fix (see Deviations). It used to string-compare the row against the list and now runs the same gate. | | `assertListComparandShapes` (spec shared face) | **already compliant.** This is ruling 乙's own face (objectstack-ai#19882). This PR calls it and does not change it. | | `compileScopedFilterToSql` (service-analytics read scope) | **already compliant.** Since objectstack-ai#19975 (landed `e8f163fc3a`) it refuses both spellings with `READ_SCOPE_COMPILE_FAILED` / 500. Its matrix cells and `read-scope-eq-array-refusal.test.ts` are green in this PR's package run. Not touched. | | `matchesFilterCondition` (formula) | **already compliant**, per the table in PR objectstack-ai#19994 (400 for the implicit list, `$eq` and `$not`). Not re-measured here. | | `applyFilterCondition` (driver-sql) | **already compliant**, per the table in the objectstack-ai#19882 changeset. Not re-measured here. | | `buildWhereSQL` (driver-turso RemoteTransport) | **already compliant**, per the table in PR objectstack-ai#19994. Not re-measured here. | | `checkCondition` (driver-memory) | **already compliant**: 400 at every depth, per the table in the objectstack-ai#19882 changeset. Not re-measured here. | | `translateFieldOperators` (driver-mongodb) | **out of scope.** The driver answers with MongoDB array equality. A platform door reaches it only through the shared face, which refuses the list (the declared scope of objectstack-ai#19882). | | `applyHaving` / `matchesHaving` / `checkCondition` (objectql HAVING) | **out of scope.** This is a caller-authored filter over aggregated rows, a different door from this card's. Its answers are recorded in the objectstack-ai#19886 stage-2a report. | ## Tests and evidence (head `896e1e70f3`) - **New `src/__tests__/where-equality-slot-list-refusal.test.ts`, 59 tests.** Every refusal asserts `code` + `status`. - The `$eq` list at 9 positions: every depth, the empty list, and beside another operator in either key order. - The implicit list at 7 positions, including the empty list and a nested relation. - Byte identity (4): the object spelling equals the FilterArray spelling, which equals the face's own message. - List before member (4). - 12 neighbouring shapes that must compile as before: a scalar, a `Date`, the null predicate, the `$in` remedy, the `$in: []` / `$nin: []` constants, a nested scalar, a `$field` reference, `$between`, and `$ne` (not judged). - Four faces over a real sql.js engine (native execute, echo, ObjectQL engine path, draft preview) × 4 spellings, plus a control. Each is refused before any statement runs and before any `engine.aggregate` call. - A stored dataset through the service doors (6): the dashboard door and the draft preview, for a scope filter and a measure filter; the registered cube on the ObjectQL door; and a control. - **`comparand-door-single-source.test.ts`:** the `array` row's `whereEq` cell is re-judged from `accept` to `INVALID_FILTER/400`, with a note. It had pinned `qty = 'al'` with `'be'` dropped. - **Two existing pins re-judged, not rewritten by rote:** - `filter-normalizer-not-null-safe.test.ts`: the bare `[]` is now refused, and `$in: []` keeps its FALSE constant. - `filter-normalizer-undefined-comparand.test.ts`: the `{ d: [1, undefined] }` row leaves the undefined table, and the `{ d: [1, null] }` row leaves the null control group. Each carries a note: its enclosing shape is now refused whole. - **Package run.** `pnpm --filter @objectstack/service-analytics test`: 117 files and 2541 tests passed (base: 116 files, 2484 tests). `typecheck` exited 0, and `tsc --listFiles` includes all four touched test files. - **Ablations.** Each was run from the committed fix through `scripts/ablation-replace.mjs`, with the red/green count predicted before running. The tests import the source by relative path, so no build is on the path. Each restore was proven by the blob hash matching HEAD and by an empty `git diff HEAD`. - **A: the `in` reading put back.** The gate call in `lowerAnalyticsWhere` was deleted and the old bare-array arm restored in `fieldLeaves`. Predicted 41 red; measured **41 red / 161 green** over the four touched test files: - 9 `$eq`, 7 implicit, 4 byte identity, 4 list-first; - 12 faces: native, echo and engine × 4 spellings. The preview cells stayed green, because the preview runs the gate itself; - 3 stored, 1 matrix `array` where row, 1 bare `[]`. - Sample failures: `native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefined` and `expected 'accept' to be 'INVALID_FILTER/400'`. - **B: the preview's gate call deleted.** Predicted 6; measured **6 red / 53 green**: the four preview face cells and the two stored-dataset preview-door cells. Sample: `draft preview: expected a refusal, got rows: expected [ 'd5' ] to be undefined`. - **Gates.** `dispatch-gates` derived 60 at `896e1e70f3`. The run also covered the dispatch-time list's `check:dispatcher-error-vocabulary`, for 61 in total. 59 exited 0. - **NOT MEASURED (2):** `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3 (PREREQUISITE NOT MET). They need the whole workspace built, which CI does. - `check:lean-entry-closure` exited 3 until objectql's closure was built, then 0. - `--ran` reconciliation: 60 derived, 58 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. - Among the passes: `check-adr-0087-registration --base origin/main` (1 declared-breaking changeset, `not-required (already-registered)`), `check-changeset-no-major`, `check:changeset-gate-self-tests`, `check:nul-bytes`, and `check-issue-citations` in its board-probing mode (19 citations, all of which resolve). - **Lint, narrowed to the change.** `eslint --no-inline-config --format json` over the six touched TypeScript files: 6 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each of them. `eslint.config.mjs` never enables type-aware linting (its note near line 327), so this diff cannot change the verdict on any untouched file. - **Dependents.** `pnpm --filter '...@objectstack/service-analytics'` names 19 downstream packages. The AST scan above found no filter carrying the shape in their sources or tests. Their suites were not run here; CI's affected set runs them. ## Deviations from the dispatch, stated 1. **File surface.** The claim declared `filter-normalizer.ts`, the matrix's `where*` cells, new test files and the changeset. This PR also touches three more files: - `src/preview-evaluator.ts`: one import, one call and comments. This is a bounded in-place fix, and all four conditions hold: it is the same defect class; it is a mechanical call of the same gate, whose shape ruling 乙 pins; no open PR touches this package, per the claim's own reading; and it is the same gate family, with no new verification surface. The claim's file surface needs this path added. - `filter-normalizer-not-null-safe.test.ts` and `filter-normalizer-undefined-comparand.test.ts`: three pins asserted the reading this ruling removes. They are re-judged, with notes (above). 2. **The changeset's ADR-0087 disposition** is `not-required (already-registered filter-equality-array-comparand-refused)`, not the dispatched `not-required (no-migration-prescription)`. A stored dataset, widget or measure filter can carry this shape, because the authoring schema admits it (measured). So the changeset carries a FROM → TO table. The gate refuses `no-migration-prescription` on a body that carries one, and that disposition would also claim that no author has to rewrite anything. The transition itself has been on the ledger since objectstack-ai#19757, and that entry's surface and prescription cover this door verbatim. PR objectstack-ai#19374 used the same disposition for the same situation. The gate accepts it. ## Acceptance notes - **A registry sentence this PR makes false.** The registered entry `filter-equality-array-comparand-refused` (`packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts`, and its copy in `registry.ts`) says that the analytics normalizer's OBJECT form "still reads as membership". That is no longer true. Editing it is a `packages/spec` change, outside this dispatch, so the carrier is the seat's call. - **`$ne` with a list** is not judged (ruling A of objectstack-ai#19886). Measured at `896e1e70f3`: `{ stage: { $ne: ['won', 'lost'] } }` compiles to `stage IS NULL OR stage != 'won'`, so `'lost'` rows are served. Reported to the seat. - **The shared face's other arms.** The object-form door still runs none of them: the null list member, the null ordering comparand, the null or blank `$between` bound, and the scalar `$in`. The FilterArray spelling of each is refused on the same door. This package's own pins hold several of the object-form answers. Reported, not addressed. - **The authoring door.** It still admits the list: `DatasetSchema` with `filter: { stage: ['won', 'lost'] }` parses. The objectstack-ai#19757 changeset declared this. Reported. - **The draft preview does not lower a FilterArray `where`.** It answered no row for `['stage', '=', 'won']` in the probe. Reachability through the dataset door is not established, so this is an observation only. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…in the comparand face's own words (objectstack-ai#19889) (objectstack-ai#20047) Fixes objectstack-ai#19889 Clause-②: no This carries out ruling **5805248669** (letter **A**): the schema door refuses an array in the equality slot, with the compile face's own words. `Clause-②: no` is the claim's line, copied as it stands. The changeset carries `Clause-②: no (narrowing)` because AGENTS.md makes a narrowing BREAKING and `check:adr-0087-registration` reads the arm there. Both lines give the same value. Session `session_019c3Hi6ZMU1p6m6aA6Bz45d`, branch `claude/issue-19889-filter-schema-door-array-equality`, base `a0920b42dc`. Every reading below was taken at head `46e1c81727` unless it says otherwise. ## 1. Reproduction (before the change, on `origin/main` `a0920b42dc`) | input | answer | |:--|:--| | `DatasetSchema.safeParse`, control `filter: { stage: 'won' }` | `success: true` | | `DatasetSchema.safeParse`, `filter: { stage: ['won','lost'] }` | **`success: true`** | | `DatasetSchema.safeParse`, measure `filter: { stage: { $eq: ['won','lost'] } }` | **`success: true`** | | `FilterConditionSchema.safeParse` on both shapes; `FieldOperatorsSchema.safeParse({ $eq: [...] })` | **`success: true`** (all three) | | `assertListComparandShapes({ stage: ['won','lost'] })` (the compile face) | `INVALID_FILTER` / 400 | | `assertListComparandShapes({ stage: { $eq: [...] } })` | `INVALID_FILTER` / 400 | The face's refusal text read, verbatim: `The implicit-equality comparand on field "stage" requires a single comparable value, but received an array (["won","lost"]) at where.stage. For "one of these values" use {"$in": […]} (authoring: in); for "the stored list holds a value" on a multi-value field, {"$contains": "…"} (authoring: contains), an $or of those for any-of. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.`⚠️ **The ruling calls it "one constant", but no remedy constant existed.** On `main` the face built the text inline in `arrayEqualityComparandError`. This PR extracts it rather than copying it (§2). ## 2. The change - **`packages/spec/src/data/filter-comparand-refusal-text.ts` (new, internal).** It holds the ONE remedy constant `ARRAY_EQUALITY_COMPARAND_REMEDY` and the one message builder `arrayEqualityComparandMessage`, plus `shapePreview`, the `$contains` operator and the `in` spelling row. Those three moved out of the face so both doors render the value and the prescription from one source. - Both doors import this module. The face cannot import `filter.zod.ts`, because of the import cycle the face already documents. - ⛔ **It is deliberately NOT in the `data` barrel**, following the `currency-fraction-digits.ts` precedent. Exporting the text would widen `@objectstack/spec/data` for no reader, which a `Clause-②: no` card must not do. `check:api-surface` stays green. - **The face (`filter-comparand-shape.ts`)** now calls the builder. Its message is byte-for-byte what it was: every existing face pin, and the analytics / mongodb / memory suites, pass unchanged. - **`FilterConditionSchema`** refuses both arms inside its existing refinement walk: - `{ field: [...] }` at the issue path `field`; - `{ field: { $eq: [...] } }` at `field.$eq`; - the empty array too, and inside `$and` / `$or` / `$not` members through their own re-parse. - **`FieldOperatorsSchema.$eq`** refuses an array comparand. So does its documentation copy **`EqualityOperatorSchema.$eq`**, through one shared `equalityComparandSchema` factory, following the file's own pairing rule (`orderingComparandSchema`). `NormalizedFilterSchema` validates against it and refuses too. - **Reach = the face's, no wider.** A field spec with no `$` key (a nested-relation or deep-equality condition) is not judged at this door, because the face never descends one. A pin holds both doors to the same answer on that shape (§4, control rows). - **Not dropped.** A refused document fails its parse and nothing is stripped. The pin `CONTROL — the same documents with $in publish, and keep their filter` proves that accepted means kept. ### "The parse-door message equals the compile-face message": what equality holds⚠️ Stated rather than settled silently. A zod refinement cannot see its absolute location: in zod 4.6.1 the refinement context is the parse payload, with no path. So the schema door does not print the face's `at where.FIELD` clause. Its issue carries the location as its `path` instead (`filter.stage`, `measures.0.filter.stage.$eq`). Pin §3 asserts **character equality after removing exactly that one clause**, and asserts the clause is present exactly once, so the removal is not vacuous. That holds on 8 shapes: implicit and `$eq`, empty, and under `$and` / `$or` / `$not`. Printing `at where.stage` at save time would name a location that does not exist in the saved document, and the wrong one for a combinator member. The `$eq` operator slot cannot see its field either (the key belongs to the enclosing record). Its pin asserts equality after removing both the ` on field "stage"` clause and the location clause. If the seat reads ruling item 4 as strict byte-equality, the alternative is to print a fixed `where.` location; I recommend against it for the reason above. ## 3. Carrier census (ruling item 3) **Schemas that embed `FilterConditionSchema` directly.** Each one refuses now; the right-hand column is measured at head: | schema · key | refuses? | |:--|:--| | `DatasetSchema.filter`, `DatasetMeasureSchema.filter` | yes (pinned) | | `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter` | yes (widget pinned) | | `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter` | yes (report pinned) | | `FieldSchema.relatedListFilter`, `FieldSchema.summaryOperations.filter` | yes, as a `FilterConditionSchema` carrier | | `BlueprintSummaryOperationsSchema.filter` | yes, as a carrier | | `AnalyticsQuerySchema.where`, and through it `AnalyticsQueryRequestSchema.where` | yes (measured) | | `DatasetSelectionSchema.runtimeFilter` | yes (measured) | | `QuerySchema.where` / `.having`, `AggregationNodeSchema.filter`, `QueryFilterSchema.where` | yes (`where` / `having` measured) | | `EngineAggregateOptionsSchema.having` | yes (measured) | | `Engine{Query,Update,Delete,Aggregate,Count}OptionsSchema.where`, `DataEngineVectorFindRequestSchema.where` | **no**: the union's first arm is an open record, so it parses (measured). The face refuses it at execution. | **Schemas that embed `FieldOperatorsSchema`:** `NormalizedFilterSchema` (through its field-condition record). `EqualityOperatorSchema` is the documentation copy. **Where the refusal is met.** Every one of these was measured at head: | door | answer | |:--|:--| | `defineStack` (strict) | `STACK_SCHEMA_INVALID`, `datasets.0.filter.stage: …` | | `os validate` | parses `ObjectStackDefinitionSchema`, read at source | | metadata-protocol `saveMetaItem`, dataset | **`INVALID_METADATA` / 422, issue path `filter.status`**; the `$in` control saved. This was a throwaway probe on the `protocol.dashboard-dataset-publish-gate` harness and was deleted, tree clean. | | REST `datasetSelectionRefusal` | **`VALIDATION_FAILED` / 400**, `selection.runtimeFilter.stage:` followed by the sentence; previously the analytics compiler's `INVALID_FILTER` / 400 | | runtime analytics body (`handleAnalyticsRequest`) | **`VALIDATION_FAILED`**, `where.stage: …`; the bridge maps it to 400, pinned in `dispatcher-validation-error.real.test.ts` | The read path does not re-validate stored rows, so a stored document keeps loading and its next save is refused. ⛔ Nothing is rewritten or dropped. **Stored and shipped instances carrying the shape: 0.** - **Text scan.** A brace-matched scan of every `filter` / `where` / `runtimeFilter` / `having` / `relatedListFilter` literal in `packages/**`, `examples/**`, `apps/**`, `content/docs/**` and `skills/**` read 7785 files and 4709 carrier literals. It found 20 field entries whose value opens an array. 16 are test fixtures; they are its positive control, including the mongodb refusal pin rows. The other 4 are not `FilterCondition` carriers: `client/realtime-api.ts` `eventTypes` and `core/PHASE2_IMPLEMENTATION.md` plugin-permission `filter`. There is no YAML-authored carrier block. - **`$eq` grep.** A grep for `$eq` followed by an array found 24 lines: prose, MongoDB aggregation `$eq` expressions, and one `door-refusal` conformance row. - **Runtime census.** - `app-crm`, `app-todo` and `app-multi-package` load through strict `defineStack` / `composeStacks`: 0 hits. - `app-showcase`'s config imports connector plugins with no `dist/` here. Its barrels were parsed with the registered type schemas instead: 22 objects, 4 datasets, 3 dashboards, 4 reports. That gave 0 hits and 0 other failures. - A planted `{ stage: ['won','lost'] }` dataset fired once at `filter.stage`. - Deployed datasets, dashboards and reports are **NOT MEASURED**. ## 4. Pins, each proven able to fail `filter-equality-array-schema-door.test.ts` has 46 tests: §1 FilterConditionSchema refusals, §2 operator slot, §3 same words, §4 controls at both doors, §5 carriers. Each ablation used `scripts/ablation-replace.mjs`: the anchor hit exactly once, and each leg printed its blob change. After every leg, restore was proven by blob == HEAD `be90963c90` and an empty `git diff HEAD`. The spec tests import from `src`, so no `dist/` leg was needed. | leg | mutation | result | |:--|:--|:--| | A | implicit arm off | **14 red** / 32 green (implicit refusals, same-words rows, `DatasetSchema filter.stage`, widget) | | B | `$eq` arm off | **9 red** (`$eq` refusals, same-words rows, `measures.0.filter.stage.$eq`, report) | | C | operator-slot refinement off | **4 red** (`FieldOperatorsSchema`, `EqualityOperatorSchema`, `NormalizedFilter`, slot same-words) | | D | schema door prints a location of its own | **5 red** (the §3 equality rows) | | E | reach widened into nested-relation specs | **1 red** (the nested-relation control) | | F | operator slot also refuses `null` | **1 red** (the scalar/null control) | | control | restored tree | 46 / 46 green; `git status` clean at `46e1c81727` | Re-judged, not dropped: `filter-comparand-shape.test.ts` pinned `FieldOperatorsSchema`'s array-accepting set as `['$between','$eq','$in','$ne','$nin']`, "`$eq` … only because both are `z.any()`". `$eq` has left that set, so the expectation now reads `['$between','$in','$ne','$nin']` and the comment says why. ## 5. ADR-0087 - **New semantic entry `filter-equality-array-comparand-refused-at-save`.** The changeset marker is `registered filter-equality-array-comparand-refused-at-save`. The gate read it back as `[BREAKING+bang+clause-②-narrowing] registered … (new here …)`. Every factual claim in it was measured at head (§3). - **Corrected the unreleased entry `filter-equality-array-comparand-refused`.** At `:46-49` it said the analytics normalizer's OBJECT form "still reads as membership" because it "does not route through the shared face". That has been false since objectstack-ai#20008. It now states which doors refuse the shape at this release, and with what: - the face (`parseFilterAST`, the engine seam): `INVALID_FILTER` / 400; - the analytics `where` door, in both spellings: the same, and that door alone also refuses a nested-relation list; - the schema door on save: the same sentence, as a parse issue. The object form's four old readings are named, and its acceptance criteria point to the new sibling entry. - `gen:migration-registry` regenerated `registry.ts`, and `check:migration-registry` is green. `gen:upgrade-guide` and `gen:spec-changes` were run and wrote **zero diff**. Both project steps up to `PROTOCOL_MAJOR` (17, `PROTOCOL_VERSION` `17.0.0`), so no step-18 entry appears in them yet, this one included. `check:upgrade-guide` and `check:spec-changes` are green. ## 6. Changesets, and two DELIBERATE CORRECTIONS for the seat to confirm - New: `.changeset/19889-filter-schema-door-array-equality.md`. It declares `@objectstack/spec: minor`, the **BREAKING** banner, `fix(spec)!:`, a FROM → TO table and the measured census. The grade follows AGENTS.md: a `(narrowing)` is BREAKING, and the launch-window convention ships it `minor`. `check-changeset-no-major` is green. -⚠️ **DELIBERATE CORRECTION 1**, `.changeset/19757-equality-slot-array-refused.md` (pending). Its bullet said: "The schema doors are not touched. `FilterConditionSchema` still parses `{ field: [...] }` … A document carrying the shape therefore still publishes". That is false in the release both ship in. It now says this change did not touch the schema doors, and that a separate change in the same release does. -⚠️ **DELIBERATE CORRECTION 2**, `.changeset/19888-analytics-implicit-array.md` (pending, services lane). Its sentence said: "The authoring schema still admits the shape, so such a document still publishes, and it is refused when it is charted". That is false for the same reason. It now says the schema refuses the shape on save, except for a list inside a nested relation, which only the analytics door judges. - `check-empty-changeset` is therefore **red by design** ("DELIBERATE CORRECTION … say so on the PR … get it confirmed"). Neither file is restored from base, because that would republish a false sentence. ## 7. Tests (at `46e1c81727`) | package | result | |:--|:--| | `@objectstack/spec` (full) | 564 files · **16273 passed** · 2 todo | | `@objectstack/spec` typecheck (`tsc`, scripts, test layer) | green; `check:test-typecheck` OK | | `@objectstack/service-analytics` | 117 files · 2541 passed | | `@objectstack/objectql` | 312 files · 5243 passed | | `@objectstack/rest` | 195 files · 3273 passed · 1 skipped | | `@objectstack/metadata-protocol` | 188 files (3 skipped) · 2676 passed · 19 skipped | | `@objectstack/runtime` | 278 files · 3962 passed · 1 skipped | | `@objectstack/lint` | 108 files · 4138 passed | | `@objectstack/cli`, unit layer | 224 files · 3158 passed | Notes on the table: - **`cli`.** The first run's two `published-subpath-*` pins refused on their prerequisite, "packages/cli is not built". After `cli` was built they ran: 2 files, 29 passed. The integration layer is declared to CI, since the diff touches no spawn entry or integration file. - **Build.** The build ran through turbo for the closures of the packages above: 56 + 57 tasks, all successful. - **Lint, narrowed.** The 8 touched `.ts` files were linted with `eslint --no-inline-config --format json`: 8 files counted from the JSON, **0 errors / 0 warnings**, and `isPathIgnored` is false for each. The config enables no type-aware linting (no `parserOptions.project`, per its own comment at `eslint.config.mjs:328`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` belongs to CI. ## 8. Gates (at `46e1c81727`) `dispatch-gates --commands --repo objectstack-ai/objectstack` derived **87** commands. The `--ran` reconciliation, with an exit code per line, reads: **87 derived, 85 run, 2 NOT-MEASURED, 0 UNRUN**. - **84 exited 0.** These include `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:doc-authoring` ("16302 customer-facing string(s) … clean"; sibling prose ids "no growth"), `check:nul-bytes` ("scanned 9435 … no raw ASCII control bytes"), `check:engine-double-contract` and `check:cross-package-test-inputs`. - **1 exited 1:** `check-empty-changeset`. This is the deliberate-correction refusal in §6. - **NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`.** Reason: both exit 3, `PREREQUISITE NOT MET`, because they read the whole workspace's `dist/`. PR objectstack-ai#19882 recorded the same two. - `@objectstack/spec check:generated` reports all 15 artifacts current after the build. ## Acceptance notes - **Residual (finding, not fixed here).** A list inside a nested-relation condition on a dataset or measure filter, `{ account: { region: ['a'] } }`, still passes `DatasetSchema`. Since objectstack-ai#20008 the analytics `where` door refuses it (`INVALID_FILTER` / 400, measured). Refusing it at the shared schema door would refuse a deep-equality comparand the shared face and the engine accept. That is a different decision, and it is left to the seat. - **Error code at two request doors.** An analytics request carrying the shape now answers `VALIDATION_FAILED` / 400 at the request door (REST dataset selection, runtime analytics body) instead of `INVALID_FILTER` / 400 at the compiler. Both are class-1 400s carrying the same sentence. No test pinned the old code for this input: the `rest`, `runtime` and `service-analytics` suites are green. - The published JSON Schema cannot state the check (`z.toJSONSchema()` has no custom-check projection). Three sites are declared in `dropped-refinements.baseline.json`: `data/FieldOperators` `$eq`, `data/EqualityOperator` `$eq` and `data/NormalizedFilter` `…valueType.$eq`, and its `measured` counts moved 206→207 schemas and 571→574 sites. - `$ne` carrying a list is untouched. objectstack-ai#19886 and the face's `it.todo` carry it. objectstack-ai#19886 remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…t the CEL lowering and $ne arrays at the mongodb face (objectstack-ai#19947) Refs objectstack-ai#19886 Clause-②: no (narrowing) <sub>Rewritten short by the `domain:spec#5` seat (2026-09-24T07:02Z; round 4 after record `5808690296`). Stage 2c of objectstack-ai#19886. Seat rulings: `5806391955`, `5806943154`, `5807743099`, `5808108434`. Dev reports on the card: `5806886759`, `5807587023`, `5808082032`, `5808419047`, `5809358163`.</sub> Some row-level or sharing CEL predicates no longer lower: those comparing a field with `==` / `!=` against a list, whether a list literal or a `current_user` membership array. Every consumer fails closed: - the RLS compiler drops the policy; - the sharing bootstrap skips the rule; - the authoring lint reports the literal forms. driver-mongodb's `translateFilter` refuses `$ne` with an array comparand (`INVALID_FILTER` / 400). ## What changed - `packages/formula/src/cel-to-filter.ts` adds the refusal, covering list literals and resolved arrays, both orientations, and forms under `!`. - `packages/drivers/driver-mongodb/src/mongodb-filter.ts` adds the `$ne` array refusal at any depth. - `packages/lint/src/validate-rls-predicate-enforceability.ts`: the reference pass probes each kernel `current_user` key with its runtime type (scalar keys as scalars, membership keys as arrays). - Brought in line with the merged stage 2a (PR objectstack-ai#19946): a CEL-authored `check` carrying the shape is now dropped at compile (403 when no other policy applies), so `matchesFilterCondition`'s 400 remains for a filter passed to it directly. Its ADR-0087 entry, its plugin-security pin and the docblock spans made false by this are corrected by cuts. So are three spans of its PENDING changeset: a objectstack-ai#17712 DELIBERATE CORRECTION, so `Check Changeset` is red by design and landing waits on the maintainer's confirmation (see the gate comment). - Changeset: BREAKING, at `minor` for formula, driver-mongodb, plugin-security, plugin-sharing and lint, and at `patch` for spec, which carries the ADR-0087 entry `cel-predicate-list-comparand-refused`. ## Compile faces This PR changes one compile face, driver-mongodb `translateFilter` (`$ne` with an array → 400). The equality-slot array is left to the shared face (PR objectstack-ai#19882). For the other faces, see PR objectstack-ai#19946's table. ## Verification (the dev's, at `3bf405b501`; round 4 re-measured at the merged head) - The suites of every touched package are green, and CI is green. - End to end on driver-mongodb (mingo proxy; live `mongod` NOT MEASURED) and driver-sql, every probe fails closed: reads return no rows, and a `check` gets 403 with nothing stored. That includes `!(record.x == current_user.org_user_ids)`, which read every row on driver-mongodb before. - The controls are unchanged: `in`, `not in`, scalar `==` / `!=`, `null` and `{ $field }`. - Ablating the lowering refusal turns the refusal pins red, including the re-judged 2a pin (which then receives 2a's 400). ## Not in this PR - objectstack-ai#19951: the lint is silent on `==` / `!=` against a membership key. - objectstack-ai#19949: driver-mongodb `{ $field }`. - Stage 2b: the shared face, after PR objectstack-ai#19882. - Stage 2d. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19757
Clause-②: no (narrowing)
This executes ruling 5793368540 (batch #217 item 3, letter 乙, 「217 同意」): an array in the implicit-equality slot is refused at the shared comparand-shape face, for every driver at once, with ⛔ no alias and ⛔ no grace window.
The
Clause-②value isno, as the claim and the ruling state it. The(narrowing)arm is added because of AGENTS.md's changeset rule: a narrowing is BREAKING, and the changeset carries the PR'sClause-②line, whichcheck:adr-0087-registrationreads for the arm. Both changesets and this body therefore carry the same line.Session
session_013RDBh5DqXd2xnLwvHLgLFr, branchclaude/issue-19757-equality-slot-array-refused. Every reading below was taken onorigin/main@2548ba57deunless it says otherwise.1. Measured first (before the change)
What
parseFilterASTlowers each spelling to:['tags','equals',['a']], and the same on=,==,eq{"tags":["a"]}(implicit form)['tags','ne',['a']], and the same onnot_equals,!=,neq,notequalsand the angle-bracket pair spelling{"tags":{"$ne":["a"]}}{tags:{$eq:['a']}},{tags:[]}, and{tags:['a']}nested under$and/$or/$not@objectstack/objectql's delegating wrapperassertListComparandShapes('deal','find', …)also passed{tags:['a']}and{tags:{$eq:['a']}}. Through a recording driver, both engine doors handed the shape to the driver:[['tags','equals',['a']]]isFilterASTandparseFilterAST$eqcountwith the shape under$orHow each backend answered it, on the lowered node, beside a scalar and an
$incontrol. The rows werer1=['a'],r2='a',r3=['a','b'],r4=['b','a'],r5=[['a'],'x'],r6=[['a']],r7='b'andr8=[].{tags:['a']}{tags:{$eq:['a']}}{tags:{$ne:['a']}}{$not:{tags:['a']}}driver-sql, SQLiteINVALID_FILTERDATABASE_ERROR;$and/$ornesting is the same 500driver-memoryformulamatchesFilterConditionr1includeddriver-mongodbtranslateFilter{"$nor":[{"tags":["a"]}]}r1,r5,r6r1,r5,r6r2,r3,r4,r7,r8r2,r3,r4,r7,r8Also measured:
service-analytics' filter normalizer read[['stage','=',['won','lost']]]and{stage:['won','lost']}asstage IN (won, lost).mongod(mingo is the proxy), MySQL, PostgreSQL and a live Turso server.driver-tursoanddriver-sqlite-wasmare built ondriver-sqland were not run as backends.Which operators the ruling's words cover. The ruling covers the implicit and explicit equality slots:
{f:[...]}from any equality spelling, and$eq, at any depth under$and/$or/$not, the empty array included.⛔
$neis left out on purpose. It is equality's negation, not equality, and it measured the same split. It is reported for its own ruling and not absorbed here. This follows the face's own precedent for the$in{ $field }member question, which it left to a separate card. Anit.todorecords it, with ⛔ no green pin. The other scalar operators carrying an array ($gt,$contains,$like, …) are not covered either.Spellings, read at source. The ruling names
FilterOperatorSchema. No such export exists onorigin/main: it has zero hits underpackages/spec/src, while the controlFieldOperatorsSchemadoes resolve. So the two prescribed operators are read offFieldOperatorsSchema's keys andAST_OPERATOR_MAP's lowering:$in, with authoring spellingin, is the declared list operator.$contains, with authoring spellingcontains, is the membership test the spec declares for amultiple: true/ JSON-stored column.A pin reconciles both against the schema and the vocabulary. The vocabulary declares no array-valued contains operator:
$containsisz.string().2. What changed
packages/spec/src/data/filter-comparand-shape.tsgains the equality-slot arm.{field:[...]}and{field:{$eq:[...]}}are refused with the face's existingINVALID_FILTER/ 400 envelope.driver-memory'sarrayComparandErrorverbatim, so one condition keeps one wording.{"$in": […]}(authoringin) and{"$contains": "…"}(authoringcontains) on a multi-value field, with an$orof those for any-of.$eq:null, every scalar and a{ $field }reference pass exactly as before.parseFilterAST. Thedriver-mongodbpin measures both through the engine, as described in §3.FILTER_COMPARAND_TYPE_CASESgains threedoor-refusalrows: implicit,$eq, and nested under$or. This is the one door-refusal table every driver suite already runs throughparseFilterAST(driver-sql,driver-memory,driver-mongodb,driver-sqlite-wasm,driver-turso). Its "What belongs here" note says why an array where ONE value belongs sits beside "a plain object in a scalar slot".FILTER_TEXT_CASESis untouched.driver-mongodbpin —mongodb-equality-array-comparand-refusal.test.ts, 13 tests. ⛔ No driver source edit.parseFilterASTthentranslateFilteris refused, andtranslateFilteris never reached.translateFilterrefuses both doors and$eq, andcountnested under$or, withtranslateFiltercalled zero times.$inand$eq: null.translateFilterhanded the shape directly still emits it unchanged, so the face is the only guard.mongodis stated as NOT MEASURED.18.filter-equality-array-comparand-refusedis added, following the18.view-filter-rule-scalar-operator-array-refusedprecedent.registry.tswas regenerated withgen:migration-registry, andcheck:migration-registryis green.@objectstack/spec: minor, with the**BREAKING**banner,fix(spec)!:,Clause-②: no (narrowing)and theadr-0087 registered filter-equality-array-comparand-refuseddisposition marker. The level isminorbecause AGENTS.md makes a(narrowing)BREAKING whilecheck-changeset-no-majorforbidsmajor. The launch-window convention ships an accept-set narrowing asminor, and the finding(spec):ViewFilterRuleSchemaaccepts two shapes every consumer refuses, andObjectGridProps.defaultFiltersisz.unknown()so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514 changeset is the sibling precedent.check-changeset-no-majoritself prints "narrowing — a BREAKING change; during the launch window it shipsminor".@objectstack/metadata-core: patch, for the retired dispatch rows below.3. Tests, firing control, and gates
Firing control: the refusal pins turn red on the face as it stood. The two new throws were disabled through
scripts/ablation-replace.mjs. Each anchor hit once, and the blob moved fromef772a616ctof8fd530c31. An EXIT/INT/TERM trap restored the file.filter-comparand-shape+filter-field-reference-lowering, spec sourcedriver-mongodbpin, spec rebuiltdriver-memorycomparand-type conformance, spec rebuiltFor the two spec-rebuilt rows,
ablation-dist-preflightfound both markers present inpackages/spec/diston the mutate leg. On the restore leg it found them absent from all 216 built files, with the tree clean. The restore was proven with a HEAD blob-hash match and an emptygit diff HEAD.Suites, run on
bec8f4c737.438d385af3differs only by the prose-id baseline JSON.@objectstack/spec: 525 files, 15510 passed, 2 todoobjectql: 304 files, 5069 passeddriver-memory: 52 files, 1248 passedmetadata-core: 16 files, 285 passeddriver-mongodb: 26 files passed and 5 live-mongod files skipped; 578 passedmetadata-protocol: 188 files, 2673 passedservice-queue: 5 files, 77 passedRun on
723f254402, before the consumer fixes:driver-sql2648 passed (168 skipped),formula915,driver-turso1302,driver-sqlite-wasm521,service-analytics2442,plugin-sharing913,lint4121.Typecheck is clean for
spec,driver-mongodb,driver-memoryandmetadata-core.--listFilesshows each touched test file inside its package's program.Gates, run on the final head
438d385af3:dispatch-gates --commandsderived 95 families. 93 exit 0.check:dual-build-cjs-loadsandcheck:type-check-debt.--ranreconciliation: 95 derived, 93 run, 2 NOT-MEASURED, 0 UNRUN.check-adr-0087-registration:[BREAKING+bang+clause-②-narrowing] registered filter-equality-array-comparand-refused (new here)check:doc-authoring: clean, with the baseline shrink belowcheck:engine-double-contractandcheck:nul-bytes: green@objectstack/spec check:generated: all 15 artifacts current.4. Consumers that broke, and how each was re-judged
The repo was grepped (examples, seeds, docs, published skills, fixtures, tests) for the FilterArray triple on
=/==/equals/eqcarrying an array, for$eqcarrying an array, and for filter / where objects with an array field value. No shipped example, seed, doc or skill authors the shape. The full suites above went red in exactly four places:filter-comparand-shape.test.tspinned{tags:{$eq:['a','b']}}and{tags:['a','b']}as passing. It pinned the exact slot the ruling closes, so both rows are inverted.filter-field-reference-lowering.test.tspinned['stage','=',['a','b']]lowering to the implicit form. The row is re-judged, not dropped: it now asserts the refusal names the implicit slot and not$eq, which still proves that an array is not promoted the way a reference is.['a','b']through every AST spelling. They areloweredOperatorOfin the spec suite and indriver-memory'smemory-filter-ast-vocabulary.test.ts, and the latter turned 4 tests red. Each helper stated that the probe "never trips the shape door". The equality spellings now refuse it, so the helper reads that refusal asundefined, which is the answer it always gave them.@objectstack/metadata-core's engine-double dispatch tables carried three ARRAYwhere.idrows: deletearray id, no multi, and updatearray id, no multiandSCALAR data.id beside an ARRAY where.id. The real engine now refuses that input at the face before the dispatch runs, andobjectql's 测试替身比真实实现宽松:四个缺陷因此带着绿灯发布——需要一条把替身钉在真实契约上的闸门 #4550 harness requires the engine's words to equal the predicate's. That turned 4 tests red. The rows are retired with a note, and the predicates are unchanged. The$inrows keep the non-scalar-id coverage, andscalar*Id's array pins stay. The changeset ispatch.check:doc-authoring's prose-id baseline shrank by one (#112306 → 5 in that file) through its own--census-ledgerremedy.ENGINE_*_DISPATCH_CASESclaimed a dispatch refusal the real engine no longer gives.Two alternatives were weighed and not taken:
objectql's harness accept the face's refusal. That would carve an exception into the A compare-and-setwhereon a by-idupdateis silently inert — the extra predicate keys never reach the driver, andSqlHttpOutbox.redeliver's status guard is one of them #11009 contract that "both halves must refuse with the SAME words".update('task'):prefix.The seat may prefer another disposition, and the change is reversible.
Files beyond the claim's declared surface, each for the reason given:
filter-comparand-type.ts, one comment sentence. It said the equality-slot array is "answered per driver", which this change made false.filter-comparand-type-conformance.ts, where the conformance rows live.filter-field-reference-lowering.test.ts: item 2.driver-memory/src/memory-filter-ast-vocabulary.test.ts: item 3, test-only.metadata-core/src/engine-{delete,update}-dispatch.ts: item 4, table rows and notes only.scripts/doc-authoring-prose-id.baseline.json: the shrink.5. Compile surfaces, face by face (seat amendment after the at-tier FAIL
5808368753)Written by the
domain:specseat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d), which took this card over on the maintainer's 「你接手派补丁轮」. The FAIL's one blocking item was the missing face-by-face declaration. Each face below gives the review's file:line evidence and one of three conclusions: changed, already compliant, or out of scope with the reason. No code changed for this amendment; the head is still438d385af3.driver-sql2548ba57de;origin/mainhas since carried #19885, whose nested leaf takesassertCompilableComparanddriver-tursoRemoteTransport(remote mode)remote-transport.ts:487classifies a bare array asrequireValue,:612names it for refusal,:660setsINVALID_FILTER. "driver-tursois built ondriver-sql" above is true of local mode only; remote mode is its own compilerread-scope-sqlcompileScopedFilterToSqlnative-sql-strategy.ts:654,objectql-strategy.ts:559) callparseFilterAST. A bare array fails closed asREAD_SCOPE_COMPILE_FAILED/ 500 (:755,:436-440).$eq: [...]binds the array (:1273). Filed as #19975filter-normalizerparseFilterAST(:1521). The OBJECT form, read asIN, is #19888formulaorigin/main; this PR changes no formula filematches-filter.ts:196-255refuses the bare array and$eq/$nearrayshaving(half-face)engine.tsgateswhere(:866,:939) andaggregations[i].filter(:14776) but handsast.havingtoapplyHavingungated (:14885,:14929).having-filter.ts:357-363:having: { total: [5] }is true. A cross-laneobjectqledit outside this claim; filed as #19974driver-memory/driver-mongodbmongodb-equality-array-comparand-refusal.test.ts, 13 tests)Correction to §2: "Both changesets and this body therefore carry the same line" is not exact. The
metadata-corechangeset (apatchwith no BREAKING) carries noClause-②line; the spec changeset and this body do.Acceptance notes
These are observed and not fixed here. The report carries each one.
driver-sqlanswers the equality-slot array nested under$and/$or/$notwith a 500DATABASE_ERRORon a directSqlDriver.find: SQLite cannot bind the list. The top-level form gets its own 400. Every platform door now refuses the shape first, so only a direct-driver caller still reaches the 500. This is reported as a finding.service-analytics' normalizer does not route the OBJECT form{field:[...]}through the shared face, and still charts it asIN. Its FilterArray form is now refused. Reported as a finding.FilterConditionSchemastill parses{field:[...]}, becauseFieldOperatorsSchema.$eqisz.any(). A stored filter carrying the shape therefore publishes clean and is refused at query time. That schema-door twin is not in the ruling and is reported as a finding.$necarrying an array measured the same cross-backend split and is reported for its own ruling. TheViewFilterRuleschema door already refusesnot_equalsplus an array.driver-memory'sarrayComparandErrorstill says the spec "comparand door leaves this position to the driver", andfilter-comparand-type.test.ts's test title says "their semantics are per-driver today". Carrier: none.origin/mainhas moved 6 commits past the base. The branch is not merged with it. A driver-lessgit merge-tree --write-treeagainstorigin/mainis clean, and the only overlapping path is the generatedregistry.ts, which is a sorted union. None of the upstream-added lines authors the shape. CI's merge-ref run and the queue validate the merged tree.Generated by Claude Code