From 325052f1c1775c6465934aa7f72831f7a9c1a485 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 10:41:58 +0000 Subject: [PATCH 1/2] fix(spec): refuse an absent value on a value-taking view filter operator at authoring time The scalar arm of the view filter rule value check returned early on an absent value for every operator, so a rule such as { field: 'name', operator: 'icontains' } parsed green while its published description declares a scalar for every non-unary operator, and the query path refuses the lowered [field, operator] node with 400 INVALID_FILTER. The four unary operators stay valueless; in / not_in / between keep their own arms. Adds the ADR-0087 semantic entry under protocol major 18 and the regenerated migration registry. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- ...1-view-filter-rule-absent-value-refused.md | 42 ++++++ ...8.view-filter-rule-absent-value-refused.ts | 59 ++++++++ packages/spec/src/migrations/registry.ts | 55 +++++++ .../ui/view-filter-rule-value-shape.test.ts | 137 +++++++++++++++++- packages/spec/src/ui/view.zod.ts | 66 +++++++-- 5 files changed, 341 insertions(+), 18 deletions(-) create mode 100644 .changeset/19751-view-filter-rule-absent-value-refused.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts diff --git a/.changeset/19751-view-filter-rule-absent-value-refused.md b/.changeset/19751-view-filter-rule-absent-value-refused.md new file mode 100644 index 00000000000..638f45fcb45 --- /dev/null +++ b/.changeset/19751-view-filter-rule-absent-value-refused.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query (#19751) + +**BREAKING** — an accept-set narrowing on a published authoring surface, pulling `ViewFilterRuleSchema` back to what its own `value` description already declares: every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and only the unary operators ignore the key. The level is `patch` because nothing widens; during the launch window breaking-ness is carried by this banner and the ADR-0087 disposition below, not by the level. The hand-migration prescription is registered under protocol major 18 as `view-filter-rule-absent-value-refused`. + +## What changes + +A filter rule that omits `value` (or carries `value: undefined`) on `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before` or `after` used to parse green and then fail at query time: the rule lowers to `[field, operator]`, and the query path refuses that with `400 INVALID_FILTER` ("Filter comparand at … is undefined") — which failed the whole view, not just that rule. It is now refused when the view is saved, at the rule's `value` path, on every carrier of `ViewFilterRuleSchema` (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter): + +```text +Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, … +``` + +## What stays accepted + +- The unary operators `is_empty` / `is_not_empty` / `is_null` / `is_not_null`, with or without a value. +- `in` / `not_in` / `between` refused an absent value before this change and still do, with their own wording. +- `value: null` on a scalar operator is a value (the null predicate), not an absent one, and still parses. + +## Migration + +For each refused rule, decide what it meant: + +```ts +// FROM — no value on an operator that takes one +{ field: 'status', operator: 'equals' } + +// TO — a comparison: write the value +{ field: 'status', operator: 'equals', value: 'open' } + +// TO — a test for "no value": use an operator that takes none +{ field: 'status', operator: 'is_empty' } +``` + +A rule that was an unfinished row is deleted. The console's filter builder never saved this shape (it drops a half-filled row before saving), so the rules to look for are hand-authored or written by another tool. + +Clause-②: no (narrowing) — no key is added, removed or renamed and no exported symbol moves; the accept set of `ViewFilterRule.value` narrows back to what its published description declares. + + diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts new file mode 100644 index 00000000000..dfa1c875d4a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The absent-value half of the coupling #6227 declared, recorded beside its +// array half (`view-filter-rule-scalar-operator-array-refused`) rather than +// amended onto it: that entry's own replacement prose told an upgrading author +// "an omitted value is still an omitted value", and an upgrade guide that +// quietly rewrites a shipped prescription leaves the reader who followed it with +// no trace of why their metadata now fails. +export const entry: SemanticMigration = { + id: 'view-filter-rule-absent-value-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, ' + + 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, ' + + 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, ' + + 'less_than_or_equal, before or after (an alias spelling of any of them included), on ' + + 'every carrier of ViewFilterRuleSchema', + replacement: + 'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on ' + + 'after. A rule that meant "the field has no value" becomes one of the four operators that ' + + 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction ' + + 'from their name and still parse with or without a value. A rule that was an unfinished ' + + 'row is deleted. The list operators (in / not_in) and the range operator (between) ' + + 'refused an absent value before this change and still do, in their own words', + reason: + '#19751. The value key\'s own published description has declared since #6227 that every ' + + 'operator outside the list, range and unary sets takes a scalar, and that only the unary ' + + 'operators ignore the key; the refinement implementing the coupling returned early on an ' + + 'absent value for every operator, so a rule with no value parsed green on all thirteen ' + + 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — ' + + 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element ' + + '[field, operator] node, which the filter-AST lowering reads as an undefined comparand ' + + 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing ' + + 'between storage and the query drops the rule, so one such rule failed every query that ' + + 'read its view, the view\'s other rules included. The first-party producer does not write ' + + 'the shape: the console filter builder drops a row whose operator takes a value and whose ' + + 'value is missing before it saves, and the drill-down save-as-view path checks each rule ' + + 'against this schema before persisting it (read at the pinned objectui commit). ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: ' + + 'there is no value to infer, and writing a value, switching to a unary operator and ' + + 'deleting the rule are three different predicates only the author can choose between. ' + + 'The read path does not re-validate stored rows (the reading the sibling entry ' + + 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading ' + + '— and keeps failing its queries, as it did before this change; what changes is that ' + + 'RE-SAVING it is refused at the value path, naming the operator and the field. ' + + 'ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and object-* blocks for a filter rule that has no value ' + + 'key and whose operator is none of the four unary operators, then decide per rule which ' + + 'of three things it meant: a comparison (write the value), a test for emptiness (switch ' + + 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). ' + + 'os validate reports each one by path with the operator and the field, so the sweep is ' + + 'mechanical rather than by eye. A view carrying one of these rules was refusing every ' + + 'query before this change, so re-check what it is supposed to show rather than assuming ' + + 'any earlier result set.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8430b24a9f3..b0d63c6d1b2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13261,6 +13261,61 @@ const step18: MigrationStep = { + 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the ' + 'rail.', }, + // The absent-value half of the coupling #6227 declared, recorded beside its + // array half (`view-filter-rule-scalar-operator-array-refused`) rather than + // amended onto it: that entry's own replacement prose told an upgrading author + // "an omitted value is still an omitted value", and an upgrade guide that + // quietly rewrites a shipped prescription leaves the reader who followed it with + // no trace of why their metadata now fails. + { + id: 'view-filter-rule-absent-value-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, ' + + 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, ' + + 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, ' + + 'less_than_or_equal, before or after (an alias spelling of any of them included), on ' + + 'every carrier of ViewFilterRuleSchema', + replacement: + 'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on ' + + 'after. A rule that meant "the field has no value" becomes one of the four operators that ' + + 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction ' + + 'from their name and still parse with or without a value. A rule that was an unfinished ' + + 'row is deleted. The list operators (in / not_in) and the range operator (between) ' + + 'refused an absent value before this change and still do, in their own words', + reason: + '#19751. The value key\'s own published description has declared since #6227 that every ' + + 'operator outside the list, range and unary sets takes a scalar, and that only the unary ' + + 'operators ignore the key; the refinement implementing the coupling returned early on an ' + + 'absent value for every operator, so a rule with no value parsed green on all thirteen ' + + 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — ' + + 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element ' + + '[field, operator] node, which the filter-AST lowering reads as an undefined comparand ' + + 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing ' + + 'between storage and the query drops the rule, so one such rule failed every query that ' + + 'read its view, the view\'s other rules included. The first-party producer does not write ' + + 'the shape: the console filter builder drops a row whose operator takes a value and whose ' + + 'value is missing before it saves, and the drill-down save-as-view path checks each rule ' + + 'against this schema before persisting it (read at the pinned objectui commit). ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: ' + + 'there is no value to infer, and writing a value, switching to a unary operator and ' + + 'deleting the rule are three different predicates only the author can choose between. ' + + 'The read path does not re-validate stored rows (the reading the sibling entry ' + + 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading ' + + '— and keeps failing its queries, as it did before this change; what changes is that ' + + 'RE-SAVING it is refused at the value path, naming the operator and the field. ' + + 'ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and object-* blocks for a filter rule that has no value ' + + 'key and whose operator is none of the four unary operators, then decide per rule which ' + + 'of three things it meant: a comparison (write the value), a test for emptiness (switch ' + + 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). ' + + 'os validate reports each one by path with the operator and the field, so the sweep is ' + + 'mechanical rather than by eye. A view carrying one of these rules was refusing every ' + + 'query before this change, so re-check what it is supposed to show rather than assuming ' + + 'any earlier result set.', + }, // The scalar half of the coupling #6227 declared and did not judge. Recorded // here rather than amended onto `view-filter-rule-value-shaped-by-operator` // because that entry's own prose states the OPPOSITE reading as accepted, and diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index fb6575afc23..9bbf7f137da 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -23,6 +23,14 @@ * own pins, because the #5685 side of this file is what stops a narrowing from * running on past the query path. * + * [#19751] The absent-value carve-out went the same way, for the same reason: it + * was recorded as one the query path makes, and measurement found the query path + * refusing it — a rule with no `value` lowers to `[field, operator]`, which the + * runtime refuses with its undefined-comparand `INVALID_FILTER` / 400 on every + * operator that takes a value. Two pins moved from the accepted side to the + * refused side, and the four valueless operators are now the ONLY carve-out for + * an absent value; the sweep below holds that in both directions. + * * Every rejection pin asserts the issue PATH and the message's leading sentence, * not merely that a throw happened: a bare `.toThrow()` cannot tell "refused for * the right reason at the right key" from "refused because the value union @@ -31,6 +39,7 @@ import { describe, expect, it } from 'vitest'; import { + ListViewSchema, VIEW_FILTER_LIST_VALUE_OPERATORS, VIEW_FILTER_OPERATORS, VIEW_FILTER_PAIR_VALUE_OPERATORS, @@ -68,6 +77,15 @@ const UNSHAPED_VALUE_OPERATORS: readonly string[] = VIEW_FILTER_OPERATORS.filter ).includes(operator), ); +/** + * Every operator that TAKES a value — the canonical vocabulary minus the four + * valueless ones. DERIVED, like the set above, and wider than it: it keeps the + * list and range operators, whose own arms already refuse an absent value. + */ +const VALUE_TAKING_OPERATORS: readonly string[] = VIEW_FILTER_OPERATORS.filter( + (operator) => !(VALUELESS_OPERATORS as readonly string[]).includes(operator), +); + /** The single `value`-path issue a shape refusal must produce. */ function valueIssue(result: ReturnType) { expect(result.success).toBe(false); @@ -185,10 +203,10 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the ['greater_than + ISO string', { field: 'd', operator: 'greater_than', value: '2026-01-01' }], ['before + string', { field: 'd', operator: 'before', value: '2026-01-01' }], ['after + string', { field: 'd', operator: 'after', value: '2026-01-01' }], - // A scalar operator with NO value: `value` is optional and absence is not a - // shape. The scalar arm must not turn an omitted comparand into a refusal. - ['equals + omitted', { field: 'f', operator: 'equals' }], - ['greater_than + omitted', { field: 'd', operator: 'greater_than' }], + // [#19751] `equals + omitted` and `greater_than + omitted` sat here, recorded + // as "absence is not a shape". They moved to the absent-value block below: + // the query path refuses both, so accepting them was the #5685 mismatch + // pointing the other way. // Every scalar type the declared union carries still parses on a scalar operator. ['equals + null', { field: 'f', operator: 'equals', value: null }], ['equals + boolean', { field: 'f', operator: 'equals', value: false }], @@ -293,6 +311,117 @@ describe('#19514 — the scalar arm, in both directions', () => { }); }); +describe('#19751 — an ABSENT value on an operator that takes one is refused', () => { + /** The rule as a view stores it when the comparand was never written. */ + const valueless = (operator: string, field = 'f') => ({ field, operator }); + + it.each([ + ['equals + omitted', 'equals'], + ['greater_than + omitted', 'greater_than'], + ])('refuses %s — recorded as ACCEPTED until this change, reversed on measurement', (_label, operator) => { + const issue = valueIssue(parse(valueless(operator))); + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['value']); + expect(issue.message).toContain(`Filter comparand for operator "${operator}" on field "f" is undefined.`); + }); + + it('refuses the card example, naming the operator and the field the author wrote', () => { + const issue = valueIssue(parse(valueless('icontains', 'name'))); + // Leading sentence: the runtime's undefined-comparand sentence, with the + // location named in the view vocabulary (a view rule has no `where` path). + expect(issue.message).toContain('Filter comparand for operator "icontains" on field "name" is undefined.'); + // Traceable to the query-path refusal by its ERROR CODE, not a tracker id. + expect(issue.message).toContain('400 INVALID_FILTER'); + }); + + it('sweeps every operator that takes a value: an omitted value REFUSES at the value path', () => { + expect(VALUE_TAKING_OPERATORS.length).toBeGreaterThan(0); + for (const operator of VALUE_TAKING_OPERATORS) { + const result = parse(valueless(operator)); + expect(result.success, `${operator} + omitted`).toBe(false); + if (result.success) continue; + const issues = result.error.issues.filter((i) => i.path.join('.') === 'value'); + // ONE issue — `icontains` must not be answered twice (shape arm + comparand arm). + expect(issues, operator).toHaveLength(1); + } + }); + + it('answers the SCALAR operators with the new wording, and leaves list and range to their own arms', () => { + // The scalar set — every value-taking operator outside the list and range + // sets — is where absence used to ride through. The list and range arms + // refused an absent value before this change and keep their own wording, so + // an author is told what SHAPE to write rather than only that one is missing. + for (const operator of UNSHAPED_VALUE_OPERATORS) { + const issue = valueIssue(parse(valueless(operator))); + expect(issue.message, operator).toContain(`Filter comparand for operator "${operator}" on field "f" is undefined.`); + expect(issue.message, operator).not.toContain('requires a SCALAR value'); + } + for (const operator of VIEW_FILTER_LIST_VALUE_OPERATORS) { + const issue = valueIssue(parse(valueless(operator))); + expect(issue.message, operator).toContain('requires an ARRAY of values'); + expect(issue.message, operator).not.toContain('Filter comparand'); + } + for (const operator of VIEW_FILTER_PAIR_VALUE_OPERATORS) { + const issue = valueIssue(parse(valueless(operator))); + expect(issue.message, operator).toContain('requires a [min, max] value array'); + expect(issue.message, operator).not.toContain('Filter comparand'); + } + }); + + it('refuses a key that is PRESENT but undefined exactly as an omitted one', () => { + // `{ value: undefined }` is what a JS producer spreading an unset variable + // writes; the runtime cannot tell it from an omitted key, and neither does this. + const issue = valueIssue(parse({ field: 'f', operator: 'contains', value: undefined })); + expect(issue.message).toContain('Filter comparand for operator "contains" on field "f" is undefined.'); + }); + + it('refuses through an ALIAS spelling too, naming the CANONICAL operator', () => { + for (const [alias, canonical] of [['eq', 'equals'], ['gt', 'greater_than'], ['startsWith', 'starts_with']]) { + const issue = valueIssue(parse(valueless(alias!))); + expect(issue.message, alias).toContain(`Filter comparand for operator "${canonical}"`); + } + }); + + it('prescribes both repairs: write the value, or use an operator that takes none', () => { + const issue = valueIssue(parse(valueless('equals'))); + expect(issue.message).toContain('write the value to compare against'); + // The valueless operators are named from the schema's own set — every one of + // them, so an author who meant "no value" finds the spelling for it. + for (const operator of VALUELESS_OPERATORS) expect(issue.message).toContain(`"${operator}"`); + }); + + it('carries no internal tracker id — the reader of this string cannot open one', () => { + const issue = valueIssue(parse(valueless('equals'))); + expect(issue.message).not.toMatch(/(? { + // The equality this file cannot import: the schema's valueless set is + // private, and this transcription is held to it by behaviour. An operator in + // the schema's set but missing here would refuse below as value-taking; one + // listed here but not in the schema's set would refuse in the second loop. + for (const operator of VIEW_FILTER_OPERATORS) { + const accepted = parse(valueless(operator)).success; + expect(accepted, `${operator} + omitted`).toBe((VALUELESS_OPERATORS as readonly string[]).includes(operator)); + } + for (const operator of VALUELESS_OPERATORS) { + expect(parse(valueless(operator)).success, operator).toBe(true); + } + }); + + it('reaches through a carrier: a ListView filter reports the rule by its index', () => { + const result = ListViewSchema.safeParse({ columns: ['name'], filter: [ + { field: 'status', operator: 'equals', value: 'open' }, + { field: 'name', operator: 'icontains' }, + ] }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const issues = result.error.issues.filter((i) => i.path.join('.') === 'filter.1.value'); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toContain('Filter comparand for operator "icontains" on field "name" is undefined.'); + }); +}); + describe('#6227 — the exported vocabularies are the ones the check reads', () => { it('declares exactly the operators that lower to $in / $nin', () => { expect([...VIEW_FILTER_LIST_VALUE_OPERATORS]).toEqual(['in', 'not_in']); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1339953de4d..b6b2f445605 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -573,7 +573,12 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * The first two checks below are `assertListComparandShapes`' constraints, one * for one: `$in`/`$nin` must be an array, `$between` must be a 2-array. The * third — a SCALAR operator handed an array — is `driver-sql`'s - * `assertCompilableComparand` scalar arm. + * `assertCompilableComparand` scalar arm. The fourth — a scalar operator handed + * NO value (#19751) — is `parseFilterAST`'s undefined-comparand refusal: a + * stored rule without `value` lowers to `[field, operator]`, which lowers in + * turn to an undefined comparand and is refused for every scalar operator + * (thirteen at this change, `icontains` among them); the unary four lower to + * `$null` and never reach it. * Nothing beyond those is judged, deliberately — #5685 already ruled on the * opposite error, where `FieldOperatorsSchema` declared `$gt` as * `number | Date | FieldReference` while every first-party producer put an ISO @@ -645,14 +650,15 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * exception and it is not an analogy — `FILTER_TEXT_CASES` declares that * comparand refused as data, and {@link checkViewFilterRuleTextComparand} * below answers those rows and only those rows. - * - **A unary operator carrying a value** (`is_empty: ''`, and `is_empty: []`). - * The null predicates take their direction from the operator NAME — - * `convertComparison` maps them to `{ $null: true|false }` and ignores the - * value position entirely — and the ObjectUI client deliberately sends a - * truthy PLACEHOLDER value for both `isnull` and `isnotnull`. Refusing it would - * break a live first-party producer to enforce nothing, which is why the scalar - * arm skips them explicitly rather than by accident. `value`'s own - * `.describe()` carves them out in the same words. + * - **A unary operator carrying a value** (`is_empty: ''`, and `is_empty: []`) + * **or carrying none.** The null predicates take their direction from the + * operator NAME — `convertComparison` maps them to `{ $null: true|false }` and + * ignores the value position entirely — and the ObjectUI client deliberately + * sends a truthy PLACEHOLDER value for both `isnull` and `isnotnull`. Refusing + * it would break a live first-party producer to enforce nothing, which is why + * the scalar arm skips them explicitly, BEFORE its absent-value check, rather + * than by accident. `value`'s own `.describe()` carves them out in the same + * words, and they are the only operators on which an absent value parses. * * ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed) * @@ -690,6 +696,15 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * two moments it can be reported. The TAIL deliberately differs: the runtime's * closing fact is "the filter was NOT applied", which is false here — nothing * ran, the metadata is being refused — so this one prescribes the fix instead. + * + * The absent-value arm keeps `undefinedComparandRefusal`'s leading sentence, + * "Filter comparand at PATH is undefined", with the one substitution the list + * and range arms already make: the location is named in the vocabulary the + * author wrote — operator and field — because a view rule has no `where.…` + * path, and the `$` spelling in that path is not one a view author can write. + * Its prescription is this vocabulary's, not the runtime's: the runtime tells a + * `$` author to write `{"$eq": null}`, and a view author's equivalent is one of + * the valueless operators. */ function checkViewFilterRuleValueShape( rule: { field?: unknown; operator?: unknown; value?: unknown }, @@ -737,12 +752,35 @@ function checkViewFilterRuleValueShape( } // Everything left takes a SCALAR — `value`'s own `.describe()` has said so - // since #6227 and nothing judged it, so the whole class rode through. The two - // carve-outs are the ones the query path itself makes: an ABSENT value (the - // key is optional, and a unary operator need not carry one) and the valueless - // operators, whose `value` position is discarded by `convertComparison`. - if (value === undefined) return; + // since #6227 and nothing judged it, so the whole class rode through. The one + // carve-out is the one the query path itself makes: the valueless operators, + // whose `value` position is discarded by `convertComparison` — so for them + // anything goes, an absent value included, and they are answered FIRST. + // + // [#19751] An ABSENT value is NOT a carve-out on any other operator, though + // this comment used to name it as one. The key is optional because the unary + // operators need none; every operator that reaches this line takes one. Both + // lowerings of a stored rule — the console's and the REST picker route's — + // emit an absent value as the two-element `[field, operator]` node, and + // `parseFilterAST` refuses that with its undefined-comparand `INVALID_FILTER` + // / 400 for every one of these operators (measured over the whole class). + // Nothing on the way drops the rule first, so one valueless rule failed every + // query that read its view. if ((VIEW_FILTER_VALUELESS_OPERATORS as readonly string[]).includes(operator)) return; + if (value === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `Filter comparand for operator "${operator}" on field "${field}" is undefined. ` + + `The rule carries no value, and "${operator}" compares the field against one — write ` + + `the value to compare against, or, if the rule means the field has no value, use an ` + + `operator that takes none ("${VIEW_FILTER_VALUELESS_OPERATORS.join('" / "')}"), which ` + + `reads its direction from its name. This is refused at authoring time because the ` + + `query path refuses it too (400 INVALID_FILTER).`, + }); + return; + } if (!Array.isArray(value)) return; ctx.addIssue({ code: 'custom', From 22a14a10384056741dda7a2d8e2626901bf8fd56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 11:14:38 +0000 Subject: [PATCH 2/2] test(spec): the icontains parse-door absence pin reads the absent-value arm The pin asserted that an icontains view rule with no value parses. That carve-out is gone: the value-shape check now refuses a missing value on every operator that takes one. The pin keeps its own half, that the text comparand door never judges absence: the refusal arrives once, in the absent-value arm's words, never in the conformance table's. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude --- .../src/data/filter-icontains-parse-door.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/data/filter-icontains-parse-door.test.ts b/packages/spec/src/data/filter-icontains-parse-door.test.ts index c8f417d472a..65e9a38d956 100644 --- a/packages/spec/src/data/filter-icontains-parse-door.test.ts +++ b/packages/spec/src/data/filter-icontains-parse-door.test.ts @@ -201,11 +201,21 @@ describe('#19514 §2 — the view vocabulary refuses the same two comparands', ( expect(rule(value).success).toBe(true); }); - it('ABSENCE is left unjudged — this vocabulary HAS an absent, and no row is about it', () => { + it('ABSENCE is not the table\'s to judge — no row is about it, and this door stays out of it', () => { // `isRefusedTextComparand(undefined)` answers TRUE, and its docblock hands // the carve-out to callers with an "absent". `value` is optional on every // view rule, so an omitted comparand is not a comparand the table judged. - expect(rule().success).toBe(true); + // + // [#19751] It IS refused now — by the value-shape check's absent-value arm, + // which refuses a missing value on every operator that takes one, because + // the query path refuses the lowered `[field, operator]` node. What this pin + // keeps is the half that is this door's: the refusal arrives ONCE and in the + // absent-value arm's words, never in the table's. + const issue = issueAt(rule(), 'value'); + expect(issue.message).toContain('Filter comparand for operator "icontains" on field "name" is undefined.'); + expect(issue.message).not.toContain('EMPTY STRING'); + expect(issue.message).not.toContain('not a string'); + expect(issue.message).not.toContain(`"${DOLLAR_SPELLING}"`); }); it('an ARRAY is the SHAPE arm defect, reported once and with the shape wording', () => {