Skip to content

Commit cc6dfd9

Browse files
fix(spec): refuse an absent value on a value-taking view filter operator at authoring time (#19861)
Fixes #19751 Clause-②: no (narrowing) ## What changes `checkViewFilterRuleValueShape` (the value-shape refinement of `ViewFilterRuleSchema`, `packages/spec/src/ui/view.zod.ts`) now refuses a rule with NO `value` on every operator that takes one. Its scalar arm returned early on `value === undefined` for every operator, so `{ field: 'name', operator: 'icontains' }` parsed green, while the key's published description says every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and the query path refuses the lowered rule with `400 INVALID_FILTER`. - The four unary operators (`is_empty`, `is_not_empty`, `is_null`, `is_not_null`) are answered first and stay valueless, with or without a value. - `in` / `not_in` / `between` keep their own arms, which already refused an absent value. - The key stays `.optional()` on the shape; the coupling lives in the refinement, like the other arms. - The `value` `.describe()` is unchanged (it already declares this contract), so no generated reference page moves. - The code comment above the scalar arm, which named an absent value as a carve-out "the query path itself makes", now says the opposite and why. The docblock's "mirrors the query path" list and its runtime-wording section name the new arm. Refusal text, one issue at the rule's `value` path: > Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, and "icontains" 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 ("is_empty" / "is_not_empty" / "is_null" / "is_not_null"), which reads its direction from its name. This is refused at authoring time because the query path refuses it too (400 INVALID_FILTER). The leading sentence is the runtime's undefined-comparand sentence ("Filter comparand at PATH is undefined.") with the location named in the view vocabulary: operator and field, the same substitution the list and range arms already make. A view rule has no `where` path, and the `$` spelling in that path is not one a view author can write. The unary operator names in the tail come from the schema's own `VIEW_FILTER_VALUELESS_OPERATORS`. ## Producer reading (step 1): objectui at the pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` Read with `git show SHA:PATH` from a local clone at that sha, not from a working tree. **Does the console ever save a value-taking rule with no `value`? No.** | writer | file at the pinned sha | what happens to a half-filled row | |---|---|---| | `foldFilterGroupToSpecRules`, the one fold every view-filter writer shares | `packages/app-shell/src/views/viewFilterFold.ts` | a row whose operator takes a value is dropped when `isFilterValueComplete(operator, value)` is false (`if (takesValue && isMissingValue(...)) continue`) | | `isFilterValueComplete` | `packages/components/src/custom/filter-builder.tsx` | false for `value == null` (also `''`, `[]`, a half-filled pair), so an absent value is always incomplete | | `FilterBuilderField` / `FilterBuilderWidget`: the `filter-builder` widget that `view.form.ts` names for `filter` and `page.form.ts` for `filterBy`, plus the per-tab filter editor | `packages/app-shell/src/views/metadata-admin/widgets.tsx` | calls the fold on every change; the runtime `ViewConfigPanel` hosts the same inspector (`ViewConfigPanel.tsx`, `ViewVariantInspector`) | | list toolbar | `packages/app-shell/src/views/ObjectView.tsx` | no automatic write at all (its docblock: "There is deliberately NO persistViewFilter"); explicit saves go through the fold | | drill-down "Save as view", `foldUrlFilterTriplesToSpecRules` | `packages/app-shell/src/views/ObjectDataPage.tsx` | `ViewFilterRuleSchema.safeParse` per rule, refused rules dropped; the URL triples (`drillUrlFilters.ts`, `parseUrlFilterTriples`) skip an empty param and always carry a value | One edge, stated rather than hidden: `handleViewConfigSave` (`ObjectView.tsx`) persists the config draft whole. A view whose STORED body already carries such a rule (hand-authored, or written by another tool) and is re-saved through the panel without its filter being touched now gets the refusal at save. That view already fails every query today (next table). **Does anything drop a valueless row between storage and the query? No.** | layer | file | reading | |---|---|---| | console lowering: `viewFilterRuleToNode`, behind `toFilterNode` / `mergeFilterNodes` (plugin-list `buildEffectiveFilter`, plugin-view `ObjectView`, `ObjectGrid`, `RelatedList`, `LineItemsPanel`) | objectui `packages/core/src/utils/filter-converter.ts` | a rule without `value` lowers to the 2-tuple `[field, operator]` and nothing skips it; its own comment records the runtime throwing `INVALID_FILTER` / 400 for `['name','icontains']` | | REST lookup-picker route: `lowerViewFilterRule` | this repo, `packages/rest/src/view-filter-rule-lowering.ts` | the same 2-tuple; the module forwards and never drops | | query normalizer | this repo, `packages/metadata-protocol/src/protocol.ts` | `isFilterAST`, then `parseFilterAST`, which throws | Measured on this tree's spec source (4112752): `isFilterAST(['and', ['name','equals'], ['status','equals','open']])` is true, and `parseFilterAST` of it throws `INVALID_FILTER` / 400, "Filter comparand at where.$and[0].name is undefined". One valueless rule fails the WHOLE view's query, its good rules included. So no working flow saves or executes this shape, and refusing it at save breaks nothing that works today. ## Today's behaviour for the whole class (step 2) Measured at `origin/main` 4112752 by script. The operator list is `VIEW_FILTER_OPERATORS` read at runtime; the unary set was derived by behaviour from the schema's own scalar arm (an array is refused on every non-list, non-range operator outside the private valueless set). | operators | `ViewFilterRuleSchema`, `value` omitted, before this change | `parseFilterAST([field, op])` | |---|---|---| | `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after` (13) | **ACCEPT** | throws `INVALID_FILTER` / 400, "Filter comparand at where.name (or where.name.$op) is undefined" | | `in`, `not_in` | refused by the list arm | throws, "requires an ARRAY of values" | | `between` | refused by the range arm | throws, "requires a [min, max] value array" | | `is_empty`, `is_not_empty`, `is_null`, `is_not_null` | accept | `{ "$null": true }` / `{ "$null": false }` | After this change the 13 are refused. The other rows are unchanged. ## ADR-0087 reading (step 4) - This narrows a published accept set. The repo's rule for that during the launch window is in the header of `scripts/check-changeset-no-major.mjs`: the level does not carry breaking-ness, and "the mandatory information carriers for breaking-ness in the meantime are the **BREAKING** banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition". `scripts/check-adr-0087-registration.mjs` then requires a disposition on the declared-breaking changeset. - The disposition is `registered`, not `not-required`. The author has a hand prescription (write the value, switch to a unary operator, or delete an unfinished row), and `no-migration-prescription` is refused for a body that carries one. None of the other categories fits: the package publishes, no existing entry covers absence, and the surface is a schema, not a runtime interface or a type surface. - Precedents: `view-filter-rule-scalar-operator-array-refused` (the sibling arm of this same check) and `filter-preset-ordering-comparand-refused` (a shape that never executed usefully) both registered a semantic entry under protocol major 18. - Added: `packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts`. `packages/spec/src/migrations/registry.ts` was regenerated by `pnpm --filter @objectstack/spec gen:migration-registry` and not hand-edited; `check:migration-registry` is green. No D2 conversion: there is no value to infer. - `check-adr-0087-registration --base origin/main` reads the changeset as `[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused (new here)`. - `spec-changes.json` and `docs/protocol-upgrade-guide.md` did not move. The protocol-18 step stays inert until the protocol major reaches 18, and `check:spec-changes` / `check:upgrade-guide` are green without regeneration. ## Changeset (step 7) `.changeset/19751-view-filter-rule-absent-value-refused.md`, `minor` on `@objectstack/spec`. `files[]` ships `dist` and `src/**/*.zod.ts`, and both carry the refinement. Its summary is the user-visible change: a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query. It carries the BREAKING banner, a FROM/TO block, `Clause-②: no (narrowing)` and the registered disposition marker. One sentence names that it reverses the carve-out the still-pending #19514 changeset records (an omitted value "still parses", an absent comparand "is left unjudged"), so the two entries do not contradict each other in the compiled CHANGELOG. The #19514 file itself is not edited. Level: `minor`. An accept-set narrowing declared `(narrowing)` is BREAKING (AGENTS.md, Post-Task Checklist step 3), and during the launch window a breaking change ships as `minor`: the header of `scripts/check-changeset-no-major.mjs` says "During the launch window we ship breaking changes as `minor`", and that the BREAKING banner and the ADR-0087 disposition carry the break, not the level. Both precedents above shipped `minor` with the same banner. The first round graded this `patch`; the at-tier contract review (record 5808364674) failed that, and the patch-round commit ab0104d changes the frontmatter to `minor` and rewrites the banner sentence to state the convention ("Shipped as `minor` under the repo's launch-window convention for accept-set narrowings"). That commit moves no package file. The PR's `Clause-②: no (narrowing)` line is the claim's, copied verbatim, and matches the changeset's line. With the arm present, the level axis of `check-changeset-no-major.mjs` judges the level instead of standing down. Measured offline with `--event` on this body, it refuses the first round's `patch` head 22a14a1 (exit 1) and passes `minor` at ab0104d (exit 0). ## Fixtures, examples and pins (step 5) - An AST scan of every tracked `.ts` / `.tsx` / `.mts` / `.js` / `.mjs` / `.json` outside `content/docs/references/` (1,739 files mention `operator`) found 311 object literals with `field` plus a string-literal value-taking operator (aliases folded). 20 of them have no `value` key, and none is a view filter rule in a shipped example or seed: - 7 are QA assertions (`expectedValue`, a different schema) in `examples/app-showcase/qa/platform-smoke.test.json`; - 5 are QA assertions in `packages/core/src/qa/runner.test.ts` and `packages/spec/src/qa/testing.test.ts`; - 1 is a skill trigger condition in `packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts`; - 4 are a structural walk with no schema in `packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts`; - 3 are in `view-filter-rule-value-shape.test.ts`. Markdown (`.md` / `.mdx`) has no match. No fixture was an authoring mistake, so no fixture was edited. - Pins that pinned the removed carve-out and moved with it: - `packages/spec/src/ui/view-filter-rule-value-shape.test.ts`: `equals + omitted` and `greater_than + omitted`, from accepted to refused. - `packages/spec/src/data/filter-icontains-parse-door.test.ts`: "ABSENCE is left unjudged" now asserts that absence is refused once, in the absent-value arm's words and never in the conformance table's. ⚠️ This file is outside the claim's declared file surface. It is a view-filter-rule test that lives in `src/data/`, not beside `view.zod.ts`, and it had to move with the carve-out it pinned. - Carriers named in the docblock (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter, plus `ObjectGridProps.defaultFilters`) are all `z.array(ViewFilterRuleSchema)`. The full spec suite is green, and a new pin drives the refusal through `ListView.filter` at `filter.1.value`. ## Tests - New pins, with operator lists derived at runtime: value-taking is `VIEW_FILTER_OPERATORS` minus the four valueless operators. The valueless set is module-private in `view.zod.ts` and deliberately not exported, so the test reuses the file's existing transcription, and a new two-way sweep holds that transcription equal to the private set by behaviour: over every operator, an absent value is accepted exactly when the operator is valueless. - `vitest run --project local` on `view-filter-rule-value-shape.test.ts` and `filter-icontains-parse-door.test.ts`: 104 passed. - Firing control at 325052f, through `scripts/ablation-replace.mjs`: the anchor `if (value === undefined) {` was replaced by `if (value === undefined) return;` followed by `if (false) {`, which is the base behaviour (an absent value returns before any issue). The anchor went 1 to 0 and the blob b6b2f44 to 8c2839db. Result: **11 new pins red, 57 green**. After the restore, the blob equals HEAD and `git diff HEAD` is empty. A first attempt was refused by the tool before anything ran, because its replacement contained the anchor; nothing was measured on that attempt. - Full spec `local` project at 22a14a1: 522 files passed, 15,420 tests passed. One file skipped by its own stale-dist condition (`scripts/root-entry-type-nameability.pin.test.ts`); after a rebuild at the same head it ran with `OS_EXPECT_ROOT_NAMEABILITY=1`: 2 passed. - Spec `repo` project at 22a14a1: 34 files, 587 tests passed. - `pnpm --filter @objectstack/spec typecheck` at 22a14a1: exit 0 (tsc, scripts typecheck, `check:test-typecheck` OK). ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at 22a14a1 derived 86 commands. All were run and reconciled with `--ran`: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 PREREQUISITE NOT MET because they need the whole-workspace build. They are left to CI. `check:generated` is 15/15 up to date after a fresh build at that head. Generated files that moved: `packages/spec/src/migrations/registry.ts` only, via `gen:migration-registry`. Patch round at ab0104d: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 86 commands from a tree 53 commits behind origin/main 2c1011b. origin/main's selector over the same six paths adds one family, `check:migration-registry`, which was run too (exit 0). The `--ran` reconciliation reads 84 run, all exit 0, 2 NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`: exit 3 PREREQUISITE NOT MET, they need the whole-workspace build, left to CI), and 0 unrun. `check-changeset-no-major --base origin/main` prints "This diff introduces no `major` bump." `check-adr-0087-registration --base origin/main` reads `[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused`. CI at ab0104d: 35 check runs, 32 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke), 0 failure. ## Scope held - In `view.zod.ts`, only `checkViewFilterRuleValueShape` and its docblock changed. The `ViewFilterRuleSchema` block is untouched: its JSDoc and `.describe()` are still true. None of PR #19809's regions is touched. - `FILTER_TEXT_CASES` gains no row. There are no objectui or runtime (`parseFilterAST`) edits. - `origin/main` has moved 8 commits past the branch point: #19598 touches the `ListView` shape in `view.zod.ts`, and #19657 touches `registry.ts`. A driver-less `merge-tree` of HEAD onto `origin/main` 8cbc3c0 is clean. Main is not merged in. ## Acceptance notes - The sibling entry `view-filter-rule-scalar-operator-array-refused` says, in its replacement prose, "An omitted value is still an omitted value". That was true of its own arm; after this change an omitted value on a scalar operator is refused. Both entries sit in the uncut protocol-18 step. The new entry's leading comment names the reversal, and the sibling's text was left as it is (it is outside this card's file surface). - `checkViewFilterRuleTextComparand`'s docblock, carve-out 1, says an omitted comparand "is left to whatever judges absence". That stays true: the shape arm now judges it. Not edited. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7536721 commit cc6dfd9

6 files changed

Lines changed: 355 additions & 20 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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)
6+
7+
**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. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings: during the launch window a breaking change ships as `minor`, so the level alone does not signal the break — this banner and the ADR-0087 disposition below carry it. The hand-migration prescription is registered under protocol major 18 as `view-filter-rule-absent-value-refused`.
8+
9+
## What changes
10+
11+
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):
12+
13+
```text
14+
Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, …
15+
```
16+
17+
This reverses a carve-out the #19514 entry records: its statements that an **omitted** value still parses (`value` is optional) and that an **absent** `icontains` comparand is left unjudged on a view rule no longer hold for any operator that takes a value — such a rule is now refused once, with the message above.
18+
19+
## What stays accepted
20+
21+
- The unary operators `is_empty` / `is_not_empty` / `is_null` / `is_not_null`, with or without a value.
22+
- `in` / `not_in` / `between` refused an absent value before this change and still do, with their own wording.
23+
- `value: null` on a scalar operator is a value (the null predicate), not an absent one, and still parses.
24+
25+
## Migration
26+
27+
For each refused rule, decide what it meant:
28+
29+
```ts
30+
// FROM — no value on an operator that takes one
31+
{ field: 'status', operator: 'equals' }
32+
33+
// TO — a comparison: write the value
34+
{ field: 'status', operator: 'equals', value: 'open' }
35+
36+
// TO — a test for "no value": use an operator that takes none
37+
{ field: 'status', operator: 'is_empty' }
38+
```
39+
40+
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.
41+
42+
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.
43+
44+
<!-- adr-0087: registered view-filter-rule-absent-value-refused -->

