Repository navigation
fix(service-analytics)!: the analytics where door refuses a list in the equality slot instead of reading it as IN (#19888) - #20008
Conversation
…ement before the where-door fix Measurement only; no source change. Taken on this branch at its base (origin/main e8f163f) before any fix. In-repo stored filters carrying a list in the equality slot ----------------------------------------------------------- An AST scan (TypeScript compiler API over every git-tracked .ts/.js/.json file; fenced ts/js/json blocks for md/mdx) with four detectors: D1 a `$eq` whose value is an array literal; D2 a field entry whose value is an array literal inside an object under a filter-bearing key (filter, filters, where, runtimeFilter, relatedListFilter, having, baseFilter, defaultFilter, readScope, rowFilter, scopeFilter), walked as a FilterCondition ($and/$or/$not and nested-relation objects descended); D3 a FilterArray triple [field, =|==|equals|eq, [...]]; D4 a filter rule { field, operator: <equality spelling>, value: [...] }. Positive controls on the same walkers: a synthetic fixture (TS and JSON) carrying one of each hits 4/4 in both; C1 counts `$in` array literals; C2 counts filter-bearing objects walked. - examples/**: 228 files (3 tsconfig JSONC unparsed). C1 = 1 (matches the text grep: two `$in` in shipping sources, one a literal), C2 = 91. Hits: 0. Every dataset, dashboard widget, report runtimeFilter and measure filter in app-crm, app-showcase and app-todo uses scalars, `$ne`, `$nin`, `$gte`/`$lt` or date tokens. - packages/**: 6910 files, C1 = 522, C2 = 4636. Non-test hits: 5, all false positives (2 realtime-subscription `eventTypes` lists in packages/client/src/realtime-api.ts; 3 MongoDB aggregation-EXPRESSION `$eq: [a, b]` operands in driver-memory memory-analytics.ts and driver-mongodb mongodb-aggregation.ts). Test/fixture hits: 65, all deliberate refusal fixtures or pins. - md/mdx fenced blocks (content, skills, docs, examples, packages, .changeset): 1545 files, 3162 blocks, C1 = 12, C2 = 176. Hits: 3, all false positives (a `{ field, operator: 'nin', value }` rule in a design note; a plugin-permission `filter` in packages/core PHASE2 notes). - Text grep for a `$eq` list outside packages/ and examples/: 7 hits, all in changesets describing the refusal itself (19757, 19886, 19975); control `$in` list: 140. - Deployed sys_metadata rows: NOT MEASURED (no deployment data here). Conclusion: no in-repo artefact carries the shape; nothing to convert. The analytics faces, three spellings, measured at base ------------------------------------------------------- Probe over a real sql.js engine (SqliteWasmDriver), rows d1 won, d2 lost, d3 open, d4 NULL, d5 'won,lost', d6 '["won","lost"]': - `{ stage: ['won','lost'] }`: native execute and the /analytics/sql echo compile `stage IN (?, ?)` -> d1,d2; the ObjectQL engine path hands the engine `{ stage: { $in: [...] } }`, so the engine's shared-face seam never sees the array; the draft preview string-compares -> d5. - `{ stage: { $eq: ['won','lost'] } }`: `stage = ?` bound to 'won' only -> d1; the second member is dropped in silence; engine path gets `{ stage: 'won' }`; preview -> d5. - `{ stage: { $eq: [] } }`: no WHERE at all -> EVERY row (native, echo, engine `{}`); preview -> none. - `{ stage: [] }`: the FALSE constant -> no row. - FilterArray `['stage', '=' | 'equals' | '==' | 'eq', [...]]`: refused INVALID_FILTER / 400 on the tree, native, echo and engine faces (it is lowered through parseFilterAST, which runs the shared face). - CONTROL `{ stage: { $in: ['won','lost'] } }`: d1,d2 on every face. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…ot through the shared face 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>
…rrowing 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>
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cdbf68847a4b750c3e5aa024b6e243b1750366dc && git checkout cdbf68847a4b750c3e5aa024b6e243b1750366dc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1f05ea4fb296357dedf86ed34a234a1e779b1381 896e1e70f3fd96102dac6a5e5a0f86dbf34217ff && git checkout -B drift-repro 1f05ea4fb296357dedf86ed34a234a1e779b1381 && git merge --no-ff 896e1e70f3fd96102dac6a5e5a0f86dbf34217ff
node scripts/docs-audit/affected-docs.mjs --json 1f05ea4fb296357dedf86ed34a234a1e779b1381 |
… the shared comparand-shape face on the object spelling (objectstack-ai#20010) (objectstack-ai#20032) Part of objectstack-ai#20010: this PR carries the shared comparand-SHAPE face's arms. objectstack-ai#20010 remains open for two arms: `$ne` with a list, which waits on objectstack-ai#19886 stage 2, and the comparand-TYPE face, which the card has answered and objectstack-ai#20035 carries. Clause-②: no (narrowing) ## What this changes The analytics `where` door (`lowerAnalyticsWhere` in `packages/services/service-analytics/src/strategies/filter-normalizer.ts`) now hands every field entry of an object-form condition to the shared comparand-shape face, `assertListComparandShapes` (`@objectstack/spec/data`). Before, it ran only the equality arm (PR objectstack-ai#20008). Each shape below is now refused with `INVALID_FILTER` / 400, carrying the face's own message, path and prescription. The `FilterArray` spelling of the same condition gets the same bytes, because it met the face inside `parseFilterAST` all along. The face's docblock records the rulings that put these positions on it: - 2026-08-31 (objectstack-ai#13357): "A `null` member of `$in` / `$nin`, and a `null` `$between` endpoint (objectstack-ai#13495's shape), are refused at this door". - 2026-09-01 (objectstack-ai#14080): "A `null` comparand of `$gt` / `$gte` / `$lt` / `$lte` … refused at this door, same envelope, so the divergent cells are constructively unreachable". - 2026-09-20 (objectstack-ai#19071): "`''` and `undefined` are refused, naming the blank side (MIN / MAX and the index)". - Its original rule (objectstack-ai#5869, moved to the face by objectstack-ai#9228): `$in` / `$nin` / `$between` receive a list at all. How: - `assertWhereComparandShapes` makes two passes over one shared traversal, `forEachWhereFieldEntry`. The traversal walks `$and` / `$or` / `$not` and field entries, the way the face walks them, and also descends a nested relation, because this compiler flattens one to its dotted member. - Pass 1 is the objectstack-ai#19888 equality pass, unchanged, so a list in the equality slot is still diagnosed first. - Pass 2 hands each whole entry to the face as a one-entry node with the same path seed, `where`. The face therefore reports exactly the path and field it reports on the whole condition. - The draft-data preview (`preview-evaluator.ts`) calls the same exported gate, so a drafted chart refuses what the published chart refuses. ## Measured first (recorded on this branch as `a508dddc2c`, before any source change) Base `origin/main` `44639665ee`. 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, ''). It ran five faces: - `normalizeAnalyticsFilterTree`; - the native SQL execute; - the `/analytics/sql` echo; - the ObjectQL engine path, where a probe `engine.aggregate` records the condition it receives and the engine seam's two spec faces are then run on it; - the draft preview. | arm | object spelling at base | `FilterArray` spelling | governing record | |:--|:--|:--|:--| | null `$in` member `{stage:{$in:['won',null]}}` | tree `in ['won',null]`; native `stage IN (?, ?)`: d1; the engine seam refused it; the preview served d1, d4 | 400, null list member | 2026-08-31 ruling, objectstack-ai#13357 record 5472662504 | | null `$nin` member | `notSet OR notIn ['won',null]`; native: d4 only; the preview served d2, d3, d5 | 400 | 2026-08-31 | | null ordering comparand `$gt`/`$gte`/`$lt`/`$lte` | `amt > NULL`: no row; the preview served d1, d2, d3, d5 for `$lt` / `$lte` | 400, null ordering comparand | 2026-09-01 ruling, objectstack-ai#14080 record 5494521768 | | null `$between` endpoint `[null, 5]` | `amt >= NULL AND amt <= 5`: no row; the engine received `{amt:{$gte:null,$lte:5}}` and refused it as a `$gte`, an operator the author never wrote | 400, null range bound | 2026-08-31 | | blank `$between` endpoint `['', 5]` | `amt >= '' AND amt <= 5`; the engine path ACCEPTED `{$gte:''}`; the preview served d1, d2, d5 | 400, blank bound, MIN named | 2026-09-20 ruling, objectstack-ai#19071 record 5748839561 | | scalar `$in` `'won'` | laundered to `in ['won']`: native d1; the engine accepted it as a list | 400, requires an ARRAY | objectstack-ai#5869 / objectstack-ai#9228 | | scalar `$nin` `'won'` | laundered: native d2, d3, d4, d5; the engine accepted it | 400 | objectstack-ai#5869 / objectstack-ai#9228 | | `$in: null` | `in [null]`; the engine refused it as a null MEMBER (after the laundering) | 400, requires an ARRAY | objectstack-ai#5869 / objectstack-ai#9228 | | undefined `$between` endpoint | refused 400, in this door's objectstack-ai#6386 wording | refused 400, the face's blank wording | 2026-09-20 names `undefined`; wording only | | `{ $field }` `$between` endpoint | refused 400, this door's objectstack-ai#7598 wording | refused 400, the face's wording | 2026-08-11 (objectstack-ai#7596); wording only | | one-bound / scalar `$between` | refused 400, this door's "two-element" wording | refused 400, the face's wording | objectstack-ai#5869 / objectstack-ai#9228; wording only | | nested relation `{acct:{amt:{$gt:null}}}`, dotted `{'acct.amt':…}` | compiled `acct.amt gt [null]` | refused | as the arm | | `$ne` with a list `{stage:{$ne:['won','lost']}}` | both spellings compile `stage IS NULL OR stage != 'won'`; `'lost'` is dropped | the same: the face does not judge `$ne` yet | waits on objectstack-ai#19886 stage 2 (ruling A, record 5805254639) | After the change, re-measured at `142b554f0e`, every row above except `$ne` is refused on all five faces. The object message equals the `FilterArray` message byte for byte, for every arm that has a `FilterArray` spelling. The one exception is the nested-relation spelling: the face names the leaf key (`"amt"` at `where.acct.amt.$gt`), which is the convention objectstack-ai#19888 set, while the dotted spelling names `"acct.amt"`. The `$ne` row is unchanged on both spellings. **Stored filters in this repository.** A text scan covered 4013 non-test tracked files under `examples/`, `packages/`, `content/`, `skills/`, `apps/` and `docs/`. Its detectors were: a `null` member of a `$in` / `$nin` list; a `null` ordering comparand; a `null` / blank / `undefined` `$between` endpoint; and a scalar `$in` / `$nin`. Positive controls: a synthetic fixture hit on all four detectors; C1 = 451 `$in`/`$nin` lists, C2 = 495 ordering operators and C3 = 61 `$between` lists were counted. Every hit is a false positive: a comment, an operator-name map, a doc line or a CHANGELOG line. **No authored filter carries a moved shape.** Limits: this is a text scan, not an AST scan, so a comparand held in a variable is invisible to it. Deployed `sys_metadata` rows: NOT MEASURED. ## Compile surfaces (the face's non-equality arms) | surface | verdict | |:--|:--| | `lowerAnalyticsWhere` / `normalizeAnalyticsFilterTree`: caller `where`, dataset scope `filter`, measure `filter`; native execute, `/analytics/sql` echo, ObjectQL engine path | **changed.** Every arm is refused before any statement or `engine.aggregate` call. Measured over sql.js before and after; pinned per face. | | `evaluateAnalyticsQueryOverRows` / `matchesWhere` (draft preview) | **changed**, as a bounded in-place fix (see Deviations). It calls the same exported gate. | | `assertListComparandShapes` (the spec's shared face) | **already compliant.** It is the face; this PR calls it and does not change it. | | `parseFilterAST` (this door's `FilterArray` spelling) | **already compliant.** Measured at base: every arm's `FilterArray` spelling was refused. | | ObjectQL engine seam (`lowerWhereFilterArray`, `engine.ts`) | **already compliant.** It runs the shape and type faces on the object form. At base it refused the null arms it received, but it accepted what this door had already laundered (`$in: ['won']`, `$gte: ''`). That is no longer reachable from this door. | | `compileScopedFilterToSql` (service-analytics read scope) | **out of scope.** It is a separate door with a separate envelope (500), and `read-scope-sql.ts` was held by PR objectstack-ai#20017 during this dispatch. Not re-measured here. | | `applyFilterCondition` (driver-sql), `checkCondition` (driver-memory), `buildWhereSQL` (driver-turso), `matchesFilterCondition` (formula), `translateFieldOperators` (driver-mongodb) | **already compliant at the platform doors.** Each sits behind `parseFilterAST` or the engine seam, which run the face (the rulings' own scope). Not re-measured here. | | `applyHaving` / `matchesHaving` (objectql HAVING) | **out of scope.** A different door, over aggregated rows. | ## Tests and evidence (head `33c73fdff4`, `origin/main` `26550c6603` merged in) - **Round 2 (`33c73fdff4`): a consumer pin in `packages/rest`.** CI was red on `a1bdbf1d59` (Test Core 3/6): `src/analytics-filter-refusal-envelope.test.ts` asserted the old analytics sentence for a one-bound `$between` (`/needs a two-element \[min, max\] array/`). The verdict was already right; only the message had moved. That one case is re-judged, test-only. Its verdict assertions stay (400 `INVALID_FILTER` through the real dataset route, which is objectstack-ai#5352's point), and the message assertion now matches the face's sentence, with a note citing the face's rule (objectstack-ai#5869, moved to the face by objectstack-ai#9228). - **Census of consumer pins** on the three re-worded refusals, across every test file under `packages/**` and `examples/**` (4014 files). It searched for the analytics "two-element" sentence, the objectstack-ai#7598 `{ $field }` endpoint sentence ("may not be a field reference", "has the field reference"), the objectstack-ai#6386 `undefined` sentence at a `$between` position, and the `[analytics] "$between"` prefix. It then checked, one by one, every test in the 100 files outside this package that reach `service-analytics`, for `$between` / null / scalar-`$in` shapes. **One hit:** this `packages/rest` case. Every other "two-element" hit is a date-range arity message, a search-term arity or a spec test. The remaining `$between` hits are drivers' own refusals or the read-scope door, none of which this PR moves. - **Full `@objectstack/rest` suite** (built against this branch's `service-analytics` `dist/`): 194 files, 3265 passed and 1 skipped. `VERDICT command-exit 0`. The touched file alone: 31 passed. - **Ablation D, dist-resolved.** This case reads `@objectstack/service-analytics` through `dist/`, so it was run as a dist ablation. The all-arms pass was removed through `scripts/ablation-replace.mjs`, the package was rebuilt, and `ablation-dist-preflight` put the marker in 2 built files. Predicted 1; measured **1 red / 30 green**: `expected '[analytics] "$between" on "amount" ne…' to match /Operator "\$between" on field "amount…/`. Restore: source blob == HEAD, rebuilt, `--absent` read the marker absent from all 6 built files, whole tree clean. - `pnpm --filter @objectstack/rest typecheck` exits 0, but that package's `tsconfig` does not compile its test files (0 in `--listFiles`), so it measures nothing about this one. `eslint --no-inline-config` on the file: 0 errors, 0 warnings. - **New `src/__tests__/where-face-arms-refusal.test.ts`, 101 tests.** Every refusal asserts `code` and `status`, plus the face's opening sentence. - Every arm at 25 positions: each null, blank, non-list, one-bound and `{ $field }` shape; under `$and`, `$or`, `$not` and `$not` over `$or`; beside a legal operator; inside a nested relation; on a dotted member. - Byte identity (17): the object spelling equals the `FilterArray` spelling, which equals the face's own message. - What is diagnosed first (3). The equality list comes first even when another refused entry precedes it. The face comes before this door's member gates (objectstack-ai#6386 undefined, objectstack-ai#6444 mixed wrapper). CONTROL: an `undefined` outside a `$between` endpoint keeps objectstack-ai#6386's sentence. - `$ne` held as PARITY with the face (2). The test does not pin the objectstack-ai#19886 defect in either direction. - 15 neighbouring shapes that compile as before: `$in: []`, `$nin: []`, falsy members, scalar ordering comparands, `{ $field }` in an ordering slot, the null predicate spellings, `$contains: null`, a nested scalar, and four legal `$between` ranges, including a whitespace-only endpoint, which the face does not judge. - Four faces over a real engine × 8 arms, plus a CONTROL. Each is refused before any statement and before any `engine.aggregate` call. - A stored dataset through the service doors (6): the dashboard door and the draft preview, for a scope filter and a measure filter; the registered cube on the ObjectQL door; and a CONTROL with the face's prescribed spelling. - **Pins re-judged with a note, each quoting the ruling that moves it:** - `comparand-door-single-source.test.ts`: the `null` row's `whereIn` cell goes from `accept` to `INVALID_FILTER/400` (2026-08-31). Its six-types assertion now names that one exception. - `filter-normalizer-undefined-comparand.test.ts`: four `null`-control rows leave the group: `$gt: null` (objectstack-ai#5526's by-construction reading, which the face records was a position "no ruling covers" until 2026-09-01), `$in: [null]`, `$nin: [null]` and `$between: [null, 5]`. The `$between: [undefined, 5]` row moves to the new file (2026-09-20 names `undefined`). - `filter-value-type-fidelity.test.ts`: `$gt: null` goes from "binds NULL" to refused before anything binds. The case's own concern, that no `''` is bound, still holds. - `comparand-shape-refusal.test.ts`: the `$in` member list drops its `null`, and the null-carrying list is asserted refused in the face's words. - `cross-field-reference-refusal.test.ts` (2), `filter-operator-coverage.test.ts` (1) and `filter-refusal-envelope.test.ts` (1): a `{ $field }` endpoint and a one-bound `$between` keep their verdict and now carry the face's wording. - **Package run** (`pnpm --filter @objectstack/service-analytics test`, at `a1bdbf1d59`; no `service-analytics` file changed since): 119 files, 2656 tests passed. `typecheck` exited 0, and `tsc --listFiles` includes all ten touched files. Base (before the merge of `origin/main`): 117 files, 2541 tests. - **Ablations.** Each ran from the committed fix through `scripts/ablation-replace.mjs` (the anchor must hit, the mutation is verified on disk, the restore is proven by the blob matching HEAD and an empty `git diff HEAD`), with the red count predicted before running. The tests import the source by relative path, so no build is on the path. - **A: the all-arms pass removed** (the equality pass kept). Predicted 87; measured **87 red / 2550 green**: 80 in the new file, plus the 7 re-judged pins in 6 files. Samples: `native execute: expected a refusal, got rows: expected [ 'd1' ] to be undefined`; `draft preview: expected a refusal, got rows: expected [ 'd1', 'd4' ] to be undefined`; `expected 'accept' to be 'INVALID_FILTER/400'`. - **B: the preview's gate call removed.** Predicted 16; measured **16 red / 2621 green**: this file's 8 preview face cells and 2 stored preview-door cells, plus objectstack-ai#19888's 4 and 2, since the one call now carries both. Sample: `draft preview: expected a refusal, got rows: expected [ 'd1', 'd2', 'd3', 'd4' ] to be undefined`. - **C: the equality-first pass removed.** Predicted 1; measured **1 red / 2636 green**: the precedence case. The all-arms pass runs the face's equality arm per entry, so pass 1 buys exactly the cross-entry order. - **Gates.** `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 61 at `33c73fdff4`: the `packages/rest` edit adds `check:dispatcher-error-vocabulary`, which round 1 ran from the dispatch list. The union was re-run on this head. - 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`: 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN. - Among the passes: `check-adr-0087-registration --base origin/main` (one declared-breaking changeset, `not-required (already-registered)`), `check-changeset-no-major`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check:where-matcher`, `check:test-source-alias`, and `check-issue-citations` in its board-probing mode (32 citations, all of which resolve). - **Lint, narrowed to the change.** `eslint --no-inline-config --format json` over the 10 touched TypeScript files: 10 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each. `eslint.config.mjs` never enables type-aware linting (its note near line 327), so this diff cannot move the verdict on an untouched file. ## Deviations from the dispatch, stated 1. **The comparand-TYPE face is not carried.** Measured, the object spelling skips it too. A plain object under `$gt` / `$ne` / `$eq`, a `$between` endpoint that is a plain object, a binary or `Map` comparand, and a bigint beyond 2^53 are all accepted on the native path, bound as JSON text or as-is. `{stage:{$ne:{a:1}}}` served every row. The `FilterArray` spelling and the engine seam refuse each of them. `undefined` is not one of these: it was already refused on both spellings (the wording differs). - Measured cost of carrying the whole type walk the way `parseFilterAST` and the engine seam run it: **48 pins red, 35 more than the shape face**, across nine files. - It re-words this door's own refusals from five earlier cards: objectstack-ai#6386's undefined prescription, objectstack-ai#5234's unbindable `$in` member and LIKE-family sentences, objectstack-ai#6444's order, and objectstack-ai#7598 / objectstack-ai#7693. - It flips the `{ $eq: {…} }` account that objectstack-ai#5234 "left open" and that objectstack-ai#7598 made load-bearing, and objectstack-ai#8186's recorded binary admission. - Carrying only the accept-cells instead needs the type walk run after this door's own gates, as an assert whose narrowing is discarded. That is a second architecture. - Two readings lead to two architectures, so this set is returned as a decision (the dispatch's four-axis block, in the report). No door-specific ruling gives these cells a different meaning; the objectstack-ai#7872 ruling covers them. The stop is taken on the role file's two-architectures clause, not on Section 1's ruling clause. - **Round 2:** the card has answered it, and objectstack-ai#20035 carries it. Nothing of it is started here. 2. **File surface.** The claim declared `filter-normalizer.ts`, the matrix's `where*` cells, the named pin files, new tests and the changeset. This PR also touches: - `src/preview-evaluator.ts`: one import, one call and comments. This is a bounded in-place fix, and all four conditions hold. It is the same defect class. It is a mechanical call of the same exported gate, whose shape the face pins. No open PR touches the file: objectstack-ai#20017, the only other PR in this package, landed before this branch merged `origin/main`. It is the same gate family, with no new verification surface. Without it, this change would have created a publish-refuses / preview-charts split for every moved arm. The claim's file surface needs this path added. - `comparand-shape-refusal.test.ts`, `cross-field-reference-refusal.test.ts`, `filter-operator-coverage.test.ts` and `filter-refusal-envelope.test.ts`: existing pins on a moved arm or its wording, re-judged with notes. - **Round 2:** `packages/rest/src/analytics-filter-refusal-envelope.test.ts` (the `domain:cli` lane), one case, test-only. The seat authorized it as this claim's own follow-through, and the claim's file surface is revised on the card. The census found no other consumer pin. 3. **ADR-0087 disposition.** The changeset's marker is `not-required (already-registered filter-between-blank-endpoint-refused)`. The blank endpoint is the one moved arm whose transition is on the ledger. The null-member, null-endpoint and null-ordering transitions were ruled at the face with no ledger entry: their changesets declared `no-migration-prescription`. This changeset carries the FROM → TO table the dispatch asked for, so `no-migration-prescription` would be refused by the gate. The marker's own text states each arm's ledger status. 4. **`origin/main` merged in** (`da44a2be39`): PR objectstack-ai#20017 landed in this package during the run. After a reinstall and a rebuild, the package suite, the typecheck and the gate union were re-run on the final head. 5. **Round 1 missed a consumer outside the package.** Round 1 ran only the `service-analytics` suite and so did not see the `packages/rest` pin on the re-worded one-bound `$between` sentence; CI did. Round 2 fixed that case, ran the census, and ran the full `@objectstack/rest` suite. ## Acceptance notes - **`$ne` with a list** is carried the day objectstack-ai#19886 stage 2 puts its refusal on the face, with no change here. The parity test holds this door to whatever the face answers. - **Wording convergence.** Three shapes this door already refused now read in the face's words: a one-bound `$between`, a `{ $field }` endpoint and an `undefined` endpoint. `fieldLeaves`' own checks for them stay as that function's invariants, annotated, and are no longer reached from the door. - **Nested-relation field naming** follows objectstack-ai#19888: the leaf key in the message, the full path in `at`. - **Draft preview and an `undefined` comparand** (not this card's arm; reported). `evaluateAnalyticsQueryOverRows` answers no row for `{stage: undefined}` / `{amt: {$gt: undefined}}`, while every published face refuses it 400 (objectstack-ai#6386). The preview never ran objectstack-ai#6386's gate. This is reachable only in-process, because `undefined` cannot cross JSON. --------- Co-authored-by: Claude <noreply@anthropic.com>
…in the comparand face's own words (objectstack-ai#19889) (objectstack-ai#20047) Fixes objectstack-ai#19889 Clause-②: no This carries out ruling **5805248669** (letter **A**): the schema door refuses an array in the equality slot, with the compile face's own words. `Clause-②: no` is the claim's line, copied as it stands. The changeset carries `Clause-②: no (narrowing)` because AGENTS.md makes a narrowing BREAKING and `check:adr-0087-registration` reads the arm there. Both lines give the same value. Session `session_019c3Hi6ZMU1p6m6aA6Bz45d`, branch `claude/issue-19889-filter-schema-door-array-equality`, base `a0920b42dc`. Every reading below was taken at head `46e1c81727` unless it says otherwise. ## 1. Reproduction (before the change, on `origin/main` `a0920b42dc`) | input | answer | |:--|:--| | `DatasetSchema.safeParse`, control `filter: { stage: 'won' }` | `success: true` | | `DatasetSchema.safeParse`, `filter: { stage: ['won','lost'] }` | **`success: true`** | | `DatasetSchema.safeParse`, measure `filter: { stage: { $eq: ['won','lost'] } }` | **`success: true`** | | `FilterConditionSchema.safeParse` on both shapes; `FieldOperatorsSchema.safeParse({ $eq: [...] })` | **`success: true`** (all three) | | `assertListComparandShapes({ stage: ['won','lost'] })` (the compile face) | `INVALID_FILTER` / 400 | | `assertListComparandShapes({ stage: { $eq: [...] } })` | `INVALID_FILTER` / 400 | The face's refusal text read, verbatim: `The implicit-equality comparand on field "stage" requires a single comparable value, but received an array (["won","lost"]) at where.stage. For "one of these values" use {"$in": […]} (authoring: in); for "the stored list holds a value" on a multi-value field, {"$contains": "…"} (authoring: contains), an $or of those for any-of. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.`⚠️ **The ruling calls it "one constant", but no remedy constant existed.** On `main` the face built the text inline in `arrayEqualityComparandError`. This PR extracts it rather than copying it (§2). ## 2. The change - **`packages/spec/src/data/filter-comparand-refusal-text.ts` (new, internal).** It holds the ONE remedy constant `ARRAY_EQUALITY_COMPARAND_REMEDY` and the one message builder `arrayEqualityComparandMessage`, plus `shapePreview`, the `$contains` operator and the `in` spelling row. Those three moved out of the face so both doors render the value and the prescription from one source. - Both doors import this module. The face cannot import `filter.zod.ts`, because of the import cycle the face already documents. - ⛔ **It is deliberately NOT in the `data` barrel**, following the `currency-fraction-digits.ts` precedent. Exporting the text would widen `@objectstack/spec/data` for no reader, which a `Clause-②: no` card must not do. `check:api-surface` stays green. - **The face (`filter-comparand-shape.ts`)** now calls the builder. Its message is byte-for-byte what it was: every existing face pin, and the analytics / mongodb / memory suites, pass unchanged. - **`FilterConditionSchema`** refuses both arms inside its existing refinement walk: - `{ field: [...] }` at the issue path `field`; - `{ field: { $eq: [...] } }` at `field.$eq`; - the empty array too, and inside `$and` / `$or` / `$not` members through their own re-parse. - **`FieldOperatorsSchema.$eq`** refuses an array comparand. So does its documentation copy **`EqualityOperatorSchema.$eq`**, through one shared `equalityComparandSchema` factory, following the file's own pairing rule (`orderingComparandSchema`). `NormalizedFilterSchema` validates against it and refuses too. - **Reach = the face's, no wider.** A field spec with no `$` key (a nested-relation or deep-equality condition) is not judged at this door, because the face never descends one. A pin holds both doors to the same answer on that shape (§4, control rows). - **Not dropped.** A refused document fails its parse and nothing is stripped. The pin `CONTROL — the same documents with $in publish, and keep their filter` proves that accepted means kept. ### "The parse-door message equals the compile-face message": what equality holds⚠️ Stated rather than settled silently. A zod refinement cannot see its absolute location: in zod 4.6.1 the refinement context is the parse payload, with no path. So the schema door does not print the face's `at where.FIELD` clause. Its issue carries the location as its `path` instead (`filter.stage`, `measures.0.filter.stage.$eq`). Pin §3 asserts **character equality after removing exactly that one clause**, and asserts the clause is present exactly once, so the removal is not vacuous. That holds on 8 shapes: implicit and `$eq`, empty, and under `$and` / `$or` / `$not`. Printing `at where.stage` at save time would name a location that does not exist in the saved document, and the wrong one for a combinator member. The `$eq` operator slot cannot see its field either (the key belongs to the enclosing record). Its pin asserts equality after removing both the ` on field "stage"` clause and the location clause. If the seat reads ruling item 4 as strict byte-equality, the alternative is to print a fixed `where.` location; I recommend against it for the reason above. ## 3. Carrier census (ruling item 3) **Schemas that embed `FilterConditionSchema` directly.** Each one refuses now; the right-hand column is measured at head: | schema · key | refuses? | |:--|:--| | `DatasetSchema.filter`, `DatasetMeasureSchema.filter` | yes (pinned) | | `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter` | yes (widget pinned) | | `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter` | yes (report pinned) | | `FieldSchema.relatedListFilter`, `FieldSchema.summaryOperations.filter` | yes, as a `FilterConditionSchema` carrier | | `BlueprintSummaryOperationsSchema.filter` | yes, as a carrier | | `AnalyticsQuerySchema.where`, and through it `AnalyticsQueryRequestSchema.where` | yes (measured) | | `DatasetSelectionSchema.runtimeFilter` | yes (measured) | | `QuerySchema.where` / `.having`, `AggregationNodeSchema.filter`, `QueryFilterSchema.where` | yes (`where` / `having` measured) | | `EngineAggregateOptionsSchema.having` | yes (measured) | | `Engine{Query,Update,Delete,Aggregate,Count}OptionsSchema.where`, `DataEngineVectorFindRequestSchema.where` | **no**: the union's first arm is an open record, so it parses (measured). The face refuses it at execution. | **Schemas that embed `FieldOperatorsSchema`:** `NormalizedFilterSchema` (through its field-condition record). `EqualityOperatorSchema` is the documentation copy. **Where the refusal is met.** Every one of these was measured at head: | door | answer | |:--|:--| | `defineStack` (strict) | `STACK_SCHEMA_INVALID`, `datasets.0.filter.stage: …` | | `os validate` | parses `ObjectStackDefinitionSchema`, read at source | | metadata-protocol `saveMetaItem`, dataset | **`INVALID_METADATA` / 422, issue path `filter.status`**; the `$in` control saved. This was a throwaway probe on the `protocol.dashboard-dataset-publish-gate` harness and was deleted, tree clean. | | REST `datasetSelectionRefusal` | **`VALIDATION_FAILED` / 400**, `selection.runtimeFilter.stage:` followed by the sentence; previously the analytics compiler's `INVALID_FILTER` / 400 | | runtime analytics body (`handleAnalyticsRequest`) | **`VALIDATION_FAILED`**, `where.stage: …`; the bridge maps it to 400, pinned in `dispatcher-validation-error.real.test.ts` | The read path does not re-validate stored rows, so a stored document keeps loading and its next save is refused. ⛔ Nothing is rewritten or dropped. **Stored and shipped instances carrying the shape: 0.** - **Text scan.** A brace-matched scan of every `filter` / `where` / `runtimeFilter` / `having` / `relatedListFilter` literal in `packages/**`, `examples/**`, `apps/**`, `content/docs/**` and `skills/**` read 7785 files and 4709 carrier literals. It found 20 field entries whose value opens an array. 16 are test fixtures; they are its positive control, including the mongodb refusal pin rows. The other 4 are not `FilterCondition` carriers: `client/realtime-api.ts` `eventTypes` and `core/PHASE2_IMPLEMENTATION.md` plugin-permission `filter`. There is no YAML-authored carrier block. - **`$eq` grep.** A grep for `$eq` followed by an array found 24 lines: prose, MongoDB aggregation `$eq` expressions, and one `door-refusal` conformance row. - **Runtime census.** - `app-crm`, `app-todo` and `app-multi-package` load through strict `defineStack` / `composeStacks`: 0 hits. - `app-showcase`'s config imports connector plugins with no `dist/` here. Its barrels were parsed with the registered type schemas instead: 22 objects, 4 datasets, 3 dashboards, 4 reports. That gave 0 hits and 0 other failures. - A planted `{ stage: ['won','lost'] }` dataset fired once at `filter.stage`. - Deployed datasets, dashboards and reports are **NOT MEASURED**. ## 4. Pins, each proven able to fail `filter-equality-array-schema-door.test.ts` has 46 tests: §1 FilterConditionSchema refusals, §2 operator slot, §3 same words, §4 controls at both doors, §5 carriers. Each ablation used `scripts/ablation-replace.mjs`: the anchor hit exactly once, and each leg printed its blob change. After every leg, restore was proven by blob == HEAD `be90963c90` and an empty `git diff HEAD`. The spec tests import from `src`, so no `dist/` leg was needed. | leg | mutation | result | |:--|:--|:--| | A | implicit arm off | **14 red** / 32 green (implicit refusals, same-words rows, `DatasetSchema filter.stage`, widget) | | B | `$eq` arm off | **9 red** (`$eq` refusals, same-words rows, `measures.0.filter.stage.$eq`, report) | | C | operator-slot refinement off | **4 red** (`FieldOperatorsSchema`, `EqualityOperatorSchema`, `NormalizedFilter`, slot same-words) | | D | schema door prints a location of its own | **5 red** (the §3 equality rows) | | E | reach widened into nested-relation specs | **1 red** (the nested-relation control) | | F | operator slot also refuses `null` | **1 red** (the scalar/null control) | | control | restored tree | 46 / 46 green; `git status` clean at `46e1c81727` | Re-judged, not dropped: `filter-comparand-shape.test.ts` pinned `FieldOperatorsSchema`'s array-accepting set as `['$between','$eq','$in','$ne','$nin']`, "`$eq` … only because both are `z.any()`". `$eq` has left that set, so the expectation now reads `['$between','$in','$ne','$nin']` and the comment says why. ## 5. ADR-0087 - **New semantic entry `filter-equality-array-comparand-refused-at-save`.** The changeset marker is `registered filter-equality-array-comparand-refused-at-save`. The gate read it back as `[BREAKING+bang+clause-②-narrowing] registered … (new here …)`. Every factual claim in it was measured at head (§3). - **Corrected the unreleased entry `filter-equality-array-comparand-refused`.** At `:46-49` it said the analytics normalizer's OBJECT form "still reads as membership" because it "does not route through the shared face". That has been false since objectstack-ai#20008. It now states which doors refuse the shape at this release, and with what: - the face (`parseFilterAST`, the engine seam): `INVALID_FILTER` / 400; - the analytics `where` door, in both spellings: the same, and that door alone also refuses a nested-relation list; - the schema door on save: the same sentence, as a parse issue. The object form's four old readings are named, and its acceptance criteria point to the new sibling entry. - `gen:migration-registry` regenerated `registry.ts`, and `check:migration-registry` is green. `gen:upgrade-guide` and `gen:spec-changes` were run and wrote **zero diff**. Both project steps up to `PROTOCOL_MAJOR` (17, `PROTOCOL_VERSION` `17.0.0`), so no step-18 entry appears in them yet, this one included. `check:upgrade-guide` and `check:spec-changes` are green. ## 6. Changesets, and two DELIBERATE CORRECTIONS for the seat to confirm - New: `.changeset/19889-filter-schema-door-array-equality.md`. It declares `@objectstack/spec: minor`, the **BREAKING** banner, `fix(spec)!:`, a FROM → TO table and the measured census. The grade follows AGENTS.md: a `(narrowing)` is BREAKING, and the launch-window convention ships it `minor`. `check-changeset-no-major` is green. -⚠️ **DELIBERATE CORRECTION 1**, `.changeset/19757-equality-slot-array-refused.md` (pending). Its bullet said: "The schema doors are not touched. `FilterConditionSchema` still parses `{ field: [...] }` … A document carrying the shape therefore still publishes". That is false in the release both ship in. It now says this change did not touch the schema doors, and that a separate change in the same release does. -⚠️ **DELIBERATE CORRECTION 2**, `.changeset/19888-analytics-implicit-array.md` (pending, services lane). Its sentence said: "The authoring schema still admits the shape, so such a document still publishes, and it is refused when it is charted". That is false for the same reason. It now says the schema refuses the shape on save, except for a list inside a nested relation, which only the analytics door judges. - `check-empty-changeset` is therefore **red by design** ("DELIBERATE CORRECTION … say so on the PR … get it confirmed"). Neither file is restored from base, because that would republish a false sentence. ## 7. Tests (at `46e1c81727`) | package | result | |:--|:--| | `@objectstack/spec` (full) | 564 files · **16273 passed** · 2 todo | | `@objectstack/spec` typecheck (`tsc`, scripts, test layer) | green; `check:test-typecheck` OK | | `@objectstack/service-analytics` | 117 files · 2541 passed | | `@objectstack/objectql` | 312 files · 5243 passed | | `@objectstack/rest` | 195 files · 3273 passed · 1 skipped | | `@objectstack/metadata-protocol` | 188 files (3 skipped) · 2676 passed · 19 skipped | | `@objectstack/runtime` | 278 files · 3962 passed · 1 skipped | | `@objectstack/lint` | 108 files · 4138 passed | | `@objectstack/cli`, unit layer | 224 files · 3158 passed | Notes on the table: - **`cli`.** The first run's two `published-subpath-*` pins refused on their prerequisite, "packages/cli is not built". After `cli` was built they ran: 2 files, 29 passed. The integration layer is declared to CI, since the diff touches no spawn entry or integration file. - **Build.** The build ran through turbo for the closures of the packages above: 56 + 57 tasks, all successful. - **Lint, narrowed.** The 8 touched `.ts` files were linted with `eslint --no-inline-config --format json`: 8 files counted from the JSON, **0 errors / 0 warnings**, and `isPathIgnored` is false for each. The config enables no type-aware linting (no `parserOptions.project`, per its own comment at `eslint.config.mjs:328`), so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` belongs to CI. ## 8. Gates (at `46e1c81727`) `dispatch-gates --commands --repo objectstack-ai/objectstack` derived **87** commands. The `--ran` reconciliation, with an exit code per line, reads: **87 derived, 85 run, 2 NOT-MEASURED, 0 UNRUN**. - **84 exited 0.** These include `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:doc-authoring` ("16302 customer-facing string(s) … clean"; sibling prose ids "no growth"), `check:nul-bytes` ("scanned 9435 … no raw ASCII control bytes"), `check:engine-double-contract` and `check:cross-package-test-inputs`. - **1 exited 1:** `check-empty-changeset`. This is the deliberate-correction refusal in §6. - **NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`.** Reason: both exit 3, `PREREQUISITE NOT MET`, because they read the whole workspace's `dist/`. PR objectstack-ai#19882 recorded the same two. - `@objectstack/spec check:generated` reports all 15 artifacts current after the build. ## Acceptance notes - **Residual (finding, not fixed here).** A list inside a nested-relation condition on a dataset or measure filter, `{ account: { region: ['a'] } }`, still passes `DatasetSchema`. Since objectstack-ai#20008 the analytics `where` door refuses it (`INVALID_FILTER` / 400, measured). Refusing it at the shared schema door would refuse a deep-equality comparand the shared face and the engine accept. That is a different decision, and it is left to the seat. - **Error code at two request doors.** An analytics request carrying the shape now answers `VALIDATION_FAILED` / 400 at the request door (REST dataset selection, runtime analytics body) instead of `INVALID_FILTER` / 400 at the compiler. Both are class-1 400s carrying the same sentence. No test pinned the old code for this input: the `rest`, `runtime` and `service-analytics` suites are green. - The published JSON Schema cannot state the check (`z.toJSONSchema()` has no custom-check projection). Three sites are declared in `dropped-refinements.baseline.json`: `data/FieldOperators` `$eq`, `data/EqualityOperator` `$eq` and `data/NormalizedFilter` `…valueType.$eq`, and its `measured` counts moved 206→207 schemas and 571→574 sites. - `$ne` carrying a list is untouched. objectstack-ai#19886 and the face's `it.todo` carry it. objectstack-ai#19886 remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19888
Clause-②: no (narrowing)
What this changes
The analytics
wheredoor (lowerAnalyticsWhereinpackages/services/service-analytics/src/strategies/filter-normalizer.ts) now refuses a list in the equality slot of an object-form filter,{ f: [...] }and{ f: { $eq: [...] } }, withINVALID_FILTER/ 400. This applies ruling 乙 of #19757 (record5793368540: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to the one analytics spelling that never reached the shared face.The new gate,
assertNoListInEqualitySlot, walks the object-form condition the way the shared face does:$and/$or,$notand field entries, from the samewherepath seed. It hands each equality-slot list toassertListComparandShapes(@objectstack/spec/data) as a one-entry node. That node holds nothing but the list, so only the face's equality arm can fire, and the refusal is the face's own: its envelope, wording, path and$inprescription. TheFilterArrayspelling of the same condition was already refused insideparseFilterAST. Both spellings now produce the same bytes, and a test pins this.Every filter source of this door passes through the gate: the caller
where, a dataset's scopefilterand a measure'sfilter. That holds on the NativeSQL execute path, the/analytics/sqlecho and the ObjectQL engine path. The draft-data preview (preview-evaluator.ts) calls the same exported gate, so a drafted chart refuses what the published chart refuses. Theinreading is deleted fromfieldLeaves. So are the two arms that existed only because of it: the bare-array member sweep inassertDefinedComparandsand the bare-array guard innullGuardForFieldSpec.Two details:
{ acct: { region: [...] } }) is descended too. The shared face leaves such an object alone, because a driver reads it as a deep-equality comparand or another object's condition. This compiler flattens it to the dotted memberacct.region, and the list is in that member's equality slot.{ d: [1, undefined] }gets the list refusal, not the undefined-comparand one. The shared face andread-scope-sql.tsuse the same order.$newith a list is not judged, because ruling 乙 names equality only. The list operators keep their lists, including$in: []and$nin: []. Every scalar compiles as before, includingnull.Measured first (recorded on this branch as
cca9717240, before any source change)In-repo stored filters. An AST scan (TypeScript compiler API) covered every git-tracked
.ts/.js/.jsonfile, plus the fenced ts/js/json blocks of md/mdx files. It used four detectors:$eqwith an array literal.filter,filters,where,runtimeFilter,relatedListFilter,havingand five more). The object is walked as a FilterCondition, descending$and/$or/$notand nested relations.=/==/equals/eqcarrying an array.{ field, operator: EQUALITY_SPELLING, value: [...] }.The positive controls ran on the same walkers:
$inarray literals (C1);examples/**packages/**eventTypeslists, 3 MongoDB aggregation-expression$eq: [a, b]operands. 65 in tests, all deliberate refusal fixtures or pins.ninrule in a design note; a plugin-permissionfilter)A text grep for a
$eqlist outsidepackages/andexamples/found 7 hits, all in changesets that describe the refusal itself. The control ($inlist) found 140. No in-repo artefact carries the shape, so nothing needed converting. Deployedsys_metadatarows: NOT MEASURED (there is no deployment data here).The analytics faces at base, over a real sql.js engine. The rows are d1
won, d2lost, d3open, d4 NULL, d5 the text'won,lost':where{ stage: ['won', 'lost'] }stage IN (?, ?), rows d1, d2{ stage: { $in: [...] } }, so the engine's own shared-face check never saw the list'won,lost': d5{ stage: { $eq: ['won', 'lost'] } }stage = ?bound to'won': d1, and'lost'was dropped without a word{ stage: 'won' }{ stage: { $eq: [] } }{}{ stage: [] }['stage', '=' / 'equals' / '==' / 'eq', [...]]INVALID_FILTER/ 400{ stage: { $in: ['won', 'lost'] } }After this change, every object-form cell above is refused with
INVALID_FILTER/ 400, carrying the face's message (re-measured at896e1e70f3).Compile surfaces (a list in the equality slot)
lowerAnalyticsWhere/normalizeAnalyticsFilterTree(analytics callerwhere, dataset scopefilter, measurefilter; NativeSQL execute,/analytics/sqlecho, ObjectQL engine path)INVALID_FILTER/ 400 at any depth, measured over sql.js before and after.evaluateAnalyticsQueryOverRows/matchesWhere(analytics draft-data preview)assertListComparandShapes(spec shared face)compileScopedFilterToSql(service-analytics read scope)e8f163fc3a) it refuses both spellings withREAD_SCOPE_COMPILE_FAILED/ 500. Its matrix cells andread-scope-eq-array-refusal.test.tsare green in this PR's package run. Not touched.matchesFilterCondition(formula)$eqand$not). Not re-measured here.applyFilterCondition(driver-sql)buildWhereSQL(driver-turso RemoteTransport)checkCondition(driver-memory)translateFieldOperators(driver-mongodb)applyHaving/matchesHaving/checkCondition(objectql HAVING)Tests and evidence (head
896e1e70f3)src/__tests__/where-equality-slot-list-refusal.test.ts, 59 tests. Every refusal assertscode+status.$eqlist at 9 positions: every depth, the empty list, and beside another operator in either key order.Date, the null predicate, the$inremedy, the$in: []/$nin: []constants, a nested scalar, a$fieldreference,$between, and$ne(not judged).engine.aggregatecall.comparand-door-single-source.test.ts: thearrayrow'swhereEqcell is re-judged fromaccepttoINVALID_FILTER/400, with a note. It had pinnedqty = 'al'with'be'dropped.filter-normalizer-not-null-safe.test.ts: the bare[]is now refused, and$in: []keeps its FALSE constant.filter-normalizer-undefined-comparand.test.ts: the{ d: [1, undefined] }row leaves the undefined table, and the{ d: [1, null] }row leaves the null control group. Each carries a note: its enclosing shape is now refused whole.pnpm --filter @objectstack/service-analytics test: 117 files and 2541 tests passed (base: 116 files, 2484 tests).typecheckexited 0, andtsc --listFilesincludes all four touched test files.scripts/ablation-replace.mjs, with the red/green count predicted before running. The tests import the source by relative path, so no build is on the path. Each restore was proven by the blob hash matching HEAD and by an emptygit diff HEAD.inreading put back. The gate call inlowerAnalyticsWherewas deleted and the old bare-array arm restored infieldLeaves. Predicted 41 red; measured 41 red / 161 green over the four touched test files:$eq, 7 implicit, 4 byte identity, 4 list-first;arraywhere row, 1 bare[].native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefinedandexpected 'accept' to be 'INVALID_FILTER/400'.draft preview: expected a refusal, got rows: expected [ 'd5' ] to be undefined.dispatch-gatesderived 60 at896e1e70f3. The run also covered the dispatch-time list'scheck:dispatcher-error-vocabulary, for 61 in total. 59 exited 0.check:dual-build-cjs-loadsandcheck:type-check-debtexited 3 (PREREQUISITE NOT MET). They need the whole workspace built, which CI does.check:lean-entry-closureexited 3 until objectql's closure was built, then 0.--ranreconciliation: 60 derived, 58 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun.check-adr-0087-registration --base origin/main(1 declared-breaking changeset,not-required (already-registered)),check-changeset-no-major,check:changeset-gate-self-tests,check:nul-bytes, andcheck-issue-citationsin its board-probing mode (19 citations, all of which resolve).eslint --no-inline-config --format jsonover the six touched TypeScript files: 6 files, 0 errors, 0 warnings.eslint --print-configresolves a config for each of them.eslint.config.mjsnever enables type-aware linting (its note near line 327), so this diff cannot change the verdict on any untouched file.pnpm --filter '...@objectstack/service-analytics'names 19 downstream packages. The AST scan above found no filter carrying the shape in their sources or tests. Their suites were not run here; CI's affected set runs them.Deviations from the dispatch, stated
filter-normalizer.ts, the matrix'swhere*cells, new test files and the changeset. This PR also touches three more files:src/preview-evaluator.ts: one import, one call and comments. This is a bounded in-place fix, and all four conditions hold: it is the same defect class; it is a mechanical call of the same gate, whose shape ruling 乙 pins; no open PR touches this package, per the claim's own reading; and it is the same gate family, with no new verification surface. The claim's file surface needs this path added.filter-normalizer-not-null-safe.test.tsandfilter-normalizer-undefined-comparand.test.ts: three pins asserted the reading this ruling removes. They are re-judged, with notes (above).not-required (already-registered filter-equality-array-comparand-refused), not the dispatchednot-required (no-migration-prescription). A stored dataset, widget or measure filter can carry this shape, because the authoring schema admits it (measured). So the changeset carries a FROM → TO table. The gate refusesno-migration-prescriptionon a body that carries one, and that disposition would also claim that no author has to rewrite anything. The transition itself has been on the ledger since [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757, and that entry's surface and prescription cover this door verbatim. PR fix(spec): refuse a blank $between endpoint at the runtime filter door #19374 used the same disposition for the same situation. The gate accepts it.Acceptance notes
filter-equality-array-comparand-refused(packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts, and its copy inregistry.ts) says that the analytics normalizer's OBJECT form "still reads as membership". That is no longer true. Editing it is apackages/specchange, outside this dispatch, so the carrier is the seat's call.$newith a list is not judged (ruling A of [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886). Measured at896e1e70f3:{ stage: { $ne: ['won', 'lost'] } }compiles tostage IS NULL OR stage != 'won', so'lost'rows are served. Reported to the seat.$betweenbound, and the scalar$in. The FilterArray spelling of each is refused on the same door. This package's own pins hold several of the object-form answers. Reported, not addressed.DatasetSchemawithfilter: { stage: ['won', 'lost'] }parses. The [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 changeset declared this. Reported.where. It answered no row for['stage', '=', 'won']in the probe. Reachability through the dataset door is not established, so this is an observation only.Generated by Claude Code