Repository navigation
Commit e8f163f
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
- .changeset
- packages/services/service-analytics/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
Lines changed: 6 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
153 | 158 | | |
154 | 159 | | |
155 | 160 | | |
156 | | - | |
| 161 | + | |
157 | 162 | | |
158 | 163 | | |
159 | 164 | | |
| |||
Lines changed: 247 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
Lines changed: 23 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
94 | | - | |
| 94 | + | |
| 95 | + | |
95 | 96 | | |
96 | | - | |
| 97 | + | |
97 | 98 | | |
98 | 99 | | |
99 | 100 | | |
| |||
233 | 234 | | |
234 | 235 | | |
235 | 236 | | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
236 | 249 | | |
237 | 250 | | |
238 | 251 | | |
| |||
323 | 336 | | |
324 | 337 | | |
325 | 338 | | |
326 | | - | |
| 339 | + | |
327 | 340 | | |
328 | | - | |
329 | | - | |
330 | | - | |
331 | | - | |
332 | | - | |
333 | | - | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
334 | 348 | | |
335 | 349 | | |
336 | 350 | | |
| |||
0 commit comments