From 60102b7b42d1ab5e9cfd142b52872ba0c0d9cbc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:04:02 +0000 Subject: [PATCH 1/3] feat(spec): type ViewFilterRule.operator's input as the canonical ViewFilterOperator The preprocess on ViewFilterRuleSchema.operator now runs through a module-private wrapper whose parameter is ViewFilterOperator, so zod types the rule's input (and every carrier's) as the canonical vocabulary instead of unknown. The wrapper delegates to the unchanged exported fold, so stored rows and plain-JS producers that carry an alias still parse and fold. Adds the two-half pin beside it: @ts-expect-error on an alias and a number through ViewFilterRule and three carriers, and the runtime fold/refusal. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../view-filter-operator-input-typed.test.ts | 120 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 45 ++++++- 2 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 packages/spec/src/ui/view-filter-operator-input-typed.test.ts diff --git a/packages/spec/src/ui/view-filter-operator-input-typed.test.ts b/packages/spec/src/ui/view-filter-operator-input-typed.test.ts new file mode 100644 index 00000000000..f8f9447cf62 --- /dev/null +++ b/packages/spec/src/ui/view-filter-operator-input-typed.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two-half pin for `ViewFilterRule.operator`'s typed input. + * + * `operator` is a `z.preprocess` over the alias fold, and zod types a + * preprocess's INPUT from its function's parameter. While that parameter was + * `normalizeFilterOperator`'s `unknown`, `ViewFilterRule` (a `z.input`) typed + * `operator` as `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule + * on every carrier and was refused only at parse time. The typed input is now + * the canonical `ViewFilterOperator`, and the runtime fold is untouched. + * + * The two halves pin different facts, and neither implies the other: + * + * 1. COMPILE TIME: the type refuses an alias spelling and a non-string, on the + * rule and on its carriers. These `@ts-expect-error` lines are real checks + * only because `tsconfig.test.json` compiles this file and + * `check:test-typecheck` gives it no ledger entry: a directive that stops + * applying is a TS2578, and that is red. + * 2. RUN TIME: the door still folds every alias the table declares, so stored + * `sys_metadata` rows and plain-JS producers keep parsing, and it still + * refuses a non-string with the enum's own issue. `.parse` takes `unknown`, + * which is the deliberate escape: these fixtures exercise the fold, so they + * are never respelled to canonical to satisfy the type. + */ + +import { describe, expect, it } from 'vitest'; +import type { InterfacePageConfig } from './page.zod'; +import { + VIEW_FILTER_LIST_VALUE_OPERATORS, + VIEW_FILTER_OPERATORS, + VIEW_FILTER_OPERATOR_ALIASES, + VIEW_FILTER_PAIR_VALUE_OPERATORS, + ViewFilterRuleSchema, + type ListView, + type ViewFilterOperator, + type ViewFilterRule, + type ViewTab, +} from './view.zod'; + +type ListViewFilterRule = NonNullable[number]; +type TabFilterRule = NonNullable[number]; +type PageFilterRule = NonNullable[number]; + +// Lit control: the canonical spelling compiles on the rule and on each carrier, +// so the directives below fail on the OPERATOR, not on some other key. +const canonical: ViewFilterRule = { field: 'status', operator: 'equals', value: 'open' }; +const canonicalOnListView: ListViewFilterRule = { field: 'status', operator: 'equals', value: 'open' }; +const canonicalOnTab: TabFilterRule = { field: 'status', operator: 'equals', value: 'open' }; +const canonicalOnPage: PageFilterRule = { field: 'status', operator: 'equals', value: 'open' }; + +// @ts-expect-error an alias spelling is not the typed input (the runtime still folds it) +const alias: ViewFilterRule = { field: 'status', operator: 'eq', value: 'open' }; +// @ts-expect-error a non-string is not the typed input (the runtime refuses it) +const numeric: ViewFilterRule = { field: 'status', operator: 42, value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: an alias is refused on ListView.filter +const aliasOnListView: ListViewFilterRule = { field: 'status', operator: 'eq', value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: a non-string is refused on ListView.filter +const numericOnListView: ListViewFilterRule = { field: 'status', operator: 42, value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: an alias is refused on a tab filter +const aliasOnTab: TabFilterRule = { field: 'status', operator: 'eq', value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: an alias is refused on Page.filterBy +const aliasOnPage: PageFilterRule = { field: 'status', operator: 'eq', value: 'open' }; + +/** The value shape the door's value check wants for a canonical operator. */ +function valueFor(operator: ViewFilterOperator): { value?: string | string[] } { + if ((VIEW_FILTER_LIST_VALUE_OPERATORS as readonly string[]).includes(operator)) return { value: ['open'] }; + if ((VIEW_FILTER_PAIR_VALUE_OPERATORS as readonly string[]).includes(operator)) return { value: ['a', 'z'] }; + return { value: 'open' }; +} + +describe('ViewFilterRule.operator: the typed input is canonical, the runtime fold is not narrowed', () => { + it('compiles the canonical spelling on the rule and its carriers, and the door accepts it', () => { + for (const rule of [canonical, canonicalOnListView, canonicalOnTab, canonicalOnPage]) { + expect(ViewFilterRuleSchema.parse(rule).operator).toBe('equals'); + } + }); + + it('folds an alias the type refuses to the canonical id the alias table names', () => { + const expected = VIEW_FILTER_OPERATOR_ALIASES.eq; + // The table's answer is itself a canonical member, so this is not vacuous. + expect((VIEW_FILTER_OPERATORS as readonly string[]).includes(expected ?? '')).toBe(true); + for (const rule of [alias, aliasOnListView, aliasOnTab, aliasOnPage]) { + expect(ViewFilterRuleSchema.parse(rule).operator).toBe(expected); + } + }); + + it('folds EVERY declared alias at run time', () => { + const entries = Object.entries(VIEW_FILTER_OPERATOR_ALIASES); + expect(entries.length).toBeGreaterThan(0); + for (const [spelling, target] of entries) { + const rule = { field: 'status', operator: spelling, ...valueFor(target) }; + expect(ViewFilterRuleSchema.parse(rule).operator, spelling).toBe(target); + } + }); + + it('still reaches the case-folded branch of the fold (`GT`, `NotIn`)', () => { + // The fold lower-cases a spelling the table does not hold verbatim; the + // expected ids are read off the table under the lower-cased key. + const cases = [['GT', 'gt'], ['NotIn', 'notin']] as const; + for (const [spelling, key] of cases) { + const target = VIEW_FILTER_OPERATOR_ALIASES[key]; + expect(target, key).toBeDefined(); + const rule = { field: 'status', operator: spelling, ...valueFor(target as ViewFilterOperator) }; + expect(ViewFilterRuleSchema.parse(rule).operator, spelling).toBe(target); + } + }); + + it("refuses a non-string the type refuses, with the enum's own issue at `operator`", () => { + for (const rule of [numeric, numericOnListView]) { + const result = ViewFilterRuleSchema.safeParse(rule); + expect(result.success).toBe(false); + const issues = result.error?.issues ?? []; + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue).toMatchObject({ code: 'invalid_value', path: ['operator'] }); + expect(issue && 'values' in issue ? issue.values : undefined).toEqual([...VIEW_FILTER_OPERATORS]); + } + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4cc29d8a5ac..4403a5bbea6 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -663,7 +663,7 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed) * * 1. **`z.discriminatedUnion` cannot read this discriminator — it does not - * construct.** `operator` is `z.preprocess(normalizeFilterOperator, z.enum(…))` + * construct.** `operator` is a `z.preprocess` over `normalizeFilterOperator` * — the alias fold that lets a stored `notIn` / `nin` / `gt` parse. Zod 4 * extracts a discriminator's literal values from the option's own def, and a * preprocess wrapper hides them: building the union throws @@ -857,6 +857,38 @@ function checkViewFilterRuleTextComparand( }); } +/** + * [#20450] The preprocess half of `ViewFilterRule.operator`. Its PARAMETER type + * is the rule's typed input. + * + * Zod types a `z.preprocess`'s INPUT from its function's parameter, so the + * parameter here is what `ViewFilterRule` (a `z.input`) and every carrier of it + * (`ListView.filter`, a tab filter, `Page.filterBy`, the component filter doors) + * publish for `operator`. It is the canonical {@link ViewFilterOperator}, because + * the alias table's own contract is that new producers emit canonical ids. + * Before this, the parameter was `normalizeFilterOperator`'s `unknown`, so + * `{ field: 'status', operator: 42 }` compiled as a rule on every carrier and was + * refused only at parse time. + * + * The RUNTIME is unchanged. The body hands whatever arrived to the exported + * {@link normalizeFilterOperator}, so a stored `sys_metadata` row or a plain-JS + * producer that carries an alias still parses and folds, and a non-string still + * reaches the enum and is refused there. The parameter type is therefore + * deliberately narrower than what this function receives: ⛔ never narrow its + * body on it. + * + * ⛔ Not an annotation on `normalizeFilterOperator` itself. That export exists + * so producers and renderers can fold UNTYPED stored metadata, and its callers + * pass raw strings and `unknown` by design: `@objectstack/lint`'s + * preset-comparand check and `@objectstack/rest`'s rule lowering pass a stored + * rule's `operator`, and the conversion registry folds an AST operator and the + * literal `'eq'`. Narrowing that parameter breaks them, or pushes a cast into + * each, to change a type only this schema publishes. + */ +function foldAuthoredViewFilterOperator(op: ViewFilterOperator): string { + return normalizeFilterOperator(op); +} + /** * View Filter Rule Schema * Standardized filter condition used in list views, tabs, and page-level filters. @@ -916,11 +948,14 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ /** Field name to filter on */ field: z.string().describe('Field name to filter on'), /** - * Filter operator (canonical vocabulary). Legacy shorthand/camelCase - * spellings (`eq`, `gt`, `isNull`, …) are accepted and normalized to - * canonical on parse. + * Filter operator (canonical vocabulary). The TYPED input is the canonical + * {@link ViewFilterOperator}: a typed author writing an alias or a + * non-string is refused at compile time. At RUNTIME the legacy + * shorthand/camelCase spellings (`eq`, `gt`, `isNull`, …) that stored + * metadata and plain-JS producers carry are still accepted and normalized + * to canonical on parse — see {@link foldAuthoredViewFilterOperator}. */ - operator: z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS)) + operator: z.preprocess(foldAuthoredViewFilterOperator, z.enum(VIEW_FILTER_OPERATORS)) .describe('Filter operator'), /** * Filter value (optional for unary operators like is_empty, is_null). From 40a8170143df5aa2692d43d4bbafa2b41dc9f316 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:11:08 +0000 Subject: [PATCH 2/3] chore(spec): register the view filter operator input narrowing and add its changeset Adds the semantic migration entry view-filter-rule-operator-input-canonical (a type-surface narrowing carried to the upgrade guide, nothing at rest rewritten), regenerates the migration registry regions, and adds the BREAKING-narrowing minor changeset with its FROM -> TO table and ADR-0087 registered disposition. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../20450-view-filter-operator-input-typed.md | 30 +++++++++++ ...ew-filter-rule-operator-input-canonical.ts | 51 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 47 +++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 .changeset/20450-view-filter-operator-input-typed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts diff --git a/.changeset/20450-view-filter-operator-input-typed.md b/.changeset/20450-view-filter-operator-input-typed.md new file mode 100644 index 00000000000..436ec70376e --- /dev/null +++ b/.changeset/20450-view-filter-operator-input-typed.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a view filter rule's `operator` is typed as the canonical `ViewFilterOperator`, not `unknown` + +**BREAKING for TypeScript code that writes a view filter rule through a published type**: `ViewFilterRule`, and every carrier of it — `ListView.filter`, a view tab's `filter`, `InterfacePageConfig.filterBy`, and the related-list, record-picker and `object-*` block filter doors. A narrowing of a published TYPE, landing as `minor` (the bump level is not the carrier; this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes. + +`operator` is a `z.preprocess` over the alias fold, and zod types a preprocess's input from its function's parameter. That parameter was `unknown`, so `ViewFilterRule['operator']` was `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule on every carrier, and was refused only when the schema parsed it. The input type is now `ViewFilterOperator`, the vocabulary the alias table's own contract says new producers emit, so an alias spelling or a non-string is refused by the compiler. + +What does not change: + +- **The runtime.** `ViewFilterRuleSchema` still folds every legacy spelling it folded before (`eq`, `gt`, `notIn`, `isNull`, …) to its canonical id, and still refuses a non-string at `operator` with the enum's own issue. Stored `sys_metadata` rows, YAML and JSON bodies and plain-JS producers that carry an alias parse exactly as before, and `os validate` answers as before. +- **`normalizeFilterOperator`.** Its parameter stays `unknown`: it exists to fold untyped stored metadata, and its callers pass raw strings by design. +- **The parsed type.** `ViewFilterRuleParsed['operator']` was already the canonical enum. + +## FROM → TO + +| Wrote (TypeScript) | Write instead | +| --- | --- | +| `{ field: 'status', operator: 'eq', value: 'open' }` | `{ field: 'status', operator: 'equals', value: 'open' }` | +| `{ field: 'amount', operator: 'gte', value: 100 }` | `{ field: 'amount', operator: 'greater_than_or_equal', value: 100 }` | +| `{ field: 'stage', operator: 'notIn', value: ['lost'] }` | `{ field: 'stage', operator: 'not_in', value: ['lost'] }` | +| `operator: someString` (a value typed `string`) | type the unvalidated rule `unknown` and `ViewFilterRuleSchema.safeParse` it, or fold it with `normalizeFilterOperator` and check it against `VIEW_FILTER_OPERATORS` first | + +The one-line fix: write the canonical id. Every alias maps to exactly one, and `VIEW_FILTER_OPERATOR_ALIASES` is that map; the rewritten rule selects the same rows, because the schema already folded the alias to that id. + +Clause-②: yes (narrowing) + + diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts new file mode 100644 index 00000000000..0801f2b9270 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A TYPE-surface narrowing registered here for the reason +// `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript +// declaration, so there is no stored source for a D2 conversion to rewrite, and +// the compiler error it produces names the canonical vocabulary but not which +// member an alias spelling maps to. This guide is the channel that carries that +// second half. Nothing at rest moves and the runtime accept set is unchanged. +export const entry: SemanticMigration = { + id: 'view-filter-rule-operator-input-canonical', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every ' + + 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the ' + + 'related-list, record-picker and object-* block filter doors)', + replacement: + 'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A ' + + 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps ' + + 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to ' + + 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, ' + + 'isNull to is_null, and the rest). A value that is not yet known to be an operator — ' + + 'read from storage, a URL or user input — is typed unknown and handed to ' + + 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the ' + + 'schema stays the judge', + reason: + 'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s ' + + 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a ' + + 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, ' + + 'compiled on every carrier and was refused only when the door parsed it. The typed input ' + + 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract ' + + 'says new producers emit. ' + + 'The RUNTIME does not move: the door still folds every spelling it folded before to ' + + 'canonical and still refuses a non-string with the enum\'s own ' + + 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS ' + + 'producer that carries an alias keep parsing exactly as before, and os validate answers ' + + 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' + + 'conversion: what narrows is only what TypeScript source may write. ' + + 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists ' + + 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ' + + 'ADR-0122.', + acceptanceCriteria: + 'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a ' + + 'non-string, naming the canonical vocabulary. Rewrite each alias to the id ' + + 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the ' + + 'door already folded it to that id — and for a value typed string that is really ' + + 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need ' + + 'no action: they load and parse as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f3d2c181696..bcc2fff9e5e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16902,6 +16902,53 @@ const step18: MigrationStep = { + 'query before this change, so re-check what it is supposed to show rather than assuming ' + 'any earlier result set.', }, + // A TYPE-surface narrowing registered here for the reason + // `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript + // declaration, so there is no stored source for a D2 conversion to rewrite, and + // the compiler error it produces names the canonical vocabulary but not which + // member an alias spelling maps to. This guide is the channel that carries that + // second half. Nothing at rest moves and the runtime accept set is unchanged. + { + id: 'view-filter-rule-operator-input-canonical', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every ' + + 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the ' + + 'related-list, record-picker and object-* block filter doors)', + replacement: + 'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A ' + + 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps ' + + 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to ' + + 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, ' + + 'isNull to is_null, and the rest). A value that is not yet known to be an operator — ' + + 'read from storage, a URL or user input — is typed unknown and handed to ' + + 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the ' + + 'schema stays the judge', + reason: + 'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s ' + + 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a ' + + 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, ' + + 'compiled on every carrier and was refused only when the door parsed it. The typed input ' + + 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract ' + + 'says new producers emit. ' + + 'The RUNTIME does not move: the door still folds every spelling it folded before to ' + + 'canonical and still refuses a non-string with the enum\'s own ' + + 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS ' + + 'producer that carries an alias keep parsing exactly as before, and os validate answers ' + + 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' + + 'conversion: what narrows is only what TypeScript source may write. ' + + 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists ' + + 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ' + + 'ADR-0122.', + acceptanceCriteria: + 'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a ' + + 'non-string, naming the canonical vocabulary. Rewrite each alias to the id ' + + 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the ' + + 'door already folded it to that id — and for a value typed string that is really ' + + 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need ' + + 'no action: they load and parse as before.', + }, // 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 From 00a3e0d22e9ce3349dc0cbbacbe78df5c9af22ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:03:10 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20declare=20the=20view=20?= =?UTF-8?q?filter=20operator=20input=20narrowing=20as=20Clause-=E2=91=A1?= =?UTF-8?q?=20no=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff narrows a published type and widens no accept set or public surface, which scripts/pm/clause2-line.mjs spells `no (narrowing)`. The minor level, the BREAKING banner, the FROM -> TO table and the ADR-0087 registered disposition are unchanged. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20450-view-filter-operator-input-typed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20450-view-filter-operator-input-typed.md b/.changeset/20450-view-filter-operator-input-typed.md index 436ec70376e..cc35473e23e 100644 --- a/.changeset/20450-view-filter-operator-input-typed.md +++ b/.changeset/20450-view-filter-operator-input-typed.md @@ -25,6 +25,6 @@ What does not change: The one-line fix: write the canonical id. Every alias maps to exactly one, and `VIEW_FILTER_OPERATOR_ALIASES` is that map; the rewritten rule selects the same rows, because the schema already folded the alias to that id. -Clause-②: yes (narrowing) +Clause-②: no (narrowing)