‎packages/spec/src/data/filter-icontains-parse-door.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,21 @@ describe('#19514 §2 — the view vocabulary refuses the same two comparands', (
201201
expect(rule(value).success).toBe(true);
202202
});
203203

204-
it('ABSENCE is left unjudged — this vocabulary HAS an absent, and no row is about it', () => {
204+
it('ABSENCE is not the table\'s to judge — no row is about it, and this door stays out of it', () => {
205205
// `isRefusedTextComparand(undefined)` answers TRUE, and its docblock hands
206206
// the carve-out to callers with an "absent". `value` is optional on every
207207
// view rule, so an omitted comparand is not a comparand the table judged.
208-
expect(rule().success).toBe(true);
208+
//
209+
// [#19751] It IS refused now — by the value-shape check's absent-value arm,
210+
// which refuses a missing value on every operator that takes one, because
211+
// the query path refuses the lowered `[field, operator]` node. What this pin
212+
// keeps is the half that is this door's: the refusal arrives ONCE and in the
213+
// absent-value arm's words, never in the table's.
214+
const issue = issueAt(rule(), 'value');
215+
expect(issue.message).toContain('Filter comparand for operator "icontains" on field "name" is undefined.');
216+
expect(issue.message).not.toContain('EMPTY STRING');
217+
expect(issue.message).not.toContain('not a string');
218+
expect(issue.message).not.toContain(`"${DOLLAR_SPELLING}"`);
209219
});
210220

211221
it('an ARRAY is the SHAPE arm defect, reported once and with the shape wording', () => {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// The absent-value half of the coupling #6227 declared, recorded beside its
6+
// array half (`view-filter-rule-scalar-operator-array-refused`) rather than
7+
// amended onto it: that entry's own replacement prose told an upgrading author
8+
// "an omitted value is still an omitted value", and an upgrade guide that
9+
// quietly rewrites a shipped prescription leaves the reader who followed it with
10+
// no trace of why their metadata now fails.
11+
export const entry: SemanticMigration = {
12+
id: 'view-filter-rule-absent-value-refused',
13+
// No backticks in `surface` — build-upgrade-guide renders it inside a code
14+
// span already, and a nested backtick would close it.
15+
surface:
16+
'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, '
17+
+ 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, '
18+
+ 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, '
19+
+ 'less_than_or_equal, before or after (an alias spelling of any of them included), on '
20+
+ 'every carrier of ViewFilterRuleSchema',
21+
replacement:
22+
'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on '
23+
+ 'after. A rule that meant "the field has no value" becomes one of the four operators that '
24+
+ 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction '
25+
+ 'from their name and still parse with or without a value. A rule that was an unfinished '
26+
+ 'row is deleted. The list operators (in / not_in) and the range operator (between) '
27+
+ 'refused an absent value before this change and still do, in their own words',
28+
reason:
29+
'#19751. The value key\'s own published description has declared since #6227 that every '
30+
+ 'operator outside the list, range and unary sets takes a scalar, and that only the unary '
31+
+ 'operators ignore the key; the refinement implementing the coupling returned early on an '
32+
+ 'absent value for every operator, so a rule with no value parsed green on all thirteen '
33+
+ 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — '
34+
+ 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element '
35+
+ '[field, operator] node, which the filter-AST lowering reads as an undefined comparand '
36+
+ 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing '
37+
+ 'between storage and the query drops the rule, so one such rule failed every query that '
38+
+ 'read its view, the view\'s other rules included. The first-party producer does not write '
39+
+ 'the shape: the console filter builder drops a row whose operator takes a value and whose '
40+
+ 'value is missing before it saves, and the drill-down save-as-view path checks each rule '
41+
+ 'against this schema before persisting it (read at the pinned objectui commit). '
42+
+ 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: '
43+
+ 'there is no value to infer, and writing a value, switching to a unary operator and '
44+
+ 'deleting the rule are three different predicates only the author can choose between. '
45+
+ 'The read path does not re-validate stored rows (the reading the sibling entry '
46+
+ 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading '
47+
+ '— and keeps failing its queries, as it did before this change; what changes is that '
48+
+ 'RE-SAVING it is refused at the value path, naming the operator and the field. '
49+
+ 'ADR-0049 / ADR-0087 / ADR-0112.',
50+
acceptanceCriteria:
51+
'Grep your authored views, pages and object-* blocks for a filter rule that has no value '
52+
+ 'key and whose operator is none of the four unary operators, then decide per rule which '
53+
+ 'of three things it meant: a comparison (write the value), a test for emptiness (switch '
54+
+ 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). '
55+
+ 'os validate reports each one by path with the operator and the field, so the sweep is '
56+
+ 'mechanical rather than by eye. A view carrying one of these rules was refusing every '
57+
+ 'query before this change, so re-check what it is supposed to show rather than assuming '
58+
+ 'any earlier result set.',
59+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13639,6 +13639,61 @@ const step18: MigrationStep = {
1363913639
+ 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the '
1364013640
+ 'rail.',
1364113641
},
13642+
// The absent-value half of the coupling #6227 declared, recorded beside its
13643+
// array half (`view-filter-rule-scalar-operator-array-refused`) rather than
13644+
// amended onto it: that entry's own replacement prose told an upgrading author
13645+
// "an omitted value is still an omitted value", and an upgrade guide that
13646+
// quietly rewrites a shipped prescription leaves the reader who followed it with
13647+
// no trace of why their metadata now fails.
13648+
{
13649+
id: 'view-filter-rule-absent-value-refused',
13650+
// No backticks in `surface` — build-upgrade-guide renders it inside a code
13651+
// span already, and a nested backtick would close it.
13652+
surface:
13653+
'ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, '
13654+
+ 'or present and undefined, on equals, not_equals, contains, not_contains, icontains, '
13655+
+ 'starts_with, ends_with, greater_than, less_than, greater_than_or_equal, '
13656+
+ 'less_than_or_equal, before or after (an alias spelling of any of them included), on '
13657+
+ 'every carrier of ViewFilterRuleSchema',
13658+
replacement:
13659+
'the value the rule compares against — value: "open" on equals, value: "2026-01-01" on '
13660+
+ 'after. A rule that meant "the field has no value" becomes one of the four operators that '
13661+
+ 'take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction '
13662+
+ 'from their name and still parse with or without a value. A rule that was an unfinished '
13663+
+ 'row is deleted. The list operators (in / not_in) and the range operator (between) '
13664+
+ 'refused an absent value before this change and still do, in their own words',
13665+
reason:
13666+
'#19751. The value key\'s own published description has declared since #6227 that every '
13667+
+ 'operator outside the list, range and unary sets takes a scalar, and that only the unary '
13668+
+ 'operators ignore the key; the refinement implementing the coupling returned early on an '
13669+
+ 'absent value for every operator, so a rule with no value parsed green on all thirteen '
13670+
+ 'scalar operators. The query path refuses the same rule: both lowerings of a stored rule — '
13671+
+ 'the console\'s and the REST lookup-picker route\'s — emit it as the two-element '
13672+
+ '[field, operator] node, which the filter-AST lowering reads as an undefined comparand '
13673+
+ 'and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing '
13674+
+ 'between storage and the query drops the rule, so one such rule failed every query that '
13675+
+ 'read its view, the view\'s other rules included. The first-party producer does not write '
13676+
+ 'the shape: the console filter builder drops a row whose operator takes a value and whose '
13677+
+ 'value is missing before it saves, and the drill-down save-as-view path checks each rule '
13678+
+ 'against this schema before persisting it (read at the pinned objectui commit). '
13679+
+ 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: '
13680+
+ 'there is no value to infer, and writing a value, switching to a unary operator and '
13681+
+ 'deleting the rule are three different predicates only the author can choose between. '
13682+
+ 'The read path does not re-validate stored rows (the reading the sibling entry '
13683+
+ 'view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading '
13684+
+ '— and keeps failing its queries, as it did before this change; what changes is that '
13685+
+ 'RE-SAVING it is refused at the value path, naming the operator and the field. '
13686+
+ 'ADR-0049 / ADR-0087 / ADR-0112.',
13687+
acceptanceCriteria:
13688+
'Grep your authored views, pages and object-* blocks for a filter rule that has no value '
13689+
+ 'key and whose operator is none of the four unary operators, then decide per rule which '
13690+
+ 'of three things it meant: a comparison (write the value), a test for emptiness (switch '
13691+
+ 'to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). '
13692+
+ 'os validate reports each one by path with the operator and the field, so the sweep is '
13693+
+ 'mechanical rather than by eye. A view carrying one of these rules was refusing every '
13694+
+ 'query before this change, so re-check what it is supposed to show rather than assuming '
13695+
+ 'any earlier result set.',
13696+
},
1364213697
// The scalar half of the coupling #6227 declared and did not judge. Recorded
1364313698
// here rather than amended onto `view-filter-rule-value-shaped-by-operator`
1364413699
// because that entry's own prose states the OPPOSITE reading as accepted, and

0 commit comments

Comments
 (0)