From 65507a7b8ff57cff2e288296c2f48c964bb6786b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:01:55 +0000 Subject: [PATCH 1/3] fix(spec)!: refuse an array under $ne at the shared comparand-shape face and at FieldOperatorsSchema.$ne Ruling A (record 5805254639): the shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator, $nin. - filter-comparand-refusal-text.ts: NIN_OPERATOR_SPELLINGS, the $ne remedy and arrayInequalityComparandMessage, sharing one template with the equality-slot sentence. - filter-comparand-shape.ts: a $ne arm in the existing walk; the $nin row reads its spellings from the shared text module. - filter.zod.ts: inequalityComparandSchema, shared by FieldOperatorsSchema.$ne and its documentation copy EqualityOperatorSchema.$ne. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../src/data/filter-comparand-refusal-text.ts | 98 +++++++++++++- .../spec/src/data/filter-comparand-shape.ts | 122 ++++++++++++++++-- packages/spec/src/data/filter.zod.ts | 55 +++++++- 3 files changed, 252 insertions(+), 23 deletions(-) diff --git a/packages/spec/src/data/filter-comparand-refusal-text.ts b/packages/spec/src/data/filter-comparand-refusal-text.ts index 2c3a3694b02..bbf0ced3b08 100644 --- a/packages/spec/src/data/filter-comparand-refusal-text.ts +++ b/packages/spec/src/data/filter-comparand-refusal-text.ts @@ -16,6 +16,11 @@ * `LIST_COMPARAND_OPERATORS` docblock records. A module both import breaks the * tie. It imports nothing. * + * [#19886] The `$ne` slot is the second tenant, for the same reason. Ruling A + * (record 5805254639) refuses an array under `$ne` at the shared face and at + * `FieldOperatorsSchema.$ne` with "one remedy text", so that sentence is + * assembled here too, by {@link arrayInequalityComparandMessage}. + * * ## Why it is NOT in the `data` barrel * * Nothing here is a contract a consumer calls; the two refusals are, and they @@ -37,6 +42,18 @@ */ export const IN_OPERATOR_SPELLINGS: readonly string[] = ['in']; +/** + * [#19886] The authoring spellings that lower to `$nin`. + * + * `$nin` is the list-negation operator `FieldOperatorsSchema` declares ("Not + * in list"), and it is what the `$ne` refusal prescribes. So, as with + * {@link IN_OPERATOR_SPELLINGS}, the face's `LIST_COMPARAND_OPERATORS` reads its + * `$nin` row from here, and the refusal of a malformed `$nin` and the `$ne` + * refusal (which prescribes `$nin`) name one spelling list. + * `filter-comparand-shape.test.ts` reconciles the list against `AST_OPERATOR_MAP`. + */ +export const NIN_OPERATOR_SPELLINGS: readonly string[] = ['nin', 'not_in', 'notin']; + /** * [#19757] The ARRAY-CONTAINMENT operator the equality-slot refusal prescribes, * with its authoring spelling: `$contains`, which `FieldOperatorsSchema` @@ -135,11 +152,86 @@ export function arrayEqualityComparandMessage( site: ArrayEqualityComparandSite, ): string { const position = site.op - ? `Operator "${site.op}"${site.field === undefined ? '' : ` on field "${site.field}"`}` + ? operatorPosition(site.op, site.field) : `The implicit-equality comparand on field "${site.field}"`; - const location = site.path === undefined ? '' : ` at ${site.path}`; + return singleValueSlotMessage(position, value, site.path, ARRAY_EQUALITY_COMPARAND_REMEDY); +} + +/** + * [#19886] The ONE remedy for an array under `$ne`: the declared list-negation + * operator, by its spec spelling and its authoring spellings, then the sentence + * a caller cannot infer from a status code. + * + * Ruling A on #19886 (record 5805254639) asks for exactly this: "one remedy + * text, naming the declared list-negation operator by its spec spelling", read + * off `FieldOperatorsSchema`, where `$nin` is the operator declared as "Not in + * list". ⛔ Nothing else is offered. `$notContains` is declared on a STRING + * comparand and is not a list operator, and a second remedy here would be an + * invented one. `$nin` is also the remedy the formula evaluator and + * `driver-mongodb` already name for the same shape. + * + * Written like {@link ARRAY_EQUALITY_COMPARAND_REMEDY}: no field wrapper and + * `…` for the value, because the field is the subject of the sentence in front + * of this one. + */ +export const ARRAY_INEQUALITY_COMPARAND_REMEDY: string = + `For "none of these values" use {"$nin": […]} (authoring: ${NIN_OPERATOR_SPELLINGS.join(', ')}). ` + + `The filter was NOT applied, and an unapplied filter would have returned the ` + + `UNFILTERED result set.`; + +/** + * [#19886] Where the refused array under `$ne` sits, as far as the calling door + * can see. The same rules as {@link ArrayEqualityComparandSite}'s `$eq` arm: + * `FieldOperatorsSchema.$ne` judges one field's operator map and cannot see the + * field, and the face passes `path` because a zod refinement cannot know it. + */ +export type ArrayInequalityComparandSite = { readonly field?: string; readonly path?: string }; + +/** + * [#19886] The refusal of an ARRAY under `$ne`, as both doors print it — the + * shared comparand-shape face and `FieldOperatorsSchema.$ne`. + * + * The leading sentence is `driver-memory`'s `arrayComparandError` for the same + * operator, word for word, so one condition keeps one wording across packages + * (#5240's rule). Then the received list, bounded by {@link shapePreview}, the + * location when the door knows it, and {@link ARRAY_INEQUALITY_COMPARAND_REMEDY}. + * + * As with {@link arrayEqualityComparandMessage}, the two doors print the same + * characters for a given value, except that the face adds ` at ` and names + * the field. `filter-ne-array-schema-door.test.ts` pins exactly that. + */ +export function arrayInequalityComparandMessage( + value: unknown, + site: ArrayInequalityComparandSite, +): string { + return singleValueSlotMessage( + operatorPosition('$ne', site.field), + value, + site.path, + ARRAY_INEQUALITY_COMPARAND_REMEDY, + ); +} + +/** `Operator "$op"`, plus ` on field "f"` when the door can see the field. */ +function operatorPosition(op: string, field: string | undefined): string { + return `Operator "${op}"${field === undefined ? '' : ` on field "${field}"`}`; +} + +/** + * The sentence both single-value-slot refusals share: the position, the words + * `driver-memory` uses for this condition, the received list, the location and + * the slot's own remedy. One template, so the `$eq` and `$ne` refusals cannot + * drift apart in their first sentence. + */ +function singleValueSlotMessage( + position: string, + value: unknown, + path: string | undefined, + remedy: string, +): string { + const location = path === undefined ? '' : ` at ${path}`; return ( `${position} requires a single comparable value, but received an array ` + - `(${shapePreview(value)})${location}. ${ARRAY_EQUALITY_COMPARAND_REMEDY}` + `(${shapePreview(value)})${location}. ${remedy}` ); } diff --git a/packages/spec/src/data/filter-comparand-shape.ts b/packages/spec/src/data/filter-comparand-shape.ts index ff16f10303e..b257aa9d037 100644 --- a/packages/spec/src/data/filter-comparand-shape.ts +++ b/packages/spec/src/data/filter-comparand-shape.ts @@ -11,6 +11,10 @@ * operator takes a list" and "an equality comparand is not one". The second * is the 2026-09-23 ruling's, recorded in its own section below. * + * [#19886] And the same question for equality's negation: whether a `$ne` + * comparand received ONE value rather than a list. Ruling A's, recorded in the + * last "Refused BY RULING" section. + * * `FieldOperatorsSchema` (`./filter.zod.ts`) declares three operators whose * comparand is a LIST rather than a scalar: * @@ -70,8 +74,8 @@ * delegating wrapper that supplies the engine's `find('deal')` context prefix; * there is exactly one implementation of "a list operator takes a list". * - * That sentence is true of TWO slots, and each is named because it was once - * true of only one: + * That sentence is true of THREE slots, and each is named because it was once + * true of fewer: * * - **The list-operator slot** (`$in` / `$nin` / `$between`) — a scalar where a * list belongs, since #9228 moved the rule here. @@ -81,6 +85,9 @@ * SQL family and `driver-memory` refused the shape, `@objectstack/formula` * excluded every row, and `driver-mongodb` alone ANSWERED it, as MongoDB's * array equality. That is the arm's section below. + * - **The inequality slot** (`$ne`) — a list where one value belongs, since + * ruling A on #19886 (2026-09-24). Until then this face passed it and each + * backend answered it alone. That is the last "Refused BY RULING" section. * * ## Both engine doors, because only one of them carries an array * @@ -255,13 +262,13 @@ * `$not`. An EMPTY array is still an array: `{ tags: [] }` is refused too. * - **`$eq`** — `{ field: { $eq: [...] } }`, the explicit spelling of the same * comparison. - * - ⛔ **`$ne` is NOT judged here.** It is equality's negation, not equality, - * and the ruling names implicit and explicit equality. It measured the same + * - **`$ne` is not THIS arm's.** It is equality's negation, not equality, and + * this ruling names implicit and explicit equality. It measured the same * split (refused on the SQL family and `driver-memory`, answered by - * `driver-mongodb`), which makes it its own card needing its own ruling — - * absorbing it silently here is the move this family exists to refuse. The - * other scalar operators (`$gt`, `$contains`, …) carrying an array are - * likewise not this ruling's. + * `driver-mongodb`), so it went to its own card and got its own ruling — + * the last section below, with its own remedy. The other scalar operators + * (`$gt`, `$contains`, …) carrying an array are not this ruling's, and not + * that one's either. * - **The list operators keep their lists**, `$in: []` / `$nin: []` included, * and every scalar — `null` above all, since `{ field: null }` and * `$eq: null` ARE the has-no-value predicate (#5332) — keeps passing. @@ -290,6 +297,57 @@ * `./filter-comparand-refusal-text.ts`, which both doors import; this door adds * only its `at ` location and its envelope. * + * ## Refused BY RULING, 2026-09-24: an ARRAY under `$ne` (#19886) + * + * Ruling A (record 5805254639, the director seat, class 1): "The shared + * comparand-shape face refuses an array under `$ne` for every driver, and + * `FieldOperatorsSchema.$ne` refuses it at parse — one remedy text, naming the + * declared list-negation operator by its spec spelling" — ⛔ no alias, ⛔ no + * window. The governing text is `$ne`'s own published describe + * (`NE_DESCRIPTION`, `./filter.zod.ts`): "the comparand is a literal, or a + * { $field } reference to another column of the same table". An array is + * neither. So this refusal pulls the face back to what `$ne` already declared. + * + * {@link parseFilterAST} lowers `['tags', 'ne', ['a']]` (and `!=`, `<>`, `neq`, + * `not_equals`, `notequals`) to `{ tags: { $ne: ['a'] } }`, and until this arm + * the face passed that shape. Measured on the card before any stage landed: + * the SQL family and `driver-memory` refused it (400); `driver-mongodb` + * ANSWERED it as MongoDB reads `$ne` against an array operand, "not equal to + * that array and not holding it as an element", which is every scalar row + * (mingo, the named proxy; a live mongod is NOT measured); and + * `@objectstack/formula` matched EVERY row, which on the row-level write check + * admitted every write a `!=` policy against a list was written to refuse. + * The two answering faces were closed first, each at its own face (the formula + * evaluator, and `driver-mongodb`'s walk). This arm closes the shared one, so + * the shape is refused before any driver runs, on every driver at once. + * + * The scope is `$ne` carrying an ARRAY, and nothing wider: + * + * - **Every depth** under `$and` / `$or` / `$not`, and the EMPTY array too. + * `$ne: []` is an array in a one-value slot; only `$in: []` / `$nin: []` are + * declared predicates. + * - **The pass list is the equality arm's.** `$ne: null` is the has-a-value + * predicate (#5332). Every scalar, a `Date`, and a `{ $field }` reference + * (`$ne` is one of the six comparisons a reference may be the whole + * comparand of) are not arrays, and pass exactly as before. + * - **The remedy is ONE operator**, `$nin` ("none of these values"), by its spec + * spelling and its authoring spellings, as the ruling says. It is read off + * `FieldOperatorsSchema`, where `$nin` is the operator declared as "Not in + * list". ⛔ `$notContains` is not offered: it is declared on a STRING + * comparand and is no list operator. `filter-comparand-shape.test.ts` + * reconciles the spellings against `FieldOperatorsSchema` and the AST table. + * - ⛔ **Not the ordering operators, a nested array inside `$in`, or a + * `{ $field }` referent to a multi-valued field.** Those are the same class, + * are recorded on the card, and are scoped separately. + * - **The leading sentence is `driver-memory`'s** `arrayComparandError` for + * `$ne`, word for word, so one condition keeps one wording across packages. + * + * The schema door's half is `FieldOperatorsSchema.$ne` and its documentation + * copy `EqualityOperatorSchema.$ne` (`./filter.zod.ts`), which print this + * sentence without the location. As with the equality slot, the whole sentence + * is assembled in `./filter-comparand-refusal-text.ts`, which both doors + * import. + * * ## Refusal envelope * * Every refusal carries `code: 'INVALID_FILTER'` and `status: 400` (ADR-0112 @@ -302,11 +360,14 @@ * @see https://github.com/objectstack-ai/objectstack/issues/5869 (the rule) * @see https://github.com/objectstack-ai/objectstack/issues/9228 (the move) * @see https://github.com/objectstack-ai/objectstack/issues/19757 (the equality-slot arm) + * @see https://github.com/objectstack-ai/objectstack/issues/19886 (the `$ne` arm) */ import { IN_OPERATOR_SPELLINGS, + NIN_OPERATOR_SPELLINGS, arrayEqualityComparandMessage, + arrayInequalityComparandMessage, shapePreview, } from './filter-comparand-refusal-text'; @@ -327,7 +388,8 @@ const LIST_COMPARAND_OPERATORS: ReadonlyMap = new Map // [#19889] The `$in` row is read from the shared refusal text, so the operator // the equality-slot refusal prescribes and this row cannot name two lists. ['$in', IN_OPERATOR_SPELLINGS], - ['$nin', ['nin', 'not_in', 'notin']], + // [#19886] The `$nin` row likewise, for the operator the `$ne` refusal prescribes. + ['$nin', NIN_OPERATOR_SPELLINGS], ['$between', ['between']], ]); @@ -683,12 +745,34 @@ function arrayEqualityComparandError( ); } +/** + * An ARRAY under `$ne` — refused BY RULING, 2026-09-24 (#19886, ruling A); see + * the module note's sixth "Refused BY RULING" section. + * + * The sentence is `arrayInequalityComparandMessage` + * (`./filter-comparand-refusal-text.ts`), which `FieldOperatorsSchema.$ne` + * prints on parse. Its leading sentence is `driver-memory`'s + * `arrayComparandError` for `$ne`, word for word, and its one remedy is the + * declared list-negation operator `$nin` with its authoring spellings. As with + * {@link arrayEqualityComparandError}, this door adds only its `at ` + * location and its envelope. + */ +function arrayInequalityComparandError( + context: string | undefined, + field: string, + value: unknown, + path: string, +): Error { + return invalidFilterComparandError(context, arrayInequalityComparandMessage(value, { field, path })); +} + /** * Walk one `FilterCondition` and refuse every list-shaped operator whose * comparand cannot be one — and, since the two null rulings (2026-08-31, * 2026-09-01), the null comparand positions those rulings carved out; and, * since the 2026-09-23 ruling (#19757), every EQUALITY comparand (implicit or - * `$eq`) that IS a list. + * `$eq`) that IS a list; and, since ruling A on #19886, every `$ne` comparand + * that is a list. * * Read-only and allocation-free on the overwhelmingly common path (a filter * with no list operator walks its own keys and returns). Runs on every engine @@ -763,16 +847,26 @@ function assertFieldListComparands( // condition. Not descended into; see the module note. if (!keys.some((key) => key.startsWith('$'))) return; for (const op of keys) { - // The explicit spelling of the same equality slot (#19757). Strictly - // `$eq`: `$ne` is equality's negation and not this ruling's — see the - // module note — and `null` / every scalar / a `{ $field }` reference are - // not arrays, so they pass exactly as before. + // The explicit spelling of the same equality slot (#19757). `null` / every + // scalar / a `{ $field }` reference are not arrays, so they pass exactly as + // before. if (op === '$eq') { if (Array.isArray(spec[op])) { throw arrayEqualityComparandError(context, field, spec[op], `${path}.${op}`, '$eq'); } continue; } + // Its negation (ruling A on #19886): `$ne` takes one value too. Its own + // arm and its own remedy (`$nin`), because the equality remedy (`$in` / + // `$contains`) names the wrong operators for a negation. The same pass + // list: `null` is the has-a-value predicate, and scalars and a `{ $field }` + // reference are not arrays. + if (op === '$ne') { + if (Array.isArray(spec[op])) { + throw arrayInequalityComparandError(context, field, spec[op], `${path}.${op}`); + } + continue; + } // The ordering carve-out (2026-09-01 ruling, #14080). Strictly `null`: // `undefined` keeps the TYPE door's own sentence, and every other // comparand type in these slots is that door's question, not this one's. diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index ced35530652..774debf8f6d 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -6,7 +6,10 @@ import { assertListComparandShapes } from './filter-comparand-shape'; // [#19889] The equality-slot refusal's words, shared with the comparand-shape // face so the save door and the query door print one sentence (ruling A, // record 5805248669: "one constant, two doors"). -import { arrayEqualityComparandMessage } from './filter-comparand-refusal-text'; +import { + arrayEqualityComparandMessage, + arrayInequalityComparandMessage, +} from './filter-comparand-refusal-text'; import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './date-range-presets'; // [#19514] The text-comparand door this package publishes for the @@ -276,7 +279,9 @@ const NE_DESCRIPTION = * named here because this slot cannot see it — the key belongs to the record * enclosing the operator map — and the issue's own `path` says where it is. * `null`, every scalar, a `Date` and a `{ $field }` reference are not arrays and - * pass exactly as before; `$ne` is not judged (the ruling names equality). + * pass exactly as before. `$ne` is not this factory's (the ruling names + * equality); it has its own, {@link inequalityComparandSchema}, with its own + * remedy. * * ⚠️ `z.toJSONSchema()` has no arm for a custom check, so the published JSON * Schema still reads `{}` here. The site is declared in @@ -288,6 +293,38 @@ const equalityComparandSchema = () => ctx.addIssue({ code: 'custom', message: arrayEqualityComparandMessage(value, { op: '$eq' }) }); }).optional().describe(EQ_DESCRIPTION); +/** + * [#19886] The `$ne` slot, shared by the documentation copy + * ({@link EqualityOperatorSchema}) and the enforced copy + * (`FieldOperatorsSchema`), exactly as {@link equalityComparandSchema} is for + * `$eq`: the two copies share the CODE, so the published reference cannot + * describe a slot the enforced copy narrows. + * + * Open (`z.any()`) EXCEPT for an ARRAY, refused by ruling A on #19886 (record + * 5805254639): "`FieldOperatorsSchema.$ne` refuses it at parse — one remedy + * text, naming the declared list-negation operator by its spec spelling". Its + * own describe already said so: the comparand is "a literal, or a { $field } + * reference" ({@link NE_DESCRIPTION}), and an array is neither. The words are + * `arrayInequalityComparandMessage`, which the comparand-shape face prints too; + * the field is not named because this slot cannot see it, and the issue's own + * `path` says where it is. `null` (the has-a-value predicate), every scalar, a + * `Date` and a `{ $field }` reference are not arrays and pass exactly as before. + * + * ⚠️ Scope: this is the OPERATOR slot. `FilterConditionSchema` (every stored + * filter carrier) does not parse a field's operator map through + * `FieldOperatorsSchema`; its own walk, `checkFilterConditionComparands`, does + * not judge `$ne`, and the ruling names this slot, not that walk. + * + * ⚠️ `z.toJSONSchema()` has no arm for a custom check, so the published JSON + * Schema still reads `{}` here. The site is declared in + * `dropped-refinements.baseline.json`. + */ +const inequalityComparandSchema = () => + z.any().superRefine((value, ctx) => { + if (!Array.isArray(value)) return; + ctx.addIssue({ code: 'custom', message: arrayInequalityComparandMessage(value, {}) }); + }).optional().describe(NE_DESCRIPTION); + /** * Comparison operators for equality and inequality checks. * Supported data types: Any @@ -297,7 +334,7 @@ export const EqualityOperatorSchema = lazySchema(() => z.object({ $eq: equalityComparandSchema(), /** Not equal to - SQL: <> or != | MongoDB: $ne */ - $ne: z.any().optional().describe(NE_DESCRIPTION), + $ne: inequalityComparandSchema(), })); /** @@ -1479,8 +1516,10 @@ export const FieldOperatorsSchema = lazySchema(() => z.object({ // operator can be described in one copy and blank in the other. [#19889] // `$eq` is built from the same `equalityComparandSchema` factory the // documentation copy uses: it refuses an ARRAY comparand, in the face's words. + // [#19886] `$ne` likewise, from `inequalityComparandSchema`, with the `$nin` + // remedy the face prints for the same shape. $eq: equalityComparandSchema(), - $ne: z.any().optional().describe(NE_DESCRIPTION), + $ne: inequalityComparandSchema(), // Ordering. `string` is in the union for the reason {@link ComparisonOperatorSchema} // gives at length (#5685): the date-macro resolver and all three first-party @@ -1644,8 +1683,12 @@ function isPlainFilterNode(value: unknown): value is Record { * door accepts, which is the split this arm exists to close. * - **Not dropped.** The refusal fails the parse; nothing is stripped from the * document. A dropped filter would show MORE rows than the author asked for. - * - ⛔ `$ne` is not judged (the ruling names equality), and the list operators - * keep their lists, `$in: []` / `$nin: []` included. + * - ⛔ `$ne` is not judged by this walk (the ruling names equality), and the + * list operators keep their lists, `$in: []` / `$nin: []` included. [#19886] + * Ruling A refuses an array under `$ne` at the face and at the OPERATOR slot + * `FieldOperatorsSchema.$ne` (`inequalityComparandSchema`); it names neither + * this walk nor `FilterConditionSchema`, so a stored carrier still saves a + * `$ne` list and the face refuses it at query time. * * Descends non-`$` keys only (operator specs and nested relations): the * `$and` / `$or` / `$not` members are re-parsed by {@link FilterConditionSchema} From 3757d64a06203eba0827ffdebeffa08f3570b249 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:09:35 +0000 Subject: [PATCH 2/3] test(spec): pin the $ne array refusal at the face and the operator slot; register the ADR-0087 entry - filter-comparand-shape.test.ts: the $ne arm's refusal rows (envelope, path, the $nin remedy, every $ne AST spelling), its controls, the 500-char bound rows; the todo that stood for this arm is gone; the array-valued LIT CONTROL set is exactly the list operators now. - filter-ne-array-schema-door.test.ts: FieldOperatorsSchema.$ne and EqualityOperatorSchema.$ne refuse with code, path and prescription; the NormalizedFilter AST; one text, two doors; controls at both doors. - filter-equality-array-schema-door.test.ts: the $ne control row's face half is false now; re-judged into a test that keeps its guard. - service-analytics where-equality-slot-list-refusal.test.ts: the $ne pin flipped to the face's $ne sentence (pin sweep). - semantic entry filter-ne-array-comparand-refused, registry regenerated, dropped-refinements ledger gains the three $ne sites. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../where-equality-slot-list-refusal.test.ts | 19 +- .../spec/dropped-refinements.baseline.json | 7 +- .../src/data/filter-comparand-shape.test.ts | 143 ++++++++-- .../filter-equality-array-schema-door.test.ts | 26 +- .../data/filter-ne-array-schema-door.test.ts | 255 ++++++++++++++++++ .../18.filter-ne-array-comparand-refused.ts | 72 +++++ packages/spec/src/migrations/registry.ts | 68 +++++ 7 files changed, 570 insertions(+), 20 deletions(-) create mode 100644 packages/spec/src/data/filter-ne-array-schema-door.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts diff --git a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts index ff8e37879a6..3d705930101 100644 --- a/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts +++ b/packages/services/service-analytics/src/__tests__/where-equality-slot-list-refusal.test.ts @@ -201,8 +201,23 @@ describe('[#19888] the neighbouring shapes compile exactly as before', () => { }); }); - it('$ne with a list is not judged by this gate (not ruling 乙’s)', () => { - expect(() => tree({ stage: { $ne: ['won'] } })).not.toThrow(/requires a single comparable value/); + it('$ne with a list is not judged by this gate (not ruling 乙’s) — the shared face refuses it in the $ne sentence', () => { + // [#19886] RE-JUDGED. This pinned `.not.toThrow(/requires a single + // comparable value/)`, which was true while no face judged `$ne`. Ruling A + // on #19886 (record 5805254639) put the refusal on the shared face, and + // this door runs that face, so the shape is refused here with the face's + // own `$ne` sentence. What the row protects is unchanged: THIS door's + // equality-slot gate does not claim `$ne`, so the words are the `$ne` + // arm's (`$nin` remedy), never the equality slot's (`$in` remedy). + const where = { stage: { $ne: ['won'] } }; + const err = refusalOf(() => tree(where)); + expectEnvelope(err); + expect(err.message).toBe(refusalOf(() => assertListComparandShapes(where)).message); + expect(err.message.startsWith('Operator "$ne" on field "stage" requires a single comparable value')).toBe(true); + expect(err.message).toContain('For "none of these values" use {"$nin": […]}'); + expect(err.message).not.toContain(REMEDY); + expect(err.message.startsWith(EQ('stage'))).toBe(false); + expect(err.message.startsWith(IMPLICIT('stage'))).toBe(false); }); }); diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 77a87e632cc..2c71a486713 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 207, - "droppedRefinementSites": 574, + "droppedRefinementSites": 577, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -607,7 +607,8 @@ }, "data/EqualityOperator": { "sites": [ - "$eq" + "$eq", + "$ne" ] }, "data/Field": { @@ -623,6 +624,7 @@ "$between.items[1]", "$eq", "$in", + "$ne", "$nin" ] }, @@ -689,6 +691,7 @@ "lazy.$not.options[0].valueType.$between.items[1]", "lazy.$not.options[0].valueType.$eq", "lazy.$not.options[0].valueType.$in", + "lazy.$not.options[0].valueType.$ne", "lazy.$not.options[0].valueType.$nin" ] }, diff --git a/packages/spec/src/data/filter-comparand-shape.test.ts b/packages/spec/src/data/filter-comparand-shape.test.ts index 777f9769542..811c227c847 100644 --- a/packages/spec/src/data/filter-comparand-shape.test.ts +++ b/packages/spec/src/data/filter-comparand-shape.test.ts @@ -47,6 +47,9 @@ const refusalOf = (run: () => unknown): Refusal => { * trips the door it is used to find. The EQUALITY spellings do refuse it, since * the 2026-09-23 arm (#19757) — and they answer `undefined` either way: before * that arm they lowered it to the implicit form, which carries no `$` key. + * [#19886] The `$ne` spellings refuse it too, since ruling A, so they answer + * `undefined` here now; the `$ne` pins below find their spellings with a + * SCALAR probe instead. */ const loweredOperatorOf = (op: string): string | undefined => { let lowered: Record | undefined; @@ -594,17 +597,18 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 it('LIT CONTROL — every array-valued operator the vocabulary declares keeps its array', () => { // Read off the enforced schema rather than listed: the operators whose - // declared comparand ACCEPTS an array. `$ne` is in that set only because - // it is `z.any()` there and is not judged at this door (see the todo - // below) — so the loop covers exactly the list operators, and a fourth - // array-valued operator added to the schema lands in it without an edit - // here. [#19889] `$eq` left the set when the schema door began refusing an - // array there too (ruling A, record 5805248669), in this face's words — - // `filter-equality-array-schema-door.test.ts` pins that door. + // declared comparand ACCEPTS an array — exactly the list operators, so a + // fourth array-valued operator added to the schema lands in the loop + // without an edit here. [#19889] `$eq` left the set when the schema door + // began refusing an array there too (ruling A, record 5805248669), in this + // face's words — `filter-equality-array-schema-door.test.ts` pins that + // door. [#19886] `$ne` left it the same way (ruling A, record 5805254639), + // and it is no longer skipped below: the set IS the list operators now, and + // `filter-ne-array-schema-door.test.ts` pins the `$ne` door. const arrayValued = Object.keys(FieldOperatorsSchema.shape).filter((op) => FieldOperatorsSchema.safeParse({ [op]: ['a', 'b'] }).success); - expect(arrayValued.sort()).toEqual(['$between', '$in', '$ne', '$nin']); - for (const op of arrayValued.filter((o) => o !== '$ne')) { + expect(arrayValued.sort()).toEqual(['$between', '$in', '$nin']); + for (const op of arrayValued) { expect(parseFilterAST({ tags: { [op]: ['a', 'b'] } }), op).toEqual({ tags: { [op]: ['a', 'b'] } }); } // The empty lists stay the declared predicates they are. @@ -622,12 +626,116 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 expect(parseFilterAST({ author: { tags: ['a'] } })).toEqual({ author: { tags: ['a'] } }); }); - it.todo( - '$ne carrying an ARRAY — equality\'s negation measured the same split (refused by the SQL family ' - + 'and driver-memory, answered by driver-mongodb) but the 2026-09-23 ruling names implicit and ' - + 'explicit equality only. Reported for its own ruling; ⛔ not pinned green here, because a green ' - + 'pin would read as a ruling nobody made', - ); + // ── the `$ne` arm, ruling A on #19886 (record 5805254639) ───────────── + // + // 「The shared comparand-shape face refuses an array under `$ne` for every + // driver … one remedy text, naming the declared list-negation operator by + // its spec spelling」 — ⛔ no alias, ⛔ no window. A `todo` stood here until + // that ruling, because a green pin would have read as a ruling nobody made. + // Before the arm, every row below RETURNED from this face (measured on + // `origin/main` `9e7824a4`, and again by this PR's ablation). + + it.each([ + ['the lowered array form, "ne"', [['tags', 'ne', ['a']]], 'where.tags.$ne'], + ['the lowered array form, "not_equals"', [['tags', 'not_equals', ['a', 'b']]], 'where.tags.$ne'], + ['the lowered array form, "!="', [['tags', '!=', ['a']]], 'where.tags.$ne'], + ['the object passthrough', { tags: { $ne: ['a'] } }, 'where.tags.$ne'], + ['an EMPTY array — still an array in a one-value slot', { tags: { $ne: [] } }, 'where.tags.$ne'], + ['nested under $and (lowered)', ['and', ['amount', '>', 5], ['tags', 'neq', ['a']]], 'where.$and[1].tags.$ne'], + ['nested under $or', { $or: [{ stage: 'won' }, { tags: { $ne: ['a'] } }] }, 'where.$or[1].tags.$ne'], + ['nested under $not', { $not: { tags: { $ne: ['a'] } } }, 'where.$not.tags.$ne'], + ['beside a legal $eq on the same field', { tags: { $eq: 'x', $ne: ['a'] } }, 'where.tags.$ne'], + ])('refuses an ARRAY under $ne — %s', (_label, where, path) => { + const err = refusalOf(() => parseFilterAST(where)); + // ADR-0112 class 1, both halves. + expect(err.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status).toBe(400); + expect(err.message).toContain(`at ${path}.`); + // The `$ne` sentence, never the equality slot's. + expect(err.message).toMatch(/^Operator "\$ne" on field "tags" requires a single comparable value, but received an array/); + }); + + it('the $ne refusal names the operator and prescribes $nin, by its spec and authoring spellings, and nothing else', () => { + const err = refusalOf(() => parseFilterAST([['tags', 'not_equals', ['a', 'b']]])); + // The leading sentence is driver-memory's `arrayComparandError` for `$ne`, + // word for word (one condition, one wording across packages). + expect(err.message).toMatch( + /^Operator "\$ne" on field "tags" requires a single comparable value, but received an array \(\["a","b"\]\) at where\.tags\.\$ne\. /, + ); + // The ruling's prescription: the declared list-negation operator. + expect(err.message).toContain('For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin).'); + expect(err.message).toMatch(/The filter was NOT applied, .*UNFILTERED result set\.$/); + // ONE remedy: the equality slot's two operators are the wrong answer for + // a negation, and `$notContains` (a STRING operator) is not a list one. + expect(err.message).not.toContain('$in"'); + expect(err.message).not.toContain('$contains'); + expect(err.message).not.toContain('$notContains'); + // A caller-supplied context keeps its prefix, as on every sibling arm. + expect(refusalOf(() => assertListComparandShapes({ tags: { $ne: ['a'] } }, "find('deal')")).message) + .toMatch(/^find\('deal'\): Operator "\$ne" on field "tags"/); + }); + + it('every AST spelling that lowers to $ne refuses an array — the vocabulary, read at source', () => { + // Found with a SCALAR probe: an array would now trip the very arm being + // looked for. (`in` / `between` refuse a scalar outright; a throw is a "no".) + const inequality = [...VALID_AST_OPERATORS].filter((op) => { + try { + const lowered = parseFilterAST([['probe', op, 'x']]) as Record | undefined; + const spec = lowered?.probe as Record | undefined; + return spec !== null && typeof spec === 'object' && Object.keys(spec).join() === '$ne'; + } catch { + return false; + } + }); + // Guards the loop from passing vacuously. + expect(inequality.sort()).toEqual(['!=', '<>', 'ne', 'neq', 'not_equals', 'notequals']); + for (const op of inequality) { + const err = refusalOf(() => parseFilterAST([['tags', op, ['a']]])); + expect(err.code, op).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(err.status, op).toBe(400); + expect(err.message, op).toMatch(/^Operator "\$ne" on field "tags"/); + } + }); + + it('the prescribed operator is DECLARED and its spellings are the vocabulary\'s — the refusal invents nothing', () => { + // `$nin` is spelled by hand in the shared text (`filter.zod.ts` imports + // the face, so deriving it would be a cycle). What keeps it the + // vocabulary's own: it is a key of the enforced operator schema, and the + // spellings the message lists are EXACTLY the ones that lower to it. + expect(Object.keys(FieldOperatorsSchema.shape)).toContain('$nin'); + const ninSpellings = [...VALID_AST_OPERATORS].filter((op) => loweredOperatorOf(op) === '$nin').sort(); + expect(ninSpellings).toEqual(['nin', 'not_in', 'notin']); + const message = refusalOf(() => parseFilterAST({ tags: { $ne: ['a'] } })).message; + expect(message).toContain(`(authoring: ${ninSpellings.join(', ')})`); + // …and the prescribed spelling actually lowers and passes this face. + expect(parseFilterAST([['tags', 'not_in', ['a', 'b']]])).toEqual({ tags: { $nin: ['a', 'b'] } }); + expect(parseFilterAST({ tags: { $nin: ['a', 'b'] } })).toEqual({ tags: { $nin: ['a', 'b'] } }); + }); + + it('LIT CONTROL — every non-array $ne comparand keeps passing, the has-a-value predicate first', () => { + // `$ne: null` IS the has-a-value predicate (#5332); the arm is array-shaped + // and nothing wider. + expect(parseFilterAST({ tags: { $ne: null } })).toEqual({ tags: { $ne: null } }); + expect(parseFilterAST([['tags', 'ne', null]])).toEqual({ tags: { $ne: null } }); + expect(parseFilterAST({ tags: { $ne: 'a' } })).toEqual({ tags: { $ne: 'a' } }); + expect(parseFilterAST([['tags', '!=', 'a']])).toEqual({ tags: { $ne: 'a' } }); + expect(parseFilterAST({ n: { $ne: 0 } })).toEqual({ n: { $ne: 0 } }); + expect(parseFilterAST({ on: { $ne: false } })).toEqual({ on: { $ne: false } }); + expect(parseFilterAST({ tags: { $ne: '' } })).toEqual({ tags: { $ne: '' } }); + const day = new Date('2026-07-01T00:00:00.000Z'); + expect(parseFilterAST({ at: { $ne: day } })).toEqual({ at: { $ne: day } }); + // `$ne` is one of the six comparisons a `{ $field }` reference may be the + // whole comparand of — not an array, untouched. + expect(parseFilterAST([['amount', '!=', { $field: 'budget' }]])) + .toEqual({ amount: { $ne: { $field: 'budget' } } }); + }); + + it('a $ne array INSIDE a no-$-key field spec is still not descended into', () => { + // The nested-relation / deep-equality boundary the equality arm keeps too: + // this face judges the field's own operator map, never the inside of a + // nested condition it does not walk. + expect(parseFilterAST({ author: { tags: { $ne: ['a'] } } })).toEqual({ author: { tags: { $ne: ['a'] } } }); + }); // ── the wording contract (#5346 / #5348), unchanged by the move ──────── @@ -695,6 +803,11 @@ describe('the list-comparand shape door (#5869) runs inside parseFilterAST (#922 { close_date: { $eq: ['a'] } }, { close_date: ['aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'] }, { $or: [{ close_date: [] }] }, + // The `$ne` arm (#19886, ruling A): one prescription plus the received + // list, the long list cut at the same 60-char preview bound. + { close_date: { $ne: ['a'] } }, + { close_date: { $ne: ['aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'] } }, + { $not: { $or: [{ close_date: { $ne: [] } }] } }, ]) { const err = refusalOf(() => parseFilterAST(where, "find('deal')")); expect(err.message.length, JSON.stringify(where)).toBeLessThan(500); diff --git a/packages/spec/src/data/filter-equality-array-schema-door.test.ts b/packages/spec/src/data/filter-equality-array-schema-door.test.ts index c92c307c985..b7b3c44dc83 100644 --- a/packages/spec/src/data/filter-equality-array-schema-door.test.ts +++ b/packages/spec/src/data/filter-equality-array-schema-door.test.ts @@ -191,7 +191,10 @@ describe('#19889 §4 — what stays accepted, at BOTH doors', () => { ['$in: [] stays the declared predicate', { stage: { $in: [] } }], ['$nin keeps its list', { stage: { $nin: ['lost'] } }], ['$between keeps its pair', { amount: { $between: [1, 9] } }], - ['$ne carrying an array — not this ruling', { stage: { $ne: ['won'] } }], + // [#19886] A row `$ne carrying an array — not this ruling` stood here. Its + // face half is FALSE since ruling A on #19886 (record 5805254639): the face + // refuses that shape now, in its own `$ne` sentence. It is re-judged, not + // dropped, in the dedicated test after this table. ['an array inside a nested-relation spec — the face never descends one', { owner: { region: ['NA'] } }], ['$eq array inside a nested-relation spec — likewise', { owner: { region: { $eq: ['NA'] } } }], ])('%s', (_label, where) => { @@ -202,6 +205,27 @@ describe('#19889 §4 — what stays accepted, at BOTH doors', () => { expect(result.data).toEqual(where); }); + it('$ne carrying an array is not the EQUALITY arm\'s at either door', () => { + // What the old §4 row guarded, kept: the equality arm does not claim `$ne`. + // What it can no longer say: that the face PASSES the shape. Since ruling A + // on #19886 the face refuses it — in the `$ne` sentence with the `$nin` + // remedy, never the equality sentence with `$in` / `$contains`. + const where = { stage: { $ne: ['won'] } }; + const face = faceRefusal(where); + expect(face.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(face.status).toBe(400); + expect(face.message).toMatch(/^Operator "\$ne" on field "stage" requires a single comparable value/); + expect(face.message).toContain('{"$nin": […]}'); + expect(face.message).not.toContain('{"$in": […]}'); + // The carrier walk's EQUALITY arm raises nothing for it. Asserted on the + // equality sentences, not on `success`: whether this walk should judge + // `$ne` is outside ruling A's letter (it names the face and the operator + // slot), and a `success: true` pin here would read as a ruling nobody made. + const result = FilterConditionSchema.safeParse(where); + const issues = result.success ? [] : result.error.issues; + expect(issues.filter((i) => /implicit-equality comparand|Operator "\$eq"/.test(i.message))).toEqual([]); + }); + it('the { $field } reference an equality spelling lowers to passes both doors', () => { const lowered = parseFilterAST([['amount', '=', { $field: 'budget' }]]); expect(lowered).toEqual({ amount: { $eq: { $field: 'budget' } } }); diff --git a/packages/spec/src/data/filter-ne-array-schema-door.test.ts b/packages/spec/src/data/filter-ne-array-schema-door.test.ts new file mode 100644 index 00000000000..7b4f7e53709 --- /dev/null +++ b/packages/spec/src/data/filter-ne-array-schema-door.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19886] An ARRAY under `$ne` is refused at the SCHEMA door's operator slot, + * `FieldOperatorsSchema.$ne`, in the comparand-shape face's own words. + * + * Ruling A (record 5805254639, the director seat, class 1): + * + * 1. 「The shared comparand-shape face refuses an array under `$ne` for every + * driver, and `FieldOperatorsSchema.$ne` refuses it at parse — one remedy + * text, naming the declared list-negation operator by its spec spelling」. + * 3. 「pins: `$ne` arrays refused at the compile face and the schema door, + * scalars and `{ $field }` still pass」. + * + * The face's own pins live in `filter-comparand-shape.test.ts`. This file pins + * the operator slot and the one-text rule between the two doors. + * + * Measured before the change, on `origin/main` `9e7824a4`: + * `FieldOperatorsSchema.safeParse({ $ne: ['a'] })`, the same on its + * documentation copy `EqualityOperatorSchema`, and + * `NormalizedFilterSchema.safeParse({ $and: [{ s: { $ne: ['a'] } }] })` all + * answered `success: true`, and `assertListComparandShapes` passed + * `{ tags: { $ne: ['a'] } }` at every depth. + * + * ## What would make these pins worthless, and what stops it + * + * - A slot that refuses EVERYTHING passes every REFUSE row. §4 runs each + * control through BOTH doors and requires both to accept and KEEP the value. + * - "Same message" read as "similar message". §3 compares the two doors' + * strings for EQUALITY after removing only the two clauses the face adds and + * the slot cannot know (` on field "…"` and ` at `), and proves each + * clause is really there first, so the removal is not vacuous. + */ + +import { describe, expect, it } from 'vitest'; + +import { StandardErrorCode } from '../api/errors.zod'; +import { assertListComparandShapes } from './filter-comparand-shape'; +import { + EqualityOperatorSchema, + FieldOperatorsSchema, + NormalizedFilterSchema, + parseFilterAST, +} from './filter.zod'; + +type Issue = { code?: string; path: PropertyKey[]; message: string }; +type Parsed = { success: boolean; data?: unknown; error?: { issues: readonly Issue[] } }; + +/** The one issue at `path` (dot-joined), failing loudly on none or several. */ +function issueAt(result: Parsed, path: string): Issue { + expect(result.success, `expected a refusal at ${path}`).toBe(false); + const issues = (result.error?.issues ?? []).filter((i) => i.path.join('.') === path); + expect( + issues, + `issues raised: ${JSON.stringify((result.error?.issues ?? []).map((i) => i.path))}`, + ).toHaveLength(1); + return issues[0]!; +} + +/** The face's refusal of `where`, failing loudly when the face accepts it. */ +function faceRefusal(where: unknown): Error & { code?: string; status?: number } { + try { + assertListComparandShapes(where); + } catch (error) { + return error as Error & { code?: string; status?: number }; + } + throw new Error(`the face accepted ${JSON.stringify(where)}`); +} + +/** The prescription, verbatim: the declared list-negation operator and its authoring spellings. */ +const REMEDY = 'For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin).'; +const NOT_APPLIED = /The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set\.$/; + +const SLOTS = [ + ['FieldOperatorsSchema (the enforced copy)', FieldOperatorsSchema], + ['EqualityOperatorSchema (the documentation copy)', EqualityOperatorSchema], +] as const; + +// --------------------------------------------------------------------------- +// §1 The $ne operator slot refuses an array — both copies, one factory +// --------------------------------------------------------------------------- + +describe('#19886 §1 — the $ne operator slot refuses an array', () => { + for (const [label, schema] of SLOTS) { + it(`${label} refuses $ne: [...] at $ne — issue code, path and the prescription`, () => { + const issue = issueAt(schema.safeParse({ $ne: ['won', 'lost'] }), '$ne'); + expect(issue.code).toBe('custom'); + expect(issue.message).toBe( + 'Operator "$ne" requires a single comparable value, but received an array (["won","lost"]). ' + + 'For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter ' + + 'was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.', + ); + }); + + it(`${label} refuses the EMPTY array too — still an array in a one-value slot`, () => { + const issue = issueAt(schema.safeParse({ $ne: [] }), '$ne'); + expect(issue.code).toBe('custom'); + expect(issue.message).toMatch(/^Operator "\$ne" requires a single comparable value, but received an array \(\[\]\)\. /); + expect(issue.message).toContain(REMEDY); + expect(issue.message).toMatch(NOT_APPLIED); + }); + } + + it('refuses a $ne array beside a legal $eq in the same map, and only the $ne', () => { + const result = FieldOperatorsSchema.safeParse({ $eq: 'won', $ne: ['lost'] }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['$ne']); + }); + + it('the normalized AST — which validates field conditions against FieldOperatorsSchema — refuses it at the nested path', () => { + const issue = issueAt( + NormalizedFilterSchema.safeParse({ $and: [{ stage: { $ne: ['won'] } }] }), + '$and.0.stage.$ne', + ); + expect(issue.message).toMatch(/^Operator "\$ne" requires a single comparable value/); + expect(issue.message).toContain(REMEDY); + // Control: the same member with a scalar is a normalized field condition. + expect(NormalizedFilterSchema.safeParse({ $and: [{ stage: { $ne: 'won' } }] }).success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// §2 The face refuses the same shape — the compile half of the ruling +// --------------------------------------------------------------------------- + +describe('#19886 §2 — the shared face refuses $ne: [...] with the ADR-0112 envelope', () => { + it.each([ + ['top level', { stage: { $ne: ['won', 'lost'] } }, 'where.stage.$ne'], + ['EMPTY', { stage: { $ne: [] } }, 'where.stage.$ne'], + ['under $and', { $and: [{ amount: { $gt: 1 } }, { stage: { $ne: ['won'] } }] }, 'where.$and[1].stage.$ne'], + ['under $not', { $not: { stage: { $ne: ['won'] } } }, 'where.$not.stage.$ne'], + ])('%s', (_label, where, facePath) => { + const face = faceRefusal(where); + expect(face.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(face.status).toBe(400); + expect(face.message).toContain(` at ${facePath}. ${REMEDY}`); + expect(face.message).toMatch(NOT_APPLIED); + }); +}); + +// --------------------------------------------------------------------------- +// §3 One remedy text, two doors +// --------------------------------------------------------------------------- + +describe('#19886 §3 — one text, two doors', () => { + it.each([ + ['two members', ['won', 'lost']], + ['one member', ['won']], + ['EMPTY', []], + ['a long list, cut at the shared preview bound', ['aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc']], + ])('%s — the operator slot prints the face\'s sentence, less the field and the location', (_label, list) => { + const face = faceRefusal({ stage: { $ne: list } }).message; + const field = ' on field "stage"'; + const location = ' at where.stage.$ne.'; + // The two clauses only the face can write are really there, exactly once… + expect(face.split(field)).toHaveLength(2); + expect(face.split(location)).toHaveLength(2); + // …and removing them leaves the slot's message, character for character. + const slot = issueAt(FieldOperatorsSchema.safeParse({ $ne: list }), '$ne').message; + expect(slot).toBe(face.replace(field, '').replace(location, '.')); + // The documentation copy is the same factory, so the same characters. + expect(issueAt(EqualityOperatorSchema.safeParse({ $ne: list }), '$ne').message).toBe(slot); + }); + + it('every FilterArray spelling of $ne reaches the face through parseFilterAST with the same sentence', () => { + const face = faceRefusal({ stage: { $ne: ['won', 'lost'] } }).message; + for (const op of ['ne', '!=', '<>', 'neq', 'not_equals', 'notequals']) { + let lowered: Error | undefined; + try { + parseFilterAST([['stage', op, ['won', 'lost']]]); + } catch (error) { + lowered = error as Error; + } + expect(lowered?.message, op).toBe(face); + } + }); + + it('the remedy names the DECLARED list-negation operator, and only it', () => { + // Read off the enforced schema, as the ruling says: `$nin` is declared, and + // it is the operator whose comparand is a list of values NOT to match. + const declared = Object.keys(FieldOperatorsSchema.shape); + expect(declared).toContain('$nin'); + expect(FieldOperatorsSchema.safeParse({ $nin: ['won', 'lost'] }).success).toBe(true); + const slot = issueAt(FieldOperatorsSchema.safeParse({ $ne: ['won'] }), '$ne').message; + expect(slot).toContain(REMEDY); + // ⛔ No second remedy: `$notContains` is declared on a STRING comparand, + // and the equality slot's `$in` / `$contains` answer a different question. + expect(FieldOperatorsSchema.safeParse({ $notContains: ['won'] }).success).toBe(false); + for (const other of ['$notContains', '$contains', '{"$in"']) { + expect(slot, other).not.toContain(other); + } + // Inside the 500-char client bound the face's messages are held to. + const longest = faceRefusal({ + close_date: { $ne: ['aaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbb', 'cccccccccccccccccccc'] }, + }).message; + expect(longest.length).toBeLessThan(500); + }); +}); + +// --------------------------------------------------------------------------- +// §4 CONTROLS — both doors accept, and keep, every non-array $ne comparand +// --------------------------------------------------------------------------- + +describe('#19886 §4 — what stays accepted, at BOTH doors', () => { + const day = new Date('2026-07-01T00:00:00.000Z'); + it.each([ + ['a string', 'won'], + ['the empty string', ''], + ['a number, zero', 0], + ['a boolean', false], + ['null — the has-a-value predicate', null], + ['a Date', day], + ['a { $field } reference', { $field: 'budget' }], + ])('%s', (_label, comparand) => { + const where = { stage: { $ne: comparand } }; + expect(() => assertListComparandShapes(where)).not.toThrow(); + for (const [label, schema] of SLOTS) { + const result = schema.safeParse({ $ne: comparand }) as Parsed; + expect(result.success, `${label}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + // Accepted means KEPT: the slot returns the comparand it was given. + expect(result.data, label).toEqual({ $ne: comparand }); + } + }); + + it('the { $field } reference a $ne spelling lowers to passes both doors', () => { + const lowered = parseFilterAST([['amount', '!=', { $field: 'budget' }]]); + expect(lowered).toEqual({ amount: { $ne: { $field: 'budget' } } }); + expect(FieldOperatorsSchema.safeParse({ $ne: { $field: 'budget' } }).success).toBe(true); + }); + + it('the list operators keep their lists at the slot — $nin: [] included', () => { + expect(FieldOperatorsSchema.safeParse({ $nin: [] }).success).toBe(true); + expect(FieldOperatorsSchema.safeParse({ $in: ['a'] }).success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// §5 The boundary this change does NOT move +// --------------------------------------------------------------------------- + +describe('#19886 §5 — the stored-filter carrier walk', () => { + // Why the operator slot's refusal does not reach a stored carrier: + // `FilterConditionSchema` parses a field entry as `unknown` and judges it + // with its own walk (`checkFilterConditionComparands`), which judges `$eq` + // and not `$ne`. The `$eq` side of that walk is pinned in + // `filter-equality-array-schema-door.test.ts`, including that its equality + // arm raises nothing for `$ne`. + it.todo( + 'FilterConditionSchema (every stored filter carrier: dataset, widget, report, rollup …) refusing ' + + '$ne: [...] on SAVE. Ruling A names the face and FieldOperatorsSchema.$ne, not the carrier walk, ' + + 'so the carrier still saves the shape and the face refuses it at query time. Reported to the ' + + 'seat for its own decision; ⛔ not pinned green here, because a green pin would read as a ' + + 'ruling nobody made', + ); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts new file mode 100644 index 00000000000..93e2640d2b5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// Ruling A on #19886, item 1: the $ne slot's half of the question +// filter-equality-array-comparand-refused answered for equality. One entry for +// both named positions — the shared comparand-shape face every query crosses, +// and the $ne operator slot of FieldOperatorsSchema — because the ruling gives +// them one remedy text and the two land together. The formula evaluator and +// driver-mongodb faces landed earlier under their own entries +// (rls-predicate-array-comparand-refused, cel-predicate-list-comparand-refused). +export const entry: SemanticMigration = { + id: 'filter-ne-array-comparand-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of ' + + '$ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and ' + + 'the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar ' + + '["field", "ne", [...]] lowers to, and likewise "!=", "<>", "neq", "not_equals" and ' + + '"notequals", at any depth under $and / $or / $not, the empty array included. At parse: ' + + 'FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the ' + + 'NormalizedFilter AST that validates against it', + replacement: + 'the declared list-negation operator. "None of these values" is $nin: ' + + '{ field: { $nin: ["a", "b"] } } (authoring spellings "nin", "not_in", "notin"). A filter ' + + 'that meant a single value writes that value: { field: { $ne: "a" } }. $ne: null (the ' + + 'has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and ' + + 'the list operators ($in / $nin / $between) keep their arrays, empty lists included', + reason: + 'Ruling A on #19886 (record 5805254639, the director seat, class 1): the shared ' + + 'comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne ' + + 'refuses it at parse, with one remedy text naming the declared list-negation operator by its ' + + 'spec spelling — no alias, no window. The governing text is $ne\'s own published describe: ' + + 'the comparand is a literal, or a { $field } reference to another column of the same table. ' + + 'An array is neither, so the refusal pulls the doors back to what $ne already declared. ' + + 'Measured on the card before any stage landed, on the lowered { tags: { $ne: ["a"] } }: ' + + 'driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB ' + + 'reads $ne against an array operand, not equal to that array and not holding it as an ' + + 'element, which is every scalar row (mingo, the named proxy; a live mongod was NOT ' + + 'measured); and the formula evaluator matched EVERY row, which on the row-level write check ' + + 'admitted every write a != policy against a list was written to refuse. Those two answering ' + + 'faces were closed first, each at its own face, under rls-predicate-array-comparand-refused ' + + 'and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and ' + + 'before this change: the shared face passed the shape at every depth (so did its ' + + 'FilterArray lowering, and the engine\'s delegating wrapper), and FieldOperatorsSchema, ' + + 'EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face ' + + 'refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses ' + + 'it on parse, with one sentence from one builder: the face names the field and appends the ' + + 'location (at where.tags.$ne); the slot cannot see either, and its issue carries the ' + + 'location as its path. On the SQL family and driver-memory the verdict does not move (400 ' + + 'before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not ' + + 'moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses ' + + 'through (dataset, dashboard widget, report, rollup and the rest), does not parse a field\'s ' + + 'operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a ' + + 'carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the ' + + 'face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry ' + + 'adds no D2 conversion: a list under $ne has no single honest value, and whether it meant ' + + 'none of these values or one value is the author\'s call. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep stored filters, dataset and widget filters, flow node filters and code that builds a ' + + 'where for $ne whose comparand is an array — { field: { $ne: [...] } }, or a FilterArray ' + + 'triple on ne, !=, <>, neq, not_equals or notequals carrying an array — then decide per ' + + 'filter what it meant: none of these values ($nin), or one value ($ne with that value). Each ' + + 'is refused at query time with INVALID_FILTER / 400 naming the field, the path and $nin, so ' + + 'a test suite that exercises the query finds every one; code that parses a filter with ' + + 'FieldOperatorsSchema or the NormalizedFilter AST is refused on parse at the $ne path. ⛔ A ' + + 'clean re-save of a stored carrier is NOT a sweep: the carrier schema does not refuse the ' + + 'shape, so exercise each stored filter or grep it. On driver-mongodb re-check what the query ' + + 'is supposed to return rather than assuming the old rows were right: the old answer was ' + + 'MongoDB array inequality, which $nin does not reproduce.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0c2dd0ff6c4..80b007efc1d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9120,6 +9120,74 @@ const step18: MigrationStep = { + 'what the view is supposed to show rather than assuming the old result set was correct. ' + 'Both refusals now arrive at the authoring path.', }, + // Ruling A on #19886, item 1: the $ne slot's half of the question + // filter-equality-array-comparand-refused answered for equality. One entry for + // both named positions — the shared comparand-shape face every query crosses, + // and the $ne operator slot of FieldOperatorsSchema — because the ruling gives + // them one remedy text and the two land together. The formula evaluator and + // driver-mongodb faces landed earlier under their own entries + // (rls-predicate-array-comparand-refused, cel-predicate-list-comparand-refused). + { + id: 'filter-ne-array-comparand-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of ' + + '$ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and ' + + 'the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar ' + + '["field", "ne", [...]] lowers to, and likewise "!=", "<>", "neq", "not_equals" and ' + + '"notequals", at any depth under $and / $or / $not, the empty array included. At parse: ' + + 'FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the ' + + 'NormalizedFilter AST that validates against it', + replacement: + 'the declared list-negation operator. "None of these values" is $nin: ' + + '{ field: { $nin: ["a", "b"] } } (authoring spellings "nin", "not_in", "notin"). A filter ' + + 'that meant a single value writes that value: { field: { $ne: "a" } }. $ne: null (the ' + + 'has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and ' + + 'the list operators ($in / $nin / $between) keep their arrays, empty lists included', + reason: + 'Ruling A on #19886 (record 5805254639, the director seat, class 1): the shared ' + + 'comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne ' + + 'refuses it at parse, with one remedy text naming the declared list-negation operator by its ' + + 'spec spelling — no alias, no window. The governing text is $ne\'s own published describe: ' + + 'the comparand is a literal, or a { $field } reference to another column of the same table. ' + + 'An array is neither, so the refusal pulls the doors back to what $ne already declared. ' + + 'Measured on the card before any stage landed, on the lowered { tags: { $ne: ["a"] } }: ' + + 'driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB ' + + 'reads $ne against an array operand, not equal to that array and not holding it as an ' + + 'element, which is every scalar row (mingo, the named proxy; a live mongod was NOT ' + + 'measured); and the formula evaluator matched EVERY row, which on the row-level write check ' + + 'admitted every write a != policy against a list was written to refuse. Those two answering ' + + 'faces were closed first, each at its own face, under rls-predicate-array-comparand-refused ' + + 'and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and ' + + 'before this change: the shared face passed the shape at every depth (so did its ' + + 'FilterArray lowering, and the engine\'s delegating wrapper), and FieldOperatorsSchema, ' + + 'EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face ' + + 'refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses ' + + 'it on parse, with one sentence from one builder: the face names the field and appends the ' + + 'location (at where.tags.$ne); the slot cannot see either, and its issue carries the ' + + 'location as its path. On the SQL family and driver-memory the verdict does not move (400 ' + + 'before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not ' + + 'moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses ' + + 'through (dataset, dashboard widget, report, rollup and the rest), does not parse a field\'s ' + + 'operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a ' + + 'carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the ' + + 'face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry ' + + 'adds no D2 conversion: a list under $ne has no single honest value, and whether it meant ' + + 'none of these values or one value is the author\'s call. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep stored filters, dataset and widget filters, flow node filters and code that builds a ' + + 'where for $ne whose comparand is an array — { field: { $ne: [...] } }, or a FilterArray ' + + 'triple on ne, !=, <>, neq, not_equals or notequals carrying an array — then decide per ' + + 'filter what it meant: none of these values ($nin), or one value ($ne with that value). Each ' + + 'is refused at query time with INVALID_FILTER / 400 naming the field, the path and $nin, so ' + + 'a test suite that exercises the query finds every one; code that parses a filter with ' + + 'FieldOperatorsSchema or the NormalizedFilter AST is refused on parse at the $ne path. ⛔ A ' + + 'clean re-save of a stored carrier is NOT a sweep: the carrier schema does not refuse the ' + + 'shape, so exercise each stored filter or grep it. On driver-mongodb re-check what the query ' + + 'is supposed to return rather than assuming the old rows were right: the old answer was ' + + 'MongoDB array inequality, which $nin does not reproduce.', + }, { id: 'filter-preset-ordering-comparand-refused', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a From a6f82079895181cad7ac1e3cbf4323753c110863 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:59:57 +0000 Subject: [PATCH 3/3] chore(changeset): the $ne array refusal at the face and the operator slot, a minor narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-②: no (narrowing), the BREAKING banner at minor, and the ADR-0087 registered marker for filter-ne-array-comparand-refused. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- ...rray-comparand-refused-at-face-and-slot.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .changeset/19886-ne-array-comparand-refused-at-face-and-slot.md diff --git a/.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md b/.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md new file mode 100644 index 00000000000..896f10d80bb --- /dev/null +++ b/.changeset/19886-ne-array-comparand-refused-at-face-and-slot.md @@ -0,0 +1,71 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: an ARRAY under `$ne` is refused at the shared comparand-shape face and at `FieldOperatorsSchema.$ne`, with one remedy text naming `$nin` (#19886) + +**BREAKING** — an accept-set narrowing at the runtime filter doors and at one published operator slot, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19886 (record 5805254639, ruling A, class 1): "The shared comparand-shape face refuses an array under `$ne` for every driver, and `FieldOperatorsSchema.$ne` refuses it at parse — one remedy text, naming the declared list-negation operator by its spec spelling". No alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-ne-array-comparand-refused`. + +## What changes + +`$ne` already declared its comparand as "a literal, or a { $field } reference to another column of the same table". An array is neither. Two doors now say so. + +**The shared comparand-shape face** (`assertListComparandShapes` in `@objectstack/spec/data`) refuses `{ field: { $ne: [...] } }` with `INVALID_FILTER` / 400. It refuses it at any depth under `$and` / `$or` / `$not`, and it refuses the empty array too. `parseFilterAST` lowers `['tags', 'ne', ['a']]` to that shape, and so do `!=`, `<>`, `neq`, `not_equals` and `notequals`. The face runs inside `parseFilterAST` and at the engine's lowering seam, so the refusal lands before any driver runs. The message is: + +```text +Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set. +``` + +Its first sentence is `driver-memory`'s own wording for this condition. It names one remedy: `$nin`, the list-negation operator `FieldOperatorsSchema` declares ("Not in list"), with its authoring spellings. + +**The operator slot** `FieldOperatorsSchema.$ne` refuses an array on parse. So do its documentation copy `EqualityOperatorSchema.$ne` and the `NormalizedFilter` AST that validates against it. They print the same sentence without ` on field "…"` and ` at `, because a slot cannot see either. The issue's own `path` carries the location instead (`$ne`, `$and.0.stage.$ne`). + +How each door answered `{ tags: { $ne: ['a'] } }` on `origin/main` `9e7824a4`, just before this change: + +| door | before | after | +|:--|:--|:--| +| the shared face, `parseFilterAST`, the engine's lowering seam | **passed** it to the driver, at every depth | `INVALID_FILTER` / 400, before any driver runs | +| `FieldOperatorsSchema`, `EqualityOperatorSchema`, the `NormalizedFilter` AST | **parsed it green** | refused on parse at `$ne` | +| the analytics `where` door (`normalizeAnalyticsFilterTree`) | **compiled it**: `{ stage: { $ne: ['won', 'lost'] } }` became `stage` not-set OR `stage` not-equals `['won', 'lost']`, and both analytics strategies render a not-equals member from its first value, so `'lost'` was dropped and its rows were counted | `INVALID_FILTER` / 400 with the face's sentence | +| `driver-sql`, `driver-memory` | refused, 400, in their own words | unchanged; the face now answers first | +| `driver-mongodb`, the formula evaluator | refused, since earlier stages of this card, in their own words | unchanged; the face now answers first | + +The analytics row's compiled tree was measured with the face's `$ne` arm removed, which is `main`'s face. Its rendering from the first value was read at source (`values[0]` in the native SQL strategy, `v0` in the ObjectQL strategy) and was not executed. A live `mongod`, MySQL, PostgreSQL and a live Turso server were NOT measured. + +So on the SQL family and `driver-memory` the verdict does not move (400 before and after). What moves is the text and the moment: the refusal now arrives at the face with the `$nin` remedy, before any driver runs. On the analytics `where` door the verdict does move: a query that used to answer with a silently widened row set is now refused. + +## What does NOT change + +- **A stored filter carrier still saves the shape.** `FilterConditionSchema`, which every stored filter parses through (a dataset filter and measure filter, a dashboard widget filter, a report `runtimeFilter`, a rollup filter and the rest), does not parse a field's operator map through `FieldOperatorsSchema`. Its own walk judges `$eq` and does not judge `$ne`. The ruling names the face and the operator slot, not that walk. So `DatasetSchema` with `filter: { stage: { $ne: ['won', 'lost'] } }` still parses green, and every query that uses it is refused at the face. +- `$ne: null` is the has-a-value predicate and is untouched. Every scalar, a `Date` and a `{ $field }` reference are untouched too. `['amount', '!=', { $field: 'budget' }]` still lowers to `{ amount: { $ne: { $field: 'budget' } } }` and passes. +- The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` and `$nin: []`. +- A field spec with no `$` key (`{ author: { tags: { $ne: ['a'] } } }`) is still not descended into. +- The ordering operators carrying an array, a nested array inside `$in`, and a `{ $field }` referent to a multi-valued field are not judged here. They are the same class and are scoped separately on the card. +- The drivers are untouched. A caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own refusal. +- `ViewFilterRule` already refused an array on `not_equals` at authoring time. +- The published JSON Schema cannot state the check. `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$ne`. The three sites are declared in `dropped-refinements.baseline.json`. +- No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ stage: { $ne: ['won', 'lost'] } }`, meaning "none of these values" | `{ stage: { $nin: ['won', 'lost'] } }` | +| `[['stage', 'not_equals', ['won', 'lost']]]` (or `ne`, `!=`, `<>`, `neq`, `notequals`) | `[['stage', 'not_in', ['won', 'lost']]]` (or `nin`, `notin`) | +| `{ stage: { $ne: ['won'] } }`, meaning one value | `{ stage: { $ne: 'won' } }` | + +On `driver-mongodb`, check what the query is supposed to return and do not assume the old rows were right. Before this card, MongoDB read `$ne` against an array as "not equal to that array and not holding it as an element", and `$nin` does not reproduce that. On the analytics `where` door, the old answer dropped every member after the first, so a chart built on it counted rows its filter named. + +## Who is affected, measured + +Nothing shipped authors the shape. Each count below was read against the named tree, with a control: + +- **This repository**, `origin/main` `9e7824a4`. `$ne` followed by an array literal in `packages/**`, `examples/**`, `apps/**`, `scripts/**`, `content/**` and `skills/**` has 20 hits. All of them are tests, refusal code, comments or migration prose. The control, `$in` followed by an array literal in `packages/**` and `examples/**`, has 901 hits. The FilterArray triple on `ne`, `!=`, `<>`, `neq`, `not_equals` or `notequals` carrying an array has 0 hits. A CEL `!=` against a list literal in `packages/platform-objects/**`, `examples/**` and `packages/qa/**` (tests excluded) has 0 hits. `$ne` fed by a variable in non-test source has 11 sites, and none of them builds a list. Each passes a list's first member, a stored-form value, a named constant, a `{ $field }` reference, or a caller's comparand passed through: the better-auth adapter's `ne`, and the CEL lowering, which refuses a list under `!=` before it emits. +- **objectui** at the `.objectui-sha` pin `f8a9d0fb05`: 2 hits, both in its own refusal test. The dataset filter builder gives `notEquals` a scalar arity, and the rollup summary editor gives only `in` / `notIn` a list. The control, `$in` with an array, has 46 hits. +- **cloud** `main` `48d7066`: 0 hits. The control has 33 hits. + +Deployed stacks were NOT measured. Every query that carries the shape is refused with `INVALID_FILTER` / 400 naming the field, the path and `$nin`, so a test suite that exercises the query finds each one. A clean re-save of a stored carrier does not find them, because the carrier schema still accepts the shape. Exercise stored filters, or grep them. + +Clause-②: no (narrowing) — nothing is widened. One comparand shape in one slot, which `$ne`'s published description already excluded, is refused at the face and at the operator slot. + +