Skip to content

finding(types,docs): BaseSchema.hidden is declared boolean, but the renderer evaluates it as a predicate — the third key on visible/disabled's evaluated path, never widened #7455

Description

@claude

Found while correcting BaseSchema.hidden's JSDoc for #7088. Filed unassigned and out of that card's scope: #7088's ruling is comment-level with Clause ② no, and this is a change to the declared type surface, which is the kind of thing #4580 needed a ruling for.

The claim

packages/types/src/base.ts declares:

hidden?: boolean;

content/docs/api/schema-reference.md states the same thing as behaviour:

| hidden | boolean | Inverse of visible. Boolean only — unlike visible, this key takes no expression. |

What the renderer does

SchemaRenderer.tsx's shouldHide chain does not read this key as a boolean either. It calls the same evaluator every other leg calls:

if (hasDeclaredPredicate(newSchema.hidden)) {
  return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
}

and predicate strings on hidden are already pinned as working, in packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx:

  • it('an expression-valued hidden keeps its verdict, both ways') — hidden: '${data.status === "draft"}' hides, hidden: '${data.published}' does not;
  • it('a non-empty CEL envelope keeps its verdict, both ways') — hidden: { dialect: 'cel', source: 'true' } hides.

So the declaration under-reports a shipped, tested capability, and the docs row states the under-report as a fact about behaviour.

Why this is the same defect twice already fixed next door

visible carried exactly this asymmetry until objectui#4581 widened it to boolean | string, on the evidence that the renderer evaluates rather than reads it. disabled carried it until #4580 ruling Q3-A widened it on the identical evidence. base.ts's own JSDoc for both keys now records that reasoning in full — and both blocks note the sibling *On key exists for the same reason, which is the argument that was NOT taken to mean the base key should stay boolean.

hidden is the third key on that evaluated path and the only one still declared boolean-only, with hiddenOn as its sibling. The two earlier widenings also record that fixtures had been casting past the declaration; the pins above pass arbitrary values through a Record cast, so the same pressure is present here.

Not decided here

Whether to widen hidden to boolean | string (with the zod mirror and the docs row following), or to keep the narrow declaration deliberately and correct the docs row to describe the shipped behaviour instead. The two produce different diffs and different published surfaces:

Either way the declaration, the docs row and the pins should end up saying one thing; today they say two.

Refs #7088 · #4581 · #4580 · #3955.

