Skip to content

Commit e8f163f

Browse files
fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it (#19994)
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/sql` echo prints. It already refused a list in the implicit equality slot (`{ f: [...] }`) with `READ_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 in `compileField`, 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, record `5793368540`: 「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 as `minor` under the launch-window convention for accept-set narrowings, with a `!` on its headline, the `Clause-②: no (narrowing)` line and an ADR-0087 `not-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. - **Authoring door.** The published RLS policy schema and the RLS authoring lint's decision procedure both admit an equality predicate whose comparand is a list, whether a list literal or a membership variable. - **Lowering.** That predicate lowers to the implicit spelling. The CEL lowering emits `$eq` only 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 `$eq` at all. - **This compiler.** The implicit spelling reaches it through the security service's read filter and is refused (500). A list under `$eq` reaches it only from a host-supplied `getReadScope` or from a direct caller of the export, and it compiled. - **Engines.** On the NativeSQL execute path the bound list got four different answers depending on the engine: a driver error, zero rows, rows the scope never named, and every row when negated. Measured on better-sqlite3 and sql.js through the drivers, and on a local PostgreSQL 16 through `driver-sql` and through a plain `pg` pool. MySQL is NOT MEASURED: there is no server in the container. ## Deliberate choices - **500, not the shared face's 400.** The #5367 ruling, re-affirmed as #7598 Q2 = A and recorded in this module's header, keeps every refusal of this compiler at `READ_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. - **The module's own wording, not a call into the shared face.** `assertListComparandShapes` throws `INVALID_FILTER` / 400. It also judges more than the equality slot: list-operator shapes, null members, null ordering comparands and `$between` bounds. 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. - **`$ne` with a list is not judged.** Ruling 乙 names equality only. `$ne` falls under ruling A of #19886 and is handled on that card. ## Compile surfaces (a list in the equality slot) | surface | verdict | |:--|:--| | `compileScopedFilterToSql` (service-analytics read scope) | **changed.** A list under `$eq` is refused. The implicit list was already refused and is now pinned at every depth. | | `assertListComparandShapes` (spec shared face) | **already compliant.** This is ruling 乙's own face (#19882, landed). Measured: `INVALID_FILTER` / 400 for the implicit list, for `$eq`, and under `$not`. | | `matchesFilterCondition` (formula) | **already compliant.** Measured: `INVALID_FILTER` / 400 for the same three shapes (#19886 stage 2a). | | `applyFilterCondition` (driver-sql) | **already compliant.** It refuses with 400 at the driver and behind the engine's shared-face seam (table in the #19882 changeset; not re-measured here). | | `buildWhereSQL` (driver-turso RemoteTransport) | **already compliant.** 400 according to the compile-face table in the #19886 stage-2a report; not re-measured here. | | `checkCondition` (driver-memory) | **already compliant.** 400 at every depth (table in the #19882 changeset). | | `translateFieldOperators` (driver-mongodb) | **out of scope.** The driver answers with MongoDB array equality. Platform doors reach it only through the shared face, which refuses (the declared scope of #19882). | | `lowerAnalyticsWhere` (analytics caller `where`) | **out of scope.** This is the caller-authored filter door (the `INVALID_FILTER` / 400 family), not a read scope. Its object-form `$eq` list cell still reads `accept` in the frozen comparand matrix. The claim records #19888 against this file. | | `applyHaving` / `matchesHaving` (objectql HAVING) | **out of scope.** A caller-authored filter applied after aggregation, not a read scope. Its answers are recorded in the #19886 stage-2a report. | 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 with `INVALID_FILTER` / 400 (measured). See the acceptance notes. ## Tests and evidence (head `feb810c3d4`, after merging `origin/main` at `276d96dd23`) - New `src/__tests__/read-scope-eq-array-refusal.test.ts`, 29 tests: - `$eq` lists at every depth, including negation, and beside another operator in either key order; - list-before-member precedence (`[undefined]`, `[{ $field }]`); - the implicit list at every depth; - seven neighbouring shapes that must compile unchanged; - the NativeSQL execute face and the echo face over a real sql.js engine. Both refuse, and no statement reaches the engine. The prescribed `$in` serves 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 `$eq` array cell changes from `accept` to 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. `typecheck` exited 0, and `tsc --listFiles` includes all three touched test files. - Ablations. Each was run from the committed fix. The mutation went through `scripts/ablation-replace.mjs`, and each restore was proven by the blob hash matching HEAD. - **A:** removing the gate call turned 18 tests red. These include every `$eq` pin, both real-engine faces (zero rows served, and every row served under the negation), and inventory ⑯. - **B:** making the bare-array arm bind turned 11 tests red. - Gates. `dispatch-gates` derives the same 61 at `feb810c3d4` as at `11c11c7dc3`, and all 61 were re-run on `feb810c3d4`: 59 exited 0. Two are NOT MEASURED because their prerequisite was not met (`check:dual-build-cjs-loads` and `check:type-check-debt` need 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-tests` and `check-issue-citations` in its board-probing mode, all exit 0. - Lint, narrowed to the change. `eslint --no-inline-config --format json` over the four touched TypeScript files: 4 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each of them. `eslint.config.mjs` never enables type-aware linting (its own note, near line 326), so this diff cannot change the verdict on any untouched file. ## Acceptance notes - **Authoring door, implicit spelling.** The authoring-door half of the card for the implicit spelling is work in the #19886 lane: draft PR #19947 refuses `==` against a list at the CEL lowering and in the lint. #19975 needs nothing more from it. - **Adjacent finding, filed by the seat, not addressed here.** The analytics ObjectQL execute route answers a read-scope list with the engine's `INVALID_FILTER` / 400, not this compiler's 500. - **Premise correction.** PR #19882, ruling 乙's shared face, merged at 2026-09-24T14:44Z, before this branch was cut from `ae7a35a63b`. The dispatch described it as in flight; it was not. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 009da14 commit e8f163f

5 files changed

Lines changed: 364 additions & 10 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: the read-scope compiler refuses a list under `$eq` instead of binding it (#19975)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a compile-time refusal in the read-scope compiler: no authorable key, spelling or stored shape moves, and an authored policy never emits this spelling (the CEL lowering writes `$eq` only around a `{ $field }` reference), so a stored `sys_metadata` row needs no conversion. The remedy for a host-supplied read scope is to write the list under `$in`. -->
10+
11+
**BREAKING**: this narrows what `compileScopedFilterToSql`, exported from `@objectstack/service-analytics`, accepts. A read scope carrying `{ field: { $eq: [...] } }` compiled before this change and is refused after it. It ships as `minor` under the launch-window convention for accept-set narrowings. The remedy is `{ field: { $in: [...] } }` for "one of these values".
12+
13+
`compileScopedFilterToSql` compiles a row-level read scope into the SQL the analytics NativeSQL path and the `/analytics/sql` echo run. It already refused a list in the implicit equality slot (`{ field: [...] }`). The explicit spelling, `{ field: { $eq: [...] } }`, was compiled to an equality with the whole list bound as one parameter, so what the scope selected depended on how the executing database read a list, not on what the scope said.
14+
15+
It is now refused, at any depth under `$and` / `$or` / `$not`, with the envelope every other refusal of this compiler carries: `READ_SCOPE_COMPILE_FAILED` / 500, with the message kept for the server log. This applies ruling 乙 of #19757, which the shared comparand-shape face in `@objectstack/spec` already enforces, to a compiler that face never sees. `$ne` with a list is not part of that ruling and is not judged here.
16+
17+
No policy authored as metadata produces this shape. The refusal therefore reaches only a host-supplied `getReadScope` or a direct caller of `compileScopedFilterToSql`. A scalar, `null` or a `Date` under `$eq` compiles exactly as before, and a `{ $field }` reference there keeps its existing answer.

‎packages/services/service-analytics/src/__tests__/comparand-door-single-source.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,15 @@ const MATRIX: readonly Row[] = [
150150
bindable: false, renderable: false,
151151
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK,
152152
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: OK },
153+
// [#19975] One cell of this row moved AFTER the #8186 measurement, on
154+
// purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the
155+
// read-scope lowering now refuses it under `$eq` instead of binding the list
156+
// as one parameter (`read-scope-eq-array-refusal.test.ts`). The `where`
157+
// door's `$eq` cell is not that change's and keeps its measured answer.
153158
{ label: 'array', value: ['al', 'be'],
154159
bindable: false, renderable: false,
155160
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK,
156-
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: OK },
161+
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
157162
// A `$field` scalar comparand is SERVED on the `where` door since the
158163
// 2026-08-12 ruling (NativeSQLStrategy declines, the engine path runs it) and
159164
// refused by the read-scope lowering, which cannot honestly render it (#7598).
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19975] The read-scope compiler never binds a LIST into an equality — in
5+
* either spelling of the equality slot, at any depth.
6+
*
7+
* Ruling 乙 on #19757 refuses a list in the equality slot at the shared
8+
* comparand-shape face, for every driver at once. `compileScopedFilterToSql`
9+
* never meets that face (a read scope arrives through `getReadScope`), so the
10+
* ruling is pushed down to it here:
11+
*
12+
* - the EXPLICIT spelling, `{ f: { $eq: [...] } }`, used to compile to
13+
* `col = ?` with the whole list bound as one parameter, and what that
14+
* predicate selected was the executing engine's reading of a list. It is
15+
* now refused ({@link assertNoListInEqualitySlot} in `read-scope-sql.ts`);
16+
* - the IMPLICIT spelling, `{ f: [...] }` — the shape a CEL `field == <list>`
17+
* policy lowers to — was already refused; the depth pins below hold it.
18+
*
19+
* Both refusals carry this module's envelope, `READ_SCOPE_COMPILE_FAILED` / 500
20+
* (the #5367 ruling), never the shared face's `INVALID_FILTER` / 400: the
21+
* producer is a policy the caller cannot author.
22+
*
23+
* Four blocks: the explicit spelling at every depth, the implicit spelling at
24+
* every depth, the neighbouring shapes that must keep compiling exactly as
25+
* before, and the two strategy faces that reach this compiler, driven over a
26+
* real engine — where the refusal must land before any statement runs.
27+
*/
28+
29+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
30+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
31+
import type { Cube, FilterCondition } from '@objectstack/spec/data';
32+
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';
33+
34+
import { compileScopedFilterToSql } from '../read-scope-sql.js';
35+
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
36+
import { ObjectQLStrategy } from '../strategies/objectql-strategy.js';
37+
38+
interface Refusal extends Error {
39+
code?: unknown;
40+
status?: unknown;
41+
}
42+
43+
function refusalOf(scope: unknown): Refusal {
44+
try {
45+
const out = compileScopedFilterToSql(scope as FilterCondition, 't');
46+
throw new Error(`expected a refusal, but the scope compiled to ${JSON.stringify(out)}`);
47+
} catch (e) {
48+
return e as Refusal;
49+
}
50+
}
51+
52+
function expectEnvelope(err: Refusal): void {
53+
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
54+
expect(err.status).toBe(500);
55+
}
56+
57+
// ─────────────────────────────────────────────────────────────────────────────
58+
59+
describe('[#19975] a list under $eq is refused, never bound', () => {
60+
const CASES: Array<[string, unknown]> = [
61+
['two members', { status: { $eq: ['open', 'pending'] } }],
62+
['one member', { status: { $eq: ['open'] } }],
63+
['the empty list', { status: { $eq: [] } }],
64+
['beside another operator, listed first', { status: { $eq: ['open'], $ne: 'closed' } }],
65+
['beside another operator, listed last', { status: { $ne: 'closed', $eq: ['open'] } }],
66+
['under $and', { $and: [{ team_id: 't1' }, { status: { $eq: ['open'] } }] }],
67+
['under $or', { $or: [{ team_id: 't1' }, { status: { $eq: ['open'] } }] }],
68+
// The widening direction: a negated equality that can never hold is
69+
// constant TRUE, so this is the spelling that used to admit every row.
70+
['under $not', { $not: { status: { $eq: ['open'] } } }],
71+
['under $not over $or', { $not: { $or: [{ team_id: 't1' }, { status: { $eq: ['open'] } }] } }],
72+
];
73+
74+
for (const [name, scope] of CASES) {
75+
it(`${name}: READ_SCOPE_COMPILE_FAILED / 500, naming the field and $eq`, () => {
76+
const err = refusalOf(scope);
77+
expectEnvelope(err);
78+
expect(err.message).toContain('"status".$eq');
79+
});
80+
}
81+
82+
it('a list is diagnosed as the list, not by one of its members', () => {
83+
// The member gates (#6125's undefined comparand, #7598's field reference)
84+
// would otherwise answer first and send the operator to the wrong repair.
85+
for (const scope of [
86+
{ status: { $eq: [undefined] } },
87+
{ status: { $eq: [{ $field: 'prior_status' }] } },
88+
]) {
89+
const err = refusalOf(scope);
90+
expectEnvelope(err);
91+
expect(err.message).toContain('"status".$eq');
92+
expect(err.message).not.toMatch(/is undefined|field reference/);
93+
}
94+
});
95+
});
96+
97+
describe('[#19975] the implicit spelling — what a CEL `field == <list>` lowers to — stays refused at every depth', () => {
98+
const CASES: Array<[string, unknown]> = [
99+
['top level', { status: ['open', 'pending'] }],
100+
['the empty list', { status: [] }],
101+
['under $and', { $and: [{ team_id: 't1' }, { status: ['open'] }] }],
102+
['under $or', { $or: [{ team_id: 't1' }, { status: ['open'] }] }],
103+
['under $not', { $not: { status: ['open'] } }],
104+
];
105+
106+
for (const [name, scope] of CASES) {
107+
it(`${name}: READ_SCOPE_COMPILE_FAILED / 500, as a bare array`, () => {
108+
const err = refusalOf(scope);
109+
expectEnvelope(err);
110+
expect(err.message).toContain('bare array value for "status"');
111+
});
112+
}
113+
});
114+
115+
describe('[#19975] the neighbouring shapes keep compiling exactly as before', () => {
116+
const ACCEPTED: Array<[string, unknown, string, unknown[]]> = [
117+
['a scalar under $eq', { status: { $eq: 'open' } }, '"t"."status" = ?', ['open']],
118+
['a number under $eq', { amount: { $eq: 0 } }, '"t"."amount" = ?', [0]],
119+
['null under $eq — the has-no-value predicate', { status: { $eq: null } }, '"t"."status" IS NULL', []],
120+
['the implicit scalar', { status: 'open' }, '"t"."status" = ?', ['open']],
121+
['a list under $in — the prescribed spelling', { status: { $in: ['open', 'pending'] } }, '"t"."status" IN (?, ?)', ['open', 'pending']],
122+
['the empty $in — the FALSE constant (#5243)', { status: { $in: [] } }, '1 = 0', []],
123+
];
124+
125+
for (const [name, scope, sql, params] of ACCEPTED) {
126+
it(name, () => {
127+
const out = compileScopedFilterToSql(scope as FilterCondition, 't');
128+
expect(out.sql).toBe(sql);
129+
expect(out.params).toEqual(params);
130+
});
131+
}
132+
133+
it('a Date under $eq still binds', () => {
134+
const at = new Date('2026-01-01T00:00:00.000Z');
135+
const out = compileScopedFilterToSql({ created_at: { $eq: at } } as FilterCondition, 't');
136+
expect(out.sql).toBe('"t"."created_at" = ?');
137+
expect(out.params).toEqual([at]);
138+
});
139+
});
140+
141+
// ─────────────────────────────────────────────────────────────────────────────
142+
143+
const OBJECT = 'ticket';
144+
const ROWS = [
145+
{ id: 'r1', status: 'open' },
146+
{ id: 'r2', status: 'pending' },
147+
{ id: 'r3', status: 'closed' },
148+
{ id: 'r4', status: null },
149+
];
150+
const CUBE: Cube = {
151+
name: 'tickets',
152+
sql: OBJECT,
153+
measures: { n: { sql: '*', type: 'count', title: 'n' } },
154+
dimensions: Object.fromEntries(
155+
['id', 'status'].map((n) => [n, { name: n, label: n, type: 'string', sql: n }]),
156+
),
157+
public: false,
158+
} as unknown as Cube;
159+
const QUERY = { cube: 'tickets', dimensions: ['id'], measures: ['n'] } as AnalyticsQuery;
160+
161+
describe('[#19975] both faces that reach the compiler refuse before any statement runs (real engine)', () => {
162+
let driver: SqliteWasmDriver;
163+
let statements = 0;
164+
165+
const runRawSql = async (sql: string, params: unknown[]): Promise<Record<string, unknown>[]> => {
166+
statements++;
167+
const result = await driver.execute(sql.replace(/\$\d+/g, '?'), params);
168+
if (Array.isArray(result)) return result as Record<string, unknown>[];
169+
if (result && typeof result === 'object' && 'rows' in (result as Record<string, unknown>)) {
170+
return (result as { rows: Record<string, unknown>[] }).rows;
171+
}
172+
return [];
173+
};
174+
175+
const ctxFor = (scope: unknown, nativeSql: boolean): StrategyContext =>
176+
({
177+
getCube: (name: string) => (name === 'tickets' ? CUBE : undefined),
178+
queryCapabilities: () => ({ nativeSql, objectqlAggregate: !nativeSql, inMemory: false }),
179+
getReadScope: () => (scope ?? undefined) as FilterCondition | undefined,
180+
executeRawSql: (_object: string, sql: string, params: unknown[]) => runRawSql(sql, params),
181+
sqlDialect: () => 'sqlite',
182+
}) as unknown as StrategyContext;
183+
184+
/** NativeSQL EXECUTE: `applyReadScope` → `ctx.executeRawSql`. */
185+
const nativeIds = async (scope: unknown): Promise<string[]> => {
186+
const result = await new NativeSQLStrategy().execute(QUERY, ctxFor(scope, true));
187+
return result.rows.map((r) => String(r.id)).sort();
188+
};
189+
190+
/** The `/analytics/sql` echo, its SQL then run on the same engine. */
191+
const echoIds = async (scope: unknown): Promise<string[]> => {
192+
const { sql, params } = await new ObjectQLStrategy().generateSql(QUERY, ctxFor(scope, false));
193+
const rows = await runRawSql(sql, params);
194+
return rows.map((r) => String(r.id)).sort();
195+
};
196+
197+
const FACES = [
198+
['native execute', nativeIds],
199+
['echo', echoIds],
200+
] as const;
201+
202+
beforeAll(async () => {
203+
driver = new SqliteWasmDriver({ filename: ':memory:' });
204+
await driver.initObjects([
205+
{
206+
name: OBJECT,
207+
fields: { id: { type: 'text', name: 'id' }, status: { type: 'text', name: 'status' } },
208+
} as never,
209+
]);
210+
for (const row of ROWS) await driver.create(OBJECT, { ...row });
211+
});
212+
213+
afterAll(async () => {
214+
await driver?.disconnect?.();
215+
});
216+
217+
it('CONTROL: no scope serves every row, and the prescribed $in serves exactly the named rows', async () => {
218+
// Without this, the refusals below could pass on a harness that serves nothing.
219+
for (const [face, run] of FACES) {
220+
expect(await run(null), `${face}: no scope`).toEqual(['r1', 'r2', 'r3', 'r4']);
221+
expect(await run({ status: { $in: ['open', 'pending'] } }), `${face}: $in`).toEqual(['r1', 'r2']);
222+
}
223+
});
224+
225+
for (const [name, scope] of [
226+
['a list under $eq', { status: { $eq: ['open', 'pending'] } }],
227+
['a list under $eq, negated', { $not: { status: { $eq: ['open'] } } }],
228+
['the implicit list', { status: ['open', 'pending'] }],
229+
] as const) {
230+
for (const [face, run] of FACES) {
231+
it(`${face}: ${name} is refused, and no statement reaches the engine`, async () => {
232+
statements = 0;
233+
let err: Refusal | undefined;
234+
let served: string[] | undefined;
235+
try {
236+
served = await run(scope);
237+
} catch (e) {
238+
err = e as Refusal;
239+
}
240+
expect(served, `${face}: expected a refusal, got rows`).toBeUndefined();
241+
expect(err).toBeInstanceOf(Error);
242+
expectEnvelope(err as Refusal);
243+
expect(statements).toBe(0);
244+
});
245+
}
246+
}
247+
});

‎packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ function refusalFor(filter: unknown, alias = 'crm_opportunity'): Refusal | undef
9191
}
9292

9393
/**
94-
* Every refusing site in `read-scope-sql.ts`, in source order.
94+
* Every refusing site in `read-scope-sql.ts`, in source order — except row ⑯,
95+
* appended when it was added (its row says where it runs).
9596
*
96-
* FIFTEEN rows over THIRTEEN throw sites: TWO sites are each reached by two
97+
* SIXTEEN rows over FOURTEEN throw sites: TWO sites are each reached by two
9798
* triggers, and every trigger is listed on purpose.
9899
*
99100
* - `quoteIdent`, with two `kind` values. That alias-vs-field split was option
@@ -233,6 +234,18 @@ const REFUSALS: Array<{
233234
message: /unsupported operator "\$regex" on "owner_email" \(fail-closed\)/,
234235
sensitive: 'owner_email',
235236
},
237+
{
238+
// [#19975] Ruling 乙 (#19757) pushed down to this compiler: the explicit
239+
// spelling of the equality slot, whose implicit spelling is row ⑨. Listed
240+
// last because it was added last; in `compileField` it runs FIRST, ahead
241+
// of row ⑥, so a list under `$eq` is diagnosed as the list and never by
242+
// one of its members.
243+
name: '⑯ a list under $eq',
244+
site: 'compileField: list in the equality slot',
245+
filter: { region_code: { $eq: ['emea', 'apac'] } },
246+
message: /array value for "region_code"\.\$eq — an equality compares one value, so a list is refused rather than bound; use \{ \$in: \[\.\.\.\] \} \(fail-closed\)/,
247+
sensitive: 'region_code',
248+
},
236249
];
237250

238251
/**
@@ -323,14 +336,15 @@ describe('[#5367] every read-scope refusal carries the ADR-0112 envelope (READ_S
323336
// #5352's lesson, stated as a guard: seven of `filter-normalizer.ts`'s nine
324337
// sites carrying an envelope was indistinguishable from none of them at the
325338
// HTTP boundary, because the commonest input hit one of the two bare ones.
326-
// Fifteen inputs over the module's THIRTEEN throw sites (see the table's
339+
// Sixteen inputs over the module's FOURTEEN throw sites (see the table's
327340
// note on the two sites with two triggers each), and every one of them
328-
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth, and
329-
// [#13571] the thirteenth (the empty-`$nin` refusal); these two numbers
330-
// are the ratchet that makes a future unenveloped `throw` fail HERE instead
331-
// of at an HTTP boundary.
332-
expect(REFUSALS).toHaveLength(15);
333-
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(13);
341+
// enveloped. [#6125] added the eleventh site, [#6387] the twelfth,
342+
// [#13571] the thirteenth (the empty-`$nin` refusal) and [#19975] the
343+
// fourteenth (a list under `$eq`); these two numbers are the ratchet that
344+
// makes a future unenveloped `throw` fail HERE instead of at an HTTP
345+
// boundary.
346+
expect(REFUSALS).toHaveLength(16);
347+
expect(new Set(REFUSALS.map((c) => c.site)).size).toBe(14);
334348
for (const c of REFUSALS) {
335349
expect(refusalFor(c.filter, c.alias)?.code, `${c.site} is still bare`).toBe('READ_SCOPE_COMPILE_FAILED');
336350
}

0 commit comments

Comments
 (0)