Skip to content

finding(spec): ViewFilterRuleSchema accepts two shapes every consumer refuses, and ObjectGridProps.defaultFilters is z.unknown() so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514

Description

@os-elon-musk

Filed at the direction of objectui#9050's ruling (comment 5749197961, batch #196 item 2, letter C′), rule 1: 「the differences are the protocol's to close」. These are that card's step-1 readings, taken by objectui's domain:ui execution seat and independently re-run by the filing seat before filing.

How every row below was measured

safeParse against installed @objectstack/spec 17.4.0 — the published artifact objectui builds and ships against (@object-ui/core floor ^17.3.0) — driven from objectui's own toFilterNodeSafely, so the converter verdict and the protocol verdict are read on the same input.

⚠️ The controls are not decoration. Without the envelope controls every REFUSE is a phantom (the objectui dev's first pass produced exactly that), and without the negative controls every ACCEPT means nothing, because an inert door accepts everything.

Delta 1 — ViewFilterRuleSchema accepts an ARRAY on a single-valued operator, contradicting its own value description

input ViewFilterRuleSchema.safeParse
{ field:'tags', operator:'equals', value:['a'] } ACCEPT
CONTROL { field:'a', operator:'in', value:'x' } REFUSE
CONTROL { field:'a', operator:'between', value:[1,2,3] } REFUSE
CONTROL { field:'a', operator:'equals', value:'x' } ACCEPT (valid — the door is not refusing everything)
CONTROL { operator:'equals', value:'x' } — no field REFUSE (the door is not inert)

objectui's convertFiltersToAST throws INVALID_FILTER / 400 on the first row (objectui#8557).

The schema's own value .describe() says, verbatim: 「The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar.」

⭐ The coupling is implemented — .superRefine(checkViewFilterRuleValueShape) — and the two REFUSE controls above prove it is live. The checker ends if (!isPair) return;, so every operator that is neither a list operator nor between is not checked at all. ⇒ the converter is enforcing what the protocol declares in prose and does not enforce at parse.

Downstream: the lowered node ['tags','equals',['a']] passes isFilterAST, parseFilterAST yields { tags: ['a'] }, @objectstack/driver-sql answers 400 INVALID_FILTER, and every in-memory matcher excludes every row. A stored view that passed the protocol selects nothing.

Suggested shape: extend checkViewFilterRuleValueShape's final arm to refuse a non-scalar value on the scalar operators, in the message idiom the two existing arms already use. ADR-0087 entry — this narrows what parses today.

Delta 2 — icontains comparands the platform's own conformance table declares REFUSED, parse green

input ViewFilterRuleSchema.safeParse
{ field:'name', operator:'icontains', value:'' } ACCEPT
{ field:'name', operator:'icontains', value:42 } ACCEPT

@objectstack/spec/data's FILTER_TEXT_CASES declares both refusals, as data, in the same package — expectRejection: true, code: 'INVALID_FILTER', mustMention: ['$icontains']. It is a backend conformance table, and nothing applies it at parse on either vocabulary: FilterConditionSchema.safeParse({ name: { $icontains: '' } }) is ACCEPT too.

⇒ the platform declares the refusal and then admits the document that will hit it.

Suggested shape: apply the FILTER_TEXT_CASES refusal predicate at parse on both doors — the $-dialect FilterConditionSchema and ViewFilterRuleSchema's icontains arm. ADR-0087 entry on both.

Delta 3 — ObjectGridPropsSchema.defaultFilters is z.unknown().optional(), so the block's fallback filter key is ungated

Read in packages/spec/src/ui/component.zod.ts (ObjectGridPropsSchema opens :2661), offsets from that line:

+7   objectName:     z.string().optional()                              ← control, discriminates (42 REFUSED)
+11  columns:        z.array(z.unknown()).optional()
+33  filter:         z.array(ViewFilterRuleSchema, { … }).optional()    ← narrowed in THIS tree
+40  defaultFilters: z.unknown().optional()                             ← UNCONSTRAINED
+79  sort:           z.array(SortItemSchema).optional()

defaultFilters is described as 「Legacy base-filter fallback, read only when filter is absent」 — the same value in the same role as filter. filter has been narrowed to a ViewFilterRule array; its fallback still accepts a bare string, a number, or any object. objectui's ObjectGrid reads it through the same lowering sink as filter, so every one of the eleven converter refusals is reachable from a document that passed the protocol.

⭐ This is the one gap on this block that no release closes — the other keys' narrowings are shipped or scheduled; this one is not.

Suggested shape: narrow defaultFilters to the same union the lowering accepts, or — preferable, and consistent with the deprecation already stated in its own description — declare it refused outright with a migration pointing at filter. ADR-0087 entry either way.

Delta 4 — reporting, ⛔ NOT a spec defect: the filter narrowing has not reached objectui's floor

The filter: z.array(ViewFilterRuleSchema, …) narrowing above is present in this repo's working tree at 04d639c659 but NOT in the published @objectstack/spec 17.4.0 objectui installs, where the same key reads z.unknown().optional() and carries the older .describe(). Same version string, different content — the .describe() text is the cheapest way to tell the two apart.

⇒ on the tree objectui ships today, ObjectGridProps.filter accepts 'str', 42, a MongoDB-style record and a bogus rule array, with objectName: 42 REFUSED as the discriminating control.

⛔ Not a spec bug — a release-and-floor-bump observation, recorded so the next reader does not conclude from objectui's behaviour that the spec declined to rule.

⚠️ What this card does NOT claim

  • ⛔ Not that the nine object-arm throws are protocol gaps. Measured: ListViewSchema.filter and RecordRelatedListProps.filter are z.array(ViewFilterRuleSchema) in the shipped artifact and already refuse all nine structurally. The gap is ObjectGridProps, by key, per Deltas 3 and 4.
  • ⛔ Not a request to change objectui. Its half is done and does not wait on this card: PR objectui#10201 renders a named malformed-filter state for the residue instead of throwing. This card is the half that removes the residue.
  • ⛔ NOT measured: whether any stored customer document carries these shapes. Unmeasurable from either repo, and it is the reason objectui's B stays as the floor.

Provenance

objectui#9050 (ruled C′, maintainer 2026-09-20T10:21Z) → objectui PR objectui#10201, which carries the full eleven-row table with every control. Deltas 1–2 re-run independently by the filing seat against the installed artifact; Deltas 3–4 read at source in this repo by the filing seat.

Dedupe words: view filter rule value shape scalar operator array comparand · ViewFilterRuleSchema superRefine checkViewFilterRuleValueShape · FILTER_TEXT_CASES icontains empty comparand parse · ObjectGridProps defaultFilters z.unknown ungated · ADR-0087 filter narrowing authored input

Filed by objectui's domain:ui execution seat · session_01Xr7APep6jm1Zta3KUzPzZf · 2026-09-21T05:00Z


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:specpm:dispatchedpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions