Skip to content

fix(service-analytics)!: the analytics where door refuses a list in the equality slot instead of reading it as IN (#19888) - #20008

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19888-analytics-implicit-array
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19888-analytics-implicit-array

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19888

Clause-②: no (narrowing)

What this changes

The analytics where door (lowerAnalyticsWhere in packages/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: [...] } }, with INVALID_FILTER / 400. This applies ruling 乙 of #19757 (record 5793368540: 「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, $not and field entries, from the same where path seed. It hands each equality-slot list to assertListComparandShapes (@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 $in prescription. The FilterArray spelling of the same condition was already refused inside parseFilterAST. 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 scope filter and a measure's filter. That holds on the NativeSQL execute path, the /analytics/sql echo 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. The in reading is deleted from fieldLeaves. So are the two arms that existed only because of it: the bare-array member sweep in assertDefinedComparands and the bare-array guard in nullGuardForFieldSpec.

Two details:

  • Nested relations. A nested-relation object ({ 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 member acct.region, and the list is in that member's equality slot.
  • Precedence. The list is diagnosed before any member gate. { d: [1, undefined] } gets the list refusal, not the undefined-comparand one. The shared face and read-scope-sql.ts use the same order.

$ne with 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, including null.

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 / .json file, plus the fenced ts/js/json blocks of md/mdx files. It used four detectors:

  • D1: $eq with an array literal.
  • D2: a field entry with an array literal under a filter-bearing key (filter, filters, where, runtimeFilter, relatedListFilter, having and five more). The object is walked as a FilterCondition, descending $and / $or / $not and nested relations.
  • D3: a FilterArray triple on = / == / equals / eq carrying an array.
  • D4: a filter rule { field, operator: EQUALITY_SPELLING, value: [...] }.

The positive controls ran on the same walkers:

  • a synthetic fixture with one case per detector (4/4 hits, in both TS and JSON);
  • a count of $in array literals (C1);
  • a count of the filter-bearing objects walked (C2).
scope files C1 C2 hits
examples/** 228 (3 tsconfig JSONC unparsed) 1 (matches the text grep) 91 0
packages/** 6910 522 4636 5 in non-test files, all false positives: 2 realtime-subscription eventTypes lists, 3 MongoDB aggregation-expression $eq: [a, b] operands. 65 in tests, all deliberate refusal fixtures or pins.
md/mdx fenced blocks (content, skills, docs, examples, packages, .changeset) 1545 files, 3162 blocks 12 176 3, all false positives (a nin rule in a design note; a plugin-permission filter)

A text grep for a $eq list outside packages/ and examples/ found 7 hits, all in changesets that describe the refusal itself. The control ($in list) found 140. No in-repo artefact carries the shape, so nothing needed converting. Deployed sys_metadata rows: NOT MEASURED (there is no deployment data here).

The analytics faces at base, over a real sql.js engine. The rows are d1 won, d2 lost, d3 open, d4 NULL, d5 the text 'won,lost':

where native execute / echo ObjectQL engine path draft preview
{ stage: ['won', 'lost'] } stage IN (?, ?), rows d1, d2 the engine received { stage: { $in: [...] } }, so the engine's own shared-face check never saw the list string-compared the row against 'won,lost': d5
{ stage: { $eq: ['won', 'lost'] } } stage = ? bound to 'won': d1, and 'lost' was dropped without a word { stage: 'won' } d5
{ stage: { $eq: [] } } no WHERE at all: every row {} no row
{ stage: [] } the FALSE constant
['stage', '=' / 'equals' / '==' / 'eq', [...]] refused INVALID_FILTER / 400 refused no row (the preview does not lower an array)
control { stage: { $in: ['won', 'lost'] } } d1, d2 d1, d2 d1, d2

After this change, every object-form cell above is refused with INVALID_FILTER / 400, carrying the face's message (re-measured at 896e1e70f3).

Compile surfaces (a list in the equality slot)

surface verdict
lowerAnalyticsWhere / normalizeAnalyticsFilterTree (analytics caller where, dataset scope filter, measure filter; NativeSQL execute, /analytics/sql echo, ObjectQL engine path) changed. Both spellings are refused with INVALID_FILTER / 400 at any depth, measured over sql.js before and after.
evaluateAnalyticsQueryOverRows / matchesWhere (analytics draft-data preview) changed, as a bounded in-place fix (see Deviations). It used to string-compare the row against the list and now runs the same gate.
assertListComparandShapes (spec shared face) already compliant. This is ruling 乙's own face (#19882). This PR calls it and does not change it.
compileScopedFilterToSql (service-analytics read scope) already compliant. Since #19975 (landed e8f163fc3a) it refuses both spellings with READ_SCOPE_COMPILE_FAILED / 500. Its matrix cells and read-scope-eq-array-refusal.test.ts are green in this PR's package run. Not touched.
matchesFilterCondition (formula) already compliant, per the table in PR #19994 (400 for the implicit list, $eq and $not). Not re-measured here.
applyFilterCondition (driver-sql) already compliant, per the table in the #19882 changeset. Not re-measured here.
buildWhereSQL (driver-turso RemoteTransport) already compliant, per the table in PR #19994. Not re-measured here.
checkCondition (driver-memory) already compliant: 400 at every depth, per the table in the #19882 changeset. Not re-measured here.
translateFieldOperators (driver-mongodb) out of scope. The driver answers with MongoDB array equality. A platform door reaches it only through the shared face, which refuses the list (the declared scope of #19882).
applyHaving / matchesHaving / checkCondition (objectql HAVING) out of scope. This is a caller-authored filter over aggregated rows, a different door from this card's. Its answers are recorded in the #19886 stage-2a report.

Tests and evidence (head 896e1e70f3)

  • New src/__tests__/where-equality-slot-list-refusal.test.ts, 59 tests. Every refusal asserts code + status.
    • The $eq list at 9 positions: every depth, the empty list, and beside another operator in either key order.
    • The implicit list at 7 positions, including the empty list and a nested relation.
    • Byte identity (4): the object spelling equals the FilterArray spelling, which equals the face's own message.
    • List before member (4).
    • 12 neighbouring shapes that must compile as before: a scalar, a Date, the null predicate, the $in remedy, the $in: [] / $nin: [] constants, a nested scalar, a $field reference, $between, and $ne (not judged).
    • Four faces over a real sql.js engine (native execute, echo, ObjectQL engine path, draft preview) × 4 spellings, plus a control. Each is refused before any statement runs 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.
  • comparand-door-single-source.test.ts: the array row's whereEq cell is re-judged from accept to INVALID_FILTER/400, with a note. It had pinned qty = 'al' with 'be' dropped.
  • Two existing pins re-judged, not rewritten by rote:
    • 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.
  • Package run. pnpm --filter @objectstack/service-analytics test: 117 files and 2541 tests passed (base: 116 files, 2484 tests). typecheck exited 0, and tsc --listFiles includes all four touched test files.
  • Ablations. Each was run from the committed fix through 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 empty git diff HEAD.
    • A: the in reading put back. The gate call in lowerAnalyticsWhere was deleted and the old bare-array arm restored in fieldLeaves. Predicted 41 red; measured 41 red / 161 green over the four touched test files:
      • 9 $eq, 7 implicit, 4 byte identity, 4 list-first;
      • 12 faces: native, echo and engine × 4 spellings. The preview cells stayed green, because the preview runs the gate itself;
      • 3 stored, 1 matrix array where row, 1 bare [].
      • Sample failures: native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefined and expected 'accept' to be 'INVALID_FILTER/400'.
    • B: the preview's gate call deleted. Predicted 6; measured 6 red / 53 green: the four preview face cells and the two stored-dataset preview-door cells. Sample: draft preview: expected a refusal, got rows: expected [ 'd5' ] to be undefined.
  • Gates. dispatch-gates derived 60 at 896e1e70f3. The run also covered the dispatch-time list's check:dispatcher-error-vocabulary, for 61 in total. 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.
    • check:lean-entry-closure exited 3 until objectql's closure was built, then 0.
    • --ran reconciliation: 60 derived, 58 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun.
    • Among the passes: 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, and check-issue-citations in its board-probing mode (19 citations, all of which resolve).
  • Lint, narrowed to the change. eslint --no-inline-config --format json over the six touched TypeScript files: 6 files, 0 errors, 0 warnings. eslint --print-config resolves a config for each of them. eslint.config.mjs never enables type-aware linting (its note near line 327), so this diff cannot change the verdict on any untouched file.
  • Dependents. 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

  1. File surface. The claim declared filter-normalizer.ts, the matrix's where* 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.ts and filter-normalizer-undefined-comparand.test.ts: three pins asserted the reading this ruling removes. They are re-judged, with notes (above).
  2. The changeset's ADR-0087 disposition is not-required (already-registered filter-equality-array-comparand-refused), not the dispatched not-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 refuses no-migration-prescription on 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 — and driver-mongodb alone 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


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1f05ea4fb296357dedf86ed34a234a1e779b1381 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cdbf68847a4b750c3e5aa024b6e243b1750366dc — the merge of head 896e1e70f3fd96102dac6a5e5a0f86dbf34217ff into base 1f05ea4fb296357dedf86ed34a234a1e779b1381, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 18:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 14add48 Sep 24, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19888-analytics-implicit-array branch September 24, 2026 18:24
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant