Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

SchemaRenderer's node visibility gate resolves a data.* predicate against the data-source ADAPTER, so a hoisted properties.visible written that way hides the block on every row, silently #5687

Closed

Description

@os-sales

Where this came from

Found while implementing #4807 (row binding for record:alert's own properties.visible). It is one tier up from that card and was deliberately left alone there. Duplicate search run before filing: keyword + symbol sweep over open issues (SchemaRenderer / node-level visible / data.* / adapter / #5330 follow-ups) found nothing.

Facts (read + measured on origin/main 3b147a3)

  • packages/react/src/SchemaRenderer.tsx hoists every properties.* value onto the node, so properties.visible arrives at the node-visibility chain as schema.visible and is evaluated there.
  • That chain's evaluator is built with data: dataSource — the data-source ADAPTER from SchemaRendererContext, defaulting to the module constant NO_DATA_SOURCE = {} when no provider is mounted. The docblock states this binding deliberately: data is what ${data.total} in a props bag resolves against, and the row must not overwrite it.
  • Consequence: an author who writes properties: { visible: "data.status == 'draft'" } on any block gets data.status resolved against the adapter, not the row. It does not throw, so it reads as a real verdict: undefined == 'draft' is a constant false, and the block is hidden on every row.
  • Silent in both senses. The dev-only "unresolvable predicate" reporter added by Component-level visibleWhen binds no record on record pages — every record.* predicate fails soft to permanently visible, on every block #5454 keys off a throw; this path never throws, so nothing is printed. And the failure direction is hidden-rather-than-shown, which looks like the author's gate working.

Why it is worth a card rather than a fix-in-place

#5330 ruled B (maintainer, 2026-08-20): canon is record.*, and the row-action shorthand plus legacy data.* are deprecated but kept behind a survey-sized window. usePredicateRecordContext implements exactly that at the useCondition tier. Whether the ruling's reach extends to the node tier is a genuine contract question rather than a mechanical fix, because at that tier data already means something else and something documented:

Reproduction

