Skip to content

Commit 980bc05

Browse files
fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse (#20046)
Fixes #20018 Clause-②: no (narrowing) ## What changed `compileScopedFilterToSql` (`packages/services/service-analytics/src/read-scope-sql.ts`) is the read-scope lowering behind two analytics faces: - the NativeSQL execute face, `NativeSQLStrategy.applyReadScope`, for the base table and every joined hop; - the `/analytics/sql` echo, `ObjectQLStrategy.generateSql`. It is also a public export of the package. Once its own lowering returns, it now calls `assertReadScopeComparandsRunnable` on the scope. That is the helper PR #20017 added in the same module, and it runs `@objectstack/spec/data`'s `assertListComparandShapes` and `normalizeFilterComparandTypes` on the scope alone. A scope those faces refuse now gets `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld (the #5367 ruling, re-affirmed as #7598 Q2 = A) on the native face and the echo. The refusal comes before any statement is built or executed. That is the answer the ObjectQL execute face has given since PR #20017, so one read scope gets one verdict on all three analytics faces. - **Files:** the call itself is one line. The rest of the diff is: - the module-header section (#20018); - a note on the helper's docblock; - a one-paragraph note on the binary extra in `comparand-shape.ts`, whose "accepted in every bind position" is no longer true of the read-scope door; - tests and the changeset. - **Not touched:** `objectql-strategy.ts` and `native-sql-strategy.ts`. Both faces reach the guard through the compiler, so neither needs a call site of its own. - **Also not touched:** `filter-normalizer.ts`, `preview-evaluator.ts` and `packages/spec`. ## Measurement, recorded before the fix Everything in this section was measured on `9d81af714f` (`origin/main`, pre-fix). The rows are **executed**, not read from the compiled string. - **Harness:** a scratch probe that is not committed, plus measurement commit `8c80d9c3d2` (the new test file alone). - **Database:** one real `SqliteWasmDriver` with four fixture rows. `region` is NULL on d3, and `owner` and `amount` are NULL on d4. - **ObjectQL face:** a real `ObjectQL` engine. - **Echo:** its SQL was run on the same database. - **Native:** `NativeSQLStrategy.execute` through `executeRawSql` on the same database. It was not stubbed. - **Scopes:** the `getReadScope` contract, filled by hand. | read-scope shape class | ObjectQL execute | echo (SQL executed) | native execute | |:--|:--|:--|:--| | plain-object comparand under `$eq` *(the card's first shape)* | `READ_SCOPE_COMPILE_FAILED` / 500 | compiles; the database refuses the bind | `DATABASE_ERROR` / 500 | | null member in `$in`, `['emea', null]` *(the card's second shape)* | 500 | d1 | d1: the NULL member matches nothing | | null-only `$in` | 500 | no rows | no rows | | null member in `$in` under `$not` | 500 | d3 | d3: **only** the NULL row, which the scope names as excluded | | null member in `$nin` | 500 | d3 | d3: **only** the NULL row, which the scope names as excluded | | null comparand under `$gt` / `$lte` | 500 | no rows | no rows | | null `$between` bound | 500 | no rows | no rows | | blank `$between` bound | 500 | d1 d2 d4 | d1 d2 d4 | | plain-object comparand under `$ne` / `$gt`; empty object under `$eq` | 500 | compiles; DB refuses | `DATABASE_ERROR` / 500 | | bigint beyond 2^53 (implicit, `$in`) | 500 | no rows | no rows | | binary comparand, binary `$in` member | 500 | no rows | no rows | | `Map` or function comparand | 500 | compiles; DB refuses | `DATABASE_ERROR` / 500 | | controls (9 well-formed scopes, including the null predicates and the live RLS composite) | the same rows on all three faces | | | The card named two shapes. The measurement found the gap is every shape the two shared faces refuse and this compiler's own gates did not: null list members and bounds, null ordering comparands, blank bounds, oversized bigints, binary, and non-scalar objects in a scalar position. That is the set that moves. ### The mechanism hypotheses - **Hypothesis 1 is confirmed, and the set is wider than the card's two shapes.** These are the compile arms that accepted them: - ``case '$eq': return val === null ? … : `${col} = ${bind(params, val)}`;``. A plain object is bound, and no gate judges a `$eq` comparand's type. `assertNoFieldReferenceComparand` steps past an object that is not `{ $field: string }`. - `case '$in'` → `assertCompilableMembers(op, field, val)` → `isBindableComparand(member)`, which admits `null` (an accepted comparand type) and binary (a package-local extra), then `IN (…)` binds each member. - The ordering arms and `$between` bind whatever passes those gates. The implicit-equality arm binds any non-object. - **Hypothesis 2: measured.** See the table above, and the producers section below. - **Hypothesis 3 is confirmed in verdict, but the placement is better than "at the entry".** The two walks are pure functions of the scope, so the placement cannot change which scopes are refused, only which log sentence a doubly-refused scope carries. - Ablation A2 below put the call at the entry and turned 37 tests red in 7 files: the new ordering pin, plus 36 existing log-sentence pins, for example `read-scope-eq-array-refusal` (15) and `read-scope-undefined-comparand` (8). - After the lowering, every shape this compiler already refused keeps its own sentence (the #13926 ordering), and the faces add only what would otherwise have been lowered. ### Producers: who can emit these shapes today All readings below are on `9d81af714f`. | producer | emits a refused shape? | evidence | |:--|:--|:--| | RLS `using` predicates, through `compileCelToFilter` → `RLSCompiler.compileFilter` | **yes, from an authored predicate.** `f in ['a', null]`, `f in [null]`, `!(f in ['a', null])`, `f > null`, `f <= null` and `f != current_user` each lower verbatim into a refused shape. | Scratch probe. All six pass `isSupportedRlsExpression`, and `validateRlsPredicateEnforceability` returns 0 findings for each. | | authored policies in this repository | **none** | `git grep` of `using` / `check` predicates for a null list member or a null ordering comparand, over `examples/**` and `packages/**/*.ts` (tests excluded): 0 matches, exit 1. The control on the same tree and file set, predicates naming `current_user`, matched 77 lines in 7 files, exit 0. | | a resolved membership variable with a null member | no | The #13496 guard. Measured: `f in current_user.teams` with a null member lowers to the deny sentinel. | | `plugin-sharing` `buildReadFilter` | no | Owner ids are `String(userId)` or a resolver's `string[]`. `grantedRecordIds` filters out `null` and `''`. | | a host `getReadScope` option, or a direct caller of the export | anything | The door the card names. | Verdict: **no producer both legitimately authors one of these shapes and relies on the native answer.** Three facts support that: - The in-repo producer emits these shapes only from predicates that the standing rulings, carried by the shared faces, refuse. No policy in this repository is one of them. - The ObjectQL analytics face already refused every such scope. - The native answer was not the predicate's meaning. It dropped the null member, admitted only the NULL rows the scope excludes, returned zero rows, or hit a database error. So this is execution under the rulings, not a `needs_decision`. The authoring half is reported separately as a finding; see the Acceptance notes. ## Tests The new file is `packages/services/service-analytics/src/__tests__/read-scope-comparand-three-faces.test.ts`, with 32 cases. It uses one `SqliteWasmDriver`, a real `ObjectQL` behind the ObjectQL face, and the echo's SQL executed. - **13 refusal classes.** Each asserts on all three faces: `code` `READ_SCOPE_COMPILE_FAILED`, `status` 500, and the prose withheld. "Withheld" means `serverFaultProvenance(resolveThrownHttpError(err, 500))` is `'declared'` and `declaredRefusalMessage(err)` is undefined. - **The native face refuses before any statement reaches the database.** Zero `executeRawSql` calls across all 13 classes. - **The joined hop.** `applyReadScope`'s per-hop lowering refuses a joined object's scope, named for that hop. - **The public export** refuses every class, and a well-formed scope compiles to the same bound predicate as before. - **Log sentences.** A shape the compiler already refused, a list under `$eq`, keeps its own sentence. A shape only the faces refuse carries their sentence, the same on all three faces. - **Controls:** - with no scope, every face serves all four rows; - 9 well-formed scopes admit the same rows on every face. They include `{ region: null }`, `$ne: null`, a non-empty `$nin`, the live RLS composite with an emptied `$in` beside an own-rows grant, and the spelling the null-member ruling prescribes (`$or` of `$in` and `$null: true`); - a well-formed caller `where` composes with a well-formed scope; - a caller `where` in a refused scope shape answers exactly as it does with no scope, and is never attributed to the read scope. **Existing pins that asserted the lowering binds a refused shape.** Each is re-judged with a `[#20018]` note, and each keeps what it was there to pin: - `comparand-door-single-source.test.ts`. Three matrix cells now read `READ_SCOPE_COMPILE_FAILED/500`: `null` under `scopeIn`, `binary` under `scopeIn` and `scopeEq`, and `plain object` under `scopeEq`. This PR moves no `where`-door cell. - After merging #20032 (#20010), the `null` row carries both re-judged cells: `whereIn` is `INVALID_FILTER/400` (#20010) and `scopeIn` is `READ_SCOPE_COMPILE_FAILED/500` (this PR). - The "six accepted types, every position" check names each moved cell with the change that moved it, and holds every other cell at `accept`. - `comparand-shape-refusal.test.ts`. "Keeps binding every legitimate `$in` member" drops `null`, and a new case pins `null` refused with the face's sentence. - `cross-field-reference-refusal.test.ts`. `{ $gt: { $field: 5 } }` is refused as a plain object, with the type face's sentence and not the field-reference gate's. - `read-scope-boolean-flag-comparand.test.ts`. An inherited `$null` still cannot trip the flag gate, because the refusal carries no flag sentence. The object is refused by the type face as a non-plain value. - `read-scope-undefined-comparand.test.ts`. `$in: [null]`, `$nin: [null]` and `$between: [null, 5]` leave the null control group for their own "refused by the shared face" block. Every null predicate stays in the group, unmoved. **Results:** | run | tree | result | |:--|:--|:--| | new file, measurement commit (test only) | `8c80d9c3d2` | `Tests 17 failed \| 15 passed (32)`. Every refusal class and the three pins built on them are red; every control is green. The first face to fail in each row is the echo; the ObjectQL face passed first. | | whole package | `0fbed27877` (final; `origin/main` `adbbc5d01e` merged) | `Test Files 120 passed (120)`, `Tests 2689 passed (2689)` | | `pnpm --filter @objectstack/service-analytics typecheck` | `0fbed27877` | exit 0; `tsc --noEmit --listFiles` includes the new test file | **Ablations.** Both ran through `node scripts/ablation-replace.mjs` in WRAP mode (the anchor must hit exactly once, the blob must change, and the tool's own trap restores). The subject resolves to `src/` through relative imports, so no `dist/` leg applies. Each restore was proven: blob `34705e267dba` equals `HEAD`, and `git diff HEAD` is empty. A1 was re-run on the final head `0fbed27877`. A2 ran on `f293340e94`. `read-scope-sql.ts` is the same blob, `34705e267dba`, on both trees (neither merge touched it), so A2's placement result stands. | leg | mutation | result (whole package) | |:--|:--|:--| | A1 (at `0fbed27877`) | delete the new `assertReadScopeComparandsRunnable(filter, alias);` call | `26 failed \| 2663 passed (2689)`: all 17 negative pins in the new file, plus the 9 re-judged pins (3, 1, 1, 1 and 3 across the five files). Every control stayed green. The same 17 + 9 went red at `f293340e94` (`26 failed \| 2567 passed (2593)`). | | A2 (at `f293340e94`) | call it BEFORE `compileNode` instead of after | `37 failed \| 2556 passed (2593)`: the new ordering pin, plus 36 existing log-sentence and precedence pins in 6 files | The first attempt at A2 was a void run. Its replacement text contained its anchor, so the tool refused with "anchor count moved 1 -> 1" and restored. Nothing was measured. It was re-run with a three-line anchor, and that is the result above. ## Gates **Derived gates.** Derived at `0fbed27877` with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 61 families, not stale. They are a superset of the 48-line dispatch-time list. - **59 exited 0.** - **`check:dual-build-cjs-loads` and `check:type-check-debt`** first answered `PREREQUISITE NOT MET` (exit 3), because the workspace had no `dist/`. After building every `./packages/**` workspace package (`VERDICT command-exit 0`), both exited 0: `check:dual-build-cjs-loads` passes its floors, and `check:type-check-debt` re-measured 4 ledger entries (53 raw tsc errors) with none above its recorded number. - **The `--ran` reconciliation:** 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN. That zero is derived, not claimed: all 61 records carry an exit code and none is 3. **Other checks:** - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: exit 0; 8 citations judged, 8 resolve. - `node scripts/check-adr-0087-registration.mjs --base origin/main` (one of the derived families, at `0fbed27877`): exit 0. The changeset is `BREAKING+bang+clause-②-narrowing`, disposition `not-required (no-migration-prescription)`. - **Lint, narrowed to the 8 touched TypeScript files** with `eslint --no-inline-config --format json` at `0fbed27877`: 8 files read, 0 errors, 0 warnings, none ignored. - The changeset is outside eslint's population ("no matching configuration"). - `eslint.config.mjs` enables no type-aware linting: its `parserOptions` carry only `ecmaVersion` and `sourceType`, with no `project` and no `projectService`. So this diff cannot move a verdict on an untouched file. - `pnpm lint` itself is CI's. ## Acceptance notes - **BREAKING, `minor` with `!`.** Read scopes the native face and the echo used to serve are refused now; the table above lists what they served. The changeset carries the banner and the fix spellings: the null predicate beside a membership, `$gte` / `$lte` for a one-sided range, and a scalar comparand. - **The refusal set is the shared faces' set, not only the card's two shapes.** Judging the scope with the same function the ObjectQL face uses is what makes the verdicts equal. A narrower hand-picked subset would have left the other rows of the table as "one scope, two answers". - **Binary.** The package-local binary bindable (`isBindableComparand`) no longer reaches the read-scope door. The shared type face refuses binary, and the ObjectQL face already did. The `where` door and the predicate itself are unchanged. - **The joined-hop log sentence names the alias.** `compileScopedFilterToSql` knows only the alias, so the operator's log reads `read scope for "ALIAS"`: the object name on the base table, the join alias on a hop. The response withholds it either way. - **Precedence on the native face.** When a scope carries both a shape this compiler refuses and one only the faces refuse, the compiler's sentence answers. The verdict is the same either way. - **The CRUD path is not an analytics face and was not measured here.** The security middleware composes the same RLS filter into the engine's `where` after the engine's shared-face seam has run. What `driver-sql` answers for these shapes there is outside this card. - **Finding, class (c), not fixed here, for the seat to file.** The RLS authoring surface admits predicates that lower into scope shapes the shared faces refuse: a literal `null` in an `in` list, an ordering comparison against `null`, or a comparison against the whole `current_user` object. All three pass `isSupportedRlsExpression`, and `validateRlsPredicateEnforceability` returns no finding for them. They are refused (500) on every analytics face after this PR. The evidence is in the producers table. - **`origin/main` was merged in twice.** - At `b3735968ba`: three commits in `packages/objectql` and `packages/plugins/plugin-security`, sharing no path with this diff. - At `adbbc5d01e`: #20032 (#20010) and a driver-sql / driver-turso fix. That merge had one content conflict, in the comparand matrix, resolved as above. - `comparand-shape-refusal.test.ts` and `cross-field-reference-refusal.test.ts` auto-merged and were re-read on the merged tree. #20032's edits there are to the `where`-door pins and this PR's are to the read-scope pins, and their notes agree: the null member is refused by the same ruling at each door, each in its own envelope. One sentence in the non-string `$field` case ("it binds as JSON") is now scoped to the `where` door, where it still holds. - The dependency closure was rebuilt before the final runs. - **Files not touched:** `filter-normalizer.ts`, `preview-evaluator.ts`, `objectql-strategy.ts`, `native-sql-strategy.ts`, `packages/spec`. - **#19995** stays open behind #20020, for its engine- and driver-door residue. This PR does not address it. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57c2b73 commit 980bc05

9 files changed

Lines changed: 691 additions & 31 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: the NativeSQL execute face and the `/analytics/sql` echo refuse a read scope the shared comparand faces refuse, as the ObjectQL execute face already does (#20018)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: `packages/spec` is untouched, and the shapes refused here are ones the spec's shared comparand faces (`assertListComparandShapes`, `normalizeFilterComparandTypes`) already refuse on every object-form `where` and, since #19995, on the ObjectQL analytics face. What changes is which runtime face refuses a read scope, so `objectstack migrate meta` has nothing to act on and the ledger has no row to gain. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a read-scope comparand shape (not `registered` / `already-registered`); and the change is runtime behaviour of a function, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING** — an accept-set narrowing on the read-scope lowering, shipped as
12+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
13+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
14+
disposition above, not by the level).
15+
16+
**What changed.** `compileScopedFilterToSql` is the read-scope lowering behind the
17+
NativeSQL execute face (`NativeSQLStrategy.applyReadScope`, base table and every
18+
joined hop) and the `/analytics/sql` echo (`ObjectQLStrategy.generateSql`), and a
19+
public export of this package. Once its own lowering succeeds, it now runs the two
20+
shared comparand faces of `@objectstack/spec/data` on the scope. A scope they
21+
refuse is refused as `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld
22+
(the #5367 envelope), before any statement is built or executed. That is the
23+
answer the ObjectQL execute face has given the same scope since #19995, so one read
24+
scope now gets one verdict on every analytics face.
25+
26+
**Which read scopes stop being served.** Each was lowered and executed before, and
27+
each is refused by a standing ruling the shared faces carry. Measured on SQLite:
28+
29+
| read-scope shape | what the native face and the echo served |
30+
| --- | --- |
31+
| a `null` member of `$in` | only the named non-null values; the NULL matched nothing |
32+
| a `null` member of `$in` under `$not`, or of `$nin` | only the rows whose column is NULL, which the scope excludes |
33+
| a `null` comparand under `$gt` / `$gte` / `$lt` / `$lte`, or a `null` `$between` bound | zero rows |
34+
| a blank (`''`) `$between` bound | the rows inside the half-blank range |
35+
| a bigint beyond ±2^53, or a binary comparand | zero rows |
36+
| a plain-object or other non-plain-object comparand in a scalar position | the database refused the statement (`DATABASE_ERROR` / 500) |
37+
38+
**Who is affected.** A host `getReadScope` provider, or a direct caller of
39+
`compileScopedFilterToSql`, that produces one of these shapes. The ObjectQL
40+
analytics face already refused all of them. No in-repo read-scope producer emits
41+
them for a policy in this repository. An RLS `using` predicate can still be
42+
written so that it lowers into the null shapes (a literal `null` inside an `in`
43+
list, or an ordering comparison against `null`), and such a policy now gets the
44+
withheld 500 on every analytics face.
45+
46+
**Fix.** State absence with the null predicate. "One of these values, or no
47+
value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`,
48+
which in an RLS predicate is `f in ['a'] || f == null`. A one-sided range is `$gte`
49+
or `$lte`. A comparand is a string, number, bigint within ±2^53, boolean, `null` or
50+
`Date`.
51+
52+
**Unchanged.**
53+
54+
- Well-formed scopes compile to the same SQL and admit the same rows. That includes
55+
the null predicates, an emptied `$in` beside an own-rows grant, and the spelling
56+
above.
57+
- A shape the lowering already refused keeps its own log sentence.
58+
- The caller's own `where` never reaches this lowering, and it is untouched.

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

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
* - **binary** binds but has no faithful text rendering, so it is accepted in
4646
* a bind position and refused by the LIKE family. That asymmetry is the
4747
* reason the package carries two predicates rather than one with a flag.
48+
* [#20018] On the READ-SCOPE door it is now refused in every position: the
49+
* lowering runs the shared comparand-type face after its own gates, as the
50+
* ObjectQL execute face does. The predicate's own answer is unchanged.
4851
*
4952
* @see comparand-shape.ts — the predicates and the messages this pins
5053
* @see https://github.com/objectstack-ai/objectstack/issues/8186
@@ -131,9 +134,15 @@ const MATRIX: readonly Row[] = [
131134
// spelling used to compile it to `status IN (NULL)`; the FilterArray
132135
// spelling was already refused (`where-face-arms-refusal.test.ts`). The
133136
// read-scope cell is that door's own and is not this card's.
137+
// [#20018] …and the read-scope cell moved too, by the same ruling at the
138+
// other door: the read-scope lowering now runs the shared list-shape face
139+
// after its own gates, so a `null` MEMBER of `$in` is refused there as the
140+
// ObjectQL execute face already refused it
141+
// (`read-scope-comparand-three-faces.test.ts`). `null` as a scalar or LIKE
142+
// comparand is not a list member and moves at neither door.
134143
{ label: 'null', value: null,
135144
bindable: true, renderable: true,
136-
whereLike: OK, whereIn: REFUSED_WHERE, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK },
145+
whereLike: OK, whereIn: REFUSED_WHERE, whereEq: OK, scopeLike: OK, scopeIn: REFUSED_SCOPE, scopeEq: OK },
137146
{ label: 'Date', value: new Date('2026-01-01T00:00:00.000Z'),
138147
bindable: true, renderable: true,
139148
whereLike: OK, whereIn: OK, whereEq: OK, scopeLike: OK, scopeIn: OK, scopeEq: OK },
@@ -146,17 +155,27 @@ const MATRIX: readonly Row[] = [
146155
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE,
147156
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
148157
// binary: binds, does not render — accepted where it binds, refused by LIKE.
158+
// [#20018] Two cells moved AFTER the #8186 measurement, on purpose: the
159+
// read-scope lowering now runs the shared comparand-type face after its own
160+
// gates, and that face does not admit binary (the predicate above it still
161+
// does — the `where` door keeps the extra, the read scope no longer reaches
162+
// it). The ObjectQL execute face already refused a binary read-scope
163+
// comparand.
149164
{ label: 'binary', value: new Uint8Array([1, 2]),
150165
bindable: true, renderable: false,
151166
whereLike: REFUSED_WHERE, whereIn: OK, whereEq: OK,
152-
scopeLike: REFUSED_SCOPE, scopeIn: OK, scopeEq: OK },
167+
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
153168

154169
// ── shapes outside the fence ──────────────────────────────────────────────
155170
// `$eq` accepts them on purpose: #5234 left the `{$eq: {…}}` account alone.
171+
// [#20018] …on the `where` door. On the read-scope lowering the shared
172+
// comparand-type face now closes that account (#7872's set), the answer the
173+
// ObjectQL execute face already gave: a plain object bound as a scalar was
174+
// refused by the database at execution, not by this package.
156175
{ label: 'plain object', value: { foo: 1 },
157176
bindable: false, renderable: false,
158177
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK,
159-
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: OK },
178+
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
160179
// [#19975] One cell of this row moved AFTER the #8186 measurement, on
161180
// purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the
162181
// read-scope lowering now refuses it under `$eq` instead of binding the list
@@ -272,16 +291,22 @@ describe('[#8186] the comparand matrix is unchanged by the door reconciliation',
272291
expect(doorTypes.map((r) => r.label)).toEqual([
273292
'string', 'number', 'bigint', 'boolean', 'null', 'Date',
274293
]);
294+
// The cells a SHAPE ruling moved, not a TYPE verdict: `null` is an accepted
295+
// comparand type, but not as an `$in` MEMBER — the shared shape face's
296+
// 2026-08-31 carve-out, now run at BOTH doors. Every cell is named with the
297+
// change that moved it, so no other cell can move under this sentence.
298+
const moved: Record<string, string> = {
299+
'null whereIn': REFUSED_WHERE, // [#20010] the `where` door's envelope
300+
'null scopeIn': REFUSED_SCOPE, // [#20018] the read-scope lowering's envelope
301+
};
275302
for (const row of doorTypes) {
276-
// [#20010] ONE exception, and it is not a TYPE verdict: `null` is an
277-
// accepted comparand type, but not as an `$in` MEMBER — the SHAPE face's
278-
// 2026-08-31 carve-out, which this door now runs. Every other position
279-
// of every accepted type still accepts.
280-
const whereIn = row.label === 'null' ? REFUSED_WHERE : OK;
281-
expect(row.whereIn, `${row.label} $in`).toBe(whereIn);
282-
for (const cell of [row.whereLike, row.whereEq,
283-
row.scopeLike, row.scopeIn, row.scopeEq]) {
284-
expect(cell, row.label).toBe(OK);
303+
const cells = {
304+
whereLike: row.whereLike, whereIn: row.whereIn, whereEq: row.whereEq,
305+
scopeLike: row.scopeLike, scopeIn: row.scopeIn, scopeEq: row.scopeEq,
306+
};
307+
for (const [position, cell] of Object.entries(cells)) {
308+
const key = `${row.label} ${position}`;
309+
expect(cell, key).toBe(moved[key] ?? OK);
285310
}
286311
}
287312
});

‎packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,19 @@ describe('[#5234] the read-scope lowering refuses the same two shapes, fail-clos
269269

270270
describe('the guard is narrow here too', () => {
271271
it('keeps binding every legitimate `$in` member', () => {
272-
expect(scope({ status: { $in: ['a', null, 7, true] } }).params).toEqual(['a', null, 7, true]);
272+
expect(scope({ status: { $in: ['a', 7, true] } }).params).toEqual(['a', 7, true]);
273+
});
274+
275+
it('[#20018] a `null` member is no longer one of them — refused by the shared list-shape face', () => {
276+
// This row used to bind `null` beside the three members above. The
277+
// null-member ruling (2026-08-31) refuses it at the shared face, and the
278+
// lowering now runs that face after its own gates, as the ObjectQL
279+
// execute face does — so this door's own member gate is still narrow,
280+
// and the refusal carries the face's sentence, not this door's.
281+
const err = refusalOf(() => scope({ status: { $in: ['a', null, 7, true] } }));
282+
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
283+
expect(err.status).toBe(500);
284+
expect(err.message).toContain('does not accept null as a list member');
273285
});
274286

275287
it('keeps every primitive LIKE comparand', () => {

‎packages/services/service-analytics/src/__tests__/cross-field-reference-refusal.test.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,22 @@ describe('[#7598] the field-reference shape is read exactly as `driver-sql` read
417417
it('a NON-STRING `$field` is not a reference — it stays the object account', () => {
418418
// `driver-sql`'s `fieldReferenceOf` requires `typeof ref === 'string'`, and
419419
// this package mirrors that spelling rather than inventing a third reading.
420-
// It is therefore NOT routed either: it binds as JSON, exactly as any other
421-
// object comparand does, which is the account #5234 left open on purpose.
420+
// It is therefore NOT routed either: on the `where` door it binds as JSON,
421+
// exactly as any other object comparand does, which is the account #5234
422+
// left open on purpose there.
422423
expect(findCrossFieldComparand({ amount: { $gt: { $field: 5 } } })).toBeNull();
423424
expect(tree({ amount: { $gt: { $field: 5 } } })).toEqual({
424425
kind: 'leaf', member: 'amount', operator: 'gt', values: [{ $field: 5 }],
425426
});
426-
expect(scope({ amount: { $gt: { $field: 5 } } }).params).toEqual([{ $field: 5 }]);
427+
// [#20018] The read-scope lowering no longer BINDS it: it now runs the
428+
// shared comparand-type face after its own gates, which refuses a plain
429+
// object in a scalar position (the ObjectQL execute face's answer too). It
430+
// is still read as the object it is, not as a reference: the refusal is the
431+
// type face's, not the field-reference gate's.
432+
const err = refusalOf(() => scope({ amount: { $gt: { $field: 5 } } }));
433+
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
434+
expect(err.message).toContain('is a plain object');
435+
expect(err.message).not.toContain('compares against the field reference');
427436
});
428437

429438
it('an ordinary object comparand is untouched — #5234 left that account open', () => {

‎packages/services/service-analytics/src/__tests__/read-scope-boolean-flag-comparand.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,17 @@ describe('[#6387] the eight measured cells are REFUSED, in this module’s own e
213213
// A prototype-borne key is not something an author wrote.
214214
const spec = Object.create({ $null: 'false' }) as Record<string, unknown>;
215215
spec.$eq = 'u1';
216-
expect(compileScopedFilterToSql({ d: spec } as FilterCondition, ALIAS).sql).toBe('"t"."d" = ?');
216+
// [#20018] This used to compile to `"t"."d" = ?`. The scope as a whole is
217+
// refused now — by the shared comparand-type face, which runs after this
218+
// module's own gates and reads an object whose prototype is not
219+
// `Object.prototype` as a VALUE, not as an operator spec (the ObjectQL
220+
// execute face refuses it the same way). What this pin is about still
221+
// holds, and still discriminates: this module's gates run FIRST, so a flag
222+
// gate reading the inherited key would have answered with its own sentence.
223+
const err = refusalFor({ d: spec } as FilterCondition);
224+
expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED');
225+
expect(String(err?.message)).not.toContain('is not a boolean');
226+
expect(String(err?.message)).toContain('carries a comparand the engine refuses');
217227
});
218228
});
219229

0 commit comments

Comments
 (0)