Skip to content

Commit 7536721

Browse files
fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face (#19882)
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 is `no`, 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's `Clause-②` line, which `check:adr-0087-registration` reads for the arm. Both changesets and this body therefore carry the same line. Session `session_013RDBh5DqXd2xnLwvHLgLFr`, branch `claude/issue-19757-equality-slot-array-refused`. Every reading below was taken on `origin/main` @ `2548ba57de` unless it says otherwise. ## 1. Measured first (before the change) **What `parseFilterAST` lowers each spelling to:** | authored | lowered to | shape face | type face | |:--|:--|:--|:--| | `['tags','equals',['a']]`, and the same on `=`, `==`, `eq` | `{"tags":["a"]}` (implicit form) | passes | passes | | `['tags','ne',['a']]`, and the same on `not_equals`, `!=`, `neq`, `notequals` and the angle-bracket pair spelling | `{"tags":{"$ne":["a"]}}` | passes | passes | | `{tags:{$eq:['a']}}`, `{tags:[]}`, and `{tags:['a']}` nested under `$and` / `$or` / `$not` | unchanged | passes | passes | `@objectstack/objectql`'s delegating wrapper `assertListComparandShapes('deal','find', …)` also passed `{tags:['a']}` and `{tags:{$eq:['a']}}`. Through a recording driver, both engine doors handed the shape to the driver: - Door 2 carrying the FilterArray `[['tags','equals',['a']]]` - the object form, which is also Door 1's hand-off after `isFilterAST` and `parseFilterAST` - `$eq` - `count` with the shape under `$or` **How each backend answered it**, on the lowered node, beside a scalar and an `$in` control. The rows were `r1=['a']`, `r2='a'`, `r3=['a','b']`, `r4=['b','a']`, `r5=[['a'],'x']`, `r6=[['a']]`, `r7='b'` and `r8=[]`. | backend | `{tags:['a']}` | `{tags:{$eq:['a']}}` | `{tags:{$ne:['a']}}` | nested `{$not:{tags:['a']}}` | |:--|:--|:--|:--|:--| | `driver-sql`, SQLite | 400 `INVALID_FILTER` | 400 | 400 | **500 `DATABASE_ERROR`**; `$and` / `$or` nesting is the same 500 | | `driver-memory` | 400 | 400 | 400 | 400 | | `formula` `matchesFilterCondition` | no row, `r1` included | no row | every row | every row | | `driver-mongodb` `translateFilter` | emitted unchanged | emitted unchanged | emitted unchanged | `{"$nor":[{"tags":["a"]}]}` | | mingo 7.2.4, the named proxy for MongoDB | `r1,r5,r6` | `r1,r5,r6` | `r2,r3,r4,r7,r8` | `r2,r3,r4,r7,r8` | Also measured: `service-analytics`' filter normalizer read `[['stage','=',['won','lost']]]` **and** `{stage:['won','lost']}` as `stage IN (won, lost)`. ⚠️ NOT MEASURED: a live `mongod` (mingo is the proxy), MySQL, PostgreSQL and a live Turso server. `driver-turso` and `driver-sqlite-wasm` are built on `driver-sql` and 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. ⛔ **`$ne` is 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. An `it.todo` records 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 on `origin/main`: it has zero hits under `packages/spec/src`, while the control `FieldOperatorsSchema` does resolve. So the two prescribed operators are read off `FieldOperatorsSchema`'s keys and `AST_OPERATOR_MAP`'s lowering: - `$in`, with authoring spelling `in`, is the declared list operator. - `$contains`, with authoring spelling `contains`, is the membership test the spec declares for a `multiple: true` / JSON-stored column. A pin reconciles both against the schema and the vocabulary. The vocabulary declares no array-valued contains operator: `$contains` is `z.string()`. ## 2. What changed - **`packages/spec/src/data/filter-comparand-shape.ts`** gains the equality-slot arm. - `{field:[...]}` and `{field:{$eq:[...]}}` are refused with the face's existing `INVALID_FILTER` / 400 envelope. - The leading sentence is `driver-memory`'s `arrayComparandError` verbatim, so one condition keeps one wording. - The message names the field and the path, then prescribes `{"$in": […]}` (authoring `in`) and `{"$contains": "…"}` (authoring `contains`) on a multi-value field, with an `$or` of those for any-of. - It fits under the 500-char client bound, including a 60-char received-list preview, which the bound test pins at 498. - Scope stops at `$eq`: `null`, every scalar and a `{ $field }` reference pass exactly as before. - The header's 「closes that door for every driver at once」 now lists both slots it is true of: the list-operator slot and this one. A new "Refused BY RULING, 2026-09-23" section records the ruling, the measured table and the scope. - **Both engine doors reach the arm.** The engine's object branch calls the wrapper, and its array branch calls `parseFilterAST`. The `driver-mongodb` pin measures both through the engine, as described in §3. - **Conformance.** `FILTER_COMPARAND_TYPE_CASES` gains three `door-refusal` rows: implicit, `$eq`, and nested under `$or`. This is the one door-refusal table every driver suite already runs through `parseFilterAST` (`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_CASES` is untouched. - **`driver-mongodb` pin** — `mongodb-equality-array-comparand-refusal.test.ts`, 13 tests. ⛔ No driver source edit. - A direct caller composing `parseFilterAST` then `translateFilter` is refused, and `translateFilter` is never reached. - A recording engine whose only read path is `translateFilter` refuses both doors and `$eq`, and `count` nested under `$or`, with `translateFilter` called **zero** times. - Lit controls: a scalar, `$in` and `$eq: null`. - A reverse-direction pin shows that `translateFilter` handed the shape directly still emits it unchanged, so the face is the only guard. - mingo is the named proxy, and its readings are recorded in the docblock. mingo is not a dependency of this package, so the pin does not run it. A live `mongod` is stated as NOT MEASURED. - **ADR-0087**: the semantic entry `18.filter-equality-array-comparand-refused` is added, following the `18.view-filter-rule-scalar-operator-array-refused` precedent. `registry.ts` was regenerated with `gen:migration-registry`, and `check:migration-registry` is green. - **Changesets**: - `@objectstack/spec: minor`, with the `**BREAKING**` banner, `fix(spec)!:`, `Clause-②: no (narrowing)` and the `adr-0087 registered filter-equality-array-comparand-refused` disposition marker. The level is `minor` because AGENTS.md makes a `(narrowing)` BREAKING while `check-changeset-no-major` forbids `major`. The launch-window convention ships an accept-set narrowing as `minor`, and the #19514 changeset is the sibling precedent. `check-changeset-no-major` itself prints "narrowing — a BREAKING change; during the launch window it ships `minor`". - `@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 from `ef772a616c` to `f8fd530c31`. An EXIT/INT/TERM trap restored the file. | suite | on the ablated face | after restore | |:--|:--|:--| | `filter-comparand-shape` + `filter-field-reference-lowering`, spec source | **14 red** / 81 green; every lit control stays green | 82 + 13 green | | `driver-mongodb` pin, spec rebuilt | **10 red** / 3 green (the lit controls and the reverse pin) | 13 / 13 green | | `driver-memory` comparand-type conformance, spec rebuilt | **exactly the 3 new rows red** / 20 green | green | For the two spec-rebuilt rows, `ablation-dist-preflight` found both markers **present** in `packages/spec/dist` on 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 empty `git diff HEAD`. **Suites**, run on `bec8f4c737`. `438d385af3` differs only by the prose-id baseline JSON. - `@objectstack/spec`: 525 files, 15510 passed, 2 todo - `objectql`: 304 files, 5069 passed - `driver-memory`: 52 files, 1248 passed - `metadata-core`: 16 files, 285 passed - `driver-mongodb`: 26 files passed and 5 live-mongod files skipped; 578 passed - `metadata-protocol`: 188 files, 2673 passed - `service-queue`: 5 files, 77 passed Run on `723f254402`, before the consumer fixes: `driver-sql` 2648 passed (168 skipped), `formula` 915, `driver-turso` 1302, `driver-sqlite-wasm` 521, `service-analytics` 2442, `plugin-sharing` 913, `lint` 4121. Typecheck is clean for `spec`, `driver-mongodb`, `driver-memory` and `metadata-core`. `--listFiles` shows each touched test file inside its package's program. **Gates**, run on the final head **`438d385af3`**: - `dispatch-gates --commands` derived 95 families. **93 exit 0.** - **2 are NOT MEASURED**, both exit 3 with PREREQUISITE NOT MET because they need the whole workspace built: `check:dual-build-cjs-loads` and `check:type-check-debt`. - `--ran` reconciliation: 95 derived, 93 run, 2 NOT-MEASURED, 0 UNRUN. - Key verdict lines: - `check-adr-0087-registration`: `[BREAKING+bang+clause-②-narrowing] registered filter-equality-array-comparand-refused (new here)` - `check:doc-authoring`: clean, with the baseline shrink below - `check:engine-double-contract` and `check: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` / `eq` carrying an array, for `$eq` carrying 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: 1. **`filter-comparand-shape.test.ts`** pinned `{tags:{$eq:['a','b']}}` and `{tags:['a','b']}` as passing. It pinned the exact slot the ruling closes, so both rows are inverted. 2. **`filter-field-reference-lowering.test.ts`** pinned `['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. 3. **Two probe helpers** passed `['a','b']` through every AST spelling. They are `loweredOperatorOf` in the spec suite and in `driver-memory`'s `memory-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 as `undefined`, which is the answer it always gave them. 4. **`@objectstack/metadata-core`'s engine-double dispatch tables** carried three ARRAY `where.id` rows: delete `array id, no multi`, and update `array id, no multi` and `SCALAR data.id beside an ARRAY where.id`. The real engine now refuses that input at the face **before** the dispatch runs, and `objectql`'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 `$in` rows keep the non-scalar-id coverage, and `scalar*Id`'s array pins stay. The changeset is `patch`. `check:doc-authoring`'s prose-id baseline shrank by one (`#11230` 6 → 5 in that file) through its own `--census-ledger` remedy. ⚠️ **Conflict, stated rather than settled silently.** The dispatch said "fix a fixture only if it is plainly an authoring mistake". Items 3 and 4 are not authoring mistakes. They were fixed because the dev contract's clause wins: a published surface this change made false must be fixed in the same PR. After this change, `ENGINE_*_DISPATCH_CASES` claimed a dispatch refusal the real engine no longer gives. Two alternatives were weighed and not taken: - Make `objectql`'s harness accept the face's refusal. That would carve an exception into the #11009 contract that "both halves must refuse with the SAME words". - Teach the predicate the face. That is impossible byte-for-byte, because the predicate has no object name for the face's `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:spec` seat 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 still `438d385af3`. | # | face | conclusion | evidence | |:--|:--|:--|:--| | 1 | `driver-sql` | **changed** — behind the shared face on both engine doors | §2 above. ⚠️ "nested → 500" describes the base `2548ba57de`; `origin/main` has since carried #19885, whose nested leaf takes `assertCompilableComparand` | | 2 | `driver-turso` `RemoteTransport` (remote mode) | **already compliant**, and now also behind the shared face | `remote-transport.ts:487` classifies a bare array as `requireValue`, `:612` names it for refusal, `:660` sets `INVALID_FILTER`. "`driver-turso` is built on `driver-sql`" above is true of local mode only; remote mode is its own compiler | | 3 | `read-scope-sql` `compileScopedFilterToSql` | **out of scope** — policy scopes never pass the shared face | Neither it nor its callers (`native-sql-strategy.ts:654`, `objectql-strategy.ts:559`) call `parseFilterAST`. A bare array fails closed as `READ_SCOPE_COMPILE_FAILED` / 500 (`:755`, `:436-440`). `$eq: [...]` binds the array (`:1273`). Filed as **#19975** | | 4 | analytics `filter-normalizer` | **changed** for the FilterArray form; the OBJECT form is out of scope | FilterArray refused through `parseFilterAST` (`:1521`). The OBJECT form, read as `IN`, is #19888 | | 5 | `formula` | **already compliant at `origin/main`**; this PR changes no formula file | Since #19946 (#19886 phase 2a), `matches-filter.ts:196-255` refuses the bare array and `$eq` / `$ne` arrays | | 6 | objectql `having` (half-face) | **out of scope** — still answers by JS coercion | `engine.ts` gates `where` (`:866`, `:939`) and `aggregations[i].filter` (`:14776`) but hands `ast.having` to `applyHaving` ungated (`:14885`, `:14929`). `having-filter.ts:357-363`: `having: { total: [5] }` is true. A cross-lane `objectql` edit outside this claim; filed as **#19974** | | 7 | `driver-memory` / `driver-mongodb` | **changed** (memory: behind the face) / **pinned** (mongodb: `mongodb-equality-array-comparand-refusal.test.ts`, 13 tests) | §2–§3 above | Correction to §2: "Both changesets and this body therefore carry the same line" is not exact. The `metadata-core` changeset (a `patch` with no BREAKING) carries no `Clause-②` line; the spec changeset and this body do. ## Acceptance notes These are observed and not fixed here. The report carries each one. - `driver-sql` answers the equality-slot array **nested** under `$and` / `$or` / `$not` with a **500 `DATABASE_ERROR`** on a direct `SqlDriver.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 as `IN`. Its FilterArray form is now refused. Reported as a finding. - `FilterConditionSchema` still parses `{field:[...]}`, because `FieldOperatorsSchema.$eq` is `z.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. - `$ne` carrying an array measured the same cross-backend split and is reported for its own ruling. The `ViewFilterRule` schema door already refuses `not_equals` plus an array. - Two texts are now stale for the equality slot only, and neither is edited: ⛔ there is no driver source edit, and the test is not in the claim. `driver-memory`'s `arrayComparandError` still says the spec "comparand door leaves this position to the driver", and `filter-comparand-type.test.ts`'s test title says "their semantics are per-driver today". Carrier: none. - `origin/main` has moved 6 commits past the base. The branch is **not** merged with it. A driver-less `git merge-tree --write-tree` against `origin/main` is clean, and the only overlapping path is the generated `registry.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](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba77509 commit 7536721

14 files changed

Lines changed: 844 additions & 20 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/metadata-core": patch
3+
---
4+
5+
`ENGINE_DELETE_DISPATCH_CASES` and `ENGINE_UPDATE_DISPATCH_CASES` retire their three ARRAY `where.id` rows (#19757)
6+
7+
The engine-double conformance tables no longer carry these three rows:
8+
9+
- delete's `array id, no multi`
10+
- update's `array id, no multi`
11+
- update's `a SCALAR data.id beside an ARRAY where.id`
12+
13+
Each row puts `where: { id: ['a', 'b'] }` in the equality slot. Since this release's `@objectstack/spec` change, the shared comparand-shape face refuses an array in that slot with `INVALID_FILTER` / 400. The face runs at the engine's lowering seam, which every verb crosses before the dispatch runs, so the real engine never reaches the dispatch predicate with such an input. A row claiming a dispatch verdict for it would pin a branch the engine cannot reach. It was measured red against the real engine: `ObjectQL.delete` / `ObjectQL.update` refused the input with the face's words, not the dispatch's.
14+
15+
The predicates themselves are unchanged. `resolveEngineDeleteDispatch` / `resolveEngineUpdateDispatch` and the `assert*` helpers still answer an array `where.id` with `reject`, and `scalarDeleteId` / `scalarUpdateId` still treat an array as not-an-id. A test double bound to them therefore still refuses such a call, with the dispatch's sentence. No double runs the shared filter face, for this shape or for any other face refusal. The `$in` rows keep the "a non-scalar `where.id` is not an id" coverage, including the #11230 refusal beside a scalar payload id.
16+
17+
If you run these tables against your own engine double, it has three fewer cases to answer. Nothing else changes.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: the shared comparand-shape face refuses an ARRAY in the equality slot — `{ field: [...] }` and `{ field: { $eq: [...] } }` — for every driver at once (#19757)
6+
7+
**BREAKING** — an accept-set narrowing at the runtime filter doors, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19757 (record 5793368540, letter 乙, 「217 同意」): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-equality-array-comparand-refused`.
8+
9+
## What changes
10+
11+
`parseFilterAST` lowers `['tags', 'equals', ['a']]` — and the same triple on `=`, `==` and `eq` — to the implicit form `{ tags: ['a'] }`. That shape, and its explicit spelling `{ tags: { $eq: ['a'] } }`, now get `INVALID_FILTER` / 400 from the shared comparand-shape face (`assertListComparandShapes` in `@objectstack/spec/data`). That face runs inside `parseFilterAST` and at the engine's lowering seam on both engine doors, so the refusal lands before any driver runs, at any depth under `$and` / `$or` / `$not`. The empty array is refused too. The message names the field, the path, and the two operators a list in that slot was standing in for: `{"$in": […]}` for "one of these values" (authoring spelling `in`), and `{"$contains": "…"}` for "the stored list holds a value" on a multi-value field (authoring spelling `contains`), with an `$or` of those for any-of.
12+
13+
How each backend answered the lowered `{ tags: ['a'] }` before this change. Each was run for this change beside a scalar and an `$in` control:
14+
15+
| backend | before | how it was measured |
16+
|:--|:--|:--|
17+
| `driver-sql` (SQLite) | **refused**, 400, at the top level. Nested under `$and` / `$or` / `$not` it answered **500 `DATABASE_ERROR`**: SQLite could not bind the list. | `SqlDriver.find` on better-sqlite3 |
18+
| `driver-memory` | **refused**, 400, at every depth | `InMemoryDriver.find` |
19+
| `@objectstack/formula` | **no row**, including a row storing exactly `['a']` | `matchesFilterCondition` |
20+
| `driver-mongodb` | **answered**. `translateFilter` emits the array unchanged. MongoDB equality on an array operand selects a stored array **equal to** `['a']` **or holding** `['a']` as an element. | `translateFilter`, then mingo 7.2.4 as the named proxy for the server. Over `['a']`, `'a'`, `['a','b']`, `['b','a']`, `[['a'],'x']`, `[['a']]`, `'b'` and `[]`, it selected `['a']`, `[['a'],'x']` and `[['a']]`. |
21+
| `service-analytics` filter normalizer | **answered as membership**. The FilterArray form `[['stage', '=', ['won', 'lost']]]` charted as `stage IN ('won', 'lost')`. | `normalizeAnalyticsFilterTree` |
22+
23+
⚠️ NOT MEASURED: a live `mongod`, MySQL, PostgreSQL, and a live Turso server. `driver-turso` and `driver-sqlite-wasm` are built on `driver-sql` and were not run separately.
24+
25+
After this change, every row above that goes through a platform door gets the 400. That covers `parseFilterAST`, the engine's lowering seam on both doors (every engine verb's `where` passes through it; measured on `find` and `count`), and the analytics normalizer's FilterArray form. The drivers themselves are untouched, so a caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own answer.
26+
27+
## What does NOT change
28+
29+
- **`$ne` carrying an array is not judged.** The ruling names implicit and explicit equality. `$ne` measured the same split (refused by `driver-sql` and `driver-memory`, answered by `driver-mongodb`) and is left to its own ruling.
30+
- The other scalar operators carrying an array (`$gt`, `$contains`, `$like`, …) are not judged here either.
31+
- The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` / `$nin: []`.
32+
- Every scalar equality comparand is untouched. That includes `null`: `{ field: null }` and `{ field: { $eq: null } }` are the has-no-value predicate.
33+
- A `{ $field }` reference on an equality spelling still lowers to `$eq` and passes.
34+
- A field spec with no `$` key (`{ author: { tags: ['a'] } }`) is still not descended into.
35+
- The schema doors are not touched. `FilterConditionSchema` still parses `{ field: [...] }`, because `FieldOperatorsSchema.$eq` is `z.any()`. A document carrying the shape therefore still publishes, and is refused when it is queried.
36+
- `ViewFilterRule` already refused an array on every scalar view operator at authoring time.
37+
38+
## FROM → TO
39+
40+
| you wrote | write instead |
41+
|:--|:--|
42+
| `{ tags: ['a', 'b'] }` / `[['tags', 'equals', ['a', 'b']]]`, meaning "one of these values" | `{ tags: { $in: ['a', 'b'] } }` / `[['tags', 'in', ['a', 'b']]]` |
43+
| `{ tags: ['a'] }`, meaning "the stored list holds `a`" on a multi-value field | `{ tags: { $contains: 'a' } }` / `[['tags', 'contains', 'a']]` |
44+
| `{ tags: ['a', 'b'] }`, meaning "the stored list holds `a` or `b`" | `{ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }` |
45+
| `{ tags: ['a'] }`, meaning one value | `{ tags: 'a' }` |
46+
| `{ tags: { $eq: [...] } }` | any of the rows above |
47+
48+
On `driver-mongodb`, check what the query is supposed to return, and do not assume the old rows were right. The old answer was MongoDB array equality, and neither `$in` nor `$contains` gives the same rows. A dashboard or dataset filter written as the FilterArray sugar with an array on equality used to chart as membership. It is now refused, and `$in` is the spelling that charts the same rows.
49+
50+
## Who is affected, measured
51+
52+
Nothing in this repository's examples, seeds, docs or published skills authors the shape. The repo was grepped for the FilterArray triple on `=` / `==` / `equals` / `eq` carrying an array, for `$eq` carrying an array, and for filter / where objects whose field value is an array. The hits are tests and the engine-double conformance tables. The full suites of `@objectstack/spec`, `objectql`, `driver-memory`, `driver-sql`, `driver-mongodb`, `driver-turso`, `driver-sqlite-wasm`, `formula`, `service-analytics`, `metadata-protocol`, `metadata-core`, `plugin-sharing` and `lint` were run, and four things went red. Each was re-judged, not rewritten by rote:
53+
54+
- The comparand-shape suite pinned `{ tags: ['a','b'] }` and `$eq: ['a','b']` as shapes the face passes through. Both rows are inverted, and the shapes now live in the arm's refusal section.
55+
- The field-reference lowering suite pinned `['stage', '=', ['a','b']]` lowering to the implicit form. What that row proved still holds, because an array is not promoted to `$eq`. The row now asserts the refusal, which names the implicit slot and not `$eq`.
56+
- Two probe helpers passed a two-element array through every AST spelling to find its `$` operator. One is in this package's comparand-shape suite, the other in `driver-memory`'s vocabulary suite. Each assumed the array could never trip the face. The equality spellings now refuse it, so each helper reads that refusal as `undefined`, which is the answer the helper always gave those spellings.
57+
- `@objectstack/metadata-core`'s engine-double dispatch tables carried three ARRAY `where.id` rows. The real engine now refuses that input at the face before its dispatch runs, so the rows are retired. Their own changeset explains why.
58+
59+
`FILTER_COMPARAND_TYPE_CASES` gains three `door-refusal` rows (implicit, `$eq`, and nested under `$or`). Every driver suite that consumes the table runs them through `parseFilterAST`.
60+
61+
Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. The runtime accept set narrows: one comparand shape in one slot, which the face now refuses the way `driver-sql` and `driver-memory` already did.
62+
63+
<!-- adr-0087: registered filter-equality-array-comparand-refused -->

‎packages/drivers/driver-memory/src/memory-filter-ast-vocabulary.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,21 @@ describe('InMemoryDriver filter vocabulary ↔ VALID_AST_OPERATORS', () => {
9393
* gets a list here without anyone remembering to edit this line.
9494
*
9595
* The probe uses a two-element array, which is legal for all three list
96-
* operators, so it never trips the shape door it is used to satisfy.
96+
* operators, so it never trips the shape door it is used to satisfy. The
97+
* EQUALITY spellings (`=`, `==`, `equals`, `eq`) do refuse it, since the
98+
* 2026-09-23 equality-slot ruling (#19757) — and they answer `undefined`
99+
* either way: before that ruling they lowered it to the implicit form, which
100+
* carries no `$` key. So a refusal here reads as `undefined`, exactly the
101+
* answer this helper always gave them, and `valueFor` still hands them a
102+
* scalar.
97103
*/
98104
const loweredOperatorOf = (op: string): string | undefined => {
99-
const lowered = parseFilterAST([['probe', op, ['a', 'b']]]) as Record<string, unknown> | undefined;
105+
let lowered: Record<string, unknown> | undefined;
106+
try {
107+
lowered = parseFilterAST([['probe', op, ['a', 'b']]]) as Record<string, unknown> | undefined;
108+
} catch {
109+
return undefined;
110+
}
100111
const spec = lowered?.probe;
101112
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return undefined;
102113
return Object.keys(spec).find((key) => key.startsWith('$'));

0 commit comments

Comments
 (0)