Skip to content

Commit 6aa3188

Browse files
fix(spec)!: refuse an array in the equality slot at the schema door, in the comparand face's own words (#19889) (#20047)
Fixes #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 #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 #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 #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. #19886 and the face's `it.todo` carry it. #19886 remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66ac73d commit 6aa3188

12 files changed

Lines changed: 797 additions & 76 deletions

‎.changeset/19757-equality-slot-array-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ After this change, every row above that goes through a platform door gets the 40
3232
- Every scalar equality comparand is untouched. That includes `null`: `{ field: null }` and `{ field: { $eq: null } }` are the has-no-value predicate.
3333
- A `{ $field }` reference on an equality spelling still lowers to `$eq` and passes.
3434
- A field spec with no `$` key (`{ author: { tags: ['a'] } }`) is still not descended into.
35-
- The schema doors are not touched. `FilterConditionSchema` still parses `{ field: [...] }`, because `FieldOperatorsSchema.$eq` is `z.any()`. A document carrying the shape therefore still publishes, and is refused when it is queried.
35+
- This change does not touch the schema doors. A separate change in this release does: `FilterConditionSchema` and `FieldOperatorsSchema.$eq` now refuse the same shape when a document is saved, with this refusal's sentence (the location is carried by the issue's path instead). Its changeset and the ADR-0087 entry `filter-equality-array-comparand-refused-at-save` describe it.
3636
- `ViewFilterRule` already refused an array on every scalar view operator at authoring time.
3737

3838
## FROM → TO

‎.changeset/19888-analytics-implicit-array.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ Ruling 乙 of #19757 refuses a list in the equality slot at the shared comparand
2727

2828
Each list is now handed to the shared face's equality arm before any node is built. Both spellings of one condition therefore get the same refusal, with the same wording, path and `$in` prescription. The draft-data preview runs the same gate, so a drafted chart refuses what the published chart refuses. Before, it compared each row against the list's string form.
2929

30-
Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. The authoring schema still admits the shape, so such a document still publishes, and it is refused when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before.
30+
Who is affected: nothing in this repository's examples, seeds or docs authors the shape (measured over `examples/**`, `packages/**` and the fenced code in the docs). Stored datasets, dashboard widget filters, report runtime filters and measure filters in a deployment were NOT measured. In this release the authoring schema refuses the shape too, when such a document is saved (a separate change in `@objectstack/spec`, ADR-0087 entry `filter-equality-array-comparand-refused-at-save`). One position is judged only here: a list inside a nested relation, which this door flattens to a dotted member. A document carrying that still publishes, and it is refused when it is charted. The refusal names the field and the path. `$ne` with a list is not part of the ruling and is not judged here. The list operators (`$in`, `$nin`, `$between`) keep their lists, and every scalar, `null` included, compiles as before.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: a filter carrying an array in the equality slot is refused when it is saved, in the query face's own words (#19889)
6+
7+
**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19889 (record 5805248669, letter A). The hand-migration prescription is registered under protocol major 18 as `filter-equality-array-comparand-refused-at-save`.
8+
9+
## What changes
10+
11+
`FilterConditionSchema` now refuses an ARRAY in the equality slot at parse: the implicit form `{ field: [...] }` and the explicit form `{ field: { $eq: [...] } }`, the empty array included, at any depth under `$and` / `$or` / `$not`. `FieldOperatorsSchema.$eq` refuses an array comparand too, and so do its documentation copy `EqualityOperatorSchema.$eq` and the `NormalizedFilter` AST that validates against it.
12+
13+
The comparand-shape face (`assertListComparandShapes`) refuses both shapes on every query, from the equality-slot change earlier in this release. Before this change the schema accepted them, so a dataset or dashboard filter carrying one published clean and then failed every query that used it. Measured on `origin/main` `a0920b42dc`: `DatasetSchema.safeParse` with `filter: { stage: ['won', 'lost'] }` answered `success: true`, and so did a measure `filter` of `{ stage: { $eq: ['won', 'lost'] } }`.
14+
15+
The schema door prints the face's sentence: the field, the received list, and the two operators a list in that slot stood in for. Both doors import it from one builder. The face adds `at where.<field>`. The schema door leaves that out, because the issue's `path` already says where it is (`filter.stage`, `measures.0.filter.stage.$eq`).
16+
17+
Every schema that carries a `FilterCondition` refuses it on parse. That covers the dataset `filter` and measure `filter`, the dashboard widget `filter` and options-source `filter`, the report and joined-report-block `runtimeFilter`, the field `relatedListFilter` and rollup `summaryOperations.filter`, the solution-blueprint summary `filter`, the analytics query `where`, the dataset selection `runtimeFilter`, the query `where` and `having`, the data-engine aggregate call's `having`, the aggregation `filter`, and the query-filter `where`. So `defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the filter's path.
18+
19+
Two request doors parse these carriers, and they now answer before the analytics compiler does. The REST dataset selection (its `runtimeFilter`) and the analytics query body (its `where`) answer `VALIDATION_FAILED` / 400 with the sentence at the field. Before, the compiler answered `INVALID_FILTER` / 400.
20+
21+
## What does NOT change
22+
23+
- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. Such a filter has failed every query since the equality-slot change, so the refusal is a repair.
24+
- **The reach is the face's, and no wider.** A field spec with no `$` key, such as the nested-relation condition `{ account: { region: ['a'] } }`, is not judged, because the face does not judge it either. The analytics `where` door does refuse that shape when a dataset or measure filter is charted.
25+
- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs.
26+
- **`$ne` carrying an array is not judged.**
27+
- The list operators keep their arrays, `$in: []` and `$nin: []` included. Every scalar, `null`, a `Date` and a `{ $field }` reference pass as before.
28+
- **The published JSON Schema cannot state the check.** `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$eq`. The three sites are declared in `dropped-refinements.baseline.json`.
29+
30+
## FROM → TO
31+
32+
| you wrote | write instead |
33+
|:--|:--|
34+
| `{ stage: ['won', 'lost'] }`, meaning "one of these values" | `{ stage: { $in: ['won', 'lost'] } }` |
35+
| `{ tags: ['a'] }`, meaning "the stored list holds `a`" on a multi-value field | `{ tags: { $contains: 'a' } }` |
36+
| `{ tags: ['a', 'b'] }`, meaning "the stored list holds `a` or `b`" | `{ $or: [{ tags: { $contains: 'a' } }, { tags: { $contains: 'b' } }] }` |
37+
| `{ stage: ['won'] }`, meaning one value | `{ stage: 'won' }` |
38+
| `{ stage: { $eq: [...] } }` | any of the rows above |
39+
40+
## Who is affected, measured
41+
42+
Nothing shipped in this repository carries the shape. A brace-matched scan of every filter-carrier literal (`filter`, `where`, `runtimeFilter`, `having`, `relatedListFilter`) in `packages/**`, `examples/**`, `apps/**`, `content/docs/**` and `skills/**` read 4709 literals across 7785 files and found 20 field entries whose value opens an array. Sixteen are test fixtures, and four are not filter carriers (a realtime subscription filter and a plugin-permission filter). A grep for `$eq` followed by an array found 24 lines: prose, MongoDB aggregation expressions, and one door-refusal conformance row. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists.
43+
44+
Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. One comparand shape in one slot, which every query door already refused, is now refused on save as well.
45+
46+
<!-- adr-0087: registered filter-equality-array-comparand-refused-at-save -->

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 206,
6-
"droppedRefinementSites": 571,
5+
"publishedSchemasWithDroppedRefinements": 207,
6+
"droppedRefinementSites": 574,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -605,6 +605,11 @@
605605
"where.options[1].lazy"
606606
]
607607
},
608+
"data/EqualityOperator": {
609+
"sites": [
610+
"$eq"
611+
]
612+
},
608613
"data/Field": {
609614
"sites": [
610615
"",
@@ -616,6 +621,7 @@
616621
"sites": [
617622
"$between.items[0]",
618623
"$between.items[1]",
624+
"$eq",
619625
"$in",
620626
"$nin"
621627
]
@@ -681,6 +687,7 @@
681687
"sites": [
682688
"lazy.$not.options[0].valueType.$between.items[0]",
683689
"lazy.$not.options[0].valueType.$between.items[1]",
690+
"lazy.$not.options[0].valueType.$eq",
684691
"lazy.$not.options[0].valueType.$in",
685692
"lazy.$not.options[0].valueType.$nin"
686693
]
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19889] The words the two filter doors share — the comparand-shape FACE
5+
* (`./filter-comparand-shape.ts`, which every query crosses) and the SCHEMA
6+
* door (`./filter.zod.ts`, which every stored filter crosses on save).
7+
*
8+
* ## Why a module of its own
9+
*
10+
* The 2026-09-24 ruling on #19889 (letter A, record 5805248669) refuses an
11+
* ARRAY in the EQUALITY slot at the schema door "with the SAME remedy text the
12+
* shared compile face emits — one constant, two doors". The face already
13+
* refused it (#19757, ruling 乙). So the text has to live where BOTH doors can
14+
* import it, and neither door can be that place: `filter.zod.ts` imports the
15+
* face, and the face cannot import `filter.zod.ts` back — the cycle the face's
16+
* `LIST_COMPARAND_OPERATORS` docblock records. A module both import breaks the
17+
* tie. It imports nothing.
18+
*
19+
* ## Why it is NOT in the `data` barrel
20+
*
21+
* Nothing here is a contract a consumer calls; the two refusals are, and they
22+
* are already published (`assertListComparandShapes`, and the schemas
23+
* themselves). Exporting the text would widen `@objectstack/spec/data` for no
24+
* reader, so this file stays out of `./index.ts`, as
25+
* `./currency-fraction-digits.ts` does. Consumers read the words where the
26+
* refusals put them: the `Error` message at the face, the issue message at the
27+
* schema door.
28+
*/
29+
30+
/**
31+
* The authoring spellings that lower to `$in`.
32+
*
33+
* The face's `LIST_COMPARAND_OPERATORS` reads its `$in` row from here, so the
34+
* refusal of a malformed `$in` and the equality-slot refusal (which prescribes
35+
* `$in`) name one spelling list. `filter-comparand-shape.test.ts` reconciles
36+
* the list against `AST_OPERATOR_MAP`.
37+
*/
38+
export const IN_OPERATOR_SPELLINGS: readonly string[] = ['in'];
39+
40+
/**
41+
* [#19757] The ARRAY-CONTAINMENT operator the equality-slot refusal prescribes,
42+
* with its authoring spelling: `$contains`, which `FieldOperatorsSchema`
43+
* declares as a MEMBERSHIP test on a `multiple: true` / JSON-stored column
44+
* ("the stored list holds this value"). The other half of the prescription is
45+
* `$in`, read off {@link IN_OPERATOR_SPELLINGS}. Reconciled by pin in
46+
* `filter-comparand-shape.test.ts`.
47+
*/
48+
export const ARRAY_CONTAINMENT_OPERATOR = { op: '$contains', spellings: ['contains'] } as const;
49+
50+
/**
51+
* A short, bounded rendering of the offending value.
52+
*
53+
* Bounded because the value came off the wire and a filter comparand can be
54+
* arbitrarily large; the message is for a human reading a 400, not a dump.
55+
*
56+
* The whole message has a second, harder bound: `rest-server.ts` TRUNCATES a
57+
* declared-4xx message at `CLIENT_MESSAGE_MAX` (500) before it reaches the
58+
* client (#5423). Everything a caller needs in order to act — operator, field,
59+
* received value, position, corrected shape — is therefore front-loaded, and
60+
* `filter-comparand-shape.test.ts` pins the assembled length under that bound so
61+
* a later edit cannot silently push the tail off the wire.
62+
*
63+
* Moved here from the face (#19889) so the schema door renders a received list
64+
* with the same characters the face does.
65+
*/
66+
export function shapePreview(value: unknown): string {
67+
let text: string;
68+
try {
69+
text = JSON.stringify(value) ?? String(value);
70+
} catch {
71+
text = String(value);
72+
}
73+
return text.length > 60 ? `${text.slice(0, 59)}…` : text;
74+
}
75+
76+
/**
77+
* The ONE remedy for an array in the equality slot: the two operators an author
78+
* holding a list was reaching for, by their spec spellings and their authoring
79+
* spellings, then the sentence a caller cannot infer from a status code.
80+
*
81+
* - `$in` — "one of these values" (authoring spelling `in`).
82+
* - `$contains` — "the stored list holds a value" on a multi-value field
83+
* (authoring spelling `contains`), and an `$or` of those for any-of.
84+
*
85+
* The corrected shapes are written WITHOUT the field wrapper and with `…` for
86+
* the value: the field is the subject of the sentence in front of this one,
87+
* and the 500-char client bound buys more as prescription than as a second
88+
* echo of the field name or the received list.
89+
*
90+
* The closing sentence is the face's, and it is kept at the schema door too:
91+
* a refused filter is one that was NOT applied, and the reason for refusing
92+
* rather than dropping it is that a dropped filter shows MORE rows than the
93+
* author asked for — the ruling's own ground for never dropping one silently.
94+
*/
95+
export const ARRAY_EQUALITY_COMPARAND_REMEDY: string =
96+
`For "one of these values" use {"$in": […]} (authoring: ${IN_OPERATOR_SPELLINGS.join(', ')}); ` +
97+
`for "the stored list holds a value" on a multi-value field, ` +
98+
`{"${ARRAY_CONTAINMENT_OPERATOR.op}": "…"} ` +
99+
`(authoring: ${ARRAY_CONTAINMENT_OPERATOR.spellings.join(', ')}), an $or of those for any-of. ` +
100+
`The filter was NOT applied, and an unapplied filter would have returned the ` +
101+
`UNFILTERED result set.`;
102+
103+
/**
104+
* Where the refused array sits, as far as the calling door can see.
105+
*
106+
* - `op: '$eq'` — the explicit spelling. The `field` is optional because one
107+
* door cannot know it: `FieldOperatorsSchema.$eq` is judged inside a
108+
* single field's operator map, whose key belongs to the enclosing record.
109+
* - no `op` — the implicit form `{ field: [...] }`, which only exists as a
110+
* field entry, so the field is always known.
111+
* - `path` — the face's dotted location (`where.stage`). The schema door
112+
* passes none: a zod refinement cannot see where it sits in the document,
113+
* and the issue it raises carries that location as its own `path`.
114+
*/
115+
export type ArrayEqualityComparandSite =
116+
| { readonly op: '$eq'; readonly field?: string; readonly path?: string }
117+
| { readonly op?: undefined; readonly field: string; readonly path?: string };
118+
119+
/**
120+
* The refusal of an ARRAY as an EQUALITY comparand, as both doors print it.
121+
*
122+
* The leading sentence is `driver-memory`'s `arrayComparandError` verbatim, so
123+
* one condition keeps one wording across packages (#5240's rule, applied
124+
* across packages). Then the received list, bounded by {@link shapePreview};
125+
* then the location when the door knows it; then
126+
* {@link ARRAY_EQUALITY_COMPARAND_REMEDY}.
127+
*
128+
* For a given field, value and operator the two doors therefore print the same
129+
* characters, with one difference: the face adds ` at <path>` and the schema
130+
* door does not, because the schema door's issue carries that location as its
131+
* own `path`. `filter-equality-array-schema-door.test.ts` pins exactly that.
132+
*/
133+
export function arrayEqualityComparandMessage(
134+
value: unknown,
135+
site: ArrayEqualityComparandSite,
136+
): string {
137+
const position = site.op
138+
? `Operator "${site.op}"${site.field === undefined ? '' : ` on field "${site.field}"`}`
139+
: `The implicit-equality comparand on field "${site.field}"`;
140+
const location = site.path === undefined ? '' : ` at ${site.path}`;
141+
return (
142+
`${position} requires a single comparable value, but received an array ` +
143+
`(${shapePreview(value)})${location}. ${ARRAY_EQUALITY_COMPARAND_REMEDY}`
144+
);
145+
}

0 commit comments

Comments
 (0)