Skip to content

Commit dd1b803

Browse files
fix(spec)!: a comparand the comparand-type face refuses is refused on save, and every charted presentation filter judges its nested relations (#20116) (#20325)
Fixes #20116 Clause-②: no (narrowing) **BREAKING** (an accept-set narrowing at the save doors; the changeset carries the ADR-0087 disposition `registered filter-comparand-types-and-widget-nested-slots-refused-at-save`). Stage 2 of the save-door ↔ query-face parity collector: the two open members of the seat's release `5857575995`. With the report half folded in (seat answer `5859432781`, after PR #20238 landed as `6a6a17b6`), both members are done, and so is the collector. The objectui producer stage 1 found is carried by objectui#10790, not by this card. - **M-type — done.** `FilterConditionSchema` now asks the comparand-TYPE face (`normalizeFilterComparandTypes`) read-only, after the comparand-shape face, inside the one judge stage 1 built (`reportQueryFaceRefusals`, `packages/spec/src/data/filter-save-door-refusals.ts`). A plain object where a single value belongs, a `Map`, a class instance, a function, a Symbol, `undefined` and a bigint beyond ±2^53 are refused on save — as the comparand, an implicit-equality comparand or a list member — at every reach the save doors have, and nothing the face passes is refused. - **M-widget — done.** `DashboardWidgetSchema.filter`, `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` declare the analytics-carrier filter the two dataset carriers declare, so each judges the slots INSIDE a nested relation the way the analytics `where` door does. #20207's refinement was inline and module-private in `dataset.zod.ts`; it moved verbatim into `packages/spec/src/ui/analytics-carrier-filter.ts` (not in the `ui` barrel), byte-neutral for `Dataset` (measured below). In `report.zod.ts` only the two `runtimeFilter` lines (and the import) change. ## Zone 2, measured ### 1. Re-probe (base `origin/main` `17bd3187`; spec doors from `src`, the analytics door from `service-analytics` `src` over a fresh spec `dist`) | member | FilterCondition | Dataset.filter | Measure.filter | Widget.filter | Report.runtimeFilter | JoinedBlock.runtimeFilter | type face (top) | analytics door | |:--|:--|:--|:--|:--|:--|:--|:--|:--| | `{ stage: { $eq: { a: 1 } } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | refuse 400 | refuse 400 | | `{ stage: { $in: [{ a: 1 }] } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | refuse 400 | refuse 400 | | `{ stage: { $eq: Map } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | refuse 400 | refuse 400 | | `{ stage: Map }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | refuse 400 | refuse 400 | | each of the four under `{ acct: … }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | accept (not descended) | refuse 400 | | `{ acct: { stage: { $in: ['won', null] } } }` | ACCEPT | refuse | refuse | ACCEPT | ACCEPT | ACCEPT | accept | refuse 400 | | `{ acct: { region: ['a'] } }`, `{ acct: { region: { $eq: ['a'] } } }` (#20080) | ACCEPT | refuse | refuse | ACCEPT | ACCEPT | ACCEPT | accept | refuse 400 | | controls: `$gt: { $field }`, `$gt: Date`, `'{current_user_id}'`, `{ acct: { region: 'NA' } }` | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | ACCEPT | accept | accept | After (src, same probe): every M-type cell is refused at the top level on all six carriers, at the slot (`stage.$eq`) or the member (`stage.$in.0`); every nested M-type and M-widget cell is refused on all five analytics carriers (`widgets.0.filter.acct.stage.$in.1`, `runtimeFilter.acct.stage.$in.1`, `blocks.0.runtimeFilter.acct.stage.$in.1`); bare `FilterConditionSchema` still accepts the nested cells (the face's reach); every control is accepted. ### 2. One judge The type face is asked inside `reportQueryFaceRefusals`, the function both walks call, exactly where stage 1 asks the shape face — so it reaches the shared walk (`checkFilterConditionComparands`) and the analytics carriers' nested walk at once, with no second walk. The judge raises ONE issue per slot, the first the query doors give in their order (shape face → type face → flag rule; `parseFilterAST`, the engine seam and `normalizeWhereComparands` all run them in that order). At the top level and in the combinators, where a face refuses a slot, the shared walk's own `#19514` `$icontains` and `#8793` preset arms stay silent on that slot. Inside a nested relation on an analytics carrier they do not: those two arms still judge nested slots beside the carrier walk's faces, so a nested `$icontains` with a type-refused comparand, or a nested one-bound `$between` of a preset name, carries two issues (the review measured 303 such cells new at the head). No verdict moves either way; the changeset and the entry say exactly this. What the type face "judges only at request time" was measured: nothing. The face is context-free (`context` is a message prefix only). The request-time values are `{ $field }` references (the face steps around them), `Date`s (accepted), and `{placeholder}` strings such as `{current_user_id}` / `{today}` — strings at save time, resolved by `resolveWhereTokens` only AFTER both faces have run on the engine seam. Each is pinned accepted-and-kept (§4 controls), plus a bigint within 2^53, which the save door keeps as written (the face would narrow it on a query). Two classifications follow the type face rather than restating it: a field value is a comparand unless it is a PLAIN object (prototype `Object.prototype` / `null`), so `{ stage: new Map() }` reaches the face instead of being walked as an empty nested relation; and the whole field entry is shown to the face first, so a spec it classifies as a `{ $field }` reference is stepped around whole, as the face does (pinned as a control). ### 3. The dashboard and report carriers — extraction, byte-neutral - The move is its own commit (`dfa424f6`), verbatim; `DatasetSchema` / `DatasetMeasureSchema` call `analyticsCarrierFilter()` as before. - `z.toJSONSchema` of `DatasetSchema`, `DatasetMeasureSchema`, `DashboardSchema` and `DashboardWidgetSchema`: sha256 `246850f2b9efdf1b…` at base `17bd3187`, at the extraction commit, and after the widget carrier — byte-identical. With `ReportSchema` and `JoinedReportBlockSchema` added, the six projections hash `35ba34f964bb8fd6…` both before the report fold (`6a0cfb05`) and after it. The parse probe is identical base vs extraction. - `dropped-refinements.baseline.json`: the `ui/Dataset` and `ui/DatasetMeasure` rows are unchanged. The merge of `6a6a17b6` conflicted only in this ledger's measured header; main's side was taken and the branch's sites re-added from build-schemas' own printed "corrected entries" (no hand-picked site): `ui/DashboardWidget` `filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report` `runtimeFilter` and `blocks.element.runtimeFilter`, `ui/JoinedReportBlock` `runtimeFilter`, and the same three positions in the four installed-package envelopes — +17 sites, 0 removed, `measured.droppedRefinementSites` 605 → 622. - Pin: §7 of the parity test asserts the carrier projects to exactly `FilterConditionSchema.optional()`'s JSON Schema, and that the widget filter and the report `runtimeFilter` keep their published descriptions. ### 4. The enumerating pin `filter-save-door-face-parity.test.ts`, extended, not duplicated: - §1 `queryFacesRefuse` now asks all three rules. The operator arms still derive from `FieldOperatorsSchema`'s keys, and a new assertion requires the type face to judge EVERY declared operator over the battery (its own test reconciles its scalar/list split against the same vocabulary). The battery gained the type face's shapes and neighbours (Map, class instance, function, Symbol, `{ $field: 5 }`, `{}`, bigints within and beyond 2^53, lists holding a plain object / Map / `undefined` / big bigint, a plain-object `$between` bound, bigint pairs). - §5 runs the operator × comparand table and the implicit slot, one and two hops down, on every analytics carrier — dataset `filter`, measure `filter`, dashboard widget `filter`, report `runtimeFilter`, joined block `runtimeFilter` (the two report rows were `EXPECTED_OPEN` until the fold and now sit in `CARRIERS`), plus a pin that the carrier list is exactly those five. - §6 walks the type face's own conformance table (`FILTER_COMPARAND_TYPE_CASES`): every `door-refusal` row is refused on save; every `matches` / `compiles` row is accepted AND kept as written. ### 5. Producer census (narrowing), with lit controls — 0 hits | corpus | object in a scalar slot | object list member | bigint literal | `undefined` under an operator | `new X` under an operator | nested relation holding a list / operator map in a filter | |:--|:--|:--|:--|:--|:--|:--| | objectstack `examples/**` @ `eaf7a925` | 0 | 0 (control: `$in` lists 3) | 0 | 0 | 0 | 0 (control: filters with an operator-map first entry 14) | | objectstack non-test `packages/**` @ `eaf7a925` | 25 raw, all prose / driver `case` labels / the type face's own table (control: `$field` in a scalar slot 67) | 26 raw, all prose / `$field` members / `{placeholder}` strings (control 238) | 3, prose | 31, prose (control: `null` 83) | 6, `new Date` / the table | 0 (control 162) | | objectui @ pin `f8a9d0fb05` | 0 (control 1) | 0 (control 12) | 0 | 1, a comment | 1, a refusal message | 0 (control 13) | | cloud `main` @ `96eb092fbf` | 0 (control 2) | 1, a comment (control 11) | 0 | 0 | 0 | 0 (control 14) | Plus a runtime walk of every value under a `filter` / `runtimeFilter` / `where` / `having` / `relatedListFilter` key in the loaded example stacks, old door vs new door on each: `app-crm` 8, `app-todo` 15, `app-multi-package` 0, `app-showcase` 20 (its metadata modules; its config needs connector builds) — 0 refused by the new door alone. Lit control: a planted `{ stage: { $eq: { a: 1 } } }` and a planted nested `$in` null member fire the detector in every run. No ADR-0087 D2 conversion: nothing to convert. ## The words (changed or new refusal text) A type-face cell reads the face's own sentence less its ` at where.SLOT` clause — nothing restated: > `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: the backends disagreed on this input (crash / zero rows / silently edited query). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.` at `filter.stage.$eq`, or at the member (`filter.stage.$in.1`). `undefined` gets the face's own sentence (`Filter comparand is undefined. { key: undefined } cannot be told apart from an omitted key, … Write the null predicate — {"$eq": null} / {"$ne": null} — or omit the key. …`), a bigint beyond 2^53 its (`Filter comparand is the bigint …n, whose magnitude exceeds 2^53 — …`). The clause removed is the one the face was handed (`where` plus this slot), so nothing is parsed out of the text; if the face ever spells its location differently, the whole message is reported location included, and §2's "no ` at where.`" pin goes red. Nested cells on the widget and report carriers print the same sentence as their top-level form (stage 1 and #20207's rule): e.g. `widgets.0.filter.acct.stage.$in.1` and `runtimeFilter.acct.stage.$in.1` carry the enforced `$in` slot's null-member sentence. ## Behaviour changes, each pinned | change | pin | |:--|:--| | type-face cells refused on save, top level + combinators, every `FilterCondition` carrier | parity §1 (all positions), §3 (six carriers), §6 | | type-face cells + stage-1 cells + #20080 lists refused INSIDE a relation on the widget filter and both report `runtimeFilter`s | parity §5 (all five carriers) | | one issue per slot at the top level and in the combinators, shape → type → flag; the `$icontains` / preset arms silent on a face-refused slot there (not inside a relation on an analytics carrier) — a dedupe, no verdict moves; stated at that reach in the changeset | parity §6 "one slot, one issue" (top level) | | `POST /analytics/dataset/query` `selection.runtimeFilter` and `POST /analytics/query` `where` with `{ stage: { $eq: { a: 1 } } }` / `{ stage: { $in: ['won', { a: 1 }] } }`: `400 INVALID_FILTER` → `400 VALIDATION_FAILED` located on the member | `packages/rest/src/analytics-filter-refusal-envelope.test.ts` `AT_THE_DOOR` rows + the sibling-schema control | | request-time values accepted and kept | parity §4 | ## Pin sweep ① Every refusal code and message this touches was grepped repo-wide. The type face's text is unchanged (only called). The HTTP-door code move has two routes and both are pinned in the rest file above; no rest / runtime test sent a type-face cell through a schema door before (grep over `packages/**` tests outside spec: the plain-object / Map / `undefined` / bigint comparand hits live in the analytics door's, the drivers', objectql's engine and read-scope suites, which call the faces directly, not a schema). Spec pins whose words could move — a flag with an object / `undefined` comparand, `$icontains` with a type-refused comparand, a preset endpoint on a malformed `$between` — have no existing pin. ② The flipped rest rows assert the substance: status 400, `VALIDATION_FAILED`, exactly one `details.fields[]` entry at the member, the sentence, and no ` at where.`. ## Tests Final head `830a071a` (merges `origin/main` `dfd8e398`, then corrects the dedupe sentence of the changeset and the semantic entry — text only), everything through `scripts/pm/os-verify-lock.sh`, `VERDICT command-exit 0`: - `@objectstack/spec` at `830a071a`: build 0; `check:generated` 0 ("All 15 generated artifacts are up to date"); `typecheck` 0; full suite 587 files / 17031 passed / 1 todo; the parity pin alone 153 passed. `check-adr-0087-registration` 0 (`[BREAKING+bang+clause-②-narrowing] registered filter-comparand-types-and-widget-nested-slots-refused-at-save`) and `check-changeset-no-major` 0 at `830a071a`. - At `e9f93902` (review head): spec full suite 585 files / 16988 passed / 1 todo. - At `3d9621a8` (the fold plus the ledger, before the second `main` merge, which touches no file of this diff's behaviour): spec 585 / 16955 passed; consumer closures built (exit 0); `service-analytics` 129 files / 3041 passed; `lint` 111 / 4297; `rest` (`--project local`) 202 / 3664 passed / 1 skipped. `rest` typecheck 0 at `54b99f3c` (the rest file is unchanged since). The two new rest rows ran by name (`a plain object where a single value belongs → 400 VALIDATION_FAILED, located on the member`, `a plain object as an $in member → …`). - Ablation, through `scripts/ablation-replace.mjs` (WRAP), each anchor x1 → x0 on disk with the blob changed, each restore proven blob == HEAD blob and `git diff HEAD` empty (script trap restores on EXIT/INT/TERM). Parity pin: - at `3d9621a8`, 153 tests — **report-carrier arm**: `ReportSchema.runtimeFilter` carrier stripped (`analyticsCarrierFilter().unwrap().optional()`) → 18 failed; `JoinedReportBlockSchema.runtimeFilter` stripped → 18 failed; restored → 153 passed (`unwrap` markers on disk after restore: 0); - at `54b99f3c`, 152 tests — type face off → 48 failed; widget carrier off → 18; shared walk's Map / class-instance classification off → 6; nested walk's classification off → 3; one-issue-per-slot `continue` off → 1 (direction: MORE diagnostics — two issues at `name.$icontains`); restored → 152 passed. - No leg needs a build: the pin imports spec `src`. - Gates at `830a071a`: `dispatch-gates --commands` derived 90 on the actual paths (the 89 of the review head plus `node scripts/check-issue-citations.mjs`, which main's `7338efe0` now runs locally); 88 exit 0 — `check:doc-formula-expressions` and `check:lean-entry-closure` first answered exit 3 (their `formula` / `objectql` builds were absent in the re-created worktree) and exit 0 after that closure was built under the lock; `check:dual-build-cjs-loads` and `check:type-check-debt` exit 3 (PREREQUISITE NOT MET: whole-repo build) = NOT MEASURED; `--ran` with recorded codes: 90 accounted, 88 run, 2 NOT MEASURED. - ESLint, narrowed and proven, at `830a071a`: `eslint --no-inline-config --format json` over the 10 changed `.ts` files (count read from the JSON) → 0 errors, 0 warnings. The population is `eslint.config.mjs`, which "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file", so the diff cannot move a verdict on an untouched file. Repo-wide `pnpm lint` is CI's. - `main` moved after `830a071a` too (2 commits at the gate derivation, none touching what this answer derives from, as `dispatch-gates` read it). - NOT MEASURED: `objectql`, `metadata-protocol`, `runtime` and example suites (no fixture carries a refused shape through a schema door by the sweep above; CI runs them). ## Hand-written docs the drift check named (github-actions comment `5860213966`) Each re-read against this PR's behaviour — the type-face refusals at save, the widget / report `runtimeFilter` nested-relation refusals, and the analytics routes' code move for the type face's JSON cells: | page | verdict | why | |:--|:--|:--| | `content/docs/api/data-api.mdx` | unchanged | Its `INVALID_FILTER` sentences are about the data routes' `?filter` (the engine's normalizer, not a `FilterConditionSchema` parse), and the `/analytics/query` section says only that `where` is the `FilterCondition` `find()` accepts — still true; no sentence names the code a type-face cell gets. | | `content/docs/api/error-catalog.mdx` | unchanged | `INVALID_FILTER` / `VALIDATION_FAILED` are defined generically; no sentence claims the analytics routes answer `INVALID_FILTER` for a plain-object comparand. | | `content/docs/data-modeling/analytics.mdx` | unchanged | The widget `filter` and report `runtimeFilter` examples (`$nin` list, `{current_quarter_start}` placeholder) are accepted by the new doors; the placeholder paragraph and the "one author-facing shape" section stay true; nothing says a nested-relation filter saves on those carriers. | | `content/docs/protocol/objectql/query-syntax.mdx` | unchanged | Its `FilterConditionSchema` / field-reference / relation-traversal text stays true: `{ $field: 'col' }` is still accepted, and the page makes no claim about a plain-object, `Map` or `undefined` comparand passing validation. | No release page (`content/docs/releases/**`) names these shapes; none was touched. ## Acceptance notes - **Request doors keep two codes for one family.** `DatasetSelectionSchema.runtimeFilter` / `AnalyticsQueryRequestSchema.where` carry the shared reach, so a top-level refused slot answers `VALIDATION_FAILED` at the schema door while the same slot inside a relation answers `INVALID_FILTER` from the analytics normalizer. Both 400, both located; not a save door, so not this collector's. Noted, not filed (carrier: none). - **File surface**, as amended by the seat (`5859432781`): `report.zod.ts` (the two `runtimeFilter` carriers only); and, accepted as the order's own mechanism, `data/filter-save-door-refusals.ts` (stage 1's judge), `ui/analytics-carrier-filter.ts` + `ui/dataset.zod.ts` (Zone 2.3's extraction), `packages/rest/src/analytics-filter-refusal-envelope.test.ts` (the HTTP-door pin). - `GlobalFilterOptionsFromSchema.filter` (a dashboard's options source) is an engine query, not charted through the analytics door, so it keeps the shared reach. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 826f327 commit dd1b803

12 files changed

Lines changed: 1034 additions & 252 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and every charted presentation filter judges its nested relations (#20116)
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. The save door narrows to exactly what the query faces already refuse; this is stage 2 of the change whose first stage registered `filter-query-face-comparands-refused-at-save`. The hand-migration prescription is registered under protocol major 18 as `filter-comparand-types-and-widget-nested-slots-refused-at-save`.
8+
9+
## What changes
10+
11+
**The comparand-type face, asked on save.** `FilterConditionSchema` now refuses, at parse, every comparand the comparand-type face (`normalizeFilterComparandTypes`, the accepted set `string | number | bigint | boolean | null | Date`) refuses on every query:
12+
13+
- a plain object where a single value belongs — `{ stage: { $eq: { a: 1 } } }`, and a `{ $field: … }` whose name is not a string;
14+
- a `Map`, a class instance, a function or a Symbol;
15+
- `undefined`, as `{ owner: undefined }` or under an operator;
16+
- a bigint beyond ±2^53;
17+
18+
as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$nin` / `$between` list member (`{ stage: { $in: [{ a: 1 }] } }`). The face is called read-only as the judge, after the comparand-shape face, so the save door refuses exactly what it refuses and passes what it passes. A field value that is not a PLAIN object (a `Map`, a class instance) is the comparand the face calls it, never an empty nested relation.
19+
20+
**Every charted presentation filter is an analytics carrier.** `DashboardWidgetSchema.filter`, `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` now declare the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs each into the same analytics query. So they judge the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets.0.filter.acct.…`, `runtimeFilter.acct.…` and `blocks.0.runtimeFilter.acct.…`, as on the two dataset carriers — every shape the earlier stage refuses at the top level, and every type-face value above. That declaration moved, verbatim, into its own module shared by the five carriers; every carrier's published JSON Schema body is byte-identical.
21+
22+
Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when they are charted.
23+
24+
**One issue per slot at the top level and in the combinators — a dedupe; no verdict moves.** The faces' verdict on a slot is one refusal: the first the query doors give, in their order — the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. At the top level of a filter and in its `$and` / `$or` / `$not` members, where a face refuses a slot the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land there for one defect: `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed.
25+
26+
Inside a nested relation on an analytics carrier (a dataset or measure `filter`, a widget `filter`, a report or joined-block `runtimeFilter`) a slot can still carry TWO issues. There the carrier's nested-relation walk asks the faces, while the schema door's own `$icontains` and date-preset arms keep judging nested slots as they did before this change. So `{ acct: { name: { $icontains: new Map() } } }` gets both the `$icontains` sentence and the type face's at `filter.acct.name.$icontains`, and `{ acct: { created_at: { $between: ["last_7_days"] } } }` gets the malformed range at `filter.acct.created_at.$between` and the preset endpoint at `….$between.0`. The document is refused either way; only the issue count differs.
27+
28+
Every document refused before is still refused, and every document accepted before is still accepted — the dedupe removes only a second issue on an already-refused slot at the top level and in the combinators.
29+
30+
**The words are the type face's**, less its location clause (`at where.<field>.<op>`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member.
31+
32+
`defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path.
33+
34+
## What does NOT change
35+
36+
- **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.
37+
- **What the face passes still passes:** a `Date`, a `{ $field: "column" }` reference, a `{placeholder}` string the engine resolves at request time (`{current_user_id}`, `{today}`), and a bigint within ±2^53. The face narrows such a bigint to its number on a query; the save door keeps it as written.
38+
- **The shared reach is unchanged.** On every carrier but the three analytics ones, a field spec with no `$` key (a nested-relation condition) is not judged, because no face descends one.
39+
- **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.
40+
- **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new nested-relation rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report` `runtimeFilter` and the joined block's `runtimeFilter`, and at the same positions inside the installed-package manifests.
41+
42+
## FROM → TO
43+
44+
| you wrote | write instead |
45+
|:--|:--|
46+
| `{ stage: { $eq: { a: 1 } } }` | the one value you meant: `{ stage: { $eq: "won" } }` |
47+
| `{ stage: { $in: [{ a: 1 }] } }` | a list of values: `{ stage: { $in: ["won", "lost"] } }` |
48+
| `{ amount: { $gt: { $field: 5 } } }` | a column name: `{ amount: { $gt: { $field: "budget" } } }` |
49+
| `{ owner: undefined }`, `{ owner: { $eq: undefined } }` | `{ owner: { $eq: null } }` ("has no value"), `{ owner: { $ne: null } }` ("has a value"), or omit the key |
50+
| `{ tags: new Map(…) }`, a class instance | the value itself, as a string, number, boolean, `null` or `Date` |
51+
| `{ qty: { $gt: 2n ** 60n } }` | a bound within ±2^53, or the value compared as a string |
52+
| a widget `filter: { acct: { stage: { $in: ["won", null] } } }` | `{ $or: [{ acct: { stage: { $in: ["won"] } } }, { acct: { stage: { $null: true } } }] }` |
53+
| a widget `filter: { acct: { region: ["a", "b"] } }` | `{ acct: { region: { $in: ["a", "b"] } } }` |
54+
| a report or joined-block `runtimeFilter` with either nested shape above | the same rewrite, at `runtimeFilter` / `blocks.<n>.runtimeFilter` |
55+
56+
### FROM → TO at the HTTP doors
57+
58+
Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer.
59+
60+
| request | before | after |
61+
|:--|:--|:--|
62+
| `POST /analytics/dataset/query` with `selection.runtimeFilter: { stage: { $eq: { a: 1 } } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-type face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.stage.$eq` with the sentence `Filter comparand is a plain object ({"a":1}), which no driver can compare. …` |
63+
| the same route with `selection.runtimeFilter: { stage: { $in: ["won", { a: 1 }] } }` | `400 INVALID_FILTER` | `400 VALIDATION_FAILED` at `selection.runtimeFilter.stage.$in.1` |
64+
| `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with either shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.stage.$eq` / `where.stage.$in.1`, answered `400 VALIDATION_FAILED` |
65+
66+
A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. The other refused values cannot arrive over HTTP: JSON has no `Map`, `undefined` or bigint.
67+
68+
## Who is affected, measured
69+
70+
A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no `filter` / `runtimeFilter` / `where` / `relatedListFilter` whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists.
71+
72+
Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter and both report runtimeFilters judge the nested-relation slots the analytics door already refused on chart.
73+
74+
<!-- adr-0087: registered filter-comparand-types-and-widget-nested-slots-refused-at-save -->

‎packages/rest/src/analytics-filter-refusal-envelope.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the
220220
* earlier — at the route's schema door — and the block above no longer claims
221221
* them. [#20116] A fourth joined them: the one-bound `$between`, once
222222
* `FilterConditionSchema` began refusing on save every comparand slot the
223-
* shared comparand-shape face refuses on query.
223+
* shared comparand-shape face refuses on query. [#20116, stage 2] Then the
224+
* comparand-TYPE face's JSON-representable cells — a plain object where one
225+
* value belongs, as the comparand or as a list member — once that schema asked
226+
* the type face too.
224227
*
225228
* ⚠️ This is a CODE change on a live wire surface, so it is recorded with the
226229
* measurement that justifies it rather than as a test edit. Since #17551 the
@@ -236,6 +239,8 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the
236239
* | `{ $not: 5 }` | refused at the schema | refused at the schema |
237240
* | `{ stage: {} }` | passes the schema | passes the schema |
238241
* | `{ amount: { $between: [10] } }` | refused at the schema (#20116) | refused at the schema (#20116) |
242+
* | `{ stage: { $eq: { a: 1 } } }` | refused at the schema (#20116 stage 2) | refused at the schema (#20116 stage 2) |
243+
* | `{ stage: { $in: ['won', { a: 1 }] } }` | refused at the schema (#20116 stage 2) | refused at the schema (#20116 stage 2) |
239244
* | `{ $nor: [{…}] }` | passes the schema | passes the schema |
240245
* | `{ $or: [] }` | passes the schema | passes the schema |
241246
*
@@ -270,6 +275,23 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th
270275
member: 'selection.runtimeFilter.amount.$between',
271276
sentence: /^Operator "\$between" on field "amount" requires a \[min, max\] value array\. Received array \(\[10\]\)\. A range needs exactly two bounds/,
272277
},
278+
{
279+
// [#20116, stage 2] Before, this crossed the schema and the normalizer
280+
// refused it `INVALID_FILTER` / 400 in the comparand-TYPE face's words
281+
// (`at where.stage.$eq`). The schema door now asks that face on save and
282+
// prints the same sentence less its location, located on the member.
283+
name: 'a plain object where a single value belongs',
284+
runtimeFilter: { stage: { $eq: { a: 1 } } },
285+
member: 'selection.runtimeFilter.stage.$eq',
286+
sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\. A comparison value must be a string, number, bigint, boolean, null or Date\./,
287+
},
288+
{
289+
// [#20116, stage 2] The same face, on a list member: located on the member.
290+
name: 'a plain object as an $in member',
291+
runtimeFilter: { stage: { $in: ['won', { a: 1 }] } },
292+
member: 'selection.runtimeFilter.stage.$in.1',
293+
sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./,
294+
},
273295
];
274296

275297
for (const c of AT_THE_DOOR) {

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"measured": {
44
"zod": "4.4.3",
55
"publishedSchemasWithDroppedRefinements": 212,
6-
"droppedRefinementSites": 605,
6+
"droppedRefinementSites": 622,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -55,6 +55,7 @@
5555
"manifest.connectors.element.out",
5656
"manifest.dashboards.element.globalFilters.element",
5757
"manifest.dashboards.element.widgets.element",
58+
"manifest.dashboards.element.widgets.element.filter",
5859
"manifest.datasets.element",
5960
"manifest.datasets.element.filter",
6061
"manifest.datasets.element.include.element",
@@ -79,6 +80,8 @@
7980
"manifest.permissions.element.rowLevelSecurity.element",
8081
"manifest.reports.element",
8182
"manifest.reports.element.blocks.element",
83+
"manifest.reports.element.blocks.element.runtimeFilter",
84+
"manifest.reports.element.runtimeFilter",
8285
"manifest.reports.element.runtimeFilter.lazy",
8386
"manifest.sharingRules.element.sharedWith",
8487
"manifest.skills.element.triggerConditions.element",
@@ -156,6 +159,7 @@
156159
"data.options[1].manifest.connectors.element.out",
157160
"data.options[1].manifest.dashboards.element.globalFilters.element",
158161
"data.options[1].manifest.dashboards.element.widgets.element",
162+
"data.options[1].manifest.dashboards.element.widgets.element.filter",
159163
"data.options[1].manifest.datasets.element",
160164
"data.options[1].manifest.datasets.element.filter",
161165
"data.options[1].manifest.datasets.element.include.element",
@@ -179,6 +183,8 @@
179183
"data.options[1].manifest.permissions.element.rowLevelSecurity.element",
180184
"data.options[1].manifest.reports.element",
181185
"data.options[1].manifest.reports.element.blocks.element",
186+
"data.options[1].manifest.reports.element.blocks.element.runtimeFilter",
187+
"data.options[1].manifest.reports.element.runtimeFilter",
182188
"data.options[1].manifest.reports.element.runtimeFilter.lazy",
183189
"data.options[1].manifest.sharingRules.element.sharedWith",
184190
"data.options[1].manifest.skills.element.triggerConditions.element",
@@ -241,6 +247,7 @@
241247
"options[1].manifest.connectors.element.out",
242248
"options[1].manifest.dashboards.element.globalFilters.element",
243249
"options[1].manifest.dashboards.element.widgets.element",
250+
"options[1].manifest.dashboards.element.widgets.element.filter",
244251
"options[1].manifest.datasets.element",
245252
"options[1].manifest.datasets.element.filter",
246253
"options[1].manifest.datasets.element.include.element",
@@ -264,6 +271,8 @@
264271
"options[1].manifest.permissions.element.rowLevelSecurity.element",
265272
"options[1].manifest.reports.element",
266273
"options[1].manifest.reports.element.blocks.element",
274+
"options[1].manifest.reports.element.blocks.element.runtimeFilter",
275+
"options[1].manifest.reports.element.runtimeFilter",
267276
"options[1].manifest.reports.element.runtimeFilter.lazy",
268277
"options[1].manifest.sharingRules.element.sharedWith",
269278
"options[1].manifest.skills.element.triggerConditions.element",
@@ -287,6 +296,7 @@
287296
"data.packages.element.options[1].manifest.connectors.element.out",
288297
"data.packages.element.options[1].manifest.dashboards.element.globalFilters.element",
289298
"data.packages.element.options[1].manifest.dashboards.element.widgets.element",
299+
"data.packages.element.options[1].manifest.dashboards.element.widgets.element.filter",
290300
"data.packages.element.options[1].manifest.datasets.element",
291301
"data.packages.element.options[1].manifest.datasets.element.filter",
292302
"data.packages.element.options[1].manifest.datasets.element.include.element",
@@ -309,6 +319,8 @@
309319
"data.packages.element.options[1].manifest.permissions.element.rowLevelSecurity.element",
310320
"data.packages.element.options[1].manifest.reports.element",
311321
"data.packages.element.options[1].manifest.reports.element.blocks.element",
322+
"data.packages.element.options[1].manifest.reports.element.blocks.element.runtimeFilter",
323+
"data.packages.element.options[1].manifest.reports.element.runtimeFilter",
312324
"data.packages.element.options[1].manifest.reports.element.runtimeFilter.lazy",
313325
"data.packages.element.options[1].manifest.sharingRules.element.sharedWith",
314326
"data.packages.element.options[1].manifest.skills.element.triggerConditions.element",
@@ -1167,12 +1179,14 @@
11671179
"sites": [
11681180
"globalFilters.element",
11691181
"widgets.element",
1182+
"widgets.element.filter",
11701183
"widgets.element.filter.lazy"
11711184
]
11721185
},
11731186
"ui/DashboardWidget": {
11741187
"sites": [
11751188
"",
1189+
"filter",
11761190
"filter.lazy"
11771191
]
11781192
},
@@ -1276,6 +1290,7 @@
12761290
"ui/JoinedReportBlock": {
12771291
"sites": [
12781292
"",
1293+
"runtimeFilter",
12791294
"runtimeFilter.lazy"
12801295
]
12811296
},
@@ -1396,6 +1411,8 @@
13961411
"sites": [
13971412
"",
13981413
"blocks.element",
1414+
"blocks.element.runtimeFilter",
1415+
"runtimeFilter",
13991416
"runtimeFilter.lazy"
14001417
]
14011418
},

0 commit comments

Comments
 (0)