1212 * is the 2026-09-23 ruling's, recorded in its own section below.
1313 *
1414 * [#19886] And the same question for equality's negation: whether a `$ne`
15- * comparand received ONE value rather than a list. Ruling A's, recorded in the
16- * last "Refused BY RULING" section.
15+ * comparand received ONE value rather than a list. Ruling A's, recorded in its
16+ * own "Refused BY RULING" section.
17+ *
18+ * [#21448] And the same question for every other scalar operator — the
19+ * ordering, text and flag operators: whether the comparand is ONE value rather
20+ * than a list, whatever the column type. The 2026-10-02 ruling's, recorded in
21+ * the last "Refused BY RULING" section.
1722 *
1823 * `FieldOperatorsSchema` (`./filter.zod.ts`) declares three operators whose
1924 * comparand is a LIST rather than a scalar:
7479 * delegating wrapper that supplies the engine's `find('deal')` context prefix;
7580 * there is exactly one implementation of "a list operator takes a list".
7681 *
77- * That sentence is true of THREE slots, and each is named because it was once
82+ * That sentence is true of FOUR slots, and each is named because it was once
7883 * true of fewer:
7984 *
8085 * - **The list-operator slot** (`$in` / `$nin` / `$between`) — a scalar where a
8792 * array equality. That is the arm's section below.
8893 * - **The inequality slot** (`$ne`) — a list where one value belongs, since
8994 * ruling A on #19886 (2026-09-24). Until then this face passed it and each
90- * backend answered it alone. That is the last "Refused BY RULING" section.
95+ * backend answered it alone. That is the sixth "Refused BY RULING" section.
96+ * - **Every other scalar slot** (`$gt` / `$gte` / `$lt` / `$lte`, the text
97+ * operators, the flags) — a list where one value belongs, since the
98+ * 2026-10-02 ruling (#21448). Until then this face passed it, and the
99+ * analytics lowering bound the list's FIRST member while the drivers each
100+ * answered it alone. That is the last "Refused BY RULING" section.
91101 *
92102 * ## Both engine doors, because only one of them carries an array
93103 *
268278 * this ruling names implicit and explicit equality. It measured the same
269279 * split (refused on the SQL family and `driver-memory`, answered by
270280 * `driver-mongodb`), so it went to its own card and got its own ruling —
271- * the last section below , with its own remedy. The other scalar operators
281+ * the sixth section, with its own remedy. The other scalar operators
272282 * (`$gt`, `$contains`, …) carrying an array are not this ruling's, and not
273- * that one's either.
283+ * that one's either: they are the 2026-10-02 ruling's (#21448), the last
284+ * section below, with a remedy of their own.
274285 * - **The list operators keep their lists**, `$in: []` / `$nin: []` included,
275286 * and every scalar — `null` above all, since `{ field: null }` and
276287 * `$eq: null` ARE the has-no-value predicate (#5332) — keeps passing.
340351 * reconciles the spellings against `FieldOperatorsSchema` and the AST table.
341352 * - ⛔ **Not the ordering operators, a nested array inside `$in`, or a
342353 * `{ $field }` referent to a multi-valued field.** Those are the same class,
343- * are recorded on the card, and are scoped separately.
354+ * are recorded on the card, and are scoped separately. [#21448] The
355+ * ordering operators are now the last section's, with every other scalar
356+ * operator; a nested array inside `$in` and a `{ $field }` referent are still
357+ * not judged here.
344358 * - **The leading sentence is `driver-memory`'s** `arrayComparandError` for
345359 * `$ne`, word for word, so one condition keeps one wording across packages.
346360 *
350364 * is assembled in `./filter-comparand-refusal-text.ts`, which both doors
351365 * import.
352366 *
367+ * ## Refused BY RULING, 2026-10-02: a LIST at every other scalar operator (#21448)
368+ *
369+ * The triage ruling (record 5958292323): "A list at a scalar operator (`$gt`,
370+ * `$gte`, `$lt`, `$lte`, `$eq`, `$ne` and the rest of the scalar set) is a shape
371+ * error, whatever the column type." `$eq` and `$ne` had their arms already
372+ * (the two sections above); this arm is the rest. It does not live in the
373+ * number or boolean declared-type verdicts, because the column type does not
374+ * decide it.
375+ *
376+ * The governing declaration is `FieldOperatorsSchema`: of the declared
377+ * operators, only `$in`, `$nin` and `$between` accept an array at the schema
378+ * door, and every other slot refuses one. This face passed the shape, and each
379+ * consumer answered it alone. Measured on `b94a2a727`, SQLite and PostgreSQL
380+ * 16.14 alike, through `POST /api/v1/analytics/query` and
381+ * `/api/v1/analytics/dataset/query`, and at the engine's own `find`:
382+ *
383+ * | filter | analytics, engine-aggregate face | analytics, native face | `engine.find` |
384+ * |:--|:--|:--|:--|
385+ * | `{ amount: { $gt: [10, 99] } }` (number) | 200, the driver received `$gt: 10` | 400 (the number verdict) | 400 (the number verdict) |
386+ * | `{ note: { $gt: ['a', 'z'] } }` (text) | 200, the driver received `$gt: 'a'` | 200, bound `'a'` | 400 (`driver-sql`'s own bind refusal) |
387+ *
388+ * The analytics lowering carried the operator's whole array into one leaf, and
389+ * the leaf compilers read its FIRST member: the predicate the caller wrote was
390+ * narrowed without a word, and the answer was a 200 with wrong rows. One
391+ * position over, `driver-memory` refuses a list at the six comparisons but
392+ * ANSWERS one at a text operator, while `driver-sql` refuses both in its own
393+ * words. Refusing here makes every one of those cells unreachable through a
394+ * platform door, and the analytics `where` door needs no rule of its own: it
395+ * already hands every field entry to this face before any leaf exists.
396+ *
397+ * The scope:
398+ *
399+ * - **Every operator of the declared scalar half** (`SCALAR_COMPARAND_OPERATORS`,
400+ * `./filter-comparand-operators.ts`, the split the comparand-TYPE face reads)
401+ * other than `$eq` / `$ne`, whose arms answer first with their own remedies:
402+ * the four ordering operators, the text operators (`$contains`,
403+ * `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$like`,
404+ * `$ilike`) and the three flags. At every depth, the EMPTY list too: `[]` is a
405+ * list in a one-value slot, and only `$in: []` / `$nin: []` are declared
406+ * predicates.
407+ * - **The flags** (`$null`, `$exists`, `$empty`). A list there was already
408+ * refused on every query face by the boolean rule (#5347 / #5369), in that
409+ * rule's words; it is now refused here first, in this arm's, because HOW MANY
410+ * values comes before WHICH value. A non-boolean scalar flag keeps the
411+ * boolean rule's sentence.
412+ * - **Not judged:** the list operators keep their lists; every scalar, a `Date`
413+ * and a `{ $field }` reference pass as before (a `null` ordering comparand
414+ * keeps its own arm above); an operator outside the vocabulary (`$wat`,
415+ * retired `$regex`) is not in the set and keeps its own refusal downstream.
416+ * - **The remedy** is ONE value, or the two list operators an author holding a
417+ * list may have meant, by their spec and authoring spellings: `$in` for "one
418+ * of these values" and `$between` for a range. The leading sentence is
419+ * `driver-memory`'s `arrayComparandError`, word for word.
420+ *
421+ * The sentence is assembled in `./filter-comparand-refusal-text.ts`, and the
422+ * save door (`./filter-save-door-refusals.ts`) prints it without the location,
423+ * so a stored filter carrying the shape is refused on save the day this face
424+ * refuses it on query.
425+ *
353426 * ## Refusal envelope
354427 *
355428 * Every refusal carries `code: 'INVALID_FILTER'` and `status: 400` (ADR-0112
363436 * @see https://github.com/objectstack-ai/objectstack/issues/9228 (the move)
364437 * @see https://github.com/objectstack-ai/objectstack/issues/19757 (the equality-slot arm)
365438 * @see https://github.com/objectstack-ai/objectstack/issues/19886 (the `$ne` arm)
439+ * @see https://github.com/objectstack-ai/objectstack/issues/21448 (the other scalar slots' arm)
366440 */
367441
368442import {
443+ BETWEEN_OPERATOR_SPELLINGS ,
369444 IN_OPERATOR_SPELLINGS ,
370445 NIN_OPERATOR_SPELLINGS ,
371446 arrayEqualityComparandMessage ,
372447 arrayInequalityComparandMessage ,
448+ arrayScalarComparandMessage ,
373449 shapePreview ,
374450} from './filter-comparand-refusal-text' ;
451+ import { SCALAR_COMPARAND_OPERATORS } from './filter-comparand-operators' ;
375452
376453/**
377454 * The operators whose comparand `FieldOperatorsSchema` declares as a list, with
@@ -392,7 +469,8 @@ const LIST_COMPARAND_OPERATORS: ReadonlyMap<string, readonly string[]> = new Map
392469 [ '$in' , IN_OPERATOR_SPELLINGS ] ,
393470 // [#19886] The `$nin` row likewise, for the operator the `$ne` refusal prescribes.
394471 [ '$nin' , NIN_OPERATOR_SPELLINGS ] ,
395- [ '$between' , [ 'between' ] ] ,
472+ // [#21448] And the `$between` row, for the range the scalar-slot refusal prescribes.
473+ [ '$between' , BETWEEN_OPERATOR_SPELLINGS ] ,
396474] ) ;
397475
398476/**
@@ -768,21 +846,45 @@ function arrayInequalityComparandError(
768846 return invalidFilterComparandError ( context , arrayInequalityComparandMessage ( value , { field, path } ) ) ;
769847}
770848
849+ /**
850+ * A LIST at any other scalar operator — refused BY RULING, 2026-10-02 (#21448);
851+ * see the module note's seventh "Refused BY RULING" section.
852+ *
853+ * The sentence is `arrayScalarComparandMessage`
854+ * (`./filter-comparand-refusal-text.ts`), which the save door prints too. Its
855+ * leading sentence is `driver-memory`'s `arrayComparandError` for the same
856+ * operator, word for word, and its remedy is ONE value, or the two list
857+ * operators an author holding a list may have meant (`$in`, `$between`), with
858+ * their authoring spellings. As with {@link arrayEqualityComparandError}, this
859+ * door adds only its `at <path>` location and its envelope.
860+ */
861+ function arrayScalarComparandError (
862+ context : string | undefined ,
863+ op : string ,
864+ field : string ,
865+ value : unknown ,
866+ path : string ,
867+ ) : Error {
868+ return invalidFilterComparandError ( context , arrayScalarComparandMessage ( op , value , { field, path } ) ) ;
869+ }
870+
771871/**
772872 * Walk one `FilterCondition` and refuse every list-shaped operator whose
773873 * comparand cannot be one — and, since the two null rulings (2026-08-31,
774874 * 2026-09-01), the null comparand positions those rulings carved out; and,
775875 * since the 2026-09-23 ruling (#19757), every EQUALITY comparand (implicit or
776876 * `$eq`) that IS a list; and, since ruling A on #19886, every `$ne` comparand
777- * that is a list.
877+ * that is a list; and, since the 2026-10-02 ruling (#21448), every other
878+ * scalar operator's comparand that is a list.
778879 *
779880 * Read-only and allocation-free on the overwhelmingly common path (a filter
780881 * with no list operator walks its own keys and returns). Runs on every engine
781882 * read and write and inside every {@link parseFilterAST} call, so it stays a
782883 * walk rather than a schema parse — that cost is now the whole reason, and this
783884 * gate deliberately enforces only the three list declarations the drivers
784- * genuinely cannot agree on, the equality slot's mirror of them, plus the null
785- * carve-outs ruled onto the same door.
885+ * genuinely cannot agree on, their mirror in every scalar slot (equality since
886+ * #19757, `$ne` since #19886, the rest since #21448), plus the null carve-outs
887+ * ruled onto the same door.
786888 *
787889 * @param node the LOWERED `FilterCondition` — never the authoring array.
788890 * @param context optional caller prefix (`find('deal')`), the engine's #5346
@@ -869,6 +971,14 @@ function assertFieldListComparands(
869971 }
870972 continue ;
871973 }
974+ // Every other scalar operator (#21448): a list where ONE value belongs,
975+ // whatever the column type. The set is the declared vocabulary's scalar
976+ // half — the same split the comparand-TYPE face reads — so an operator
977+ // outside the vocabulary (`$wat`, retired `$regex`) keeps its own refusal
978+ // downstream. Before the null-ordering arm, which a list can never reach.
979+ if ( SCALAR_COMPARAND_OPERATORS . has ( op ) && Array . isArray ( spec [ op ] ) ) {
980+ throw arrayScalarComparandError ( context , op , field , spec [ op ] , `${ path } .${ op } ` ) ;
981+ }
872982 // The ordering carve-out (2026-09-01 ruling, #14080). Strictly `null`:
873983 // `undefined` keeps the TYPE door's own sentence, and every other
874984 // comparand type in these slots is that door's question, not this one's.
0 commit comments