fix(spec)!: a list at a scalar operator is refused at the shared comparand-shape face, whatever the column type (#21448) - #21484
Conversation
…shape face (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ave their list-at-scalar rows to the shape face (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…or; moved pins follow the shape face (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…'s scalar-slot arm (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…pe face's refusal at every engine and wire position (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…e shape face's refusal (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…d comparand-shape face (#21448) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…st-at-scalar-operator Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 5 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin af780c015bd78cd84e88f91b9d2a64e4cb569c81 && git checkout af780c015bd78cd84e88f91b9d2a64e4cb569c81
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa4632235ba571ef800b95e6bc18d00a30aa1d57 2b9fd4f5ebd0dc71afb93dfd6728f2969536255f && git checkout -B drift-repro aa4632235ba571ef800b95e6bc18d00a30aa1d57 && git merge --no-ff 2b9fd4f5ebd0dc71afb93dfd6728f2969536255f
node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57
|
Fixes #21448
Clause-②: no (narrowing)
What this changes
The shared comparand-shape face (
assertListComparandShapes,@objectstack/spec/data) now refuses a LIST at every scalar operator, whatever the column type. That covers$gt,$gte,$lt,$lte, the text operators ($contains,$notContains,$startsWith,$endsWith,$icontains,$like,$ilike) and the flags ($null,$exists,$empty).$eqand$nekeep their own ruled arms.INVALID_FILTER/ 400, before any read.driver-memory'sarrayComparandErrorfor the same condition, word for word.$in(authoringin) for "one of these values";$between(authoringbetween) for a range.This implements triage's ruling (5958292323) as written. There is one verdict, at the shared face. It is not in the number or boolean declared-type verdicts. The lowering gains no second rule and no
values[0]read of a list.Measured on
origin/mainb94a2a727, SQLite and PostgreSQL 16.14 alikeThrough
AnalyticsService.query/.queryDataset(whatPOST /api/v1/analytics/queryand/api/v1/analytics/dataset/queryrelay), on both faces, and throughengine.findon the realObjectQL:engine.find{ amount: { $gt: [10, 99] } }(number)$gt: 10){ amount: { $gt: [10] } }{ amount: { $lte: [12, 1] } }$lte: 12){ note: { $gt: ['a', 'z'] } }(text)$gt: 'a')'a')[['note', '>', ['a', 'z']]]{ note: { $eq: ['b'] } },{ note: { $ne: ['b'] } }{ note: { $contains: ['b', 'm'] } }$in: ['b'],$nin: ['b'],$gt: 10Triage's "measure first": the engine door's own answer for the text cell. The engine door does NOT bind the first member. On
driver-sqlit refused 400 in the driver's own words ("…cannot be bound as a SQL parameter…"). So its answer was right and only its wording was per driver. The same verdict now answers it first, in the face's words (pinned: theengine.findtext cell). One position over,driver-memoryANSWERS a list at a text operator (memory-matcher-array-and-date-comparand.test.ts). The face now refuses that before any driver runs.Dispatch assumptions this measurement corrected:
$eq: [x]/$ne: [x]already answered one 400 on both faces ([finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 / [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886's arms). The live defect was the ordering operators, plus a text column's native face.filter-normalizer.ts'sassertWhereComparandShapes(service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010), not throughcomparand-shape.ts.Design
SCALAR_COMPARAND_OPERATORS, the comparand-TYPE face's split, whichfilter-comparand-type.test.tsreconciles againstFieldOperatorsSchema's keys.filter-comparand-type.tsinto a new module outside thedatabarrel (filter-comparand-operators.ts). Both faces and the save door read ONE split, and nothing is published:check:api-surfaceis unchanged.$in/$nin/$between.lowerAnalyticsWherealready hands every field entry to the face before any leaf exists. So the arm reaches both analytics faces at every position (where,runtimeFilter, a dataset's scope, a measure'sfilter) with no code change there.filter-normalizer.tsandcomparand-shape.tschange docblocks only; they state the invariant that only a list operator's array is spread into a leaf'svalues.filter-save-door-refusals.ts).VALIDATION_FAILED/ 400, located on the member, as for every other face arm. In-process callers getINVALID_FILTER/ 400. Both layers are pinned.judgedComparandslowers throughlowerAnalyticsWherefirst, so that arm'sarrayrefusal is no longer reached at a scalar operator from any native position.native-sql-strategy.tsis untouched; the now-unreachable branch is a note, not an edit.$null/$exists/$emptynow reads in the shape sentence, because how many values comes before which value. A non-boolean scalar flag keeps the boolean rule's sentence.Pins
service-analytics:list-at-scalar-operator-both-faces.test.ts, run on SQLite and PostgreSQL. Each measured cell, plus$gte/$lt: []/$contains/$startsWith,$or/$not, and the FilterArray spelling, is checked at both doors.DatasetSchemarefuses the stored filter on save, in the sentence less its location.engine.findrefuses the text cell in the face's words, not the driver's.$in,$nin, scalar$gt,$between) count alike on both faces.$gt: [10]cell is now a both-faces cell innative-sql-number-comparand-door.test.ts.@objectstack/spec: afilter-comparand-shape.test.tsblock covering the derived operator set; every list shape (pair, one member, strings, empty); nested paths; every AST spelling that carries a value; the message and remedy ($in,$between, both declared); flags; controls; the 500-char bound.filter-number-/filter-boolean-comparand-declared-type.ts) and their tests: list rows only at list members now;filter-save-door-face-parity.test.ts(§1: every declared operator is face-judged; §2: new rows);analytics-filter-refusal-envelope.test.ts(a new HTTP cell);$emptyand type-face tests.Ablations
Each leg was committed first, mutated through
scripts/ablation-replace.mjs(WRAP, with the restore trapped), and its restore proven by blob == HEAD and an emptygit diff HEAD.ablation-dist-preflight.mjs @objectstack/spec 'throw arrayScalarComparandError(' --absentexit 0.$eq/$neand the controls stay green.engine.findanswered in driver-sql's words;$eq/$neand control cell stayed green.dist/, tree clean, 202 / 202 green.assertWhereComparandShapes' face hand-over). The subject resolves fromsrc, so no rebuild is owed.$eq, the save door andengine.findstay green.$neincluded, and the registered scope and measure). 0 failures among the FilterArray,$eq, save-door andengine.findcells.Verification, at the merged head
2b9fd4f5e(origin/mainmerged in)@objectstack/spectest: 602 files / 17754 tests green.@objectstack/service-analyticstest, with PostgreSQL 16.14: 172 files green. One file's 4 live-PG cells need a UTC server; see the acceptance notes.@objectstack/objectqltest: 366 files green. One barrel-import test timed out at 5 s at load ~7, then 34 / 34 when run alone.dispatch-gates.mjs --commandsat2b9fd4f5ederives 90 families. All 90 ran with recorded exit codes, all 0, includingcheck:dual-build-cjs-loads(105 require entries across 66 packages load).--ranreconciles 90 run / 0 NOT MEASURED.eslint --no-inline-config --format jsonover the 24 changed.tsfiles gives 24 files, 0 errors, 0 warnings, none ignored.eslint.config.mjshas no type-aware linting (noparserOptions.project/projectService), so this diff cannot move an untouched file's verdict.content/docs/**(outsidereleases/) andskills/**for the comparand-shape rules and the filter operators found no sentence made false.Blast radius
[field, op, value]and{ field, operator, value }, acrossexamples/,skills/,content/docs/,apps/andpackages/**non-test sources): none. The CEL lowering already refuses one (cel-to-filter.ts).../objectuiis not checked out here, andpackages/console/distis not built.File surface against the claim
The claim named
filter-normalizer.ts,comparand-shape.ts, specfilter-comparand-shape.tsand its test, the pins and the changeset. Added, each a consequence of the narrowing inside the rule's consumer radius:filter-comparand-operators.ts(new, internal);filter-comparand-type.ts(the split's import, and one now-false sentence);filter-comparand-refusal-text.ts(the shared sentence);filter-save-door-refusals.ts(the save door's sentence);None of
native-sql-strategy.ts,objectql-strategy.ts,analytics-service.tsorpreview-evaluator.tsis touched.Acceptance notes
Asia/Shanghai,objectql-face-order-limit.test.ts's live cells answer the newest month bucket of adatecolumn holding2026-06-01as2026-05(2 rows). At UTC the answer is2026-06(1 row). The cell is the engine-aggregate face, since the native face declines granularity. Not touched by this diff.values[0]reads stay as they are. With the face's arm, no list reaches a scalar leaf through any analytics door.2b9fd4f5ecleanly, with no overlap.Generated by Claude Code