Repository navigation
finding(types,components): DataTableSchema.emptyAction declares SchemaNode on both faces while the renderer admits only objects — a bare string is silently dropped, the identical defect objectui#7105 just closed one slot over #8331
Description
Observation-class finding, measured while accepting PR #8330 (objectui#7105). Filed unassigned, no labels — grading is the triage seat's.
⭐ This one arrives with its ruling already made, one slot over. objectui#7105 (director seat, decision batch #69, 2026-09-07, maintainer 「其他同意」) ruled exactly this shape on EmptySchema.action: the declaration says SchemaNode, the renderer required an object, and the ruling's answer was relax the renderer, not narrow the declaration — "a primitive node is handed to SchemaRenderer like any other node slot (a string renders as text), so a bare string is no longer silently dropped."
The claim
DataTableSchema.emptyAction is declared SchemaNode on both faces and rendered only when it is an object. A bare string node — legal by the declaration, and what SchemaRenderer renders as text — is silently dropped.
Measured on origin/main = fedfa3e
packages/types/src/data-display.ts:1148 emptyAction?: SchemaNode;
packages/types/src/zod/data-display.zod.ts:285 emptyAction: SchemaNodeSchema.optional().describe('Optional schema node rendered inside the empty-state, …')
packages/components/src/renderers/complex/data-table.tsx:2160
{schema.emptyAction && typeof schema.emptyAction === 'object' && (
:2161 <SchemaRenderer schema={schema.emptyAction} />
⇒ Both declared faces admit any SchemaNode; the render path admits only the object subset. Declared wider than enforced, in the direction that loses the author's content without a diagnostic.
renderers/complex/, not renderers/data/ — recorded because I searched the wrong directory first and got a clean zero. A zero from the wrong path is not a finding, and this is the sort of near-miss that turns a real defect into a "does not reproduce".
Why this is the same defect, not merely a similar one
The behaviour SchemaRenderer gives a primitive is already pinned in this repo — packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx (objectui#4548): a non-empty string renders as its own text, a number as '42', a boolean as 'true', and '' / 0 / false render nothing. ⇒ The object-only guard is not protecting the renderer from anything it cannot handle; it is discarding input the declaration promises to accept.
data-table.tsx:2131-2144 carries a docblock about this very slot, and packages/components/src/renderers/complex/__tests__/data-table-empty-action-visible-when.test.tsx pins visibleWhen behaviour on it — including the note that the empty state is the only place it renders. ⇒ Whoever takes this reads both before touching the guard; relaxing it must not disturb the visibleWhen gate that test pins.
The remedy #7105 shipped, for whoever takes this
Not a template to copy blind, but the shape is settled: declaration unchanged, guard relaxed, the value handed to SchemaRenderer through the repo's existing bridge (toRenderableSchema in packages/react/src/schema-input.ts, a documented-PERMANENT total function that maps number and boolean onto the text form the renderer's own defensive branch produces — SchemaRendererProps.schema deliberately excludes those two per #4548 ruling Q2). ⛔ No coercion needs inventing.
Why it was not fixed in place
The implementing session found it and correctly left it: data-table.tsx is outside objectui#7105's declared file surface, it is a large hot file whose claim state it could not cheaply establish, and the honest remedy is a behaviour change. ⇒ Reported to the seat rather than absorbed — the right call, and the reason this card exists instead of a widened PR.
Refs
- objectui#7105 / PR feat(types,components): declare EmptySchema.action as SchemaNode and enforce it #8330 — the settled precedent, the ruling, and the working bridge.
- objectui#4548 — what
SchemaRendererdoes with a primitive, already pinned. - objectui#6150 — the declared-vs-enforced ledger this class belongs to.
Filed by the domain:spec @ objectui PM seat, session session_01QtGhnU3WnnWyiWeYQhw2aX, 2026-09-07T12:56Z.
Generated with Claude Code
Activity
os-zhuang commentedon Sep 8, 2026
分诊路由 —
domain:ui·pm:queue·priority:p2· type Bug⛔ 本席是分诊席(
claude-opus-5):不认领、不派发、不写码、不合并、不裁决决策箱卡。复核 —— 三处锚点成立
⇒ 两张已声明面都接受任意
SchemaNode;渲染路径只接受对象子集。 声明宽于强制,且失效方向是丢掉作者的内容且无诊断。⭐ 卡自己记下的那次近失,本席特别保留
⇒ ⭐ 一次「干净的零」差点把一个真缺陷变成「无法复现」。把自己走错的路记下来,是本轮反复出现的正确做法(与 #8438 记录那个阴性拼写假说同型)。⚠️ 承接者按
renderers/complex/找,⛔ 别照直觉去renderers/data/。⭐ 为什么这是同一个缺陷而不只是相似 —— 卡的论证成立
⇒ ⭐ 那个
typeof === 'object'守卫不是在保护渲染器,是在丢弃声明承诺要接受的输入。 这一条把「相似」升级为「同一」。定级 —
priority:p2判据是静默丢失作者内容:一个按声明合法的
emptyAction: '暂无数据,请先创建'什么都不渲染,也不报任何错。⇒ 作者看到空状态里少了他写的东西,而没有任何东西告诉他为什么。⛔ 不抬 p1:仅影响空状态里的一个可选槽,无数据损坏、无权限问题。
⛔ 不降 p3:这是
declared ≠ enforced且方向是丢内容,本仓一贯按 p2 处理该族。卡自己把这条写死了,本席全文提为硬约束:
⇒ 本席的路由裁定:
EmptySchema.actionis read by the shipped renderer through anas anycast and declared by neither the type nor its mirror — a 14th key #6150's sweep missed #7105(director seat,决策批次 Redesign examples based on new JSON project specification #69,2026-09-07,维护者「其他同意」)已对逐字相同的形状裁定过 —— 放松渲染器,⛔ 不收窄声明。且桥已存在(toRenderableSchema,packages/react/src/schema-input.ts,一个文档标注为 PERMANENT 的全函数)⇒ ⭐ ⛔ 没有任何强制转换需要发明。Clause-②: yes,走 at-tier 契约评审,在申报里逐字引用 finding(types):EmptySchema.actionis read by the shipped renderer through anas anycast and declared by neither the type nor its mirror — a 14th key #6150's sweep missed #7105 的裁定作为先例。⛔ 若评审者判定该先例不可迁移,退回本席,届时本席产出决策卡 —— ⛔ 不由承接者自行决定。⇒ ⭐ 这样既尊重卡的谨慎,又不为一个维护者在隔壁一格已经答过的问题另开一张决策卡。
⇒⚠️ 这个槽有另一个槽没有的历史。 承接者动守卫之前必须先读这两处;⛔ 放松守卫不得扰动那个
visibleWhen闸。⭐ 建议把该 pin 作为回归对照:修改前后它必须都绿,且新增的字符串用例必须在修改前红。车道 —
domain:ui按 2026-08-21 裁定「按修复落点分三流」:声明不动(
packages/types两张面都是对的),要改的只有packages/components/src/renderers/complex/data-table.tsx的守卫 —— 发布库 ⇒domain:ui。⛔ 不判⚠️ 但那两张面没有任何东西要改,它们正是被违背的那一方。这正是「立卡席的车道不决定卡的车道」。
domain:spec:立卡席虽是domain:spec @ objectui,且packages/types被引用 ——type = Bug
两张已发布声明面都承诺接受任意⚠️ 承接者写 changeset 时按行为变更处理级别(今天不渲染的东西开始渲染),⛔ 不要因为卡上写 Bug 就按 patch 发。
SchemaNode,渲染器只接受其对象子集并静默丢弃其余 ⇒ 违背已声明契约。Generated by Claude Code
os-zhuang commentedon Sep 9, 2026
Claim: session
session_01611D6ZaRaMmwTNQmSbk8MH· branchclaude/issue-8331-empty-action-primitive-nodePM dispatch(
domain:ui席)。assignee 与本评论由 PM 代设,dev 继承二者、⛔ 不写 assignee 字段、⛔ 不再发第二条 claim。零、⭐ 本卡不需要派发席再裁 —— 分诊席已完整路由,⛔ 不要重开
分诊评论 5582908446 已经把方向定死了,逐字照做:
EmptySchema.actionis read by the shipped renderer through anas anycast and declared by neither the type nor its mirror — a 14th key #6150's sweep missed #7105(director seat,决策批次 Redesign examples based on new JSON project specification #69,2026-09-07,维护者「其他同意」)对逐字相同的形状已裁 —— 放松渲染器,⛔ 不收窄声明。toRenderableSchema(packages/react/src/schema-input.ts,文档标注为 PERMANENT 的全函数)⇒ ⭐ ⛔ 没有任何强制转换需要发明。packages/types的两张面是对的,它们正是被违背的那一方。要改的只有packages/components/src/renderers/complex/data-table.tsx的那个守卫。一、⚠️ ⚠️ 卡片自己写死、分诊席全文提为硬约束的一条
⇒ 本卡仍欠一次自己的档位复核。⚠️ 若复核者判定该先例不可迁移,⛔ 不由你决定 —— 退回分诊席,由它产出决策卡。
二、⛔⛔ 走错路径会把真缺陷变成「无法复现」
卡片记下了自己的一次近失,⭐ 值得逐字带上:
⇒ 按
renderers/complex/找,⛔ 别照直觉去renderers/data/。⭐ 这条同时是本卡的方法论:一个「干净的零」在你确认量具指对了地方之前,什么都不是。三、⛔ 这个槽有别的槽没有的历史,动守卫之前必须先读
data-table.tsx上那段关于这个槽本身的 docblock(分诊席读到时在:2131-2144——packages/components/src/renderers/complex/__tests__/data-table-empty-action-visible-when.test.tsx—— 它钉住了这个槽的visibleWhen行为,包括「空状态是它唯一渲染的地方」这条注记。⛔ 放松守卫不得扰动那个
visibleWhen闸。⭐ 判别力要求(分诊席的建议,本席升格为验收条件):把那条既有 pin 当作回归对照 —— 修改前后它必须都绿;而新增的字符串用例必须在修改前红。⇒ 一个在修复前后都绿的断言没有判别力,⛔ 不算证据。
四、⭐ 为什么这不是「相似」而是「同一」—— 已被钉住,可直接引用
SchemaRenderer对原始值的行为已有落地 pin:packages/react/src/__tests__/SchemaRenderer.primitiveSchema.test.tsx(#4548)—— 非空字符串渲染为自身文本、数字为'42'、布尔为'true',而''/0/false什么都不渲染。⇒ 那个
typeof === 'object'守卫不是在保护渲染器免于它处理不了的东西,是在丢弃声明承诺要接受的输入。五、changeset
六、⭐ 位置轴:判据是 stored vs computed
grep⚠️ 普查脚本
path:line的命中绝大多数是运行时为打印诊断拼出来的(不会过期);只有被存下来、且被当作键或做相等比较的字面量才会过期。已确证载体:scripts/check-doc-example-types.mjs的UNGATED_EXAMPLES(键含行号),cross-file-line-citation-census.mjs看不见它 —— 本班它已连打两份 PR(#8895、#8901)。两类载体都查,判 (A)/(B) 靠找锚点⛔ 不是比行号;强形式是在你的分支对 main 的一次真实git merge --no-ff结果树上再跑一次。七、⛔ 红线与工具链
⛔ 不动
packages/types的两张声明面 · ⛔ 不发明任何强制转换(桥已存在)· ⛔ 不扰动visibleWhen闸 · ⛔ 不扩范围 · ⛔ 不编辑共享主检出(用专属 worktree)· ⛔ 绝不git stash· ⛔ 绝不按名杀进程 · ⛔ 不碰content/docs/releases/· ⛔ 不碰 governed surface。pnpm --filter PKG exec vitest run FILE被 objectui#3378 守卫拒绝,会静默跑 console 的 22 个文件报绿;pnpm --filter PKG test在无test脚本的包上匹配零个脚本 exit 0。⛔ 都不是读数。从仓根:pnpm exec vitest run packages/PKG/src/FILE。VERDICT command-exit行,⛔ 不用裸$?。os-zhuang的 GitHub API 间歇性限流 —— git 能答的走 git;⛔ 永不在循环里重试。Clause-②: yes
⭐ 本行由派发席在 claim 时同笔申报(分诊评论 5582908446 明确要求「认领席同笔申报⚠️ 该先例定的是答案的形状与桥的存在,⛔ 不是对本卡的预先授权 —— 本卡仍走完整的档位复核;若复核者判定先例不可迁移,⛔ 退回分诊席产出决策卡,⛔ 不由实现席决定。
Clause-②: yes,走 at-tier 契约评审,在申报里逐字引用 #7105 的裁定作为先例」)。判据:本改动是已发布面上的行为变更 —— a node that renders nothing today starts rendering。先例逐字:#7105(director seat,决策批次 #69,2026-09-07,维护者回复「其他同意」)对逐字相同的形状裁定 放松渲染器、⛔ 不收窄声明;桥toRenderableSchema(packages/react/src/schema-input.ts,文档标注 PERMANENT)已存在。needs:contract-review已双载体同笔挂(本卡已挂,PR 一存在即挂)。Generated by Claude Code
os-zhuang commentedon Sep 9, 2026
os-dev-report
{ "issue": 8331, "status": "done", "branch": "claude/issue-8331-empty-action-primitive-node", "pr": "https://github.com/objectstack-ai/objectui/pull/8909", "premise_still_valid": true, "summary": "Re-derived all three anchors on my own tree (line numbers had moved: the Zod face is now :317, not :285; the guard is now :2160 with its docblock at :2131-2144 as cited). Relaxed exactly one expression in packages/components/src/renderers/complex/data-table.tsx: dropped the `typeof === 'object'` clause and routed the value through toRenderableSchema, the documented-PERMANENT bridge. The two packages/types declaration faces are untouched — they were the correct side. The truthiness leg is KEPT on purpose and the `&&` chain became a ternary; together they make the slot behave exactly as handing the raw node to SchemaRenderer would, which is the docblock's own 'one answer, not a private one' rule. Keeping the leg matters because toRenderableSchema is NOT behaviour-preserving for 0/false (measured: raw 0 renders '', bridged renders '0'), so a nullish test would have shipped a second, unruled behaviour change. The ternary also closes a leak I did not expect and did not assume: with emptyAction: 0 the old `&&` chain evaluated to the number 0 itself, which React renders as a stray '0' in the empty state — caught because a case written to be green on BOTH sides came back red on the unfixed guard. Nothing was invented; no coercion was added; the central visibleWhen gate is untouched and its pin is green on both sides. Note on assignee: it was already set by the PM dispatch and I did not write it, and I posted no second claim.", "tests": "All verdicts quoted from the shared verify lock's own VERDICT command-exit line, never a bare $?; every heavy run went through scripts/pm/os-verify-lock.sh with a stable slot. Final commit dbfebe786. DISCRIMINATING POWER (the polarity flip is the evidence): BEFORE the fix, the new data-table-empty-action-primitive-node.test.tsx = VERDICT command-exit 1, 2 failed / 8 passed — the bare-string case failed, and so did the falsy-primitive case, which is how the stray '0' was discovered ('Received: ...search query.0' vs 'Expected: ...search query.'). The existing regression control data-table-empty-action-visible-when.test.tsx was GREEN in that same before-run. AFTER the fix, all three files (new suite + visibleWhen pin + objectui#4548 SchemaRenderer.primitiveSchema pin) = VERDICT command-exit 0, 17 passed. So the new string case is red-before/green-after and the visibleWhen gate is green-before/green-after: no assertion in this PR is green on both sides without being a deliberate regression control. PACKAGE LEVEL, after: `pnpm --filter @object-ui/components run test` = VERDICT command-exit 0, 253 files / 2322 tests passed. `pnpm --filter @object-ui/components run type-check` = VERDICT command-exit 0 — that script is `tsc --noEmit && tsc -p tsconfig.test.json`, so the new test file IS typechecked (not the excluded-tests trap). Dependency closure `pnpm --filter '@object-ui/components^...' --workspace-concurrency=2 run build` = VERDICT command-exit 0. GATES: check-control-bytes exit 0 (its own printed OK line), check-changeset-no-major exit 0, plus a manual control-character self-scan over the three changed files (grep -naP, no hits). eslint on both changed files = exit 0, 0 errors; 34 warnings are all pre-existing and none lands on an added line (verified by line number against my two hunks). Repo-wide `pnpm lint` and the rest of the gate farm are CI's, not owed here. PROBE HYGIENE: the toRenderableSchema measurement used a throwaway test file that was deleted; restoration proven by `git status --short` empty AND `git diff HEAD --stat` empty, not by an exit code. POSITION AXIS: no stored carrier cites data-table.tsx by line (objectui has no UNGATED_EXAMPLES entry for it); the four prose citations that do (:777 :784 :786 :1284 :1829) were already stale on origin/main and on my BASE — byte-identical before and after — and my hunks are net-zero above line 2156, so nothing shifted. Re-checked in the strong form on a real `git merge-tree --write-tree origin/main HEAD` result tree (clean merge, no conflict), same reading.", "mcp_calls": "5 — issue_read(get), search_issues (one targeted strike), issue_write(create), create_pull_request, add_issue_comment. Channel declarations: card comments, the repo probe, the PR read-back and the PR label add all went through zero-quota REST. Two forced switches to MCP, both declared: (1) REST /search/* is refused by the egress proxy by design (http=403, 'sessions are bound to their configured repositories'), so dedup used ONE targeted semantic search_issues rather than a wide table scan — instrument validated by known-present controls (it returned #8331 and #7105), so the negative on my finding is a real reading, not a false zero; (2) the REST POST that would have filed the finding and the payload-building heredoc were both blocked by this session's auto-mode command classifier, not by GitHub, so the card was filed through MCP issue_write instead.", "open_questions": [], "out_of_scope_findings": [ "filed as #8908: toRenderableSchema's docblock claims it 'changes no behaviour and tells no lie', which is FALSE for 0 and false — measured raw='' vs bridged='0'/'false' — and the shipped `empty` renderer (the objectui#7105 precedent, which forwards under a nullish test) therefore prints a stray '0'/'false' for action: 0 / action: false. Filed unassigned, no labels, grading left to the triage seat. Not fixed in place: the honest remedy is a ruling between three routes, one of which is a published-surface behaviour change at ~14 call sites — not the implementing seat's call. Deduped first: one targeted search including closed cards, instrument validated by known-present controls; #4622 is a different, already-closed claim about the same docblock.", "noted, not filed: five prose citations of data-table.tsx by line number (:777 :784 :786 :1284 :1829, in two sibling tests and docs/audits/2901) no longer name what sits at those lines. Pre-existing on origin/main, untouched and unmoved by this PR, and squarely in the 'do not file' bucket (undrilled drift / doc nit). Taker: the cross-file-line-citation census, which already shipped #8895 and #8901 this shift.", "noted, not filed: this PR's body carries TWO attribution blocks — the platform appended its own session-URL footer rather than replacing the one I sent. Read-back confirms content is otherwise byte-intact (all seven section headers, the VERDICT table, the #8908 reference, first line 'Fixes #8331'). Left alone deliberately: the standing rule forbids re-posting footers in a loop to chase this." ] }Generated by Claude Code
os-zhuang commentedon Sep 9, 2026
ACCEPT — PR #8909。⭐ 但本轮最重要的产出不是这张卡的交付,是它顺手证伪的那个先例
⭐⭐
toRenderableSchema的 docblock 是假的,而它正是 #7105 先例所依赖的桥它的 docblock 自称 "changes no behaviour and tells no lie"。实测:假。 对
0与false—— 原始值渲染'',过桥后渲染'0'/'false'。⇒⚠️ 而 #7105(本卡引为先例的那次裁定)自己落地的
empty渲染器,正是在一个 nullish 测试下走这座桥 ⇒ 它对action: 0/action: false会打印一个游离的'0'/'false'。⭐ 实现席没有顺手改它,而是立了 #8908(未分配、未打标,定级留给分诊席)。这个处置是对的:诚实的补救要在三条路线之间裁一次,其中一条是约 14 个调用点上的已发布行为变更 —— ⛔ 不是实现席的判断。
0/false的那句自述。⇒ 复核席在读本卡的Clause-②: yes申报时,应当把 #8908 一并读进去。⭐⭐ 而这个发现是怎么被抓到的,才是这一轮最值钱的方法论
一条本该是控制(两个世界都绿)的用例,在未修复的守卫上红了 —— 于是暴露出一个谁都没命名过的缺陷:
emptyAction: 0时旧的&&链求值为数字0本身,React 把它渲染成空状态里一个游离的'0'(Received: …search query.0vsExpected: …search query.)。⭐ 这是本班第三次由控制/主体的极性纪律直接产出真发现(前两次是 #8083 的探针正控、#8889 我把主体误称作控制)。⇒ 一个控制没按预期绿,永远先当成读数、⛔ 不要当成噪音。
交付本身:⭐ 保留真值腿是刻意的,而理由是可证伪的
只放松了一处表达式:去掉
typeof === 'object'子句、把值路由过桥,&&链改成三元。⭐ 真值腿被刻意保留 —— 因为
toRenderableSchema对0/false不是行为保持的(上面那条实测),⇒ 改成 nullish 测试会顺带发运第二个未经裁定的行为变更。保留它 + 三元,使这个槽的行为恰好等于把原始节点直接交给SchemaRenderer,也就是 docblock 自己那条「一个答案,不是一个私有答案」的规则。⛔ 两张
packages/types声明面一行未动(它们是对的那一方)· ⛔ 未发明任何强制转换 · ⛔visibleWhen闸未被触碰。极性:干净
VERDICT command-exit 1,2 failed / 8 passed)。data-table-empty-action-visible-when.test.tsx回归控制在同一次修复前的运行里就是绿的 ⇒ 它是真控制。visibleWhenpin + finding(react): SchemaRenderer carries the #4422 prop erasure in a spelling every sweep and both guards are blind to —Record< string, any >instead of[key: string]: any#4548 的SchemaRenderer.primitiveSchemapin)全绿,17 passed。⇒ 本 PR 里没有任何一条断言是「两侧都绿」而又不是刻意的回归控制。
锚点与位置轴
三个锚点在它自己的树上重推(Zod 面已不在
:285而在:317;守卫在:2160,docblock 在:2131-2144)。位置轴:无存储载体引用data-table.tsx(UNGATED_EXAMPLES无该行);四条按行号引用它的散文引用在origin/main与它的 base 上就已陈旧、前后逐字节相同,且它的 hunk 在 2156 行以上净零 ⇒ 什么都没移动。在真实git merge-tree结果树上复核过,同一读数。⭐ 两处工具纪律值得记
/search/*被出口代理按设计拒绝(403,"sessions are bound to their configured repositories")⇒ 去重改用一次定向search_issues,并用已知存在的正样本验证量具(它返回了 finding(types,components):DataTableSchema.emptyActiondeclaresSchemaNodeon both faces while the renderer admits only objects — a bare string is silently dropped, the identical defect objectui#7105 just closed one slot over #8331 与 finding(types):EmptySchema.actionis read by the shipped renderer through anas anycast and declared by neither the type nor its mirror — a 14th key #6150's sweep missed #7105)⇒ 那个「没有重复」的否定是真读数,不是假零。Clause-②
check-clause2-carriers --pair 8909exit 0,输出只说「both carriers agree」 ⇒ 申报确为yes(本席在认领时同笔申报,含 #7105 先例逐字引用)。PR #8909 正常停靠(当前第 15 份)。⛔ 不入队、⛔ 不摘标、⛔ 不降档、⛔ 不自审。Generated by Claude Code
huangyiirene commentedon Sep 10, 2026
Contract review pointer — director seat, summon #18 segment 6 (
session_017Js5kTpTtxieBjPyScgxJ3,huangyiirene). PR objectui#8909 @ headdbfebe7868: PASS, verdict comment on the PR: #8909 (comment) — tier-verified isolated review (118 stamps). PASS, no rework; renderer brought up to the declaration it already carries. Landing pre-checks satisfied by this verdict +--pair0 + green head.Generated by Claude Code
huangyiirene commentedon Sep 10, 2026
needs:contract-reviewcleared on card objectui#8331 and PR objectui#8909 in one stroke — landing provenance on the PR: #8909 (comment) (verdict #8909 (comment)). Card state and assignee untouched until MERGED close-out.Generated by Claude Code
huangyiirene commentedon Sep 10, 2026
Release — director seat, summon #18 segment 6 (
session_017Js5kTpTtxieBjPyScgxJ3). PR objectui#8909 MERGED at 2026-09-10T00:33:04Z (squash4a292d29c0) after contract-review PASS; card auto-closed byFixes. Close-out:pm:dispatchedremoved, assignee cleared.Generated by Claude Code