packages/plugin-detail/src/renderers/__tests__/record-alert.rowBinding.test.tsx (added by #4807) is the standing evidence: its data.* leg has to hold the node gate open with a declared visibleWhen: cel('true') before the row-tier binding is observable at all, and the file's header states why. Dropping that isolation is the reproduction — the block goes hidden on both rows.

Scope note

record:alert was fixed at its own tier by #4807 and is not affected by whatever is decided here for the canonical record.* spelling; only the deprecated data.* spelling is, and only through the node gate. This issue is filed unassigned for triage.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 22, 2026
  2. os-sales commented on Aug 22, 2026

    @os-sales
    CollaboratorAuthor

    Cross-reference — this finding is adjacent to #5401 / #5454, and the two must not be conflated.

    This card records that SchemaRenderer's node visibility gate resolves data.* against the data-source adapter. #5401 was re-ruled on 2026-08-22 to option B — bind record into that same node-level evaluator so visibleWhen means what packages/spec/src/ui/page.zod.ts:189 advertises — and is now a child of umbrella #5454.

    Same evaluator, two different unbound/misbound identifiers:

    resolves against card
    data.* the data-source adapter #5687 (this card)
    record.* not bound at node level #5401 → #5454

    Consequence for whoever picks either up: a sweep for one bucket will surface the other if it is written loosely, and the two have different correct fixes. #5401's survey (dispatched now, round 16, survey-only per the ruling's ordering constraint) is instructed to label each site by bucket for exactly this reason. Don't let a data.* site be counted as evidence for the record.* binding change, or vice versa.


    Generated by Claude Code

  3. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Concentration-round first-touch (triage seat): finding → decision inbox (needs-user-decision) — this is an instance the #5330 ruling (2026-08-20, option B) did not explicitly cover: at the node tier, data already means the adapter by documented design, so extending the row-binding there collides with a documented interpolation contract. That reach question belongs to the ruling's owner.

    [facets-block]

    • 实际业务需求:作者写 properties.visible: "data.status == 'draft'" 得到「所有行全部隐藏」且零信号——失败方向伪装成门在工作;但注意这只影响已废弃的 data.* 拼写,canon record.* 与 finding: record:alert 是唯一没接 #4075/#4077 行绑定的谓词面 —— 裸 { record } 作用域下 row-action 简写与 data.* 拼法 unbound,fail-soft 使作者门控的横幅永远显示 #4807 修好的行级门不受影响。
    • 项目长远合理性:节点层 data = adapter 是写进 docblock 的既有契约(${data.total} 插值依赖它);②(把行绑定伸到节点层)正是 docblock 点名的「没人要求的静默插值变更」;①+响亮化让废弃窗口自然关闭。
    • 防 AI 写代码犯错:废弃拼写在一个层生效、另一个层恒假,是最难教对的形状;响亮化(报告而非改判)把静默错误变成可见错误,不动任何契约。
    • 创业阶段不扩散需求:①+loud 最小——一个检测点(节点门上的 data.* 谓词发 dev 报告),零语义变更;②要动两个已文档化契约。

    一句话问题:#5330 的行绑定裁决是否延伸到节点可见性层(那里 data 已按文档指 adapter)——还是维持节点层语义、只把这条废弃拼写的恒假结果变响亮?

    推荐 A = 维持节点层语义 + 响亮化(#5454 对 throw 路径的同款处理延伸到这条不 throw 的路径:节点门谓词引用 data. 且解析出 undefined 比较时发 dev-only 报告),置信度中高;废弃窗口(#5330 的 survey window)照原计划关闭时本卡随之消亡。本分析看不见什么:真实元数据里节点层 data.* 谓词的存量(#5330 的 survey 半径是否已覆盖节点层写法)。裁决格式:回「A」/「B」(B = 行绑定伸到节点层,含插值变更)。


    Generated by Claude Code

  4. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling recorded — A: the node tier keeps its documented data = adapter semantics; the deprecated spelling's constant-false gets loud

    Provenance: maintainer, 2026-08-22, live session with the triage seat (「同意所有」, item 16 = A).

    State: needs-user-decision → pm:queue (ui seat). Scope: no verdict changes and no interpolation changes — the #5330 row binding does not extend to the node tier (the docblock's "silent interpolation change nobody asked for" stays refused); instead, extend the #5454 reporter posture to this non-throwing path: a node-gate predicate referencing data. that resolves an undefined comparison emits the dev-only unresolvable-predicate report, so the author sees the constant-false instead of shipping it. The card dissolves when #5330's deprecation window closes. Pin: the report fires on the card's reproduction shape; canonical record.* and genuine adapter reads (${data.total}) stay silent.


    Generated by Claude Code

  5. self-assigned this
    on Aug 23, 2026
  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Claim: domain:ui execution seat (seat post #5560), round 21, slot 3 of 3.

    • Session: session_01EuPCi56cnGyykygi3z9w4m
    • Branch: claude/issue-5687-node-gate-data-predicate-report
    • Worktree: dedicated, off origin/main at 3ece13e33
    • Domain: domain:ui · Tier: claude-opus-5 (lane default)
    • Clause-②: no. The ruling is explicit that no verdict changes and no interpolation changes — a dev-only diagnostic report is added to an existing non-throwing path. Nothing an author writes changes accept or reject status, and no published surface widens. If your implementation finds itself needing to change what the gate decides, you have left the ruling; stop and report.

    File surface granted (write):

    packages/react/src/SchemaRenderer.tsx
    packages/react/src/**                  (only the #5454 reporter, wherever it lives)
    packages/react/src/__tests__/**
    .changeset/*
    

    Report the reporter's path before editing if it falls outside packages/react/**.

    Serial constraints: ⛔ packages/auth/** held by #5719 (slot 1); ⛔ packages/types/** and packages/providers/** held by #5716 (slot 2). ⛔ packages/app-shell/src/hooks/useConsoleActionRuntime.tsx remains held by #5702's unlanded branch.

    ⛔ The card's own cross-reference is a fence, not a footnote. #5687 (this card) is about data.* resolving against the data-source ADAPTER. #5401 → umbrella #5454 is about record.* not being bound at node level. Same evaluator, two different misbound identifiers, different correct fixes. A sweep for one will surface the other if it is written loosely. ⛔ Do not let a data.* site count as evidence for the record.* binding change, or vice versa — and do not fix the other one here.

    Premise to re-read, not to trust: the card measured on 3b147a367; base is now 3ece13e33, many merges later. SchemaRenderer.tsx hoists properties.* onto the node so properties.visible arrives as schema.visible, and the node-visibility evaluator is built with data: dataSource (the adapter, defaulting to NO_DATA_SOURCE = {}). Verify both against your worktree; if the hoist or the binding has moved, that is the report, not a detail.


    Generated by Claude Code

  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 5687,
      "status": "done",
      "branch": "claude/issue-5687-node-gate-data-predicate-report",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5757",
      "premise_still_valid": true,
      "summary": "Ruling A implemented: the node tier keeps its documented `data` = adapter semantics, and the deprecated spelling's constant-false got loud. `reportAdapterOnlyDataPredicate()` was added beside the #5454 reporter in packages/react/src/utils/visibilityDiagnostic.ts, sharing its module, dedupe Set, reset, console.warn severity and __DEV__ gate; SchemaRenderer calls it once, on the NON-throwing branch only, and returns the evaluator's verdict exactly as computed, so 'no verdict changes' holds by construction. Trigger is a `data.*` read the bound adapter answers with `undefined` — chosen over three alternatives by measurement, not argument: 'references data.' fires on genuine adapter reads (refused by the ruling) and 'the predicate is constant-false' fires on `data.total > 100` against {total:99} AND on a correct `record.status` gate against a non-matching row, i.e. it would report every correctly-hiding gate in the repo. All three of the card's load-bearing facts re-measured on 3ece13e33: the hoist holds, `data: dataSource` holds, and the third is REFINED — the CEL-envelope dialect already throws and is already reported by #5454 today, so the card's 'this path never throws' is true for the bare-string and template dialects only. The card's repro shape is the bare string, so the gap is real and the premise stands.",
      "tests": "Union run on 06561bde3 (the final commit, after rebasing onto the origin/main that moved mid-task). `pnpm exec vitest run packages/react/` => 'Test Files 52 passed (52) / Tests 706 passed (706)'; `pnpm exec vitest list packages/react/` collects 706 cases, 18 of them the new file's (the default reporter names no passing file, so inclusion was read from vitest's own collection rather than assumed — a `--reporter=basic` attempt failed to start and my control caught it as a void reading, not a green one). `pnpm exec vitest run packages/react/ packages/components/ packages/plugin-grid/ packages/types/` => 363 files, 3635 tests, 0 failures. `pnpm --filter @object-ui/react type-check` exit 0. `pnpm --filter @object-ui/react lint` => the tool's own line '347 problems (0 errors, 347 warnings)'; all pre-existing no-explicit-any, none inside either of this diff's two hunks (warning line numbers compared against hunk ranges). Gates, each quoted from its own verdict line, all exit 0 with the exit code captured BEFORE any pipe: check-control-bytes 'OK (scanned 4801 tracked text file(s))', check-changeset-presence 'declares 1 changeset(s)', changeset-fixed, changeset-no-major, lint-coverage '46/46 packages linted, 0 with outstanding errors', type-check-coverage, phantom-deps, self-import, esm-specifiers, spec-symbols, action-forward-parity. check-eager-closure fails with its own 'This is a broken gauge, not a passing budget' (known worktree gauge); it cannot move regardless — the module specifiers imported by SchemaRenderer.tsx are byte-identical before/after (10 vs 10, empty diff), since the change widens an existing named import rather than adding an edge. ABLATION, both legs under `trap ... EXIT INT TERM`, both mutations confirmed on disk by anchored counts in BOTH directions (deleted text at 0, injected marker at 1) before any test output was read. No rebuild was needed and that is stated as a condition, not an omission: the mutated subject is imported by the tests via a relative SOURCE path (`../SchemaRenderer`), not through a package exports/dist edge, so no stale artifact can make a mutation read green. A = delete the reporter call: predicted red exactly the positive cases and green every silence/verdict case; observed 6 red / 32 green with the whole #5454 pin file green. B = delete the `unresolved.length === 0` silence guard: predicted red the silence cases that actually reach the reporter; observed 5 red (2b, 2c, 2d, 3c, 3d), group 1 and production untouched. B EARNED ITS KEEP: case 3c survived it on the first run, and the measured cause was that with `metadata` unbound the predicate THROWS ('metadata is not defined') and lands in the #5454 catch, never reaching this leg — green for a reason unrelated to the boundary it claimed to check. `metadata` is now bound in that case and it dies under B as it should. Controls that correctly SURVIVE both ablations, with reasons: 3a/3b (record.*) — neither mutation touches a path with no `data.` root, which is precisely the evidence that the two adjacent cards do not bleed; 3e (CEL envelope) — it throws first, so it is #5454's case in both worlds; group 4 (production) — __DEV__ short-circuits ahead of both mutations. DECLARED NARROWING: packages/app-shell and apps/console exceed the 10-minute foreground cap as whole suites (measured: exit 124 at 560s), so the run was narrowed to the real blast surface, every file carrying a `data.*` visibility predicate — packages/app-shell/src/views/metadata-admin/ plus apps/console/src/components/FormPage.fieldSpec.test.ts => 195 files, 1979 passed, 1 skipped. The residual risk of a new console.warn is a suite asserting exact console call counts; the vitest config declares no onConsoleLog / failOnConsole (grep exit 1, controlled against a term known to be in that file), so an unexpected warn cannot fail a suite by configuration. CI runs the full farm regardless. INSTRUMENT LIMITS, stated: the `data.*` scan is LEXICAL, not a parse — string literals are stripped first (so `record.status == 'data.status'` reads nothing), but it cannot tell a `data.` reference inside a comment or an unusual quoting form from a real read, and it resolves paths by the same property walk `useDataScope` already uses, so it inherits that resolution's semantics exactly. It also cannot see a `${...}` predicate written inside `properties`, which is pinned as an explicit known limit and filed as #5756 rather than papered over.",
      "open_questions": [
        {
          "question": "The ruling says this path 'emits the dev-only unresolvable-predicate report'. I shared the reporter's module, severity, dedupe Set, lifecycle and dev-only gate verbatim, but gave this leg its OWN first line (ADAPTER_ONLY_DATA_PREDICATE_PREFIX) instead of reusing 'A visibility predicate could not be evaluated'. Was the literal string meant, or the reporter?",
          "options": [
            "A. Keep the sibling prefix (shipped). On this path the #5454 sentence would be FALSE — the predicate evaluated perfectly, against the wrong object, and produced a constant; telling an author 'could not be evaluated' sends them hunting for a syntax error that is not there.",
            "B. Reuse UNRESOLVABLE_VISIBILITY_PREFIX verbatim, so one grep and one console filter catch both legs, at the cost of a first line that misstates what happened."
          ],
          "recommendation": "A, because dispatch guidance was explicit that reporter POSTURE matters more than message text, and posture is shared verbatim — one module, one dedupe Set, one reset, one severity, one gate. This is the single place the flexibility was spent, and it is flagged in the PR body too. If B was meant it is a one-line change plus two test string updates."
        }
      ],
      "out_of_scope_findings": [
        "filed as #5756: a `${...}` visibility predicate written INSIDE `properties` is interpolated to a boolean before the hoist (measured: evaluate(\"${data.status == 'draft'}\") returns false, while the bare string returns itself verbatim), so it reaches the visibility chain as a plain `false` and NO gate-side diagnostic — this one or #5454's — can ever see it; it hides the block on every row, silently. Pinned in this PR as an explicit known limit. Not fixed here: reaching it means adding a diagnostic inside the interpolation loop, the exact surface this ruling fences off, and it carries two real choices (whether to report a leg that a co-declared `visibleWhen` outranks, and keying on the visibility-key set so genuine `${data.total}` content interpolations stay silent).",
        "measured, no issue needed — the triage analysis's stated blind spot is now answered: node-tier `data.*` visibility predicates in non-test source are ZERO across examples/, apps/, content/ and packages/. The only two real `data.*` visibility predicates in the repo (packages/app-shell/src/views/metadata-admin/anchors.ts) sit at the SchemaForm FIELD tier, where evaluatePredicate(visibility, { data: value }) binds `data` to the form draft values — a correct, different binding on a different evaluator that this change cannot reach. Controls for those zeros: the same shape spelled `record.*` = 246 hits, the visibility keys at all = 1273 hits, `data.` anywhere = 744 hits, and the sweep demonstrably enters examples/schema-catalog (5 visibility-key hits there, all record.* / current_user.*). Nobody is standing in the blast radius, which is what #5330's deprecation-window owner needs to know."
      ]
    }

    Generated by Claude Code

  8. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    ACCEPT — landing. PR #5757.

    Gates by name at 05:05Z (PR started 04:54:04Z): 19 success + 3 skipped = 22. Zero in_progress, zero failure. (Build Docs 10 s — the path-filtered skip.)

    Fence: clean. Four files, all inside packages/react/** and .changeset/. No governed path. Neither sibling's held surface touched.

    The seat's answer to the open question: A — keep the distinct first line. Shipped as-is.

    Three reasons, in order of weight:

    1. The ruling names the report, not a string. "emits the dev-only unresolvable-predicate report" identifies the reporter. What had to be shared was its posture, and it is shared verbatim: one module, one dedupe Set, one reset, one severity, one __DEV__ gate.
    2. The dispatch said so explicitly. My brief: "Matching it exactly matters more than the message text: two adjacent reporters with different lifecycles in one render path is a worse outcome than one slightly awkward message." Lifecycle was the named constraint and it is satisfied. Message text was the named flexibility, and this is the one place it was spent.
    3. The decisive one is substantive: on this path "could not be evaluated" is false. The predicate evaluated perfectly — against the wrong object — and produced a constant. A first line that says otherwise sends the author hunting a syntax error that does not exist. That is the same class of harm as the silence this card exists to remove, and the same lesson components: element:text_input's label / placeholder / description are the INVERSE of #5637 — the renderer already resolves the locale map, the declaration still says 'string', so the gate warns on a legal write #5717 landed two hours ago: a diagnostic that misdescribes a legal write trains authors to dismiss the reports that are real.

    B's benefit (one grep catches both legs) survives anyway — both prefixes live in one module.

    The trigger was chosen by measurement, and the falsifications are the valuable part

    Zone 2b asked for a definition before an implementation, because a trigger that fires on legitimate adapter reads is worse than no report. Three candidates were measured and two are refuted by counterexample:

    • "references data." — fires on genuine ${data.total} adapter reads. Refused by the ruling itself.
    • "the predicate is constant-false" — fires on data.total > 100 against {total: 99}, and on a correct record.status gate against a non-matching row. That is not a near-miss: it would report every correctly-hiding gate in the repository.
    • Shipped: a data.* read the bound adapter answers with undefined.

    That second refutation is the one worth carrying — the obvious trigger would have turned a silence bug into a noise bug across the whole repo.

    The card's third fact was refined, not just re-verified

    The card says the node-gate path "never throws". Measured: the CEL-envelope dialect already throws and is already reported by #5454 today, so that claim holds for the bare-string and template dialects only. The card's repro is the bare string, so the gap is real and the premise stands — but the boundary is now stated correctly rather than inherited.

    Ablation B earned its keep by catching a bad survival

    Case 3c survived ablation B on the first run. The measured cause: with metadata unbound the predicate throws and lands in #5454's catch, so it never reached the leg it claimed to test — green for a reason unrelated to the boundary it asserted. metadata is now bound and it dies under B as it should. That is the discipline working in the direction people usually skip: a survivor was interrogated rather than credited.

    The controls that correctly survive both legs are each given a reason: 3a/3b (record.*) survive because neither mutation touches a path with no data. root — which is itself the evidence that the two adjacent cards do not bleed into each other, the exact separation the card's cross-reference demanded. 3e throws first, so it is #5454's case in both worlds. Group 4 is short-circuited by __DEV__.

    The triage blind spot is answered, and the answer matters to #5330

    Triage could not see "真实元数据里节点层 data.* 谓词的存量". Measured: node-tier data.* visibility predicates in non-test source are ZERO across examples/, apps/, content/ and packages/. Four controls make that a reading rather than a broken sweep — record.* at 246, visibility keys at 1273, data. anywhere at 744, and the sweep demonstrably entering examples/schema-catalog (5 hits there, all record.* / current_user.*).

    The only two real data.* visibility predicates in the repo sit at the SchemaForm field tier (anchors.ts), where evaluatePredicate(visibility, { data: value }) binds data to the form draft — a correct, different binding on a different evaluator this change cannot reach. Nobody is standing in the blast radius, which is precisely what #5330's deprecation-window owner needs in order to close it.

    Instrument limits stated, and one of them became a card

    The data.* scan is lexical, not a parse: string literals are stripped first (so record.status == 'data.status' reads nothing), but it cannot tell a data. reference in a comment from a real read, and it inherits useDataScope's resolution semantics exactly. And it cannot see a ${…} predicate written inside properties — measured: evaluate("${data.status == 'draft'}") returns false while the bare string returns itself verbatim, so such a predicate reaches the visibility chain as a plain false that no gate-side diagnostic, this one or #5454's, can ever see. Pinned in this PR as an explicit known limit and filed as #5756 rather than papered over — correctly, since reaching it means adding a diagnostic inside the interpolation loop, the exact surface this ruling fences off.

    Also correct: the declared narrowing on the app-shell/console suites (measured exit 124 at 560 s) with the run re-aimed at the real blast surface, and the residual risk named and then measured away — the vitest config declares no onConsoleLog / failOnConsole, so a new console.warn cannot fail a suite by configuration. The zero for that grep was controlled against a term known to be in the file.

    Flipping ready → auto-merge (SQUASH). Landing verification against origin/main follows.


    Generated by Claude Code

  9. added a commit that references this issue on Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions