feat(spec)!: type ViewFilterRule.operator's input as the canonical ViewFilterOperator (#20450) - #20503
Conversation
…wFilterOperator 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 <noreply@anthropic.com>
…d 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 <noreply@anthropic.com>
…ew-filter-operator-input-typed
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2f380506f32fb069f93f9b20b3b1189cd852133c && git checkout 2f380506f32fb069f93f9b20b3b1189cd852133c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b24b8b82304e925110800efb0e092845a931d16 00a3e0d22e9ce3349dc0cbbacbe78df5c9af22ce && git checkout -B drift-repro 2b24b8b82304e925110800efb0e092845a931d16 && git merge --no-ff 00a3e0d22e9ce3349dc0cbbacbe78df5c9af22ce
node scripts/docs-audit/affected-docs.mjs --json 2b24b8b82304e925110800efb0e092845a931d16 |
… Clause-② no (narrowing) 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 <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #20450 (body; triage ① Derived judgments
② Semver level
③ Boundary flagsDev flags (round 1
Check-runs on the head, read once at 2026-09-28T20:05Z (32 runs; none failed):
Governance: the file list touches no governed surface ( Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20450
Clause-②: no (narrowing)
What
ViewFilterRuleSchema.operatoris az.preprocessover the alias fold, and zod 4.6.1 types a preprocess's INPUT from its function's parameter (preprocess(fn: (arg: B, ctx) => A, schema): ZodPreprocess(U, B), withB = unknownby default). The function wasnormalizeFilterOperator(op: unknown), soViewFilterRule['operator']wasunknown, and{ field: 'status', operator: 42 }compiled as a rule on every carrier.This PR lands triage's direction A: the typed input of
operatoris the canonicalViewFilterOperator. The runtime fold is untouched, so storedsys_metadatarows and plain-JS producers that carry an alias still parse and fold.packages/spec/src/ui/view.zod.ts: the preprocess now runs through a module-private wrapper,foldAuthoredViewFilterOperator(op: ViewFilterOperator): string, which returnsnormalizeFilterOperator(op). The wrapper is not exported, so no export is added andapi-surface/is byte-identical.packages/spec/src/ui/view-filter-operator-input-typed.test.ts: the two-half pin (see the verification record below).packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.tsand the regenerated registry region: the ADR-0087 registration (see Acceptance notes, item 1)..changeset/20450-view-filter-operator-input-typed.md:@objectstack/specminor, a BREAKING banner, a FROM → TO table and the one-line fix,Clause-②: no (narrowing)(corrected in round 2, the seat's answer5877491493), and theregistereddisposition.Which site, and why not
normalizeFilterOperator's own parameterTriage's execution line named
normalizeFilterOperator's parameter. I measured that site first. It is exported (api-surface/ui.json), and its callers pass raw strings orunknownby design:packages/lint/src/validate-preset-comparands.ts:634passes a stored rule'srule.operator.packages/rest/src/view-filter-rule-lowering.ts:72passes a stored rule'soperator.packages/spec/src/conversions/registry.tspassesop.slice(1)(:10984),op(:11167) and the literal'eq'(:11060, :11187).packages/spec/src/ui/filter-rule-array.ts:152passes the literal'eq'.ObjectView.tsx,ListView.tsx,UserFilters.tsx,filter-converter.ts,filter-builder.tsxandviewFilterFold.ts, each with a string orunknown.Narrowing that parameter would break every one of these callers. A canonical-in, canonical-out fold would also have nothing left to fold. The smallest correct site is the preprocess's own input at :923. The wrapper reaches the same result (the typed input is canonical) and leaves the exported fold's signature as it was.
Verification record
Final gate union on HEAD
bed8cabb44(after mergingorigin/mainatfc0db22bcfthroughscripts/pm/os-regen-merge.sh). My delta against the merged main commit is the five files above: +288 / -5.Premise, measured at base
8e02859185against the builtdist/*.d.ts. I ran a downstream probe that resolves@objectstack/spec/uithrough the packageexports.tscgave EXIT 0 on all of these:operator: 42,operator: 'eq'andoperator: Symbol('x'), onViewFilterRuleand on three carriers (ListView['filter'],ViewTab['filter'],InterfacePageConfig['filterBy']).Reverse verification. I rebuilt spec with the change and ran the same probe.
tscgave EXIT 2 with 9 errors, one on each of the 9 non-canonical lines. The canonical line still compiles. The runtime is byte-identical to base: a number is refused withcode: 'invalid_value',path: ['operator']and the canonicalvalues;'eq'folds toequals;'NotIn'folds tonot_in.Compile-half ablation (at
60102b7b42, throughscripts/ablation-replace.mjs). I widened the wrapper's parameter back tounknown. The anchor went 1 → 0 and the blob went4403a5bb→d8324f31.check:test-typecheckthen reported one problem:src/ui/view-filter-operator-input-typed.test.ts: 6 type error(s), which are the six@ts-expect-errordirectives (TS2578). Restored: the blob equals HEAD4403a5bbandgit diff HEADis empty.Runtime-half ablation (at
bed8cabb44). I replaced the wrapper bodyreturn normalizeFilterOperator(op);withreturn op;, which still compiles. The pin went3 failed | 2 passed: the three fold tests failed; the canonical and refusal tests held. Restored: the blob equals HEAD07956823andgit diff HEADis empty.Spec package (post-merge,
bed8cabb44):pnpm --filter @objectstack/spec typecheckexit 0 (src, scripts and the test layer).check:test-typecheck: OK — 53 file(s) / 251 error(s) / 138 pinned signature(s) held, so no test file moved.vitest run --project local:Test Files 573 passed (573),Tests 16794 passed | 1 todo.check:generated: all 15 generated artifacts up to date. Nothing records the input type:api-surface/,export-origins/,declaration-map/,authorable-surface/and the JSON schemas are unchanged.spec-changes.jsonand the upgrade guide hold because in-progress major-18 entries reach them at release (control: the siblingview-filter-rule-scalar-operator-array-refusedis in neither).Consumer typechecks. These ran against spec
distbuilt from60102b7b42; the narrowing is identical after the merge. They are a selection of@objectstack/spec's downstream consumers (the...@objectstack/specdirection), not the whole 73-package closure. Every one exited 0, and each test-layer ledger held exactly:@objectstack/lint(2 files / 6 errors)@objectstack/metadata-protocol(plaintsc, tests included)@objectstack/rest(0 / 0)@objectstack/objectql(40 / 234)@objectstack/platform-objects(1 / 3)@objectstack/plugin-sharing(2 / 3)@objectstack/plugin-security(0 / 0)@objectstack/plugin-audit(0 / 0)@objectstack/plugin-approvals(8 / 324)app-showcase,app-todo,app-crm,app-multi-package,embed-objectqlThe rest of the closure is CI's.
Census of typed producers. Non-test
.ts/.tsxunderpackages/,examples/andapps/:operator:literals carry an alias spelling. None is on a view-filter carrier: an app-showcaselookupFiltersentry, and two doc comments in other dialects. As a control, the same grep for canonical spellings hits 113 times.operator:writes. Neither is a view-filter rule: a cross-field refusal record informula, and a text-operator door verdict inobjectql.ViewFilterRule.No test fixture needed an escape: every alias-carrying fixture already reaches the fold through
.parse/.safeParse, which takeunknown.objectui at its pin
dd3f7e1be3(read-only). I exported its sources withgit archiveinto a scratch directory, so nothing was written to the objectui repo. I compiledpackages/*/srcandapps/console/srcagainst this branch's specdist, with its root compiler options,@object-ui/*mapped to source, and React 19.2.8 /@types/react19.2.18. There were 0 errors naming the operator union orViewFilterOperator.string, an alias, and astringthrough objectui's ownRecordRelatedListComponentProps.filter. Exactly those 3 lines errored, and every other error was identical to the run without the control.viewFilterFold.ts:211casts toViewFilterRule['operator'].ObjectDataPage.tsxbuilds a record and pushes the parse OUTPUT.@objectstack/spec(^17.x), so it meets this at its next spec bump, not through the Console Pin Gate.Gates.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 88 families onbed8cabb44. I ran 87 and each exited 0.--ranreconciliation:88 derived famil(ies) accounted for — 87 run, 1 NOT-MEASURED.check-adr-0087-registration:[BREAKING+bang+clause-②-narrowing] registered view-filter-rule-operator-input-canonical (new here: …).check-changeset-no-major: nomajor. Its level axis is PR-scoped and reads NOT APPLICABLE locally, so that half is CI's.NOT MEASURED:
check:type-check-debt. Reason: its--re-measureruns a full-repoturbo run build, which does not fit the foreground cap. I took targeted readings instead:tsc -p tsconfig.json) has 26 errors, equal to its DEBT ledger's 26, and 0 of them name the operator union.cloud-connection,hono,observability) contain nooperatororViewFiltertext.Acceptance notes
packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.tssits outside the claimed surface; its generated registry region is inside it. Why:check-adr-0087-registrationrefusesno-migration-prescriptionon a body with a prescription.type-surface-onlyis closed to any diff that touchespackages/specor moves a*.zod.ts(ADR-0087 D8, predicates 2 and 3). The ADR's reason is that for such a diff the ledger is a channel that can carry the change.registered. The precedent isspec-type-alias-input-suffix-retired, a type-only spec change registered the same way.no-migration-prescriptionwithout a concrete rewrite. The reviewer may prefer that route; it means dropping the FROM → TO table.Clause-②value. Round 1 carried the claim'syes. Byscripts/pm/clause2-line.mjs's own definitions this diff readsno (narrowing), "NOT a widening, but breaking": it adds no export and widens no accept set, as the family precedents (finding(spec):ViewFilterRuleSchemaaccepts two shapes every consumer refuses, andObjectGridProps.defaultFiltersisz.unknown()so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514, fix(spec): type ApiError.code and the list overlay options bag #20448) declared. The seat answered5877491493(Q1: A), and round 2 corrected the changeset line and this body's declaration line toClause-②: no (narrowing). The level (minor), the BREAKING banner and the ADR-0087 marker are unchanged.z.preprocess(normalizeFilterOperator, …):packages/metadata-protocol/src/protocol.ts:1187,protocol.graft-normalized-operators.test.ts:6,packages/spec/src/ui/view-filter-rule-wire-id.test.ts:185, and objectui'spackages/types/src/zod/complex.zod.ts:283. The preprocess now runs a wrapper that delegates to that fold, so each statement is still true about behaviour. Only the literal spelling drifted.GREATERTHANOREQUALdoes not fold: the table holdsgreaterorequalandgreaterThanOrEqual, notgreaterthanorequal. This is deliberate per the table's docblock ("the folds exist per measured legacy spelling"). The pin tests the case-fold branch onGTandNotInonly, for that reason.anyand cannot error either way. The positive control shows the channel is live for every spec-typed position.Generated by Claude Code