Repository navigation
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
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.
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.filterandRecordRelatedListProps.filterarez.array(ViewFilterRuleSchema)in the shipped artifact and already refuse all nine structurally. The gap isObjectGridProps, 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
objectstack-fleet commentedon Sep 22, 2026
Triage:
domain:spec(packages/spec/src/ui/view.zod.ts,component.zod.ts) · p2 · Bug ·area:records; rationale: maintainer-directed protocol narrowing out of objectui#9050 ruling C′, with three measured landing linesPath: 看数据的视图 —— 列表能干活 | records-forms.list-view-capabilities · saved-view-management | P2 | p2Graded by the triage seat (
session_01Tw7jnJinGHvoGSi8aFkhPJ) at 2026-09-22T17:29Z. ⛔ ThePath:line lives here rather than in the body: no instrument reads it, and rewriting a filer's body to carry it risks the platform's own citation rewrite.Filed under a ruling, ⛔ not on a seat's initiative: objectui#9050's ruling C′ (batch #196 item 2) says in terms 「the differences are the protocol's to close」, and that card is closed
completed. ⇒ this is gate ③ work with its direction already set.Class (c) with (b) beside it. The metadata is a stored view / block filter config — authored by one party, stored, re-authored by another, which is the charter's own example of (c) (「存储的视图配置是」). The consequence the card names is the sharp one: a stored view that PASSED the protocol selects nothing. The (b) leg is the published
.describe()onvalue(「every other operator takes a scalar」) againstview.zod.ts:633'sif (!isPair) return;, where every scalar operator leaves the check unjudged.Type Bug, ⛔ not Feature: the fix NARROWS the accept set back to what is declared (ADR-0087), so 条款② reads
noon the narrowing arm.Dedupe — 13 hits including closed in this repo, 2 in objectui, every nearest neighbour closed (#17475, #14406, #7119). No live twin.
Generated by Claude Code
objectstack-fleet commentedon Sep 22, 2026
认领 · 已派发
Claim:
domain:spec#5·os-justin·session_01Sfe5YjBLwB9J3y8fvm2xq1Branch:
claude/issue-19514-view-filter-rule-value-shapeSeat:
domain:spec#5Thread-read: 5780997867
Clause-②: no
2026-09-22T20:42Z,座位贴 #19357。写入走 fleet-write 中继,以
objectstack-fleet[bot]落地(route 读作dispatch)。取卡前置已跑(SKILL.md:475,在认领之前):
domain:spec开着的 P0 零;P1 七张 —— #19543 / #15403 / #15213 / #15212 / #15207 / #15204 全是pm:blocked,#18215 是 epic 无pm:*,#18670 现挂pm:retriage(本席本班实测解除了它的阻塞并升级了它的待裁问题,分诊已接手)⇒ 没有可取的 P0/P1,取本张 p2。#202 B 候选查询已跑: 落地点是
packages/spec/src/ui/view.zod.ts与component.zod.ts⇒ 产品路径,⛔ 非scripts/**/.github/**/.claude/**/packages/lint门禁规则 /packages/spec/scripts/**任一。⛔ 无Path: none(分诊已在5780997867给出Path:行)。⇒ 通过。口径,按分诊在
5780997867写下的 rider 收窄:为什么这张卡有在先裁决、可以直接派: objectui#9050 裁决 C′(batch #196 item 2)明文「the differences are the protocol's to close」,该卡已
completed⇒ 方向已定,⛔ 本席不需要也不会重开那个分叉。并且本卡收窄的是协议自己.describe()已经声明的规则(「every other operator takes a scalar」),而checkViewFilterRuleValueShape以if (!isPair) return;收尾 ⇒ 标量算子那一整类从未被判过。⇒ 这是 ADR-0049「声明即强制」方向上的补齐,⛔ 不是新立规矩。safeParse的 ACCEPT/REFUSE 必须带信封控制与否定控制(⛔ 没有控制的 REFUSE 是幻影,没有否定控制的 ACCEPT 什么都不证明 —— 这是卡面自己写下的纪律)。Generated by Claude Code
fix(spec)!: judge the scalar arm, the icontains comparand and default…
test(spec): pin all three narrowings in both directions, driven by th…
test(spec): re-judge the two fixtures the narrowings moved (#19514)
os-justin commentedon Sep 22, 2026
os-dev-report
{
"issue": 19514,
"status": "done",
"branch": "claude/issue-19514-view-filter-rule-value-shape",
"pr": "#19750",
"session": "session_01Sfe5YjBLwB9J3y8fvm2xq1",
"premise_still_valid": true,
"summary": "All three deltas landed, arm (a) only on Delta 3 per triage's rider. (1) checkViewFilterRuleValueShape gains a SCALAR arm, so a non-list, non-range operator carrying an array is refused at
value, with an absent value and the four valueless operators carved out; its docblock now records that this REVERSES the #6227 reading that such an array "lowers to a deep-equality comparand which every backend answers" — re-measured at source, driver-sql refuses it (assertCompilableComparand's scalar arm; isBindableComparand of an array is false) and every in-memory matcher excludes every row. (2) Both authoring vocabularies now apply the FILTER_TEXT_CASES icontains refusal at parse by CALLING the published isRefusedTextComparand / textComparandRefusalReason rather than restating them, so a row added to the table reaches both doors with no edit. (3) ObjectGridProps.defaultFilters moved from z.unknown() to the same z.array(ViewFilterRuleSchema) declaration itsfiltersibling carries, plus that door's conversion-table error map. Three ADR-0087 SemanticMigration entries are registered under protocol 18 and one minor + BREAKING changeset carries the disposition. Line numbers were re-derived rather than adopted: view.zod.ts:614 and :633 held, but ObjectGridPropsSchema opens at component.zod.ts:2678, not the :2661 the dispatch cited.","tests": "pnpm --filter @objectstack/spec test :: exit 0 — 516 files / 15107 passed, 1 skipped, 1 todo. pnpm --filter @objectstack/spec typecheck :: exit 0. pnpm --filter @objectstack/spec check:generated :: exit 0, 15/15 green — one artifact was proved stale (content/docs/references/) and regenerated by --fix; check:api-surface, check:authorable-surface and check:export-origins are green WITHOUT regeneration, which is the mechanical statement that no export and no authorable key moved. Consumers that parse through these schemas: @objectstack/lint 108 files / 4111 passed; @objectstack/objectql 303 files / 5057 passed. eslint . --no-inline-config over the whole repo at the final HEAD 1a2a7eb :: exit 0 — 7016 files, 0 errors, 0 warnings. That is the whole population, so no narrowing had to be argued. MANIFEST=$PWD/sdui.manifest.json check:react-declaration-parity --strict :: exit 0, no new declaration divergence vs the accepted baseline. GATES: scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 111 families for these paths and --ran reconciles at 111/111 accounted, 0 UNRUN. 109 exited 0. Exactly two exited 3 and are reported as NOT MEASURED, never as a pass, both refusing on a whole-workspace build prerequisite rather than on anything in this diff:
pnpm check:type-check-debt:: exit 3 (its --re-measure arm refuses while 30 workspace dependencies have no built type entry point) andpnpm check:dual-build-cjs-loads:: exit 3 (34 packages have no dist). CI builds that closure before both steps. ABLATION, on this change's own pins, via scripts/ablation-replace.mjs (literal anchor that must hit exactly once, on-disk blob change asserted, restore proved against HEAD). The tests import their subjects by relative SOURCE path, so vitest compiles src and no dist sits between the mutation and the assertion — the stale-dist false green cannot arise here. A1 scalar arm disabled: blob a20636191da4 -> 81e31e505fd2; 10 red — refuses equals + array, refuses not_equals + array, refuses greater_than + array, prescribes the author's own first member, has something to say about an EMPTY array, names the two vocabularies from the exported sets, carries no internal tracker id, sweeps every unshaped operator (all in view-filter-rule-value-shape.test.ts), plus 'an ARRAY is the SHAPE arm defect' in the comparand suite and 'the rule-level narrowings reach THIS key too' in the grid pin. A2 view icontains call removed: blob a20636191da4 -> d244ac0b8e40; 8 red (the four §2 comparand pins, the §2 tracker-id pin, the view half of both §3 table-driven rows, the grid pin). §1 — the $ door — stayed GREEN, which is the attribution. A3 $ dialect arm made unreachable: blob c047ab9065d9 -> 645a7a04f24c; 8 red (the six §1 pins and the $ half of both §3 rows). The view half stayed green and so did the bare-preset envelope control, which is what says the walk was still running. A4 defaultFilters reverted to z.unknown().optional(): blob 9aa7c654900b -> 4b14cfe92f95; 12 red — all seven refused-shape rows, the two-keys-agree pin, the conversion-table pin, the fall-through pin, the rule-level pin, and component.test.ts's re-judged AST-tuple pin. Every restore leg: git hash-object back to the HEAD blob (a20636191da4, c047ab9065d9, 9aa7c654900b), git diff HEAD empty, and git status --porcelain empty over the whole tree afterwards. CONTROLS: every safeParse reading in the PR body carries an ENVELOPE control refused for an unrelated reason (in/between wording unchanged, a no-field refusal, the bare date-range-preset refusal, objectName:42) and a NEGATIVE control that still accepts (the valid rule array, in + [], between + pair, the is_null placeholder, an alias spelling, a non-empty icontains comparand, the sibling string operators, an absent defaultFilters).","mcp_calls": "0 — no MCP GitHub tool was called at any point. Every GitHub read and write went through the REST proxy with GITHUB_TOKEN.",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR 19750) and POST /repos//issues/19514/comments (this report). ZERO label writes: the dispatch named no label and skip-changeset is false by measurement (the changed bytes publish — dist/ui and dist/data carry the new refusal text, and all three edited sources match files[]'s src//*.zod.ts entry), so the permitted set is empty. Reads: GET issues/19514, GET issues/19514/comments, GET pulls/19750 (full body read-back, stored byte-identical but for the trailing newline, exactly one session-URL footer). Outside the API budget: 4 git pushes to the feature branch.",
"open_questions": [],
"out_of_scope_findings": [
"class: c · A view filter rule that OMITS
valueon a value-taking operator parses green and then 400s at the query path. Measured on this branch: ViewFilterRuleSchema.safeParse({field:'name',operator:'icontains'}) is ACCEPT, and parseFilterAST(['name','icontains']) throws INVALID_FILTER — "Filter comparand at where.name.$icontains is undefined" — with the same reading for 'contains', so it is the whole value-taking class and not an icontains sub-case. A stored view config is authored by one party and re-authored by another, which is the charter's own (c) example. The (b) leg beside it: the key's published text reads "Filter value (optional for unary operators like is_empty, is_null)", which declares it NOT optional for the rest, while the schema marks it .optional() unconditionally. Seam: spec:ui/ViewFilterRule.value → runtime:parseFilterAST | renderer:ObjectGrid lowering sink. Deliberately NOT fixed here and not an oversight: FILTER_TEXT_CASES writes no row about absence and isRefusedTextComparand's own docblock hands that carve-out to the caller, so refusing it inside this card would be widening past the table — the boundary this change holds everywhere else. The bounded-in-place exemption fails its second condition: the form is not pinned (whether objectui's filter builder emits valueless rows is unmeasured, and requiringvalueon every non-unary operator is a far wider accept-set narrowing owing its own ADR-0087 entry and its own ruling). File as a sub-issue of #19514 if triage keeps that card open, otherwise as an independent card in this repo. dedupe words:ViewFilterRule value omitted non-unary operator·filter comparand undefined parse accept·view filter rule missing value INVALID_FILTER·optional value declared not enforced","carrier: the next PR that touches packages/spec/src/ui/filter-rule-array.ts · ruleArrayFilterError hard-codes its subject key as
filter. defaultFilters reuses it with asurfacestring that names the key actually written, so the sentence stays true, but akeyoption on RuleArrayFilterErrorOptions would read better. Not taken here because adding an optional member to a published options type enlarges the public face and this card is declared Clause-②: no. Noted, not filed — Acceptance notes only.","carrier: 无 · packages/spec/spec-changes.json and docs/protocol-upgrade-guide.md carry no protocol-18 semantic entries at all — not this change's three, and not the pre-existing element-data-source-and-object-block-filter-rule-array either. check:spec-changes and check:upgrade-guide are both GREEN, so this is how the projections are scoped today and not staleness introduced here. Recorded only because entries/README.md describes them as projections that "have to be regenerated and committed when an entry lands", which reads as if they would move. An observation, not any of the three filing classes ⇒ Acceptance notes only, no card."
]
}
Generated by Claude Code
chore(changeset): declare the three narrowings, their level and their…
35 remaining items
docs(spec): quote the sibling entry verbatim in the scalar reason (#1…
objectstack-fleet commentedon Sep 23, 2026
交付 · 关卡
domain:specseat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1),2026-09-23T10:08Z。PR #19750 已合入main(2b52a5b013),达档复核 PASS 记录5792583321。本卡按分诊 rider
5780997867收窄后的口径全部交付:三处「收窄到已声明的形状」(标量运算符配数组、icontains的空串 / 非字符串比较值、defaultFilters与filter同一声明),各带 ADR-0087 迁移条目;⛔ Delta 3「直接拒绝」那一臂按 rider 未做,Delta 4 按 rider 不是缺陷。本卡过程中另立的卡(#19751、#19757、#19810 等)各自独立跟进。Generated by Claude Code
fix(spec)!: the filter doors refuse the three shapes they already dec…
fix(service-analytics): the draft preview refuses a filter operator i…
fix(spec)!: refuse an array in the equality slot at the shared compar…
fix(spec): refuse an absent value on a value-taking view filter opera…
fix(service-analytics)!: the analytics where door refuses a non-boole…
fix(spec)!: a comparand the comparand-type face refuses is refused on…
feat(spec)!: type ViewFilterRule.operator's input as the canonical Vi…
test(spec): the third data/ file group's test titles state each cited…
test(spec): the first ui/ file group's test titles state each cited d…
test(spec): the third ui/ file group's test titles state each cited d…