fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it - #19994
Conversation
…ility measurement
Measurement only; no source change. Taken on this branch at its base
before any fix, against a local build of the dependency closure.
- Authoring door: the published RLS policy schema and the RLS authoring
lint's decision procedure admit an equality predicate whose comparand
is a list (a literal or a membership variable).
- Lowering: that predicate lowers to a bare-array field constraint. The
CEL lowering emits an explicit $eq only around a { $field } reference,
so no authored policy yields $eq with a list; no in-repo read-scope
producer (tenant layer, sharing, controlled-by-parent) emits one either.
- Compiler: the bare array reaching compileScopedFilterToSql through the
security service's read filter is already refused fail-closed
(READ_SCOPE_COMPILE_FAILED / 500). An explicit $eq with a list, which a
host-supplied getReadScope or a direct caller of the public export can
still hand in, is compiled with the list bound as one parameter.
- Engines on the native execute path answer that bind four different
ways: a driver error, zero rows, rows the scope never named, and under
a negation every row.
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…piler compileScopedFilterToSql refused a list in the implicit equality slot but compiled the explicit $eq spelling with the whole list bound as one parameter, leaving the predicate's meaning to the executing database. A new gate in compileField refuses it in this module's envelope (READ_SCOPE_COMPILE_FAILED / 500), ahead of the member gates so the list is diagnosed as a list. This pushes the shared comparand-shape face's equality-slot ruling down to a compiler that never meets that face. Pins: the explicit spelling at every depth, the implicit spelling at every depth, neighbouring shapes unchanged, and the NativeSQL execute and echo faces refusing before any statement runs. The refusal inventory gains the new site. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…n the comparand matrix The frozen comparand matrix pinned a list under $eq on the read-scope lowering as accepted: the bind this branch removes. The cell now reads as the module's refusal envelope, with a note that it moved after the matrix's measurement, deliberately. The where door's $eq cell is not this change's and keeps its measured answer. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 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 7a9e210c283bcc26a0353ad6197eb38c7529e3fd && git checkout 7a9e210c283bcc26a0353ad6197eb38c7529e3fd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a666e949c92a43eef9c6b9af413f43127e5ee12c feb810c3d42da65456eaa96f523e465ada3ae743 && git checkout -B drift-repro a666e949c92a43eef9c6b9af413f43127e5ee12c && git merge --no-ff feb810c3d42da65456eaa96f523e465ada3ae743
node scripts/docs-audit/affected-docs.mjs --json a666e949c92a43eef9c6b9af413f43127e5ee12c |
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
The exported compiler now refuses a read scope it compiled before, so the changeset declares Clause-② no (narrowing): minor, a bang on the headline, a BREAKING paragraph naming the refused shape and the $in remedy, and the ADR-0087 not-required disposition marker. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
… the equality slot instead of reading it as `IN` (objectstack-ai#19888) (objectstack-ai#20008) Fixes objectstack-ai#19888 Clause-②: no (narrowing) ## What this changes The analytics `where` door (`lowerAnalyticsWhere` in `packages/services/service-analytics/src/strategies/filter-normalizer.ts`) now refuses a list in the equality slot of an object-form filter, `{ f: [...] }` and `{ f: { $eq: [...] } }`, with `INVALID_FILTER` / 400. This applies ruling 乙 of objectstack-ai#19757 (record `5793368540`: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to the one analytics spelling that never reached the shared face. The new gate, `assertNoListInEqualitySlot`, walks the object-form condition the way the shared face does: `$and` / `$or`, `$not` and field entries, from the same `where` path seed. It hands each equality-slot list to `assertListComparandShapes` (`@objectstack/spec/data`) as a one-entry node. That node holds nothing but the list, so only the face's equality arm can fire, and the refusal is the face's own: its envelope, wording, path and `$in` prescription. The `FilterArray` spelling of the same condition was already refused inside `parseFilterAST`. Both spellings now produce the same bytes, and a test pins this. Every filter source of this door passes through the gate: the caller `where`, a dataset's scope `filter` and a measure's `filter`. That holds on the NativeSQL execute path, the `/analytics/sql` echo and the ObjectQL engine path. The draft-data preview (`preview-evaluator.ts`) calls the same exported gate, so a drafted chart refuses what the published chart refuses. The `in` reading is deleted from `fieldLeaves`. So are the two arms that existed only because of it: the bare-array member sweep in `assertDefinedComparands` and the bare-array guard in `nullGuardForFieldSpec`. Two details: - **Nested relations.** A nested-relation object (`{ acct: { region: [...] } }`) is descended too. The shared face leaves such an object alone, because a driver reads it as a deep-equality comparand or another object's condition. This compiler flattens it to the dotted member `acct.region`, and the list is in that member's equality slot. - **Precedence.** The list is diagnosed before any member gate. `{ d: [1, undefined] }` gets the list refusal, not the undefined-comparand one. The shared face and `read-scope-sql.ts` use the same order. `$ne` with a list is not judged, because ruling 乙 names equality only. The list operators keep their lists, including `$in: []` and `$nin: []`. Every scalar compiles as before, including `null`. ## Measured first (recorded on this branch as `cca9717240`, before any source change) **In-repo stored filters.** An AST scan (TypeScript compiler API) covered every git-tracked `.ts` / `.js` / `.json` file, plus the fenced ts/js/json blocks of md/mdx files. It used four detectors: - D1: `$eq` with an array literal. - D2: a field entry with an array literal under a filter-bearing key (`filter`, `filters`, `where`, `runtimeFilter`, `relatedListFilter`, `having` and five more). The object is walked as a FilterCondition, descending `$and` / `$or` / `$not` and nested relations. - D3: a FilterArray triple on `=` / `==` / `equals` / `eq` carrying an array. - D4: a filter rule `{ field, operator: EQUALITY_SPELLING, value: [...] }`. The positive controls ran on the same walkers: - a synthetic fixture with one case per detector (4/4 hits, in both TS and JSON); - a count of `$in` array literals (C1); - a count of the filter-bearing objects walked (C2). | scope | files | C1 | C2 | hits | |:--|--:|--:|--:|:--| | `examples/**` | 228 (3 tsconfig JSONC unparsed) | 1 (matches the text grep) | 91 | **0** | | `packages/**` | 6910 | 522 | 4636 | 5 in non-test files, **all false positives**: 2 realtime-subscription `eventTypes` lists, 3 MongoDB aggregation-expression `$eq: [a, b]` operands. 65 in tests, all deliberate refusal fixtures or pins. | | md/mdx fenced blocks (content, skills, docs, examples, packages, .changeset) | 1545 files, 3162 blocks | 12 | 176 | 3, **all false positives** (a `nin` rule in a design note; a plugin-permission `filter`) | A text grep for a `$eq` list outside `packages/` and `examples/` found 7 hits, all in changesets that describe the refusal itself. The control (`$in` list) found 140. **No in-repo artefact carries the shape, so nothing needed converting.** Deployed `sys_metadata` rows: NOT MEASURED (there is no deployment data here). **The analytics faces at base**, over a real sql.js engine. The rows are d1 `won`, d2 `lost`, d3 `open`, d4 NULL, d5 the text `'won,lost'`: | `where` | native execute / echo | ObjectQL engine path | draft preview | |:--|:--|:--|:--| | `{ stage: ['won', 'lost'] }` | `stage IN (?, ?)`, rows d1, d2 | the engine received `{ stage: { $in: [...] } }`, so the engine's own shared-face check never saw the list | string-compared the row against `'won,lost'`: d5 | | `{ stage: { $eq: ['won', 'lost'] } }` | `stage = ?` bound to `'won'`: d1, and `'lost'` was dropped without a word | `{ stage: 'won' }` | d5 | | `{ stage: { $eq: [] } }` | **no WHERE at all: every row** | `{}` | no row | | `{ stage: [] }` | the FALSE constant | | | | `['stage', '=' / 'equals' / '==' / 'eq', [...]]` | refused `INVALID_FILTER` / 400 | refused | no row (the preview does not lower an array) | | control `{ stage: { $in: ['won', 'lost'] } }` | d1, d2 | d1, d2 | d1, d2 | After this change, every object-form cell above is refused with `INVALID_FILTER` / 400, carrying the face's message (re-measured at `896e1e70f3`). ## Compile surfaces (a list in the equality slot) | surface | verdict | |:--|:--| | `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree` (analytics caller `where`, dataset scope `filter`, measure `filter`; NativeSQL execute, `/analytics/sql` echo, ObjectQL engine path) | **changed.** Both spellings are refused with `INVALID_FILTER` / 400 at any depth, measured over sql.js before and after. | | `evaluateAnalyticsQueryOverRows` / `matchesWhere` (analytics draft-data preview) | **changed**, as a bounded in-place fix (see Deviations). It used to string-compare the row against the list and now runs the same gate. | | `assertListComparandShapes` (spec shared face) | **already compliant.** This is ruling 乙's own face (objectstack-ai#19882). This PR calls it and does not change it. | | `compileScopedFilterToSql` (service-analytics read scope) | **already compliant.** Since objectstack-ai#19975 (landed `e8f163fc3a`) it refuses both spellings with `READ_SCOPE_COMPILE_FAILED` / 500. Its matrix cells and `read-scope-eq-array-refusal.test.ts` are green in this PR's package run. Not touched. | | `matchesFilterCondition` (formula) | **already compliant**, per the table in PR objectstack-ai#19994 (400 for the implicit list, `$eq` and `$not`). Not re-measured here. | | `applyFilterCondition` (driver-sql) | **already compliant**, per the table in the objectstack-ai#19882 changeset. Not re-measured here. | | `buildWhereSQL` (driver-turso RemoteTransport) | **already compliant**, per the table in PR objectstack-ai#19994. Not re-measured here. | | `checkCondition` (driver-memory) | **already compliant**: 400 at every depth, per the table in the objectstack-ai#19882 changeset. Not re-measured here. | | `translateFieldOperators` (driver-mongodb) | **out of scope.** The driver answers with MongoDB array equality. A platform door reaches it only through the shared face, which refuses the list (the declared scope of objectstack-ai#19882). | | `applyHaving` / `matchesHaving` / `checkCondition` (objectql HAVING) | **out of scope.** This is a caller-authored filter over aggregated rows, a different door from this card's. Its answers are recorded in the objectstack-ai#19886 stage-2a report. | ## Tests and evidence (head `896e1e70f3`) - **New `src/__tests__/where-equality-slot-list-refusal.test.ts`, 59 tests.** Every refusal asserts `code` + `status`. - The `$eq` list at 9 positions: every depth, the empty list, and beside another operator in either key order. - The implicit list at 7 positions, including the empty list and a nested relation. - Byte identity (4): the object spelling equals the FilterArray spelling, which equals the face's own message. - List before member (4). - 12 neighbouring shapes that must compile as before: a scalar, a `Date`, the null predicate, the `$in` remedy, the `$in: []` / `$nin: []` constants, a nested scalar, a `$field` reference, `$between`, and `$ne` (not judged). - Four faces over a real sql.js engine (native execute, echo, ObjectQL engine path, draft preview) × 4 spellings, plus a control. Each is refused before any statement runs and before any `engine.aggregate` call. - A stored dataset through the service doors (6): the dashboard door and the draft preview, for a scope filter and a measure filter; the registered cube on the ObjectQL door; and a control. - **`comparand-door-single-source.test.ts`:** the `array` row's `whereEq` cell is re-judged from `accept` to `INVALID_FILTER/400`, with a note. It had pinned `qty = 'al'` with `'be'` dropped. - **Two existing pins re-judged, not rewritten by rote:** - `filter-normalizer-not-null-safe.test.ts`: the bare `[]` is now refused, and `$in: []` keeps its FALSE constant. - `filter-normalizer-undefined-comparand.test.ts`: the `{ d: [1, undefined] }` row leaves the undefined table, and the `{ d: [1, null] }` row leaves the null control group. Each carries a note: its enclosing shape is now refused whole. - **Package run.** `pnpm --filter @objectstack/service-analytics test`: 117 files and 2541 tests passed (base: 116 files, 2484 tests). `typecheck` exited 0, and `tsc --listFiles` includes all four touched test files. - **Ablations.** Each was run from the committed fix through `scripts/ablation-replace.mjs`, with the red/green count predicted before running. The tests import the source by relative path, so no build is on the path. Each restore was proven by the blob hash matching HEAD and by an empty `git diff HEAD`. - **A: the `in` reading put back.** The gate call in `lowerAnalyticsWhere` was deleted and the old bare-array arm restored in `fieldLeaves`. Predicted 41 red; measured **41 red / 161 green** over the four touched test files: - 9 `$eq`, 7 implicit, 4 byte identity, 4 list-first; - 12 faces: native, echo and engine × 4 spellings. The preview cells stayed green, because the preview runs the gate itself; - 3 stored, 1 matrix `array` where row, 1 bare `[]`. - Sample failures: `native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefined` and `expected 'accept' to be 'INVALID_FILTER/400'`. - **B: the preview's gate call deleted.** Predicted 6; measured **6 red / 53 green**: the four preview face cells and the two stored-dataset preview-door cells. Sample: `draft preview: expected a refusal, got rows: expected [ 'd5' ] to be undefined`. - **Gates.** `dispatch-gates` derived 60 at `896e1e70f3`. The run also covered the dispatch-time list's `check:dispatcher-error-vocabulary`, for 61 in total. 59 exited 0. - **NOT MEASURED (2):** `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3 (PREREQUISITE NOT MET). They need the whole workspace built, which CI does. - `check:lean-entry-closure` exited 3 until objectql's closure was built, then 0. - `--ran` reconciliation: 60 derived, 58 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. - Among the passes: `check-adr-0087-registration --base origin/main` (1 declared-breaking changeset, `not-required (already-registered)`), `check-changeset-no-major`, `check:changeset-gate-self-tests`, `check:nul-bytes`, and `check-issue-citations` in its board-probing mode (19 citations, all of which resolve). - **Lint, narrowed to the change.** `eslint --no-inline-config --format json` over the six touched TypeScript files: 6 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each of them. `eslint.config.mjs` never enables type-aware linting (its note near line 327), so this diff cannot change the verdict on any untouched file. - **Dependents.** `pnpm --filter '...@objectstack/service-analytics'` names 19 downstream packages. The AST scan above found no filter carrying the shape in their sources or tests. Their suites were not run here; CI's affected set runs them. ## Deviations from the dispatch, stated 1. **File surface.** The claim declared `filter-normalizer.ts`, the matrix's `where*` cells, new test files and the changeset. This PR also touches three more files: - `src/preview-evaluator.ts`: one import, one call and comments. This is a bounded in-place fix, and all four conditions hold: it is the same defect class; it is a mechanical call of the same gate, whose shape ruling 乙 pins; no open PR touches this package, per the claim's own reading; and it is the same gate family, with no new verification surface. The claim's file surface needs this path added. - `filter-normalizer-not-null-safe.test.ts` and `filter-normalizer-undefined-comparand.test.ts`: three pins asserted the reading this ruling removes. They are re-judged, with notes (above). 2. **The changeset's ADR-0087 disposition** is `not-required (already-registered filter-equality-array-comparand-refused)`, not the dispatched `not-required (no-migration-prescription)`. A stored dataset, widget or measure filter can carry this shape, because the authoring schema admits it (measured). So the changeset carries a FROM → TO table. The gate refuses `no-migration-prescription` on a body that carries one, and that disposition would also claim that no author has to rewrite anything. The transition itself has been on the ledger since objectstack-ai#19757, and that entry's surface and prescription cover this door verbatim. PR objectstack-ai#19374 used the same disposition for the same situation. The gate accepts it. ## Acceptance notes - **A registry sentence this PR makes false.** The registered entry `filter-equality-array-comparand-refused` (`packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts`, and its copy in `registry.ts`) says that the analytics normalizer's OBJECT form "still reads as membership". That is no longer true. Editing it is a `packages/spec` change, outside this dispatch, so the carrier is the seat's call. - **`$ne` with a list** is not judged (ruling A of objectstack-ai#19886). Measured at `896e1e70f3`: `{ stage: { $ne: ['won', 'lost'] } }` compiles to `stage IS NULL OR stage != 'won'`, so `'lost'` rows are served. Reported to the seat. - **The shared face's other arms.** The object-form door still runs none of them: the null list member, the null ordering comparand, the null or blank `$between` bound, and the scalar `$in`. The FilterArray spelling of each is refused on the same door. This package's own pins hold several of the object-form answers. Reported, not addressed. - **The authoring door.** It still admits the list: `DatasetSchema` with `filter: { stage: ['won', 'lost'] }` parses. The objectstack-ai#19757 changeset declared this. Reported. - **The draft preview does not lower a FilterArray `where`.** It answered no row for `['stage', '=', 'won']` in the probe. Reachability through the dataset door is not established, so this is an observation only. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19975
Clause-②: no (narrowing)
What this changes
compileScopedFilterToSql(packages/services/service-analytics/src/read-scope-sql.ts) lowers a row-level read scope into the SQL that the analytics NativeSQL path executes and the/analytics/sqlecho prints. It already refused a list in the implicit equality slot ({ f: [...] }) withREAD_SCOPE_COMPILE_FAILED/ 500. The explicit spelling,{ f: { $eq: [...] } }, compiled to an equality with the whole list bound as one parameter, which left the meaning of the predicate to the executing database.A new gate,
assertNoListInEqualitySlot, refuses that spelling incompileField, at any depth under$and/$or/$not, in this module's own envelope. It runs before the member gates, so a list is reported as a list and not by one of its members. This applies ruling 乙 (#19757, record5793368540: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to a compiler that never reaches the shared face.Declaration
BREAKING: this narrows what
compileScopedFilterToSql, exported from@objectstack/service-analytics, accepts. A read scope carrying{ f: { $eq: [...] } }compiled before this change and is refused after it. The remedy is{ f: { $in: [...] } }. The changeset ships the narrowing asminorunder the launch-window convention for accept-set narrowings, with a!on its headline, theClause-②: no (narrowing)line and an ADR-0087not-required (no-migration-prescription)disposition: no authorable key, spelling or stored shape moves, and an authored policy never emits this spelling.Measured first
The measurement was recorded on this branch as
c647adb6cc, before any source change. This is an abstract summary; the tests are the pins.$eqonly around a{ $field }reference, so no authored policy produces a list under$eq. The tenant layer, the sharing read filter and the controlled-by-parent filter do not emit$eqat all.$eqreaches it only from a host-suppliedgetReadScopeor from a direct caller of the export, and it compiled.driver-sqland through a plainpgpool. MySQL is NOT MEASURED: there is no server in the container.Deliberate choices
service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 Q2 = A and recorded in this module's header, keeps every refusal of this compiler atREAD_SCOPE_COMPILE_FAILED/ 500 with the message withheld. The scope is a policy the caller cannot author, and a 4xx would echo it back to them. The card's 400 belongs at the policy's authoring door, which is not this file.assertListComparandShapesthrowsINVALID_FILTER/ 400. It also judges more than the equality slot: list-operator shapes, null members, null ordering comparands and$betweenbounds. Calling it here would change other refusals of this compiler, and each of those has its own ruling on this door. The new sentence follows this module's bare-array refusal, so both spellings of the one condition read the same way in the operator's log.$newith a list is not judged. Ruling 乙 names equality only.$nefalls under ruling A of [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 and is handled on that card.Compile surfaces (a list in the equality slot)
compileScopedFilterToSql(service-analytics read scope)$eqis refused. The implicit list was already refused and is now pinned at every depth.assertListComparandShapes(spec shared face)INVALID_FILTER/ 400 for the implicit list, for$eq, and under$not.matchesFilterCondition(formula)INVALID_FILTER/ 400 for the same three shapes (#19886 stage 2a).applyFilterCondition(driver-sql)buildWhereSQL(driver-turso RemoteTransport)checkCondition(driver-memory)translateFieldOperators(driver-mongodb)lowerAnalyticsWhere(analytics callerwhere)INVALID_FILTER/ 400 family), not a read scope. Its object-form$eqlist cell still readsacceptin the frozen comparand matrix. The claim records #19888 against this file.applyHaving/matchesHaving(objectql HAVING)The analytics ObjectQL execute route never calls this compiler. It hands the scope to
engine.aggregate, and the engine's shared-face seam refuses the list withINVALID_FILTER/ 400 (measured). See the acceptance notes.Tests and evidence (head
feb810c3d4, after mergingorigin/mainat276d96dd23)src/__tests__/read-scope-eq-array-refusal.test.ts, 29 tests:$eqlists at every depth, including negation, and beside another operator in either key order;[undefined],[{ $field }]);$inserves exactly the rows it names.read-scope-refusal-envelope.test.ts: inventory row ⑯ added, and the ratchet moves to 16 rows over 14 sites.comparand-door-single-source.test.ts: the frozen matrix's read-scope$eqarray cell changes fromacceptto the refusal, with a note. It pinned exactly the bind this PR removes.pnpm --filter @objectstack/service-analytics test: 116 files and 2484 tests passed.typecheckexited 0, andtsc --listFilesincludes all three touched test files.scripts/ablation-replace.mjs, and each restore was proven by the blob hash matching HEAD.$eqpin, both real-engine faces (zero rows served, and every row served under the negation), and inventory ⑯.dispatch-gatesderives the same 61 atfeb810c3d4as at11c11c7dc3, and all 61 were re-run onfeb810c3d4: 59 exited 0. Two are NOT MEASURED because their prerequisite was not met (check:dual-build-cjs-loadsandcheck:type-check-debtneed the whole workspace built, and CI builds it). Among the 59:check-adr-0087-registration --base origin/main(1 declared-breaking changeset, carrying its disposition),check-changeset-no-major --base origin/main,check:changeset-gate-self-testsandcheck-issue-citationsin its board-probing mode, all exit 0.eslint --no-inline-config --format jsonover the four touched TypeScript files: 4 files, 0 errors, 0 warnings.eslint --print-configresolves a config for each of them.eslint.config.mjsnever enables type-aware linting (its own note, near line 326), so this diff cannot change the verdict on any untouched file.Acceptance notes
$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 lane: draft PR fix(formula, driver-mongodb): refuse == / != against a list literal at the CEL lowering and $ne arrays at the mongodb face #19947 refuses==against a list at the CEL lowering and in the lint. read-scope-sql compiles$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975 needs nothing more from it.INVALID_FILTER/ 400, not this compiler's 500.ae7a35a63b. The dispatch described it as in flight; it was not.