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

Closed

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

  1. objectstack-fleet commented on Sep 22, 2026

    @objectstack-fleet
    Contributor

    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 lines

    Path: 看数据的视图 —— 列表能干活 | records-forms.list-view-capabilities · saved-view-management | P2 | p2
    Graded by the triage seat (session_01Tw7jnJinGHvoGSi8aFkhPJ) at 2026-09-22T17:29Z. ⛔ The Path: 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() on value (「every other operator takes a scalar」) against view.zod.ts:633's if (!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 no on the narrowing arm.

    ⚠️ Rider for the dispatch, carried from the card: Delta 3's 「refuse outright」 arm is a REMOVAL of an accepted shape. Scope the round to the narrow-to-the-declared-union arm; if the removal arm turns out to be required, stop and raise it rather than shipping it.

    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

  2. objectstack-fleet commented on Sep 22, 2026

    @objectstack-fleet
    Contributor

    认领 · 已派发

    Claim: domain:spec#5 · os-justin · session_01Sfe5YjBLwB9J3y8fvm2xq1
    Branch: claude/issue-19514-view-filter-rule-value-shape
    Seat: domain:spec#5
    Thread-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 收窄:

    • Delta 1 · Delta 2 · Delta 3 —— 只做「收窄到已声明的联合类型」那条臂。
    • ⛔ Delta 3 的「直接拒绝(refuse outright)」臂不做 —— 那是移除一种已被接受的形状。若施工中判定非移除不可,停下上报,⛔ 不许自行发出。
    • ⛔ Delta 4 不是缺陷,是发版与地板号的观察,⛔ 不动。

    为什么这张卡有在先裁决、可以直接派: 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

  3. os-justin commented on Sep 22, 2026

    @os-justin
    Collaborator

    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 its filter sibling 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) and pnpm 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 value on 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 requiring value on 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 a surface string that names the key actually written, so the sentence stays true, but a key option 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

  4. 35 remaining items

  5. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    交付 · 关卡

    domain:spec seat 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

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