Repository navigation
fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse - #20046
Conversation
…oss the three analytics faces (measurement, before the fix) The ObjectQL execute face refuses a read scope the shared comparand faces refuse; the NativeSQL execute face and the /analytics/sql echo lower the same scope through compileScopedFilterToSql. This commit adds the three-face suite alone, so its run on this tree records what each face answers today. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…rand faces, so native and echo answer as the ObjectQL face does compileScopedFilterToSql now calls assertReadScopeComparandsRunnable once its own lowering returns. A read scope the shared comparand faces refuse is refused as READ_SCOPE_COMPILE_FAILED / 500 on the NativeSQL execute face and the /analytics/sql echo, as the ObjectQL execute face already refuses it, instead of being lowered and served. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…pe lowering binds a shape the shared faces refuse Nine existing cases asserted that compileScopedFilterToSql lowers a null list member or range bound, a binary comparand, a plain-object comparand under a scalar operator, or an object with a non-Object prototype. Each is refused now, as the ObjectQL execute face already refused it; every case keeps what it was there to pin, with a note naming this change. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…ing moves on the read-scope door Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…read-scope lowering Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Resolves the #8186 comparand matrix against #20032 (#20010): the null row carries both re-judged cells, whereIn INVALID_FILTER/400 and scopeIn READ_SCOPE_COMPILE_FAILED/500, and the six-accepted-types check names each cell with the change that moved it. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…e door, where it still binds Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. 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 3768208dc084c84372d8f59559bd986d30e5b612 && git checkout 3768208dc084c84372d8f59559bd986d30e5b612
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7e6ca1787aa97a98dd88b1d6fe32ec1962655edc 0fbed27877e5a70ec591352d32cc3250bf4fe64d && git checkout -B drift-repro 7e6ca1787aa97a98dd88b1d6fe32ec1962655edc && git merge --no-ff 0fbed27877e5a70ec591352d32cc3250bf4fe64d
node scripts/docs-audit/affected-docs.mjs --json 7e6ca1787aa97a98dd88b1d6fe32ec1962655edc |
Resolves the two test conflicts with #20046 (#20018) so both sets of re-judgements hold: the comparand matrix keeps #20018's read-scope cells and this branch's where-door cells, each named with the change that moved it; the non-string $field case holds on both doors, each in its own envelope. Sentences either PR made false on the merged tree are scoped: the #5234 one-sentence note, the binary predicate note, the comparand-shape.ts binary-extra docblock (#20018's "on the where door, that is"), and this branch's changeset and normalizer lines on the read-scope binary admission. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…le stored value and comparand, not only up to their first U+0000 (objectstack-ai#20025) (objectstack-ai#20067) Fixes objectstack-ai#20025 Clause-②: no ## What this changes `service-analytics` compiles its own SQL for the text operators, in `text-match-sql.ts` `textMatchPredicateSql`, and three faces call it with `dialect: 'sqlite'`: - `compileScopedFilterToSql` (`read-scope-sql.ts`), the read scope, reached directly and through both strategies' scope merge; - `NativeSQLStrategy.buildFilterClause`, the executed `where`; - `ObjectQLStrategy.buildFilterClauseSql`, the `/analytics/sql` echo, which its caller runs on the same database. Its `sqlite` arm emitted `GLOB` for every shape. SQLite's `glob()` reads its pattern AND the stored value as C strings, so each is cut at its first U+0000. `driver-sql` left that construct in objectstack-ai#19999 (the comparand's cut) and objectstack-ai#20024 (the stored value's cut); this package re-emits the driver's construct table rather than importing it, and its copy had kept `GLOB`. The `sqlite` arm now mirrors `driver-sql`'s `textMatchPredicate` / `sqliteLengthAwareTextMatch` on `main`, cell for cell: | shape | SQLite predicate | |---|---| | `contains` (`$contains`, `$notContains`, `$icontains`) | `instr(col, ?) > 0` | | `ends` (`$endsWith`), non-empty comparand | `coalesce(substr(CAST(col AS BLOB), -length(CAST(? AS BLOB))), CAST(col AS BLOB)) = CAST(? AS BLOB)` | | `ends`, empty comparand | `instr(col, ?) > 0` | | `starts` (`$startsWith`), comparand without U+0000 | `col GLOB ?`, unchanged byte for byte | | `starts`, comparand holding U+0000 | `instr(col, ?) = 1` | - This is the wider split the card's suggested shape predates. `glob()` also cuts the stored value, so every `contains` / `ends` comparand moves, not only a comparand holding U+0000. Only `$startsWith` without U+0000 stays on `GLOB`, because neither cut can change that answer and it is the one shape an index serves. - The fold is still `lower()` on both sides, ASCII-only (objectstack-ai#4706 Q1 = A). The four case-exact operators stay case-sensitive (Q2 = A); `instr()` and the BLOB comparison are byte-wise. - The negation is `NOT (…)`, which is NULL for a NULL value, as `NOT GLOB` was. So the NULL-safe `$notContains` wrapper and the `$not` totalisation compose unchanged (objectstack-ai#5298). - Every comparand refusal, and the non-text-column constant, still runs before the text arm. Neither door is touched. - The suffix arm binds its comparand twice, in placeholder order, through the existing `TextMatchBind`. The `LIKE` arms already bind two values, so none of the three call sites changes. - Nothing is escaped on the new constructs. `instr()` and the BLOB suffix have no pattern language, so `*`, `?` and `[` bind as written. - `text-match-sql.ts`'s header now describes the `sqlite` arm as it is, and names the new parity suite beside `text-operator-case-exactness.test.ts`. ## Measured before the fix: the face table Executed, not read, at `980bc05e5b`. That is `main` after PR objectstack-ai#20046 landed, and it moved nothing in `text-match-sql.ts` since the dispatch base `2274894cc4`. It ran on better-sqlite3 (SQLite 3.53.4) and sql.js (3.49.1), each through its driver's own `execute()`. Every cell was compared with `@objectstack/formula`'s `matchesFilterCondition`. The grid: stored values with U+0000 at the start, middle and end, without it, `''` and NULL; comparands with and without U+0000; each leaf bare and under `$not`. All five faces answered alike, and so did both engines: the direct read scope, the read scope through `NativeSQLStrategy` and through the echo, the native `where`, and the echo `where`. | operator | comparand without U+0000 | comparand holding U+0000 | |---|---|---| | `$contains` | narrows → **matches JS** | widens → **matches JS** | | `$notContains` | widens → **matches JS** | narrows → **matches JS** | | `$startsWith` | matches JS (unchanged, still `GLOB`) | widens → **matches JS** | | `$endsWith` | widens and narrows → **matches JS** | widens → **matches JS** | | `$icontains` | narrows → **matches JS** | widens → **matches JS** | | each of the above under `$not` | the opposite direction → **matches JS** | the opposite direction → **matches JS** | - **In this PR's pin grid** (`text-match-sqlite-nul.test.ts`, 128 cells), every face on both engines answered 88 cells differently from JS at base, and answers 0 at head. - **The ObjectQL execute face** goes through the engine to `driver-sql`, which is already fixed on `main`. It is the control, and it answered all 128 like JS at base and at head. - **The `unknown` arm on SQLite** emits `LIKE`, and SQLite's `LIKE` cuts at U+0000 too. That was measured on both engines: `'a'` + U+0000 + `'b'` is not `LIKE '%b%'`, and `'plain'` is `LIKE '%'` + U+0000 + `'%'`. The arm is untouched. It is the residue for dialects nothing answered for, PostgreSQL-like ones among them, and PostgreSQL has no `instr()`. Choosing a construct there would be a dialect guess. It is reported to the seat as a finding and noted in the header. - **`$like` / `$ilike`** are refused on every analytics face, before this arm: `READ_SCOPE_COMPILE_FAILED` on the read scope, `INVALID_FILTER` / 400 on the `where`. This package compiles no raw-pattern operator, so there is nothing to swap. ## Controllability (triage's point 1) - **Comparand side: no caller-controlled producer measured.** The RLS compile path (`RLSCompiler.compileFilter` → `compileCelToFilter`) resolves a string method's argument from one of two places: - a literal in the admin-authored policy; - a string attribute of `current_user` (id, organization id, email). The ids are system-generated. The email is refused at every auth entry measured when it holds U+0000, and it is read-only on the user object. Membership sets are arrays, which the string methods refuse. `plugin-sharing` emits id / owner membership scopes, not text operators. Sharing-rule criteria are admin-authored and evaluated through `engine.find`, the driver face that is already fixed. - **Stored-value side: a caller-controlled producer exists.** It is any persona that holds create or edit on an object with a text field. The engine's write path stores U+0000 unchanged under a non-system context (executed). Reaching a read scope also needs a deployment-authored `using` predicate with a string method over such a field. No shipped example authors one. ## Tests (at `d000746b1d`) - **New `src/__tests__/text-match-sqlite-nul.test.ts`, 29 tests.** It covers: - the 128-cell grid on every face, on better-sqlite3 and sql.js, each through its driver's `execute()`. That is the transport `plugin.ts` uses, and a bare sql.js bind would cut a comparand holding U+0000 before any construct saw it; - each cell equal to the JS answer AND to `driver-sql`'s own rows on the same engine. That is the parity pin that keeps the two construct tables from drifting; - literal pins for the headline cells; - the compiled constructs per shape, including `$startsWith` without U+0000 emitting `GLOB` and its escaped pattern byte for byte on all three compilers. It was committed first, red at base (`ced01cbad7`): 14 failed and 15 passed; each face on each engine had 88 of 128 cells differ; `driver-sql` had 0. - **Respelled pins, never loosened.** Four existing files asserted `GLOB` for shapes that no longer compile to it: `text-operator-case-exactness.test.ts`, `icontains-dialect-sql.test.ts`, `sql-dialect-vocabulary.test.ts` and `text-operator-non-text-column.test.ts`. - Each now names the exact per-operator construct. - The read-scope and echo loops check a per-operator regex plus "not ` LIKE `", where they used one `/GLOB/`. - The `$endsWith` / `$startsWith` compiled-text rows gained SQL assertions they did not have. - `pnpm --filter @objectstack/service-analytics test`: 121 files, 2718 tests passed. `typecheck` (`tsc --noEmit`, whose program lists the touched test files) exit 0. ### Ablations: each negative pin, one shape at a time, through `scripts/ablation-replace.mjs` The subject is `src/text-match-sql.ts`, imported relatively by every face, so vitest reads source and no `dist/` is involved. Every leg landed on disk (the anchor count went from 1 to 0 and the blob changed) and was restored with its blob equal to HEAD (`e913134711cb`) and `git diff HEAD` empty. | leg | mutation | red | |---|---|---| | A1 | `contains` back on `GLOB` | 21 tests; 60/128 cells per face and engine | | A2 | `ends` back on `GLOB` | 15 tests; 22/128 cells | | A3 | `starts` holding U+0000 back on `GLOB` | 11 tests; 6/128 cells | | A4 | `starts` without U+0000 moved off `GLOB` | 4 compiled-text tests (the byte-for-byte pins); rows unchanged, as expected | | A5 | empty `ends` sent to the suffix construct | 11 tests; 2/128 cells (`''` bare and under `$not`) | | A6 | `coalesce()` removed from the suffix | 14 tests; 12/128 cells (`''` under `$not`) | The first A6 attempt was a no-op. Its replacement text already occurred inside its own anchor, so the tool refused the leg and ran nothing. It was re-run with a non-overlapping anchor (the row above). `EXPLAIN QUERY PLAN` over an indexed TEXT column, on both engines: `$contains` / `$endsWith` scanned under `GLOB` and still scan, and `$startsWith` without U+0000 keeps its `SEARCH … USING INDEX`. ## Gates (at `d000746b1d`) - **The dispatch-gates derivation**: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, run from the real diff. It gave 60 families, identical to the dispatch-time list. - 58 exit 0. - 2 are NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt` both exited 3 with PREREQUISITE NOT MET, because they read the whole repo's built closure and this worktree built only the package's own. CI builds it first. - `--ran` reconciliation: 60 derived, 58 run, 2 NOT MEASURED, 0 unrun. - `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs`: every citation this change adds resolves. - **Lint, a declared narrowing.** ESLint ran on the 6 changed TS files (`--no-inline-config --format json`): 6 files, 0 errors, 0 warnings. - The population is the config's `packages/**/*.{ts,…}` block; none of the six is in `NEVER_LINTED`. - Type-aware linting is not enabled: `--print-config` shows `parserOptions` without `project`. So this diff cannot move a verdict on an untouched file. - The full `pnpm lint` is CI's. ## Acceptance notes - **Surface.** The claim named `text-operator-case-exactness.test.ts` and new test files. Three more existing test files carried `GLOB` pins for the moved shapes and are respelled here (listed above). No call site, refusal door, driver, spec or formula file is touched. - **Stale prose, no assertion reads it.** These comments still say the SQLite arm is `GLOB` for every shape: `like-pattern.ts` (the `$icontains` note), `read-scope-sql.ts` (the `textMatch` and `$icontains` docblocks), `strategies/native-sql-strategy.ts`, `strategies/objectql-strategy.ts` and `strategies/types.ts`. They ride the next PR that touches those files. Carrier: none. - **Observation: `$icontains: ''`.** It is not a U+0000 cell, and this change does not move it. - These compilers, and `AnalyticsService.query` in-process, answer every non-NULL row for it, as they did under `GLOB '**'`. - The spec's `FILTER_TEXT_CASES` declares it refused (`INVALID_FILTER`), and `driver-sql` refuses it. - Reachability through the REST door is not measured. It is reported to the seat with its evidence. - `main` moved during this run only by `3557f85fa5` (a `driver-turso` README), which touches nothing here. No merge was needed. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…parand-TYPE face on the object spelling (objectstack-ai#20035) (objectstack-ai#20058) Fixes objectstack-ai#20035 Clause-②: no (narrowing) ## What this changes The analytics `where` door (`lowerAnalyticsWhere` in `packages/services/service-analytics/src/strategies/filter-normalizer.ts`) now runs the shared comparand-TYPE face, `normalizeFilterComparandTypes` (`@objectstack/spec/data`), on the object spelling. It runs after the comparand-shape face and before any node is built, the order `parseFilterAST` and the engine seam use. The condition the door lowers is the face's RETURN value, so a bigint within 2^53 is narrowed to its number, copy-on-write, as the engine does it. The governing record is the objectstack-ai#7872 ruling (2026-08-12): the accepted comparand types are `string | number | bigint | boolean | null | Date`, and the face 「refuses everything else loudly at the compile face」. The `FilterArray` spelling of this door (inside `parseFilterAST`) and the ObjectQL engine seam already ran it. The object spelling did not. How: - `normalizeWhereComparands` (new, exported) is the door's comparand gate: the shape passes (unchanged, now module-private as `assertWhereComparandShapes`), then the type pass over the whole condition. - The type pass hands each field entry to the face as a one-entry node with the path of the node that holds it, the same hand-over the shape pass makes. The face therefore reports exactly the path it reports on the whole condition, so the object spelling gets the `FilterArray` spelling's bytes. - Nested-relation entries are handed over too. The face leaves a nested-relation object alone as filter structure; this compiler flattens it to a dotted member, so its entries are comparands. `{ acct: { amt: … } }` is judged at `where.acct.amt`, the path the dotted spelling gets. - The shared traversal (`forEachWhereFieldEntry`, now a copy-on-write `mapWhereFieldEntries`) descends a nested relation only when it is a PLAIN object (prototype `Object.prototype` or `null`). That is the type face's own structure test. Before, a `Uint8Array`, `Map` or class instance in the implicit slot was descended as a relation: `{ stage: Uint8Array }` compiled to `stage.0 = 1 AND stage.1 = 2`. Now it is visited as the comparand it is. - The draft preview (`preview-evaluator.ts`) calls the same gate and evaluates the narrowed condition it returns. This closes the card's class-a leg: an `undefined` comparand is refused instead of answering no row. ## Measured first (recorded on this branch as `4e1cd13aac`, before any source change) Base `origin/main` `246314dffe`. The harness was a real sql.js engine (driver-sqlite-wasm) over rows d1 (amt 1, 'won'), d2 (5, 'lost'), d3 (10, 'open'), d4 (NULL, NULL) and d5 (3, ''). The faces were: the native SQL execute; the `/analytics/sql` echo; the ObjectQL engine path over a real `ObjectQL` `engine.aggregate`; `engine.find` on the same object `where` (the engine seam); and the draft preview. | cell | object spelling at base | `FilterArray` spelling | engine seam (object) | |:--|:--|:--|:--| | plain object `$ne` `{stage:{$ne:{a:1}}}` | native bound the JSON text `'{"a":1}'`: EVERY row; echo `DATABASE_ERROR` / 500; engine path 400; preview EVERY row | 400, type face at `where.stage.$ne` | 400 | | plain object `$gt` | native: no row; echo 500; engine path 400; preview: no row | 400 | 400 | | plain object `$eq` | native: no row; echo 500; the engine path received `{stage:{a:1}}` and driver-sql refused it in its own words | no `$eq` spelling (`=` lowers to a nested relation) | 400 | | plain-object `$between` endpoint | the lower bound compared `amt` against the JSON text `'{"a":1}'`: no row; echo 500; the engine path refused it as a `$gte` the author never wrote | 400 at `where.amt.$between[0]` | 400 | | plain object as an `$in` member / `$contains` | refused 400 in this door's objectstack-ai#5234 wording | 400, type face | 400 | | `{ $field: 5 }` under `$gt` (not a reference) | native: no row; echo 500; engine path 400 | 400 (a plain object) | 400 | | binary `$eq` / `$in` member | native bound JSON TEXT `'{"0":1,"1":2}'`, not a blob: no row; echo bound the raw buffer: no row; engine path 400 | 400 | 400 | | binary `$ne` | native, echo and preview: EVERY row; engine path 400 | 400 | 400 | | binary implicit `{stage: Uint8Array}` | flattened as a nested relation: native `DATABASE_ERROR` / 500; echo and engine path `INVALID_FIELD` / 400; preview refused operator "0" | 400 at `where.stage` | 400 | | `Map` `$eq` | native bound `'{}'`: no row; echo 500; engine path 400 | no `$eq` spelling | 400 | | `Map` implicit | refused as objectstack-ai#5240's zero-operator wrapper; preview EVERY row | 400 (a Map instance) | 400 | | bigint within 2^53 `{amt:{$gt:2n}}` | native, echo and engine path d2, d3, d5 (bound as a bigint); preview d2, d5 (ordered as text: '10' before '2') | narrowed to 2: d2, d3, d5 | d2, d3, d5 | | bigint within 2^53, implicit `5n` / `$in [5n,10n]` | the same rows as the narrowed number on every face | narrowed | the same rows | | bigint beyond 2^53 `$gt` / `$in` member | native and echo bound it: no row; engine path 400; preview d2, d5 / no row | 400 | 400 | | `undefined`, implicit / `$gt` / `$in` member | refused 400 in objectstack-ai#6386's wording (`[analytics] comparand at "amt".$gt is undefined`); the preview answered NO row | 400, the type face's `undefined` sentence | 400 | | `{$null: undefined}` / `{$null: {a:1}}` | lowered to `set` (IS NOT NULL): d1, d2, d3, d5 on native, echo and engine path; the preview refused `$null` as unevaluable | no spelling | 400 | | nested relation `{acct:{amt:{$gt:{a:1}}}}` | native: no row; echo 500; engine path 400 | dotted `['acct.amt','>',{a:1}]` 400 | 400 | | under `$or` `{$or:[{id:'d3'},{stage:{$ne:{a:1}}}]}` | native and preview: EVERY row; echo 500 | 400 at `where.$or[1].stage.$ne` | 400 | | controls `{amt:{$gt:2}}`, `{stage:null}` | d2, d3, d5 / d4 on every face and both spellings | the same | the same | After the change, every refusal row is refused on all four faces, before any statement or `engine.aggregate` call. The object message equals the `FilterArray` message and the face's own message, byte for byte. The bigint rows serve the same rows on all four faces, the preview included, and no face binds or receives a bigint. ## Binary: reconciled, not kept as a declared local extra The type face's docblock lets a door keep "its recorded driver-local extras — binary bindables … declared at the use site", and objectstack-ai#8186 asked for an explicit keep-or-reconcile call. **The call is reconcile.** The evidence: - **This door never delivered the extra.** `isBindableComparand` (`comparand-shape.ts`) admits binary, but the native path binds every object through `toSqlBindValue`, which JSON-stringifies it. Measured: `{stage:{$in:[Uint8Array]}}` bound `'{"0":1,"1":2}'`, a value no blob column holds. `$ne` then served every row, and the implicit spelling compiled to a dotted member no object has. The engine path and the `FilterArray` spelling refused binary outright. - **No producer relies on it.** JSON has no binary type, so no REST caller can send one. A census of the 258 non-test source files under `packages/**` and `examples/**` that mention analytics found no binary value built into a `where`. The six files that mention a binary type at all are the client's stream and upload code, `rest-server.ts`'s xlsx streaming, the two drivers' own extras, and this package's two filter files. Positive control: 21 of the 258 carry a `where:` object literal, which the scan read. - **Scope.** This PR does not touch the read-scope door (`read-scope-sql.ts`). Since objectstack-ai#20018 landed (PR objectstack-ai#20046), that door also refuses a binary comparand, with the same face after its own gates, so a binary is now refused at both analytics doors. The `isBindableComparand` predicate still admits it, and the `objectstack-ai#8186` predicate-level pins stay green. ## Pins re-judged: predicted 39 red in 9 files, measured 39 red in the same 9 files Before wiring the gate I read every candidate pin and predicted the red set. I then wired the gate and ran the package suite (`86907678c0`): `Tests 39 failed | 2617 passed (2656)`, the identical set. Each pin keeps its verdict, or flips with a note quoting the objectstack-ai#7872 ruling. ⛔ None is deleted. - `filter-normalizer-undefined-comparand.test.ts` (21). objectstack-ai#6386's twelve positions, its five `$not` rewrite paths, "says ONE thing" and "names the repairs" keep the refusal and now read in the face's sentence and path (`where.d.$gt`). The face runs BEFORE the objectstack-ai#5146 rewrite, so the rewrite block now pins that the author's own `$not` is judged. `{$null: undefined}` / `{$exists: undefined}` flip from `set` to refused, because the face judges those comparands as literals. The boolean-domain question (objectstack-ai#5347 / objectstack-ai#5369 / objectstack-ai#6387) stays untouched for accepted values: `{$null: 'false'}` still lowers as before, and a pin now says so. - `comparand-shape-refusal.test.ts` (8). objectstack-ai#5234's `$in` / `$nin` object-member and LIKE-family object sentences now read in the face's sentence. The `{ $eq: {…} }` account objectstack-ai#5234 "left open" flips to refused, `{ $eq: { $field: 5 } }` included. - `cross-field-reference-refusal.test.ts` (3). A non-string `$field` and a plain object under `$eq` flip to refused; the routing detector half is unchanged. objectstack-ai#7693's `$icontains {foo: 1}` keeps its refusal and its sibling-equality check, in the face's words. - `comparand-door-single-source.test.ts` (2). The objectstack-ai#8186 matrix: `binary` `whereIn` / `whereEq` and `plain object` `whereEq` move from accept to refused. The predicate and read-scope cells are unchanged. - `filter-normalizer-mixed-wrapper.test.ts` (1). objectstack-ai#6444's order: `{d:{$eq: undefined, nested:'x'}}` is still diagnosed as the comparand first, now by the face. - `filter-value-type-fidelity.test.ts`, `filter-refusal-envelope.test.ts`, `filter-normalizer-not-null-safe.test.ts` (1 each). The objectstack-ai#6386 wording only. - `where-face-arms-refusal.test.ts` (1). The objectstack-ai#20010 CONTROL that pinned "the TYPE face is not run here" now pins the type face's sentence. **Census of consumer pins outside the package.** I searched all 3904 tracked test files under `packages/**` and `examples/**` (outside this package) for objectstack-ai#6386's sentence, objectstack-ai#5234's two sentences, objectstack-ai#6444's wording and the face's sentence. The hits were driver-sql's and driver-turso's own refusals and spec's own tests, none through this door. A second pass covered the 127 test files that reach analytics (`@objectstack/rest` 15, `runtime` 17, `qa/dogfood` 11 and the rest). It looked for an `undefined`, plain-object, binary, `Map` or bigint comparand in a `where`, and found none. So there are **zero hits and no edit outside the package**. `@objectstack/rest` ran in full anyway (below). ## Compile surfaces | surface | verdict | |:--|:--| | `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`: caller `where`, dataset scope `filter`, measure `filter`; native execute, `/analytics/sql` echo, ObjectQL engine path | **changed.** Refused before any statement or `engine.aggregate`; a bigint within 2^53 is narrowed. Pinned per face over a real engine. | | `evaluateAnalyticsQueryOverRows` (draft preview) | **changed.** The same gate; it evaluates the narrowed condition. | | `normalizeFilterComparandTypes` (the shared face) | **already compliant.** This PR calls it and does not change it. | | `parseFilterAST` (this door's `FilterArray` spelling) | **already compliant.** Measured at base: every cell refused or narrowed. | | ObjectQL engine seam (`lowerWhereFilterArray`) | **already compliant.** Measured at base with `engine.find`. | | `compileScopedFilterToSql` (service-analytics read scope) | **out of scope.** A separate door and envelope (500); objectstack-ai#20018 is in flight in `read-scope-sql.ts`. Not touched. | | driver-sql, driver-turso RemoteTransport, formula, driver-memory, driver-mongodb | **already compliant at the platform doors.** They sit behind `parseFilterAST` or the engine seam, which run the face. Not re-measured here. | | objectql HAVING (`applyHaving` / `matchesHaving`) | **out of scope.** A different door, over aggregated rows. | ## Tests and evidence (head `f4ba18ba9a`) - **New `src/__tests__/where-type-face-refusal.test.ts`, 106 tests.** Every refusal asserts `code` + `status` + the face's opening sentence. - Byte identity for 24 cells: object = `FilterArray` = `normalizeFilterComparandTypes`. Another 9 object-only cells (`$eq`, the `$null` / `$exists` flags, `$not`) are held to the face. The nested relation is held to the dotted spelling. - What is diagnosed first: the shape face before the type face, over the whole condition (`parseFilterAST`'s order); the objectstack-ai#19888 equality list before either; the type face before objectstack-ai#6444's mixed wrapper and the unsupported-operator refusal. - What the face does not judge keeps the door's sentences: an `undefined` inside an array comparand or under an unknown operator (objectstack-ai#6386), and an array or `{ $field }` member / LIKE comparand (objectstack-ai#5234 / objectstack-ai#7598). - Narrowing: the tree carries the number, the same tree the `FilterArray` spelling compiles. The caller's condition is never edited, and the same reference comes back when nothing narrowed. CONTROL: every accepted comparand compiles as before. - Four faces over a REAL `ObjectQL` engine on sql.js, with 14 refused cells × 4 faces. Each asserts 0 statements and 0 `engine.aggregate` calls. CONTROLS: scalars and `null` serve the same rows on every face. A bigint within 2^53 serves its number's rows on every face, and no face binds or receives a bigint. - Stored datasets through the service doors: the dashboard door and the draft preview, for a scope filter and a measure filter; the registered cube on the ObjectQL door (0 aggregates); and a CONTROL. - **Package run at `f4ba18ba9a`** (`pnpm --filter @objectstack/service-analytics test && … typecheck`, under the verify lock): `Test Files 120 passed (120)`, `Tests 2762 passed (2762)`, `tsc --noEmit` exit 0. `tsc --listFiles` includes all 12 touched TS files. Base: 119 files / 2656 tests. - **`@objectstack/rest` full suite** (its closure built, service-analytics `dist/` carrying `normalizeWhereComparands`): `Test Files 194 passed (194)`, `Tests 3265 passed | 1 skipped (3266)`. - **Ablations.** Each ran from the committed fix through `scripts/ablation-replace.mjs`: anchor x1 to x0, marker `grep -c` 1, blob changed. The restore was proven as blob == HEAD with `git diff HEAD` empty, and each wrapper carried its own `trap` restore. The tests import the source relatively, so no build is on the path. Every count was predicted before the run: - **A, the type walk removed** (`return normalizeWhereComparandTypes(node, path);` became `return node;`). Predicted 138; measured `Tests 138 failed | 2624 passed (2762)`: 99 in the new file and the 39 re-judged pins. Samples: `native execute: expected a refusal, got rows: expected [ 'd1', 'd2', 'd3', 'd4', 'd5' ] to be undefined`, `expected 'accept' to be 'INVALID_FILTER/400'`, `native execute: a bigint was bound: expected true to be false`. - **B, the preview evaluates `query.where` instead of the gate's return.** Predicted 1; measured `1 failed | 2761 passed`: `draft preview: $gt 2n: expected [ 'd2', 'd5' ] to deeply equal [ 'd2', 'd3', 'd5' ]`. - **C, the door discards the gate's return.** Predicted 3; measured `3 failed | 2759 passed`: the tree-narrowing, copy-on-write and bound-bigint cases. - **D, the plain-object check removed from the nested-relation test.** Predicted 12; measured `12 failed | 2750 passed`: the binary / `Map` / class-instance implicit cells. Samples: `expected 'DATABASE_ERROR' to be 'INVALID_FILTER'`, `expected '[analytics] "stage" carries a field c…' to be 'Filter comparand at where.stage is a …'`. - **Gates.** `dispatch-gates --repo objectstack-ai/objectstack --commands` at `f4ba18ba9a` derived 60 families. The union run was 61: those 60 plus `check:dispatcher-error-vocabulary` from the dispatch list, which exited 0. 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). `--ran`: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`, 0 UNRUN. The passes include `check-adr-0087-registration --base origin/main` (it accepted the changeset's disposition), `check-changeset-no-major`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check:where-matcher`, `check:test-source-alias`, `check:cross-package-test-inputs` and `check:issue-citations`. `check-issue-citations` also ran in its board-probing mode with `GITHUB_TOKEN`: exit 0, 43 citations resolve. - **Lint, narrowed to the change.** `eslint --no-inline-config --format json` over the 12 touched TS files: 12 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each, which is the population read from eslint's own config. `eslint.config.mjs` never enables type-aware linting (its note at line 327), so this diff cannot move the verdict on an untouched file. ## Changeset and the ADR-0087 disposition `.changeset/20035-analytics-where-type-face.md`: `@objectstack/service-analytics` minor, a `!` headline, `Clause-②: no (narrowing)`, and a **BREAKING** paragraph with a before / now table for every accept-cell that now refuses. The marker is `not-required (no-migration-prescription)`, and it is the honest one. **objectstack-ai#7872's transition is not on the ledger.** No entry in `packages/spec/src/migrations/registry.ts` names the type face or its accepted set, and objectstack-ai#7872's own changeset declared no breaking change. So `already-registered` would name an id that does not cover this change, which ADR-0087's own addendum calls out. `registered` needs a ledger entry in `packages/spec`, which this card excludes. And no ledger entry could carry this change: a stored plain-object comparand has no accepted comparand to be rewritten to, and the other refused values cannot be stored as JSON at all. The table records each cell's verdict before and now and prescribes no rewrite. ## Deviations from the dispatch, stated 1. **"FROM → TO table" is spelled "before / now".** The gate reads a literal `FROM` / `TO` label as a migration prescription, which would refuse the only honest disposition above. The table is the transition in substance (the verdict each accept-cell had and has), and its columns say so. The seat may prefer registering a ledger entry in a `packages/spec` follow-up instead; that is out of this card's surface. 2. **The traversal's nested-relation test changed** (plain objects only). It was needed for byte identity: without it, `{ stage: Uint8Array }` and `{ stage: new Map() }` never reach the type face. Ablation D pins it. 3. **`assertWhereComparandShapes` is now module-private.** Its only other caller, the preview, now calls `normalizeWhereComparands`. The package entry never re-exported it. 4. **`origin/main` merged in (round 2), as a merge commit (`0164eba2b7`), after objectstack-ai#20046 (objectstack-ai#20018) landed as `980bc05e5b`.** Two test files conflicted; both sets of re-judgements hold on the merged tree (see "Round 2" below). `origin/main` has since moved one commit, to `3557f85fa5` (a driver-turso README). It touches none of these files, so it was not merged again. ## Round 2: merged with `origin/main` after objectstack-ai#20046 (head `0164eba2b7`) - **Conflict 1, `comparand-door-single-source.test.ts`.** The matrix keeps both sets of cells: - objectstack-ai#20018's scope cells: `null` `scopeIn`, and binary and plain object `scopeIn` / `scopeEq`, all `READ_SCOPE_COMPILE_FAILED/500`; - this PR's `where` cells: binary `whereIn` / `whereEq` and plain object `whereEq`, all `INVALID_FILTER/400`. Each moved cell's comment names the change that moved it. Every other cell stays `accept`, and objectstack-ai#20046's per-cell `moved` map for the six accepted types is kept as is. The header's binary bullet now says the extra is reachable at neither door. objectstack-ai#20046's "the `where` door keeps the extra" is gone, because this PR makes it false. - **Conflict 2, `cross-field-reference-refusal.test.ts`.** The non-string `$field` case now holds on both doors, each in its own envelope. The `where` door refuses it as a plain object at `where.amount.$gt` with `INVALID_FILTER` / 400 (this PR). The read-scope lowering refuses it with `READ_SCOPE_COMPILE_FAILED` / 500, in the type face's words and not the field-reference gate's (objectstack-ai#20018). The routing detector still returns `null` for it. - **Sentences one PR made false, re-read on the merged tree.** Each is now scoped: - `comparand-shape-refusal.test.ts`'s "one sentence, two envelopes" note: for a plain object, `Map` or binary, the `where` door now answers in the type face's sentence, while the read scope keeps this package's sentence because its own gates run first; - the binary predicate note in the matrix file; - the `comparand-shape.ts` binary-extra docblock (above); - this PR's changeset and normalizer lines about the read-scope binary admission. The file list against `main` is 14 files; `comparand-shape.ts` (docblock only) is the one added. - **Evidence on the merged head `0164eba2b7`.** - Dependency closure rebuilt (14 tasks, 0 cached). - The service-analytics suite: `Test Files 121 passed (121)`, `Tests 2795 passed (2795)`. `tsc --noEmit` exit 0. - Ablation A again (the type walk removed): predicted 138, measured `Tests 138 failed | 2657 passed (2795)`, with the same per-file split (99 new-file, 21 / 8 / 3 / 2 / 1×5 re-judged). Restore: blob == HEAD (`6de15db882ed`), `git diff HEAD` empty. - Gates re-derived: 60 families, identical to round 1. The union of 61 gave 59 exit 0 and 2 exit 3, the same two NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`). `--ran`: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED`. - `check-adr-0087-registration --base origin/main` exit 0 (same disposition). `check-issue-citations` board-probing exit 0 (48 resolve). - eslint over the 13 touched TS files: 0 errors, 0 warnings. ## Round 3: stale docblocks carried (head `8c78550a75`) - One commit, `comparand-shape.ts` docblocks only: 53 comment lines added, 6 removed, with no code or test line changed. So there is no ablation this round: it moves no behaviour and no assertion. - Evidence at `8c78550a75`: - the service-analytics suite: `Test Files 121 passed (121)`, `Tests 2795 passed (2795)`; `tsc --noEmit` exit 0; eslint on the file: 0 errors, 0 warnings; - `dispatch-gates --commands`: the same 60 families; the union of 61 gave 59 exit 0 and 2 exit 3 (the same NOT MEASURED pair); `--ran`: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED`; - `check-issue-citations` board-probing: exit 0, 72 resolve. - `origin/main` is at `b76aad5f6f` at the time of writing. None of its commits since the merge base `980bc05e5b` touches `service-analytics` or the shared filter faces, so it was not merged. ## Round 4: the unbindable list-member refusal no longer offers "(or a binary value)" (head `cf1563d6f8`) - **What changed.** This is the seat's round-3 answer (option A, on this PR). It is one commit, `cf1563d6f8`: 3 files, +28 / −10. - `comparand-shape.ts`: `unbindableListMemberMessage`'s runtime text no longer offers "(or a binary value)" as a repair. It now names only the accepted set. Its code, status and verdict do not change. - Two things are unchanged. `isBindableComparand`'s binary arm stays as driver-sql's mirror. driver-sql's own copy of the sentence (`sql-driver.ts`) also stays, because that driver does bind a binary. - `comparand-door-single-source.test.ts`: the objectstack-ai#8186 pin that asserted `toContain('(or a binary value)')` is re-judged, flipped, with a note. The message must not contain `binary`, and the accepted-set sentence must be followed directly by "Refusing rather than binding it". - The changeset gains one line that says so. - **Census of pins on the old wording.** This is a `git grep -F` over all 9482 tracked files at `cf1563d6f8`, so every package is covered, `packages/rest` included. The tree has no snapshot files. - "or a binary value": 4 hits. One is `sql-driver.ts:3007`, driver-sql's own copy, which is expected and stays. The other 3 are prose that names the removed words: the changeset line, the re-judged pin's comment and the `comparand-shape.ts` docblock. None of them is an assertion. - The wider "binary value" gives 7 hits. The 3 extra hits are another driver-sql message, a driver-sql comment and the docblock's quote of the old hand copy. None of them is an assertion. - "in its own right — use": 2 hits, both runtime strings: this message (`comparand-shape.ts:700`) and driver-sql's copy (`sql-driver.ts:3006`). - "Every member of an $in/$nin/$between list is a comparand": 1 hit, this message itself. - "Refusing rather than binding it": 5 hits. They are this message, the new pin (`comparand-door-single-source.test.ts:325`), driver-sql's copy, and two other messages (`comparand-shape.ts:595`, driver-turso `remote-transport.ts:4141`). - `unbindableListMemberMessage`: 9 hits. They are the definition, its two door callers (`filter-normalizer.ts:680`, `read-scope-sql.ts:1009`), their imports and the matrix test's three calls. The other two calls assert only the accepted-set sentence and `bigint`, and neither moved. - Control: "cannot be bound as a SQL", a string known to be present, gives 10 hits. They include this message (`comparand-shape.ts:698`) and three door pins that still hold on it (`where-type-face-refusal.test.ts:229-230`, `cross-field-reference-refusal.test.ts:288`). - **Result: the push missed no pin on the old wording.** - **Ablation of the new negative pin.** It ran from the committed head through `scripts/ablation-replace.mjs`, which put "(or a binary value)" back into the runtime string. - On disk: anchor x1 → x0, injected text x0 → x1, blob `4cc83d7bf44c` → `77c17ca505c8`. - Predicted: 1 red in the whole package suite. Measured: `Test Files 1 failed | 120 passed (121)`, `Tests 1 failed | 2794 passed (2795)`. - The red was `comparand-door-single-source.test.ts` › "binary stays a package-local extra the door does not admit": `AssertionError: expected '"$in" on "status" has a value at inde…' not to contain 'binary'`. - Restore: blob == HEAD (`4cc83d7bf44c`), `git diff HEAD` empty, `git status --porcelain` empty. The wrapper carried its own `trap` restore. - Vitest stops a case at its first failed assertion, so the pin's second assertion (the accepted-set sentence followed directly by "Refusing rather than binding it") was not measured on its own here. - **Evidence at `cf1563d6f8`:** - the dependency closure: 14 of 14 turbo tasks cached; - the service-analytics suite: `Test Files 121 passed (121)`, `Tests 2795 passed (2795)`; `tsc --noEmit` exit 0; - eslint `--no-inline-config --format json` on the two touched TS files: 2 files, 0 errors, 0 warnings; - `dispatch-gates --commands`: the same 60 families as rounds 1 to 3. 58 exited 0. 2 exited 3 and are NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`), because they need the whole workspace built. `--ran`: `60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`; - `check-issue-citations` in board-probing mode: exit 0, 74 citations resolve. - `origin/main` is at `66960564d9`, 5 commits past the merge base `980bc05e5b`. None of those commits touches `service-analytics` or the shared comparand-type face. This round changes no code, so `origin/main` was not merged. ## Acceptance notes - **Stale `comparand-shape.ts` docblocks: fixed.** Round 2 corrected objectstack-ai#20046's binary-extra note so that it says NEITHER door serves the extra. Round 3 (`8c78550a75`, docblocks only) fixes the rest of the file's pre-objectstack-ai#20035 `where`-door account: - the `undefined` table's `where` row now names the shared comparand-TYPE face as the first refusal (same verdict and envelope), with objectstack-ai#6386's gate kept for the two positions the face steps around; - the header's envelope clause, the non-string `$field` note, the two shared refusal sentences (`unrenderableTextComparandMessage`, `unbindableListMemberMessage`) and the two `$between`-endpoint notes are each scoped to the door they are true of. - **`unbindableListMemberMessage`'s "(or a binary value)": fixed in round 4 (`cf1563d6f8`).** Round 3 left it because that round changed docblocks only. Neither door accepts a binary any more (objectstack-ai#20018 at the read scope, this PR at the `where` door), so the runtime text now names only the accepted set. The pin in `comparand-door-single-source.test.ts` is re-judged. driver-sql's own copy keeps the words, because that driver binds a binary. - **`$ne` with a list** is still the face's to judge (objectstack-ai#19886 stage 2). The type face steps around arrays, so this change neither moves nor pins it. - **Residual door-local sentences.** An `undefined` inside an array comparand (`{d:{$contains:['a', undefined]}}`) or under an unknown operator still reads in objectstack-ai#6386's sentence, and an array or `{ $field }` member / LIKE comparand in objectstack-ai#5234's. Those are positions the face does not judge, and both spellings agree on them. - objectstack-ai#20010 remains open for its `$ne` arm, which is not this card's. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…dmission before composing it (objectstack-ai#20232) Fixes objectstack-ai#19995 Clause-②: no The analytics ObjectQL face now asks the engine's own `where` admission, `IObjectQLEngine.judgeFilter` (objectstack-ai#20157, ruling C), about each row-level read scope on its own, before composing it into the `where` it hands `executeAggregate`. A scope the engine refuses is refused in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 (objectstack-ai#5367). A scope the engine serves is still served. The caller's own `where` keeps the engine's answer. The close condition in the ruling is met on the final head: both analytics HTTP doors were re-measured over every class the ruling names (the four here, the eleven withheld by PR objectstack-ai#20017 / objectstack-ai#20046 / objectstack-ai#20072, and the four `driver-sql` doors from PR objectstack-ai#20037), and no response body carries policy content. ## What changed All in `packages/services/service-analytics/src/`. - **`read-scope-sql.ts`: new `assertReadScopeAdmittedByEngine(scope, objectName, context, host)`.** It calls the host's judge on the scope alone, under the verb every engine-bound merge runs (`'aggregate'`) and the context that merge forwards. - An `ok: false` verdict is raised through the module's one envelope helper, `readScopeCompileError`. The engine's sentence stays in the thrown message for the operator's log, and the 500 declaration withholds it on the wire. The verdict's own `code` / `status` describe a caller's mistake, so they do not travel either. - A throw from the judge itself (a fault, not a verdict) is raised in the same envelope. - No judge, or an `undefined` answer, means the scope is not judged here ("cannot answer, do not block"). - It is exported from the file only. The package entry is unchanged. - The header gains a ruling-C section, and the paragraph that said these doors were out of reach is updated. - **`strategies/objectql-strategy.ts`: called at both engine-bound merges**, `withReadScope` (direct path and cross-object base aggregate) and `resolveFkAttr` (the referenced object's scope). It runs after the existing guards (vacancy, comparand faces, placeholders), so a scope they refuse keeps their sentence. It runs before the `'policy'` mark, like them. - **`strategies/types.ts`: `DatasetScopedStrategyContext.judgeFilter?`**, the package-local hook, typed from the contract member itself (`IObjectQLEngine['judgeFilter']`, made non-nullable) plus the `undefined` answer. This is the `declaredFieldType` / `sqlDialect` pattern. - **`analytics-service.ts`: `AnalyticsServiceConfig.judgeFilter?`**, passed to the strategy context untouched. A service configured with no judge logs one `warn` on its first unjudged scoped merge, naming the consequence and the remedy. - **`plugin.ts`: the judge is wired to the engine the `executeAggregate` auto-bridge executes on**, resolved per call through the same `tryGetDataEngine`. It is wired ONLY when the plugin bridges `executeAggregate` itself. The judge must be the executor, or it would refuse scopes the executor serves, and a host that supplies its own `executeAggregate` has not said which engine that is. - A `data` engine without `judgeFilter`: `undefined`, plus one `warn` from the plugin. - No engine at all: `undefined`, silently, because the executor refuses that query itself. - **`plugin.ts`, record-label fetch (the objectstack-ai#14329 door): the same checks as `resolveFkAttr`.** This is a fourth engine-bound merge. It `$and`s the referenced object's scope into `executeAggregate` to turn a lookup dimension's ids into labels, and it ran only the vacancy guard. Measured before this change: on the dataset door, a selection ordered by a lookup dimension runs the sort-key label pass, and that pass relayed the engine's 400 with policy content. It did so for the residue classes and also for classes every other merge already withheld (a list in the equality slot, an unknown placeholder). It now runs the comparand faces, the placeholder resolver and the engine's admission on the scope alone. See "Scope" under Acceptance notes. ⛔ **Not a catch around `executeAggregate`.** The caller's own `where` is never judged here. Pinned, and ablation E6 shows those pins turning red under a blanket catch. **Why a served scope stays served.** The judge is the executing engine, under the same verb and context. `judgeFilter` runs the engine's two admission stages, the same functions in the same order execution runs, and stops before any driver. Every object-form door judges a node against the field map and the context, never against its siblings. So the scope alone is admitted exactly when the scope inside `{ $and: [userFilter, scope] }` is. Ablation E8 turns the served-placeholder control red when the judge reads a different context. ## Premises, measured before writing the fix 1. **The contract member and its implementation** (`objectql-engine.ts:306`, `engine.ts:8783` on `ce70876e4c`). Read, and measured: for one refused filter, `judgeFilter(..., { operation: 'aggregate' })` returned the same `code`, `status` and message string that `aggregate` raised. 2. **What the `data` service hands out.** In a booted `LiteKernel` with `ObjectQLPlugin` and `AnalyticsServicePlugin`, `getService('data')` is the same object as `getService('objectql')`. It is an `ObjectQL` instance, and `typeof judgeFilter` is `'function'`. It is not a wrapper. 3. **The four classes on current main.** They relayed policy content on both doors at base `ce70876e4c` (table below). 4. **The verb.** `'aggregate'` reproduces the execution message exactly (premise 1). The verb changes only the message prefix, never the verdict. 5. **The existing guards.** Kept. An unwired host relies on them, and the pins below show such a host still withholds a guarded class while the four residue classes keep today's engine 400. ## Measurement: both analytics HTTP doors, before and after **How.** A scratch probe, never committed, lived in `packages/runtime/src` only while it ran. - **Kernel:** a real `LiteKernel` booted with `ObjectQLPlugin` and `AnalyticsServicePlugin`. The plugin auto-bridged `executeAggregate`, and after the fix it wired the judge, to a real `ObjectQL` over `SqliteWasmDriver`. The plugin options supplied `getReadScope` and `admitObjectRead`, and fixed `queryCapabilities` to the ObjectQL face. Nothing else was stubbed. - **Doors:** `@objectstack/runtime`'s dispatcher, `POST /api/v1/analytics/query`, and `@objectstack/rest`, `POST /analytics/dataset/query`. - **Runs:** before = base `ce70876e4c`; after = this branch with the `service-analytics` dist rebuilt (the new sentence is present in `dist/index.js` and `dist/index.cjs`). - **"Policy content"** = the synthetic policy field name or comparand appears anywhere in the response body. **"Log"** = the refusal's detail reached the door's error-log channel. | Read-scope class | Both doors, before | Policy content in body, before | Both doors, after | Policy content in body, after | Detail in the server log, after | |---|---|---|---|---|---| | Text operator over a non-text field | `INVALID_FILTER` / 400 | yes | `READ_SCOPE_COMPILE_FAILED` / 500 | no | yes | | Temporal comparand the field cannot interpret | `INVALID_FILTER` / 400 | yes | 500 | no | yes | | Filter on a virtual (formula) field | `INVALID_FIELD` / 400 | yes | 500 | no | yes | | Dotted path through a lookup | `INVALID_FIELD` / 400 | yes | 500 | no | yes | | A residue class in the BASE scope on the cross-object path | 400 | yes | 500 | no | yes | | A residue class in the REFERENCED object's scope (text operator; dotted path into a scalar) | 400 | yes | 500 | no | yes | | The nine comparand classes (PR objectstack-ai#20017 / objectstack-ai#20046): list in the implicit equality slot, list under `$eq`, scalar under `$in`, scalar under `$nin`, one-bound `$between`, plain-object member in `$in`, `undefined` comparand, plain-object comparand under `$eq`, `null` member in `$in` | `READ_SCOPE_COMPILE_FAILED` / 500 | no | unchanged | no | yes | | The two placeholder classes (PR objectstack-ai#20072): unknown placeholder; known placeholder the context cannot resolve | 500 | no | unchanged | no | yes | | Refused `$icontains` comparand (objectstack-ai#20068) | 500 | no | unchanged | no | yes | | The four `driver-sql` doors (PR objectstack-ai#20037): missing column; retired or unknown operator; combinator with a non-array operand; non-boolean `$null` / `$exists` | `INVALID_FILTER` / 400, withheld | no | unchanged | no | unchanged | | Record-label fetch, sort-key pass (dataset door): a residue class on the referenced object | 400 | yes | 500 | no | yes | | Record-label fetch, sort-key pass (dataset door): list in the equality slot; unknown placeholder | 400 / `FILTER_TOKEN_UNKNOWN` 400 | yes | 500 | no | yes | The `/analytics/query` door has no label pass. The display label pass catches a failed fetch and renders raw ids: 200 before and after, with the detail in the `warn` log. **Controls, identical before and after:** - A well-formed scope answers 200 with exactly its rows. - A scope with a placeholder the context resolves answers 200 on the dataset door. The dispatcher harness carries no user, so that door answers the withheld 500 both before and after. - A well-formed referenced-object scope buckets what it hides as `(restricted)`. - A well-formed referenced scope on the label pass is served. - The caller's own `where` in each of four shapes (text operator on a number field, uninterpretable temporal comparand, virtual field, dotted path) answers its 400 on both doors, and the body carries the caller's own diagnostic. ## Tests New file: `src/__tests__/objectql-read-scope-engine-admission.test.ts`, 19 cases. Each builds `AnalyticsServicePlugin`'s own composition over a real `ObjectQL` + `SqliteWasmDriver` as its `data` service. Only `queryCapabilities` is fixed to the ObjectQL face. - **Refusal pins** assert `code` `READ_SCOPE_COMPILE_FAILED` and `status` 500. They also assert the two reads every analytics HTTP door takes before relaying prose: `serverFaultProvenance(resolveThrownHttpError(err, 500))` is `'declared'`, and `declaredRefusalMessage(err)` is undefined. The thrown message, which is the log channel, still names the detail. - The four classes on the direct path. - A well-formed caller `where` beside a refused scope. - The cross-object base scope, and the referenced-object scope. - The record-label sort-key pass. - A judge that throws. - **Preservation pins:** - A well-formed scope, and a placeholder the forwarded context resolves, are served with exactly their rows. - A well-formed referenced scope keeps its `(restricted)` bucket. - The label pass with a well-formed scope is served, sorted by label. - The caller's own `where` (text operator, temporal comparand, virtual field) keeps `INVALID_FILTER` / `INVALID_FIELD` / 400 with its message and no server-fault declaration. - **Unwired tiers:** - `AnalyticsService` with no judge keeps the engine's 400 for a residue class, still withholds a guarded class, serves a well-formed scope, and logs exactly one line across three queries. - A plugin host with its own `executeAggregate` is not wired to a guessed engine: a residue class keeps the engine's 400. At the record-label fetch, the comparand and placeholder guards this PR adds there withhold a guarded class and an unresolvable placeholder; they are new on that host too. - A `data` engine without `judgeFilter` keeps the 400, and the plugin logs exactly once across two queries. **Results on the final head `f6dbebe5`:** - `pnpm --filter @objectstack/service-analytics test`: `Test Files 129 passed (129)`, `Tests 3041 passed (3041)`. - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. `tsc --noEmit --listFiles` includes the new test (count 1). - **Downstream consumers**, run because the wire envelope of already-refused scopes moves. Each run was against the rebuilt `service-analytics` dist: - `@objectstack/rest`: 10 `analytics-*` files, 148 tests green. - `@objectstack/runtime`: the 19 test files that touch analytics, 566 tests green. - `@objectstack/dogfood`: the 6 analytics-touching files, 36 tests green. These boot the real stack, where the judge is wired, and include the label-scope and RLS suites. - `@objectstack/client`: the analytics test, 7 green. ## Ablations Each leg ran from committed state through `scripts/ablation-replace.mjs` in WRAP mode, against the new test file. The anchor had to hit exactly once and the blob had to change. Every restore was proven: the blob equals HEAD, and `git diff HEAD` is empty. An outer shell trap restored all four source files from `HEAD` on any exit. The subject is imported relatively from `src`, so no dist is involved. Every direction was predicted before the run; E1 reddened two more pins than predicted (below). | Leg | Mutation | Result | |---|---|---| | E1 | Delete the `withReadScope` judge call | 9 failed. Predicted 7 (the four classes, the scope beside a caller `where`, the cross-object base scope, the throwing judge). Also red: both once-log pins, which need that call to ask at all. | | E2 | Delete the `resolveFkAttr` judge call | 1 failed: the referenced-object pin | | E3 | Delete the label-fetch judge call | 1 failed: the label sort-key pin | | E4 | Delete the label-fetch comparand guard | 1 failed: the unwired plugin host's label pin | | E4b | Delete the label-fetch placeholder guard | 1 failed: the same test's placeholder assertion | | E5 | Wire the judge from the `data` engine even when the host supplied its own `executeAggregate` | 1 failed: "not wired to a guessed engine" | | E6 | Wrap the direct `executeAggregate` in a blanket catch re-raised as the withheld 500 | 6 failed: the three caller-`where` pins and the three unwired-tier pins that expect the engine's 400 | | E7 | Judge the COMPOSED tree instead of the scope alone | 3 failed: the caller's own `where` was misattributed as the scope's 500 | | E8 | Judge with no context instead of the forwarded one | 1 failed: the served-placeholder control was refused | | E9 | Drop the service's once-flag | 1 failed: two warn lines | | E10 | Drop the plugin's once-flag | 1 failed: two warn lines | | E11 | The service never logs the missing judge | 1 failed: zero warn lines | | E12b | Relay the engine's verdict as-is (its code, status and message) | 8 failed: every judge-dependent refusal pin | The first E12 attempt was a no-op: its replacement contained its own anchor, so the anchor count stayed at 1, the tool refused, and no test ran. It was re-run as E12b with a replacement that does not contain the anchor. ## Gates - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on `f6dbebe5` derived 61 commands, the same count as the dispatch-time list. All 61 exited 0, each exit code captured right after a single redirect. The `--ran` reconciliation read: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 61 recorded an exit code and none of them is 3)`. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET). After `turbo run build --filter=./packages/* --filter=./packages/*/*` (71/71 tasks) it exited 0. `check:dts-closure`, `check:sourcemap-no-sources-content`, `check:published-files` and `check:lean-entry-closure` were re-run on that build and exited 0. - `check-plugin-teardown-shape.mjs --self-test` first exited 3: the shallow checkout could not reach its pinned positive-control commit. After fetching that one commit it exited 0. - **Outside the derivation, run because the diff adds a `warn` in `plugin.ts` and in the service:** `check:startup-registry-verdict` 0, `check:durability-log-level` 0. - **Issue citations.** `node scripts/check-issue-citations.mjs`: 28 citations across 5 files, all resolve. - **Lint, narrowed to the 6 touched TypeScript files.** - `eslint --no-inline-config --format json` reported 6 files, 0 errors, 0 warnings, none ignored. - `eslint --print-config` for each file shows `parserOptions` limited to `ecmaVersion` / `sourceType`, with no `project` and no `projectService`. Type-aware linting is off, so this diff cannot move a verdict on an untouched file. - `pnpm lint` itself is CI's. ## Acceptance notes - **Exported types.** `AnalyticsServiceConfig` (exported from the package index) gains one optional member, `judgeFilter`. Its type, `ReadScopeFilterJudge`, is exported from `strategies/types.ts` only, not from the index; it reaches the published declarations through that member. `DatasetScopedStrategyContext` (not exported) gains `judgeFilter`. `AnalyticsServicePluginOptions` is unchanged. Changeset: `@objectstack/service-analytics` patch. - **Wiring.** The plugin wires the judge only when it auto-bridges `executeAggregate`. That is how every shipped composition boots (`os serve`'s capability provider, the verify harness): no host in this repository passes its own `executeAggregate`. A plugin host that does keeps today's behaviour, and logs one `warn`, everywhere except the plugin's record-label fetch, whose new comparand and placeholder guards run on every host that uses it (see Scope below). A host constructing `AnalyticsService` directly can pass `judgeFilter`, from the engine its `executeAggregate` runs on. - **Precedence.** When the caller's `where` and the scope are both refused, and only the engine would refuse the caller's clause, the scope's 500 answers first. This is the same precedence PR objectstack-ai#20017 and PR objectstack-ai#20072 recorded. - **Scope: the record-label fetch.** The fourth merge is repaired in place: same defect class, a mechanical repeat of the `resolveFkAttr` form, a file on the claim's surface, and no new gate family. On a host whose own `executeAggregate` runs on something other than ObjectQL, the label fetch now refuses off-contract scope shapes that such an executor tolerated. That is the same note PR objectstack-ai#20017 carried for `resolveFkAttr`; the ObjectQL executor refused every one of them already. The display label pass's catch is unchanged. - **The once-lines** are per service instance and per plugin instance, emitted on first use, never at init. They show up once per test file that builds a service without a judge. - **`origin/main`** moved by one commit after the base, `805af4f2` (`packages/cli` only). It shares no path or behaviour with this diff and is not merged. - **Observation, not filed (zero pull).** In a kernel with no `security` service, the analytics object-read admission bridge answers `PERMISSION_DENIED` / 403 on every query. The kernel's `getService` throws for a missing service, and the bridge reads a throw as "unusable" (fail-closed). Its init warning describes the opposite. Every shipped composition includes `SecurityPlugin`, and the failure direction is fail-closed. Measured incidentally by the probe. - **Files not touched:** `filter-normalizer.ts`, `preview-evaluator.ts`, `text-match-sql.ts`, `native-sql-strategy.ts`, `packages/objectql`, `packages/spec`. ## Seat append — patch round 1 (head `d9a1002f`) - The at-tier contract review of `f6dbebe5` (record `5856105439`) found two overclaims in the changeset prose. The dev corrected them, plus two adjacent imprecisions, in `d9a1002f` (`.changeset` only, +3/−3). - Two sentences of this body carried the same overclaim as the review's defect 2: the Tests "Unwired tiers" bullet and the Acceptance-notes "Wiring" sentence. The seat corrected both in place, using the dev's text from its round-1 report. No other byte of this body changed. --- _Generated by [Claude Code](https://claude.ai/code/session_01TEah6PeJGjxJfbHaySJjLQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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:NativeSQLStrategy.applyReadScope, for the base table and every joined hop;/analytics/sqlecho,ObjectQLStrategy.generateSql.It is also a public export of the package. Once its own lowering returns, it now calls
assertReadScopeComparandsRunnableon the scope. That is the helper PR #20017 added in the same module, and it runs@objectstack/spec/data'sassertListComparandShapesandnormalizeFilterComparandTypeson 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./analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018);comparand-shape.ts, whose "accepted in every bind position" is no longer true of the read-scope door;objectql-strategy.tsandnative-sql-strategy.ts. Both faces reach the guard through the compiler, so neither needs a call site of its own.filter-normalizer.ts,preview-evaluator.tsandpackages/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.8c80d9c3d2(the new test file alone).SqliteWasmDriverwith four fixture rows.regionis NULL on d3, andownerandamountare NULL on d4.ObjectQLengine.NativeSQLStrategy.executethroughexecuteRawSqlon the same database. It was not stubbed.getReadScopecontract, filled by hand.$eq(the card's first shape)READ_SCOPE_COMPILE_FAILED/ 500DATABASE_ERROR/ 500$in,['emea', null](the card's second shape)$in$inunder$not$nin$gt/$lte$betweenbound$betweenbound$ne/$gt; empty object under$eqDATABASE_ERROR/ 500$in)$inmemberMapor function comparandDATABASE_ERROR/ 500The 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
case '$eq': return val === null ? … : `${col} = ${bind(params, val)}`;. A plain object is bound, and no gate judges a$eqcomparand's type.assertNoFieldReferenceComparandsteps past an object that is not{ $field: string }.case '$in'→assertCompilableMembers(op, field, val)→isBindableComparand(member), which admitsnull(an accepted comparand type) and binary (a package-local extra), thenIN (…)binds each member.$betweenbind whatever passes those gates. The implicit-equality arm binds any non-object.read-scope-eq-array-refusal(15) andread-scope-undefined-comparand(8).Producers: who can emit these shapes today
All readings below are on
9d81af714f.usingpredicates, throughcompileCelToFilter→RLSCompiler.compileFilterf in ['a', null],f in [null],!(f in ['a', null]),f > null,f <= nullandf != current_usereach lower verbatim into a refused shape.isSupportedRlsExpression, andvalidateRlsPredicateEnforceabilityreturns 0 findings for each.git grepofusing/checkpredicates for a null list member or a null ordering comparand, overexamples/**andpackages/**/*.ts(tests excluded): 0 matches, exit 1. The control on the same tree and file set, predicates namingcurrent_user, matched 77 lines in 7 files, exit 0.f in current_user.teamswith a null member lowers to the deny sentinel.plugin-sharingbuildReadFilterString(userId)or a resolver'sstring[].grantedRecordIdsfilters outnulland''.getReadScopeoption, or a direct caller of the exportVerdict: no producer both legitimately authors one of these shapes and relies on the native answer. Three facts support that:
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 oneSqliteWasmDriver, a realObjectQLbehind the ObjectQL face, and the echo's SQL executed.codeREAD_SCOPE_COMPILE_FAILED,status500, and the prose withheld. "Withheld" meansserverFaultProvenance(resolveThrownHttpError(err, 500))is'declared'anddeclaredRefusalMessage(err)is undefined.executeRawSqlcalls across all 13 classes.applyReadScope's per-hop lowering refuses a joined object's scope, named for that hop.$eq, keeps its own sentence. A shape only the faces refuse carries their sentence, the same on all three faces.{ region: null },$ne: null, a non-empty$nin, the live RLS composite with an emptied$inbeside an own-rows grant, and the spelling the null-member ruling prescribes ($orof$inand$null: true);wherecomposes with a well-formed scope;wherein 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 readREAD_SCOPE_COMPILE_FAILED/500:nullunderscopeIn,binaryunderscopeInandscopeEq, andplain objectunderscopeEq. This PR moves nowhere-door cell.wheredoor runs every arm of the shared comparand-shape face on the object spelling (#20010) #20032 (service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010), thenullrow carries both re-judged cells:whereInisINVALID_FILTER/400(service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010) andscopeInisREAD_SCOPE_COMPILE_FAILED/500(this PR).accept.comparand-shape-refusal.test.ts. "Keeps binding every legitimate$inmember" dropsnull, and a new case pinsnullrefused 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$nullstill 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:
8c80d9c3d2Tests 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.0fbed27877(final;origin/mainadbbc5d01emerged)Test Files 120 passed (120),Tests 2689 passed (2689)pnpm --filter @objectstack/service-analytics typecheck0fbed27877tsc --noEmit --listFilesincludes the new test fileAblations. Both ran through
node scripts/ablation-replace.mjsin WRAP mode (the anchor must hit exactly once, the blob must change, and the tool's own trap restores). The subject resolves tosrc/through relative imports, so nodist/leg applies. Each restore was proven: blob34705e267dbaequalsHEAD, andgit diff HEADis empty.A1 was re-run on the final head
0fbed27877. A2 ran onf293340e94.read-scope-sql.tsis the same blob,34705e267dba, on both trees (neither merge touched it), so A2's placement result stands.0fbed27877)assertReadScopeComparandsRunnable(filter, alias);call26 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 atf293340e94(26 failed | 2567 passed (2593)).f293340e94)compileNodeinstead of after37 failed | 2556 passed (2593): the new ordering pin, plus 36 existing log-sentence and precedence pins in 6 filesThe 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
0fbed27877withnode 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.check:dual-build-cjs-loadsandcheck:type-check-debtfirst answeredPREREQUISITE NOT MET(exit 3), because the workspace had nodist/. After building every./packages/**workspace package (VERDICT command-exit 0), both exited 0:check:dual-build-cjs-loadspasses its floors, andcheck:type-check-debtre-measured 4 ledger entries (53 raw tsc errors) with none above its recorded number.--ranreconciliation: 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, at0fbed27877): exit 0. The changeset isBREAKING+bang+clause-②-narrowing, dispositionnot-required (no-migration-prescription).eslint --no-inline-config --format jsonat0fbed27877: 8 files read, 0 errors, 0 warnings, none ignored.eslint.config.mjsenables no type-aware linting: itsparserOptionscarry onlyecmaVersionandsourceType, with noprojectand noprojectService. So this diff cannot move a verdict on an untouched file.pnpm lintitself is CI's.Acceptance notes
minorwith!. 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/$ltefor a one-sided range, and a scalar comparand.isBindableComparand) no longer reaches the read-scope door. The shared type face refuses binary, and the ObjectQL face already did. Thewheredoor and the predicate itself are unchanged.compileScopedFilterToSqlknows only the alias, so the operator's log readsread scope for "ALIAS": the object name on the base table, the join alias on a hop. The response withholds it either way.whereafter the engine's shared-face seam has run. Whatdriver-sqlanswers for these shapes there is outside this card.nullin aninlist, an ordering comparison againstnull, or a comparison against the wholecurrent_userobject. All three passisSupportedRlsExpression, andvalidateRlsPredicateEnforceabilityreturns no finding for them. They are refused (500) on every analytics face after this PR. The evidence is in the producers table.origin/mainwas merged in twice.b3735968ba: three commits inpackages/objectqlandpackages/plugins/plugin-security, sharing no path with this diff.adbbc5d01e: fix(service-analytics)!: the analyticswheredoor runs every arm of the shared comparand-shape face on the object spelling (#20010) #20032 (service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010) and a driver-sql / driver-turso fix. That merge had one content conflict, in the comparand matrix, resolved as above.comparand-shape-refusal.test.tsandcross-field-reference-refusal.test.tsauto-merged and were re-read on the merged tree. fix(service-analytics)!: the analyticswheredoor runs every arm of the shared comparand-shape face on the object spelling (#20010) #20032's edits there are to thewhere-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$fieldcase ("it binds as JSON") is now scoped to thewheredoor, where it still holds.filter-normalizer.ts,preview-evaluator.ts,objectql-strategy.ts,native-sql-strategy.ts,packages/spec.INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 stays open behind engine + driver-sql: seven filter-refusal doors ignore the #8220 provenance mark, so a refusal inside apolicyread-scope subtree relays the policy field (and some comparands) in its 400: the #7929 redaction is not applied #20020, for its engine- and driver-door residue. This PR does not address it.Generated by Claude Code