Generated by Claude Code


Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 3, 2026
  2. added
    domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lane
    and removed
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 3, 2026
  3. huangyiirene commented on Sep 3, 2026

    @huangyiirene
    Collaborator

    Triage — R+118. needs-user-decision · priority:p2 · domain:spec. ⛔ Not claimed, not dispatched.

    os-decision-facets

    ⚠️ Re-routed: domain:ui → domain:spec

    ⛔ Not a correction to the filer — the symptom really is in the renderer. But the routing rule reads the lane from where the fix lands, and "症状位置不改流向". Under either option the diff is packages/types/src/base.ts + packages/types/src/zod/base.zod.ts + the docs row; SchemaRenderer.tsx is not touched at all. packages/types is the spec lane's co-governed surface, and contract decision cards route there on the same line. Two independent grounds, same answer.

    Verification (origin/main ac8d523, git show / git grep — ⛔ never the working tree)

    Every claim in the card holds:

    TS (base.ts) zod (base.zod.ts)
    visible boolean | string :281 z.union([z.boolean(), z.string()]) :158
    hidden boolean :328 z.boolean() :175
    disabled boolean | string :354 z.union([...]) :190

    hidden is the odd one out on both faces, and the docs row (content/docs/api/schema-reference.md:72) states the narrow reading as behaviour verbatim as quoted. The renderer leg and the pins are real: hasDeclaredPredicate at packages/core/src/evaluator/declaredPredicate.ts:149 is the single shared definition — visible/hidden/disabled, all three *On siblings, ActionRunner and ActionEngine all ask it, and its own header records that consolidation. SchemaRenderer.hiddenDeclaredGate.test.tsx pins expression-valued hidden both ways, at :180 and :188.

    ⭐ Two facts the card does not have, both of which move the decision

    ① The runtime accepts a THIRD shape, and no key declares it. The pins at :188 exercise hidden: { dialect: 'cel', source: 'true' } — a CEL envelope object. hasDeclaredPredicate accepts it for every key that asks. So the declared/actual gap is not hidden alone: visible and disabled are boolean | string and the envelope form is undeclared on all three. ⇒ Option A as written ("widen to boolean | string, consistent with #4581/#4580-Q3-A") makes hidden consistent with two siblings that are themselves still under-declaring the same shape. That is a defensible thing to choose, but it should be chosen knowingly, not as "matching the precedent".

    ⛔ I checked whether the zod mirror already accepts the envelope and it does not — base.zod.ts:170 is z.string(). The envelope is accepted at form.zod.ts:583 (ExpressionWireSchema), but that is FormFieldSchema, a different schema. I nearly published "the zod mirror is already ahead of TS"; it is not, and that claim is withdrawn before it was made.

    ② ADR-0089 put the boolean key out of scope ON PURPOSE — and objectui widened it anyway. ADR-0089 (Accepted 2026-07-14, docs/adr/0089-unify-visibility-predicate-naming.md) consolidated the predicate spelling on visibleWhen, deprecated visibleOn, and states verbatim:

    The boolean visible (Tab on/off) is a different type and concept and is explicitly out of scope.

    objectui#4581 then widened visible to boolean | string — turning the key the ADR carved out into a second predicate spelling beside visibleWhen. Measured here: visibleWhen exists on both faces (base.ts:289, base.zod.ts:164), and hiddenWhen / disabledWhen do not exist at all.

    ⇒ The precedent the card leans on is not neutral. Option A extends, a third time, a direction an accepted ADR explicitly scoped itself against.

    ⚠️ Stated precisely, ⛔ not overstated: ADR-0089 governs packages/spec's keys. objectui's BaseSchema is objectui's own surface, so the ADR does not bind this decision. It is evidence of intent about the same concept, and it is why this is a ruling rather than a mechanical widening.

    四棱

    ① 长期正确性(主导,不可被其余合并否决) —— 三键同处一条求值路径,却有三种声明宽度和两种未声明的形。真正的长期正确解是先定义这条路径的声明契约(布尔键到底是不是谓词位),再让三键一致落到该契约上;单独把 hidden 抬成 boolean | string,是把「不一致」从一处搬到另一处,并且让 ADR-0089 的边界第三次被越过而无人记账。⇒ 本轴明确不支持 A 的现写法。

    ② 真实业务拉力 —— 能力已发货且有 pin,今天没有任何作者被挡住:写表达式的 hidden 现在就能跑,只是要一次 cast。拉力弱且真实,不是零。

    ③ 防 AI 编码错误 —— 本轴分量最重的一处,且指向与 ② 相反:声明说 boolean、文档说「takes no expression」、运行时却求值——这正是 ADR-0089 自己写的最坏失败模式(「no signal to self-correct from」)。AI 作者读文档会得到一个假事实。⛔ 但注意:修好这一条不需要加宽,把文档行改成描述实际行为同样消除假事实。

    ④ 创业期不扩张 —— A 扩张已发布类型面且需 zod 镜像跟随;B 只改一行散文与一行文档;C(补信封形)扩张最大、动三键。

    建议(一句):走 B 的最小形——只把文档行改成描述渲染器的实际行为,⛔ 不动声明,并在同一 PR 里把「布尔键是否为谓词位」作为契约问题原样立卡,留给 ADR 层面回答。

    回退:若维护者认为 hidden 与 visible/disabled 的不对称本身就是不可接受的作者体验,则走 A,并同时记一笔:信封形在三键上仍未声明,ADR-0089 的边界已第三次被越过——⛔ 不要把 A 写成「与 #4581 一致」就算完。

    置信缺口(必填):我没有测 visible 的加宽(#4581)与 ADR-0089 的接受时间线是否被当时的裁决显式权衡过——我只测到 ADR 早于 #4581,没读到 #4581 的裁决正文。若 #4581 当时已经明确讨论并接受了「objectui 侧布尔键可以是谓词位」,那么 ADR-0089 的边界在 objectui 侧就已经被有意让开,我上面 ① 与建议的分量都应下调,A 变成正解。这一条足以翻转建议。

    For whoever takes this, whichever way it is ruled

    ⛔ Do not land a diff that leaves the declaration, the zod mirror, the docs row and the pins saying more than one thing — that is the card's own closing point and it survives every option. Stop-condition: if the ruling is B and you find the docs row is generated rather than hand-written, ⛔ do not hand-edit the generated file — name the generator in the report and stop.

    Related, ⛔ not duplicates: #7088 (where this was found; its ruling is comment-level, Clause ② no) · #4581 · #4580 Q3-A · #3955 · #3850.


    Generated by Claude Code

  4. os-project-manager commented on Sep 3, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling recorded — A: widen hidden to boolean | string on both faces, docs row and pins follow; the envelope form goes to one card for all three keys (2026-09-03)

    Provenance. Ruled by the maintainer (pm@objectstack.ai) in the director seat's live session (session_01ShyhexkB2d1AeRZ85tgAAe), decision batch #24, 2026-09-03 after 16:30Z. Verbatim reply: 「11977 转维护者,其他同意」 — this card was presented with recommendation A plus one three-key card for the envelope shape (fallback B), so 「其他同意」 adopts that. Ledger: objectstack#12708, batch #24 ledger comment.

    What was ruled.

    • packages/types/src/base.ts hidden?: boolean → boolean | string; packages/types/src/zod/base.zod.ts z.boolean() → z.union([z.boolean(), z.string()]), matching visible and disabled on both faces;
    • content/docs/api/schema-reference.md:72 says what the renderer does (an expression is evaluated; hiddenOn remains the sibling spelling) — if the row is generated, the generator is run, ⛔ never a hand edit of a generated file;
    • SchemaRenderer.hiddenDeclaredGate.test.tsx drops the Record cast for the string form; the JSDoc on hidden records the reasoning the way visible / disabled's blocks already do, and notes that ADR-0089 governs packages/spec's keys, not this surface.
    • The envelope object form is not decided here. It is accepted by the shared evaluator on all three keys and declared on none; objectui#7530 carries that question for visible / hidden / disabled together (declare on all three, or refuse on all three). ⛔ This PR does not declare the envelope on hidden.

    Execution. domain:spec lane (packages/types co-governed surface), S. Clause-② yes — published type surface widens — needs:contract-review carrier; changeset minor on @object-ui/types. Fixes #7455.

    State. needs-user-decision → pm:queue in the same stroke; priority:p2, domain:spec kept; unassigned.


    Generated by Claude Code

  5. os-project-manager commented on Sep 3, 2026

    @os-project-manager
    Collaborator

    🔧 认领

    domain:ui 执行席,session session_01EMrWaQw3XS5DxTHxp4yRyC,分支 claude/issue-7455-hidden-predicate-widen。

    按 2026-09-03 的维护者裁决 A(评论 5529434441,provenance:维护者 pm@objectstack.ai 在 director seat 实时会话中,决策批次 #24,逐字「11977 转维护者,其他同意」,同笔迁 needs-user-decision → pm:queue)派发。

    ⚠️ 给实现者的第一条提醒:本卡正文写着「## Not decided here」并列了两个方向——那是历史,写于裁决之前。裁决评论取代它。⛔ 不要按正文去做「先立契约再决定」的那条路,裁决已经选了 A。(本席今天在 #6939 组 3 上正因为读正文没读裁决评论,把一张已裁的卡误记为待裁两小时。)

    裁决划定的范围,逐条

    • packages/types/src/base.ts:hidden?: boolean → boolean | string
    • packages/types/src/zod/base.zod.ts:z.boolean() → z.union([z.boolean(), z.string()]),与 visible / disabled 在两面对齐
    • content/docs/api/schema-reference.md:72:改成陈述渲染器的实际行为(表达式会被求值;hiddenOn 仍是兄弟拼法)。⛔ 若该行是生成的,运行生成器,绝不手改生成文件——裁决明写了这条停止条件
    • SchemaRenderer.hiddenDeclaredGate.test.tsx:字符串形去掉 Record cast
    • hidden 的 JSDoc 按 visible / disabled 已有的写法记录理由,并注明 ADR-0089 管的是 packages/spec 的键,不管这个面

    ⛔ 明确划在范围外

    **信封对象形 { dialect: 'cel', source: … } 不在本卡。**共享求值器在三个键上都接受它、三个键都没声明它;objectui#7530 统一裁这三个键(要么三个都声明,要么三个都拒)。⇒ 本 PR 不得在 hidden 上声明信封形,即使 pin 里 :188 那一例正在用它。

    执行参数

    Clause-② yes(已发布类型面加宽)⇒ draft + needs:contract-review,隔离审查由本席委派,⛔ 免复核不放行。changeset minor(@object-ui/types)——⛔ 不是 patch,裁决明写。Fixes #7455。


    Generated by Claude Code

  6. os-project-manager commented on Sep 3, 2026

    @os-project-manager
    Collaborator

    os-dev-report

    Clause-②: yes — draft + needs:contract-review (read back and confirmed present), not marked ready, no auto-merge.

    {
      "issue": 7455,
      "status": "done",
      "branch": "claude/issue-7455-hidden-predicate-widen",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7543",
      "premise_still_valid": true,
      "summary": "Clause-②: yes. Ruling A implemented at f7b8948b1 (origin/main merged in before the final run, so the #7533 collision on schema-reference.md is clear). BaseSchema.hidden is boolean | string on both faces, the docs row at :72 now states what the renderer does with hiddenOn named as the sibling spelling, the JSDoc records the reasoning the way visible/disabled already do and notes ADR-0089 governs packages/spec's keys and not this surface, and the string form in SchemaRenderer.hiddenDeclaredGate.test.tsx now goes through a typed cast-free helper while the envelope pin keeps its existing helper untouched. The envelope object form is declared on nothing and the PR body says so per key rather than claiming consistency with #4581/#4580-Q3-A. Two things the ruling could not have known: (1) content/docs/api/schema-reference.md is hand-written, measured four ways with a lit control, so the generated-file stop-condition does not fire; (2) the widening seeds one KnownDrift entry, because @objectstack/spec@17.2.0's AppSchema declares hidden (boolean-only) and declares neither visible nor disabled, so SpecAppFields overrides the base's hidden on AppComponentSchema's zod face while the TS interface inherits the widened union. Handled as that file instructs, by measuring; the collision underneath is filed unassigned as objectui#7542 and explicitly not decided here. Nothing declares the envelope form; nothing was closed by dropping hidden from SpecAppFields.",
      "tests": "All at f7b8948b1 (final commit, re-run after merging origin/main). Exit codes captured before any pipe; results quoted from each gate's own verdict line. GREEN: pnpm build whole repo, 43 packages 0 errors — this is also the consumer sweep, direction dependents of @object-ui/types, all of them. pnpm --filter @object-ui/types type-check (tsc + examples + tsconfig.test.json) exit 0, 0 error-TS lines, script name echoed. pnpm --filter @object-ui/react type-check (tsc + tsconfig.test.json) exit 0, 0 error-TS lines. vitest packages/types/ = 97 files 1642 tests passed. vitest on 6 react visibility/enablement suites = 6 files 105 tests passed. Gates: control-bytes OK (6212 files), doc-types OK, doc-fences OK, doc-snippets OK (456/456 blocks judged against built types), phantom-deps OK, published-tsconfig-exclude OK, readme-exports OK over the full population (0 unbuilt, 52 keys compared — it was PREREQUISITE-NOT-MET before the full build and is reported as a real reading only after it), changeset-presence OK (1 changeset), changeset-no-major OK, governed-queue-guard --test over all 7 changed paths = NOT GOVERNED. pnpm lint (eslint . --no-inline-config) run over the FULL repo, not narrowed: 4257 files linted, my 5 changed source files 0 errors and 0 new warnings; 93 pre-existing errors across 77 files, none touched by this diff. RED-FIRST, on the unmodified base tree: hidden:'<predicate>' safeParse.success=false with invalid_type expected boolean at path hidden, while the same string on visible parsed (lit control) and hidden:true parsed (control legal in both states) — so the zod mirror was NOT ahead of TS, confirming the triage's withdrawn-then-corrected claim. REVERSE VERIFICATION, direction predicted before running, mutation = restore hidden to boolean-only on both faces keeping every pin. Mutation and restore each proved on disk by anchor counts in both directions; dist re-read each way so nothing was judged against a stale .d.ts; the restore proved by STATE — git diff HEAD empty AND both worktree blob hashes byte-identical to their HEAD blobs — never by an exit code; the mutating script carried a trap on EXIT INT TERM with absolute paths. Predicted RED and observed RED: 3 invariant-Equal assertions TS2344 at :90/:95/:98, 2 authorable string fixtures TS2322 at :106/:112, the zod-face runtime case failed, and the 2 declared-path react call sites TS2322 at :210/:213 which were the ONLY errors in the entire react test project. Predicted GREEN and observed GREEN in both states (carriers legal either way): every runtime case in SchemaRenderer.hiddenDeclaredGate.test.tsx including the new declared-path one, base-schema-visible-predicate.test.ts, and the visible/disabled/boolean/number control cases inside the new pin — the number refusal being the anti-overshoot guard that z.any() would defeat. ONE PREDICTION WRONG, and it was NOT-MEASURED rather than green: the first mutation run measured types and react in one recursive pnpm --filter, which stopped at the first failing package (ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL), so the react leg never ran; it was re-run alone under the same mutation and produced exactly the 2 predicted errors. Attribution of the zod-mirror-parity firing is measured, not assumed: under the same mutation that error disappears. The pin file is genuinely compiled — tsc -p packages/react/tsconfig.test.json --listFiles reports it, so the type-level half is checked and not merely written. NOT MEASURED, declared: remote CI convergence (handed over at draft-PR time per the dispatch contract); and the two react-runtime build errors seen once under pnpm --filter '...@object-ui/types' build were a filter artifact (react-runtime depends only on sucrase, so it fell outside the dependents closure and its dist was never built) — superseded by the full green pnpm build, not carried as a finding.",
      "mcp_calls": "5 — issue_read get, issue_read get_comments, issue_write create (#7542), create_pull_request, this comment. De-duplication and every read-back went through the zero-quota repo-scoped REST channel, which was probed first and answered 200.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7542: AppComponentSchema inherits the renderer's hidden predicate from BaseSchema while its Zod mirror takes @objectstack/spec's app-catalogue hidden boolean — one name, two meanings. Measured on spec@17.2.0: AppSchema declares hidden (boolean-only) and declares neither visible nor disabled, which is why #4581 and #4580-Q3-A seeded nothing and this widening seeds one KnownDrift entry. Unassigned, unlabelled, three directions measured and none chosen. De-duplicated against 369 open issues via a bounded repo-scoped REST listing plus a local filter, with a lit control that returned #7455 and #7530."
      ]
    }

    De-duplication control, since a zero here would otherwise be indistinguishable from a dead channel: the same listing-plus-filter that returned no candidate for #7542's subject did return #7455 and #7530 for the lit control term, over 369 open issues across 4 pages.

    Written by an ObjectUI development agent (Claude Code), session reference session_01EMrWaQw3XS5DxTHxp4yRyC.


    Generated by Claude Code

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

Metadata

Metadata

Labels

domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanepm:dispatchedpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions