Skip to content

Commit bec8f4c

Browse files
committed
fix(metadata-core): retire the ARRAY where.id rows the shared face now 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>
1 parent 723f254 commit bec8f4c

7 files changed

Lines changed: 70 additions & 10 deletions

File tree

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.

‎.changeset/19757-equality-slot-array-refused.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ On `driver-mongodb`, check what the query is supposed to return, and do not assu
4949

5050
## Who is affected, measured
5151

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 only hits are tests, and two of them pinned the old accept set and were re-judged rather than rewritten by rote:
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:
5353

5454
- 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.
5555
- 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.
5658

5759
`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`.
5860

‎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('$'));

‎packages/metadata-core/src/engine-delete-dispatch.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,17 @@ export const ENGINE_DELETE_DISPATCH_CASES: readonly EngineDeleteDispatchCase[] =
292292
// accepted it, and what a running server answers 500 to.
293293
{ what: 'predicate on a non-id column, no multi', options: { where: { rule_id: 'r1' } }, expect: 'reject' },
294294
{ what: '$in over ids, no multi (an operator object is NOT an id)', options: { where: { id: { $in: ['a', 'b'] } } }, expect: 'reject' },
295-
{ what: 'array id, no multi', options: { where: { id: ['a', 'b'] } }, expect: 'reject' },
295+
// [#19757] An 'array id, no multi' row — `where: { id: ['a', 'b'] }` — sat
296+
// here. Since the 2026-09-23 ruling the shared comparand-shape face
297+
// (`@objectstack/spec/data`) refuses an ARRAY in the equality slot at the
298+
// engine's lowering seam, which every verb crosses BEFORE this dispatch runs:
299+
// the real engine now answers that input with the face's INVALID_FILTER / 400,
300+
// so no dispatch verdict for it is observable any more, and a row claiming
301+
// one would pin a branch the engine never reaches. Retired rather than
302+
// re-spelled — the `$in` row above keeps the "a non-scalar is not an id"
303+
// coverage, and `scalarDeleteId`'s own array pin stays. A double bound to this
304+
// predicate still refuses an array id with the dispatch sentence: no double
305+
// runs the face, for this shape or any other face refusal.
296306
{ what: 'null id, no multi', options: { where: { id: null } }, expect: 'reject' },
297307
// The two shapes objectstack#5747 was filed for: a fake pinned to
298308
// `assertEngineDeleteDispatch` ACCEPTED both until this case-set could

‎packages/metadata-core/src/engine-update-dispatch.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,12 @@ export const ENGINE_UPDATE_DISPATCH_CASES: readonly EngineUpdateDispatchCase[] =
625625
// inherits the refusal rather than each one re-deriving it.
626626
{ what: 'a SCALAR data.id beside an $in where.id and multi:true — refused; the row SET and the declared bulk intent were BOTH silently dropped (#11230 reverses the remaining half of the #5748 pin)', data: { id: 'rec_1', title: 'x' }, options: { where: { id: { $in: ['a', 'b'] } }, multi: true }, expect: 'reject' },
627627
{ what: 'a SCALAR data.id beside an $in where.id, no multi — refused (#11230)', data: { id: 'rec_1', title: 'x' }, options: { where: { id: { $in: ['a', 'b'] } } }, expect: 'reject' },
628-
{ what: 'a SCALAR data.id beside an ARRAY where.id — refused (#11230)', data: { id: 'rec_1', title: 'x' }, options: { where: { id: ['a', 'b'] } }, expect: 'reject' },
628+
// [#19757] A 'SCALAR data.id beside an ARRAY where.id' row sat here. The
629+
// shared comparand-shape face now refuses an ARRAY in the equality slot at
630+
// the engine's lowering seam, BEFORE this dispatch runs (ruled 2026-09-23),
631+
// so the real engine answers it with the face's INVALID_FILTER / 400 and no
632+
// #11230 verdict for it is observable. Retired, not re-spelled: the two `$in`
633+
// rows above carry the #11230 "declared non-scalar where.id" refusal.
629634
{ what: 'a SCALAR data.id beside a NULL where.id — refused (#11230)', data: { id: 'rec_1', title: 'x' }, options: { where: { id: null } }, expect: 'reject' },
630635
// [#11230] The boundary that does NOT move: a FALSY scalar `where.id` IS a
631636
// scalar, so it is not this refusal's shape at all and keeps the #11142
@@ -653,7 +658,12 @@ export const ENGINE_UPDATE_DISPATCH_CASES: readonly EngineUpdateDispatchCase[] =
653658
// by hand tends to accept, and a running server answers 500 to.
654659
{ what: 'predicate on a non-id column, no multi', data: { title: 'x' }, options: { where: { tenant: 't1' } }, expect: 'reject' },
655660
{ what: '$in over ids, no multi (an operator object is NOT an id)', data: { title: 'x' }, options: { where: { id: { $in: ['a', 'b'] } } }, expect: 'reject' },
656-
{ what: 'array id, no multi', data: { title: 'x' }, options: { where: { id: ['a', 'b'] } }, expect: 'reject' },
661+
// [#19757] An 'array id, no multi' row (`where: { id: ['a', 'b'] }`) sat
662+
// here — retired for the reason the #11230 block above gives: the shared
663+
// face refuses that input before this dispatch runs. The `$in` row above
664+
// keeps the "a non-scalar is not an id" coverage; `scalarUpdateId`'s own
665+
// array pin stays, and a double bound to this predicate still refuses an
666+
// array id with the dispatch sentence (no double runs the face).
657667
{ what: 'null id, no multi', data: { title: 'x' }, options: { where: { id: null } }, expect: 'reject' },
658668
{ what: 'falsy scalar where.id (0), no multi', data: { title: 'x' }, options: { where: { id: 0 } }, expect: 'reject' },
659669
{ what: 'empty where, no multi', data: { title: 'x' }, options: { where: {} }, expect: 'reject' },

‎packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export const entry: SemanticMigration = {
4343
+ 'translateFilter emits the array unchanged, and MongoDB equality on an array operand '
4444
+ 'selects a stored array equal to ["a"] or holding ["a"] as an element — mingo 7.2.4, the '
4545
+ 'named proxy, over ["a"], "a", ["a","b"], ["b","a"], [["a"],"x"], [["a"]], "b" and [] '
46-
+ 'selected ["a"], [["a"],"x"] and [["a"]]. A live mongod, MySQL, PostgreSQL and a live '
46+
+ 'selected ["a"], [["a"],"x"] and [["a"]]. The service-analytics filter normalizer read the '
47+
+ 'FilterArray form as MEMBERSHIP: ["stage", "=", ["won", "lost"]] charted as stage IN '
48+
+ '(won, lost); that form now gets the refusal too, while its OBJECT form, which that '
49+
+ 'normalizer does not route through the shared face, still reads as membership. A live mongod, MySQL, PostgreSQL and a live '
4750
+ 'Turso server were NOT measured. So one stored filter was a 400 on most backends and a '
4851
+ 'silent, differently-shaped row set on one. The shared face now refuses it with '
4952
+ 'INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. '
@@ -62,7 +65,9 @@ export const entry: SemanticMigration = {
6265
+ 'what it meant: one of these values ($in), the stored list holds a value ($contains, an '
6366
+ '$or of them for several), or one value. Each is refused at query time with INVALID_FILTER '
6467
+ '/ 400 naming the field and the path, so a test suite that exercises the query finds '
65-
+ 'every one. On driver-mongodb re-check what the query is supposed to return rather than '
68+
+ 'every one. A dashboard or dataset filter written as the FilterArray sugar with an array on '
69+
+ 'equality charted as membership through the analytics normalizer; $in is the spelling that '
70+
+ 'charts the same rows. On driver-mongodb re-check what the query is supposed to return rather than '
6671
+ 'assuming the old rows were right: the old answer was MongoDB array equality, which '
6772
+ 'neither $in nor $contains reproduces.',
6873
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8740,7 +8740,10 @@ const step18: MigrationStep = {
87408740
+ 'translateFilter emits the array unchanged, and MongoDB equality on an array operand '
87418741
+ 'selects a stored array equal to ["a"] or holding ["a"] as an element — mingo 7.2.4, the '
87428742
+ 'named proxy, over ["a"], "a", ["a","b"], ["b","a"], [["a"],"x"], [["a"]], "b" and [] '
8743-
+ 'selected ["a"], [["a"],"x"] and [["a"]]. A live mongod, MySQL, PostgreSQL and a live '
8743+
+ 'selected ["a"], [["a"],"x"] and [["a"]]. The service-analytics filter normalizer read the '
8744+
+ 'FilterArray form as MEMBERSHIP: ["stage", "=", ["won", "lost"]] charted as stage IN '
8745+
+ '(won, lost); that form now gets the refusal too, while its OBJECT form, which that '
8746+
+ 'normalizer does not route through the shared face, still reads as membership. A live mongod, MySQL, PostgreSQL and a live '
87448747
+ 'Turso server were NOT measured. So one stored filter was a 400 on most backends and a '
87458748
+ 'silent, differently-shaped row set on one. The shared face now refuses it with '
87468749
+ 'INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. '
@@ -8759,7 +8762,9 @@ const step18: MigrationStep = {
87598762
+ 'what it meant: one of these values ($in), the stored list holds a value ($contains, an '
87608763
+ '$or of them for several), or one value. Each is refused at query time with INVALID_FILTER '
87618764
+ '/ 400 naming the field and the path, so a test suite that exercises the query finds '
8762-
+ 'every one. On driver-mongodb re-check what the query is supposed to return rather than '
8765+
+ 'every one. A dashboard or dataset filter written as the FilterArray sugar with an array on '
8766+
+ 'equality charted as membership through the analytics normalizer; $in is the spelling that '
8767+
+ 'charts the same rows. On driver-mongodb re-check what the query is supposed to return rather than '
87638768
+ 'assuming the old rows were right: the old answer was MongoDB array equality, which '
87648769
+ 'neither $in nor $contains reproduces.',
87658770
},

0 commit comments

Comments
 (0)