… refusal names the operator instead of throwing (objectui#9050)
`toFilterNode` delegates to `convertFiltersToAST`, which refuses eleven authored
shapes with a `FilterOperatorError`. Four call sites reach it off the wire path:
three from a render-time `useMemo` (`RelatedList`, `LineItemsPanel`,
`ObjectGrid.schema.filter`), where a throw is a render error with no
`classifyLoadError` above it, and one inside `ObjectGrid`'s load effect
(`defaultFilters`), which already caught the refusal but reported it as a
generic load failure.
- `@object-ui/core` gains `toFilterNodeSafely`, returning a UNION so a refusal
cannot be read as `undefined` — which means "no filter", i.e. every row, the
silently unconstrained query objectui#9001 closed.
- `FilterOperatorError` carries `operator` / `field` as data; the eleven
messages share no idiom, so scraping a token out of them would be a twelfth
dialect.
- The three render-time readers send nothing and render a state naming the
operator; `ObjectGrid`'s load-path refusal lands on the same branch.
- `view.malformedFilter` added to all ten locale packs and the three affected
`createSafeTranslation` tables.
Ruling-ref: objectui#9050 comment 5749197961 (batch #196 item 2, letter C′).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xr7APep6jm1Zta3KUzPzZf
Fixes #9050
Seat session, in prose because a footer does not survive an edit:⚠️ The five
session_01Xr7APep6jm1Zta3KUzPzZf.ObjectGrid.tsxcoordinates this body cited were addresses in a tree this very diff moves; they are now bound to the tree each was read from, per thechangeset-claim-re-readgate on this PR (⛔ correcting the number alone re-stales on the next insertion). Edited by the reviewingdomain:uiseat from the dev's own re-taken readings; ⛔ no code changed and the head did not move.Ruling-ref:comment 5749197961 — batch #196 item 2, letter C′, maintainer 「同意」 2026-09-20T10:21Z. Steps 1 and 2 in one PR, as the ruling directs. Rule 3 (no new save-time gate) is honoured: nothing here adds a validator.Clause-②: no— re-derived from the diff at the foot of this body.What changed
convertFiltersToASTrefuses eleven authored shapes with aFilterOperatorError.toFilterNodedelegates to it from four call sites that are not on the wire path, so three of them turned an authoring mistake into a render error with noclassifyLoadErrorto translate it, and the fourth reported it as a network-ish load failure.@object-ui/coregainstoFilterNodeSafely. Its result is a UNION —{ ok: true, node }or{ ok: false, refusal }— so a refusal is not representable asundefined. That is the point, not a style choice:undefinedmeans "no filter", i.e. every row, which is the silently unconstrained query objectui#9001 eliminated, and the reason the ruling refuses option A. TypeScript makes a caller narrow before it can build a query.FilterOperatorErrornow carriesoperatorandfieldas data, andfilterRefusalSubjectpicks the token a diagnostic names. The eleven messages deliberately share no idiom (the file's own docblocks argue at length that they must not), so recovering the token by pattern would be a twelfth dialect that fails silently on the next rewording.ObjectGrid'sdefaultFiltersleg keeps its existing throw — measured, it is already inside the load effect's owntry— and now lands on the same render branch, so it is diagnosed instead of reported under "Error loading grid".view.malformedFilteradded to all ten locale packs and to the three affectedcreateSafeTranslationtables.⛔ Which shapes convert is byte-identical. This is a delivery change, not an acceptance one.
Step 1 — the measurement
Re-runnable. Driven through
toFilterNodeSafely(the render-time entry, notconvertFiltersToASTdirectly), against installed@objectstack/spec17.4.0, which is the artifact this repo builds and ships against (@object-ui/corefloor:^17.3.0).The eleven throws span two authoring vocabularies, so "does the protocol refuse the same input" has to be asked at the door that actually gates each one.
ViewFilterRuleSchema— the schema the ruling names — gates throws 10 and 11; the object arm isFilterCondition's door. The two right-hand columns are the SLOT declarations, which decide whether a parse happens at all.ObjectGridProps.filterObjectGridProps.defaultFiltersListViewSchema.filterRecordRelatedListProps.filter$and)$and)$icontainsempty / non-string comparand$icontains)$notcombinator$not)tags)created)$regexoperator$regex)$startswith)$bogus)equals)icontainsempty / non-string comparandicontains)Envelope controls — every REFUSE above is unreadable without these, and my first pass produced phantom refusals until I added them:
Negative controls — without these the doors could be inert and every ACCEPT would mean nothing:
⭐ Those last four are what make row 10 a finding rather than a shrug.
ViewFilterRuleSchemadoes couple value shape to operator —superRefine(checkViewFilterRuleValueShape)is present in the shipped artifact and it refusesinwith a scalar andbetweenwith three members. It endsif (!isPair) return;, so every other operator is not checked, while the same schema's ownvaluedescription says "every other operator takes a scalar". Row 10 is the converter enforcing what the protocol declares in prose and does not enforce at parse.Rows 3, 7 and 11 are the same shape one layer over:
@objectstack/spec/data'sFILTER_TEXT_CASESdeclares these refusals, withexpectRejection: trueandcode: 'INVALID_FILTER'— for$regex, for a dangling$options, and for an empty or non-string$icontainscomparand. It is a backend conformance table, not a parse rule, soFilterConditionSchema.safeParseaccepts all three.The slot columns, and a correction worth stating plainly
On the installed 17.4.0 artifact this repo builds against:
On the
objectstackworking tree (04d639c659,packages/spec/src/ui/component.zod.ts:2661, version string also17.4.0, unreleased),filterhas since been narrowed toz.array(ViewFilterRuleSchema, { error: ruleArrayFilterError(…) }).optional(), whiledefaultFiltersis stillz.unknown().optional(). The two builds also carry differentdescribetext for that key, which is the cheapest way to tell them apart.⇒ the ruling's rule 3 premise — "the protocol's parse IS the save-time gate" — splits by key, and by artifact. It does not fail wholesale:
ObjectGrid'sschema.filterread — the render-timeuseMemothis PR rewrites (:1718ate686f4d9a,:1730at this head) — is pin lag, not a contract gap: narrowed upstream, absent from the floor this repo pins, so nothing gates it on the tree that ships today. Remedy is a release and a floor bump, not a spec change.ObjectGrid'sschema.defaultFiltersread insideloadSchemaAndData(:2092ate686f4d9a,:2123at this head) —z.unknown().optional()in both trees. Nothing gates it and nothing is scheduled to. This one is the contract gap, and it is the strongest argument for why step 2's B is not redundant.ListViewSchema.filterandRecordRelatedListProps.filterarez.array(ViewFilterRuleSchema)in the shipped artifact and already refuse all nine object-arm shapes structurally.Everything in this section belongs to the protocol, not to this PR. The objectstack card carrying these readings is drafted and filed by the seat at ACCEPT (rule 1).
Boundary readings, re-taken on this branch
The card body says a render-time throw "takes the subtree down". Measured, that is not what happens on the schema path — and the difference decides what the pin must discriminate.
SchemaRendererwraps every rendered node in a per-componentSchemaErrorBoundary, auto-recovering onresetKeypackages/react/src/SchemaRenderer.tsx— class at:624, the wrap at:2067error.message, so the operator name does survive into itSchemaRenderer.tsx:661— the heading above it is the genericComponent "TYPE" failed to renderplugin-grid0 non-testErrorBoundaryhits,plugin-form0SchemaRendererabove themplugin-detail/src/index.tsx:37,plugin-form/src/index.tsx:66,plugin-grid/src/index.tsx:23apps/console/src/dev/DevRowActions.tsx:56mountsObjectGridwith zeroSchemaRendererin the filetoFilterNode(schema.defaultFilters)call insideloadSchemaAndData, within thetrywhosecatchcallssetError— so it already rendered a visible panel, undert('grid.errorLoading'). Coordinates, each bound to the tree it was read from::2092, try:1809, catch:2258ate686f4d9a;:2123,:1828,:2289at this head28cf61ab1⇒ what B buys differs per site, and the PR says so rather than claiming one story for four:
defaultFiltersleg).A pin that only proved "the page did not blank" would have been green before this change on the schema path. Neither of these is.
Ablation
Mutated on disk by a refuse-on-zero-match writer, one leg at a time, anchor matched exactly once, verified in both directions, restored from
HEADunder atraparmed with an absolute path, restore proven by blob hash and an emptygit diff HEAD.vitest.config.mts:503aliases@object-ui/coretopackages/core/src, so a mutation there reaches the run with no rebuild.toFilterNodeSafely'sFilterOperatorErrorbranch rethrows instead of returning{ ok: false }defaultFiltersleg, which does not go throughtoFilterNodeSafelyat allfilterRefusalSubjectreturnsundefined, so the translated headline interpolates an empty subjecttoContain(operator)RED, their "filter is malformed" and "no query sent" GREEN⭐ Leg B's miss was a real defect in the pin, and it is the most useful thing this ablation produced. The banner renders the converter's own message under the headline, and that message repeats the operator token — so a
toContainover the whole banner stayed green with the name removed from the translated headline. That is precisely the "names the operator is decoration" failure this work was told not to ship. The headline is now separately addressable (*-malformed-filter-subject) and the operator assertions read it; re-run, leg B reds 16 / passes 7, with "sends no query at all" and the positive controls among the survivors. The two halves are now separable, which is the property that makes the pin worth having.Both legs restored cleanly:
RESTORE PROVEN: blob 63228ed3fce7d894402887eca67de983616dd86e == HEAD blob, git diff HEAD empty.Gates
Hand-derived from objectui's own
package.jsonscripts and.github/workflows/—dispatch-gates.mjsrefuses for this repo (it derives families from the tree the process runs in, which isobjectstack). Every exit code captured to a file and read back, ⛔ never through a pipe.All readings taken at
28cf61ab1, this branch's head.vitest run packages/core/ packages/plugin-detail/ packages/plugin-form/ packages/plugin-grid/ packages/i18n/Test Files 680 passed (680),Tests 8926 passed / 1 skipped (8927)type-checkon those five packages (tsc --noEmit+tsc -p tsconfig.test.jsoneach)error TSTest Files 4 passed (4),Tests 23 passed (23)pnpm exec eslint .(whole repo, 5230 files)pnpm check:control-bytespnpm check:test-path-rootspnpm check:new-line-citationspnpm check:changeset-claimspnpm check:vi-mock-specifierspnpm check:vi-mock-inheritpnpm check:i18n-keyspnpm check:i18n-driftpnpm check:i18n-dead-keysnode scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsnode scripts/check-governed-queue-guard.mjs --testover the six touched pathsNOT GOVERNED, 6 paths checked against 5 governed surfacesNo narrowing is declared for eslint — the repo-wide run is the whole population (5230 files), not a subset, and it is green at this branch's head. For the record, type-aware linting is not enabled (
eslint.config.jssets noproject/projectService/parserOptions), so this diff cannot move the verdict on a file it does not touch.eslint . --no-inline-configreports 97 errors in 80 files on this tree. That flag disables the repo's owneslint-disable-next-linecomments, which are load-bearing here (react-hooks/static-componentsin particular), and it is not what CI runs. It is green both before and after this change with the flag off.A changeset is present and declares
minorfor the five affected packages — ⛔ nevermajor, per the fixed-group rule.Clause-②: no, re-derived from the diffThe ruling's test: 「a render state on published components ⇒ expected
no; any converter change that widens what converts isyes」.convertFiltersToAST's eleven refusal conditions are byte-identical; the only edit inside them is a second constructor argument carrying{ operator, field }. The positive control in the core pin executes both directions — a valid object filter and a valid rule array still lower to exactly the nodes they lowered to before.toFilterNodeSafelyis a new entry point that wrapstoFilterNode; it converts nothingtoFilterNodedid not already convert, and it narrows nothing either.packages/spec/**or in theobjectstackrepo is touched. The protocol-side deltas from step 1 are a card, not a diff.⛔ The stop-and-report fork — "the alignment requires the converter to accept a shape it refuses today" — was not reached. No refusal was removed or relaxed.
Scope notes
check:i18n-keysto pass with a real key rather than a hard-coded English string. A real key cannot exist in one file:all-locales-key-parity.test.tsrequires everyenkey in all ten packs, anddefaults-maps-mirror-en-packrequires thecreateSafeTranslationrows to be byte-identical toen. So this diff also touchespackages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.tsandpackages/plugin-detail/src/useDetailTranslation.ts. None of those is on the held list (packages/i18n/src/provider.tsxis, and is untouched).packages/components/src/custom/filter-builder.tsx,packages/app-shell/src/hooks|console|layout,packages/i18n/src/provider.tsx,packages/plugin-tree/,packages/react/— all read, none edited.packages/react/src/SchemaRenderer.tsxwas read for the boundary measurement and is not in the diff.Acceptance notes
Noted, not filed — observations, out of the three filing classes:
ObjectGrid'ssortslot isz.unknown().optional()on the installed artifact for the same reasonfilteris, and the narrowing toz.array(SortItemSchema)is in the objectstack tree unreleased. Same mechanism as thefilterrow above, different key; it rides the same floor bump, so it needs no card of its own. Carrier: the objectstack card this PR's step 1 produces.FilterOperatorError.operatoris absent on exactly two arms (bare-array and exotic comparand), where the author wrote no operator.filterRefusalSubjectfalls back to the field, so the sentence reads "thetagscondition cannot be applied", which is accurate but is a field where the copy says condition. Carrier: none — it is a wording judgement on new copy, not a defect.The dependency closure for the packages above was built first under the shared verify lock, so no
TS2307/TS6305prerequisite failure is hiding in these readings.Generated by Claude Code