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
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'sdomain:uiexecution seat and independently re-run by the filing seat before filing.How every row below was measured
safeParseagainst installed@objectstack/spec17.4.0 — the published artifact objectui builds and ships against (@object-ui/corefloor^17.3.0) — driven from objectui's owntoFilterNodeSafely, so the converter verdict and the protocol verdict are read on the same input.REFUSEis a phantom (the objectui dev's first pass produced exactly that), and without the negative controls everyACCEPTmeans nothing, because an inert door accepts everything.Delta 1 —
ViewFilterRuleSchemaaccepts an ARRAY on a single-valued operator, contradicting its ownvaluedescriptionViewFilterRuleSchema.safeParse{ field:'tags', operator:'equals', value:['a'] }{ field:'a', operator:'in', value:'x' }{ field:'a', operator:'between', value:[1,2,3] }{ field:'a', operator:'equals', value:'x' }{ operator:'equals', value:'x' }— nofieldobjectui's
convertFiltersToASTthrowsINVALID_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_intake an array (any length, including []),betweentakes 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 endsif (!isPair) return;, so every operator that is neither a list operator norbetweenis 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']]passesisFilterAST,parseFilterASTyields{ tags: ['a'] },@objectstack/driver-sqlanswers400 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-scalarvalueon the scalar operators, in the message idiom the two existing arms already use. ADR-0087 entry — this narrows what parses today.Delta 2 —
icontainscomparands the platform's own conformance table declares REFUSED, parse greenViewFilterRuleSchema.safeParse{ field:'name', operator:'icontains', value:'' }{ field:'name', operator:'icontains', value:42 }@objectstack/spec/data'sFILTER_TEXT_CASESdeclares 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_CASESrefusal predicate at parse on both doors — the$-dialectFilterConditionSchemaandViewFilterRuleSchema'sicontainsarm. ADR-0087 entry on both.Delta 3 —
ObjectGridPropsSchema.defaultFiltersisz.unknown().optional(), so the block's fallback filter key is ungatedRead in
packages/spec/src/ui/component.zod.ts(ObjectGridPropsSchemaopens:2661), offsets from that line:defaultFiltersis described as 「Legacy base-filter fallback, read only whenfilteris absent」 — the same value in the same role asfilter.filterhas been narrowed to aViewFilterRulearray; its fallback still accepts a bare string, a number, or any object. objectui'sObjectGridreads it through the same lowering sink asfilter, 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
defaultFiltersto 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 atfilter. ADR-0087 entry either way.Delta 4 — reporting, ⛔ NOT a spec defect: the
filternarrowing has not reached objectui's floorThe
filter: z.array(ViewFilterRuleSchema, …)narrowing above is present in this repo's working tree at04d639c659but NOT in the published@objectstack/spec17.4.0 objectui installs, where the same key readsz.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.filteraccepts'str',42, a MongoDB-style record and a bogus rule array, withobjectName: 42REFUSED 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.
ListViewSchema.filterandRecordRelatedListProps.filterarez.array(ViewFilterRuleSchema)in the shipped artifact and already refuse all nine structurally. The gap isObjectGridProps, by key, per Deltas 3 and 4.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 inputFiled by objectui's
domain:uiexecution seat ·session_01Xr7APep6jm1Zta3KUzPzZf· 2026-09-21T05:00ZGenerated by Claude Code