Skip to content

docs(objectui): repair the section-tier and action-tier visibility examples on the two protocol pages - #13162

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-13087-section-action-visible
Aug 29, 2026
Merged

docs(objectui): repair the section-tier and action-tier visibility examples on the two protocol pages#13162
os-elon merged 2 commits into
mainfrom
claude/issue-13087-section-action-visible

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#13087

Two files, two different defects, repaired with separate reasoning. The card's own framing is the load-bearing part and this PR keeps it: four occurrences of one key name, at two tiers, with two mechanisms.

Tier A — form section: the key is refused by name

content/docs/protocol/objectui/layout-dsl.mdx, "Conditional Sections".

FormSectionSchema is strictObject and declares visibleWhen plus the @deprecated alias visibleOn — never visible. Measured against the schema, not read off the docs:

FormSectionSchema.safeParse({ label: 'Billing Information',
visible: { field: 'account_type', value: 'premium' }, fields: [...] })
-> success = false
-> { code: 'unrecognized_keys', keys: ['visible'],
message: 'Unrecognized key(s) on this form section: `visible`.
If this is the conditional-visibility predicate, the canonical key is
`visibleWhen` (ADR-0089) ...' }

Example 1 (account_type / premium) is translated, to the spelling this same page already carries 440 lines below at "Visibility Rule". That was the card's point: the page contradicted itself and the wrong half came first. The replacement is byte-for-byte the page's own canonical form, so the page now has one spelling of this predicate rather than three. It parses:

FormSectionSchema.safeParse({ label: 'Billing Information',
visibleWhen: "record.account_type == 'premium'",
fields: ['payment_method', 'billing_address'] })
-> success = true
-> visibleWhen normalized to { dialect: 'cel', source: "record.account_type == 'premium'" }

Example 2 (permission: admin) is removed, not translated — the documentation judgment the card reserved. Rewriting it as a visibleWhen position test would teach the anti-pattern the schema's own description warns about, in both directions:

  1. Nothing server-side evaluates a form-view section predicate, so the test hides controls and protects no data — the record still carries every value and every other read surface still returns them.
  2. On the console's public form route (/f/:slug) no host publishes a predicate scope, so the root is unbound, the predicate faults open, and the section it was meant to hide is shown to everyone.

In its place the section gets a Callout that records both facts and points at field-level security and row-level security — the same "removed rather than implemented" pattern this page already uses twice for other retired shapes, so the residue is preserved instead of silently vanishing. The intro sentence lost "or permissions" with it: that half of the sentence was itself the wrong claim.

Tier B — action: the key is live, the VALUE is wrong

content/docs/protocol/objectui/concept.mdx, "2. Contextual Actions". A genuinely different defect, which is why it is reasoned separately.

Action.visible and Action.disabled are both ActionConditionInputSchema = boolean, or a CEL string, or a { dialect, source } envelope (packages/spec/src/ui/action.zod.ts). The key is declared; a filter object is in none of the three arms:

ActionSchema.safeParse({ ..., visible: { status: { $ne: 'locked' } } })
-> success = false -> { code: 'invalid_union', path: ['visible'] }
ActionSchema.safeParse({ ..., visible: { permissions: { canApprove: true } } })
-> success = false -> { code: 'invalid_union', path: ['visible'] }

Note the discriminator against tier A: invalid_union on the value, versus unrecognized_keys on the key. Two mechanisms, measured.

The polarity trap here is the opposite of the field/section one, and it changes the answer. The card asked whether the has() guard that #12935 needed also applies at this tier. It does — for a different reason, with the opposite failure direction. An action predicate evaluates against whatever record the client already fetched, so on list_item an unprojected column is absent, not null, and CEL aborts at key resolution. That abort is fail-closed: the button is simply not offered, which looks exactly like the predicate having said no, and nothing logs it. (A form field or section predicate faults open.) So the replacement carries the guard the sibling page's own table prescribes for an equality comparison — has() alone:

visible: "has(record.status) && record.status != 'locked'" -> parses, normalizes to { dialect:'cel', source }
disabled: "has(record.has_children) && record.has_children == true" -> parses

The approve action is not given a visible predicate at all. Its intent was a permission gate, and the action tier has a real, server-enforced key for that — so translating it into a CEL predicate over some permissions map would repeat tier A's anti-pattern one tier up. The schema's own doc comment on visible says so:

Client-side hiding is UX, not authorization — the button is gone, the route is not. An action gated for access-control reasons must also be refused server-side (requiredPermissions, or the action's own body).

So it declares requiredPermissions instead: one declaration, dual-surface under ADR-0066 D4 — 403 on the platform action route, and the UI hide derived from the same declaration. Verified: ActionSchema.safeParse({ ..., requiredPermissions: ['approve_invoice'] }) parses. The page already documents this pairing at content/docs/automation/approvals.mdx, so the example now agrees with it.

Bounded in-place fix, declared: the adjacent disabled

disabled: { has_children: true } sits two lines below in the same fence. The card named it ("very likely the same defect and was not separately verified") without measuring it. It is repaired here under the bounded exemption, and all four conditions are stated rather than assumed:

  1. Same defect class — same declared union (ActionConditionInputSchema), same tier, same fence.
  2. Mechanical, with the correct shape already pinned — verified invalid_union on the current value, and the sibling actions page prescribes the exact guard for a truthiness test (rewrite to the equality form).
  3. No other claim holds the file — this card's carve-out covers exactly these two paths.
  4. Same gate family, no new verification surface — the derived family is byte-identical before and after.

Leaving it would have shipped a fence that still taught Mongo query syntax two lines under the correction.

What is NOT fixed here, and why

The same fence carries two further off-spec shapes that are a different class (an undeclared enum value; a key refused by name), so the fence still does not parse end to end after this PR. They are recorded rather than swept in, filed separately as #13159:

  • type: standard_edit / workflow_approve / standard_deleteinvalid_value; the enum is script, url, modal, flow, api, form, and these three strings appear nowhere under packages/. There is no mechanical rename target, so it needs a documentation decision.
  • confirm: trueunrecognized_keys, with the schema itself suggesting confirmText.

Measurements against the PM's stated assumptions

  • The four cited line numbers had NOT moved.docs(objectui): replace the phantom field-level visible key with the keys that exist #13094 did land and did edit both files, but every one of its insertions sits below the cited sites, so layout-dsl.mdx:363,369 and concept.mdx:592,595 still resolved. All four were nevertheless re-located by content, and the anchors used for the edits were read verbatim off disk with a count == 1 assertion — which caught one zero-hit anchor before it could become a silent no-op edit.
  • Population held at four, and is now one. Card instrument, both files, decomposed rather than compared as a bare number: before = 4, all four defects. After = 1, and that one is the legal action-tier use this PR wrote (visible: carrying a CEL string on a key that declares it) — the instrument matches text, so a correct use is a hit. Instrument dual: the same instrument returns 2 on actions.mdx (legal uses) and moved 4 to 1 here, so it is demonstrably able to return both non-zero and a changed reading.
  • Nothing gates either tier — re-confirmed independently.check:skill-examples filters candidate fences to lang === 'ts' || lang === 'typescript' and additionally requires an opt-in {/* os:check */} marker. A yaml fence is never a candidate. These replacements are checked by the schema parses recorded above and by nothing else.

Verification

Schema parses, @objectstack/spec (vitest, source): 12 targeted cases — the four documented shapes refused, all replacements accepted, plus a positive control per tier proving the harness can return success. Run inside the full spec suite: 11728 passed, 1 skipped, 0 failed. The harness was a throwaway file, deleted before the first commit, so it is not in this diff.

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no stale-tree warning; derived at the final commit; the matched family list is identical to the derivation taken at the first commit). 24 of the 25 matched families re-run at final HEAD d872a474e1 — all green. Their own verdict lines, quoted:

  • 260 prose examples type-check across 3 surface(s) (check:skill-examples)
  • 231 generated files in sync with packages/spec (check:docs)
  • plus check:doc-anchors, check:doc-authoring, check:doc-frontmatter, check:doc-route-spelling, check:docs-section-name, check:section-landing-index, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:role-word, check:nul-bytes, check:published-readme-links, check:react-page-adapter-contract, check:cross-package-test-inputs, check:doc-security-posture, check:doc-formula-expressions, check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs, check-ci-filter-parity, check-shard-attestation

The 25th, check-test-completeness.mjs, answers a standalone run with its usage text (exit 1) — a refusal, not a finding (#13110). Four other gates first answered PREREQUISITE NOT MET; they were re-run after building the closure and are counted only from that measured run.

check:role-word caught a real red on the first pass: the new callout used the ADR-0090 D3 reserved word three times. Rewritten to the position / permission-set vocabulary the page already uses, and green on re-run.

Repo-wide pnpm lint is provably a no-op for this diff, measured rather than skipped: ESLint's own answer for both changed paths is File ignored because no matching configuration was supplied (population read from the tool, not assumed); --format json reports 2 files in, 0 errors, and those 2 notices as the only messages; and eslint.config.mjs scopes every files glob to TypeScript/JavaScript extensions, so no untouched file's verdict can move.

Changeset

None — docs-only, and derived rather than asserted. Re-measured on this tree: 69 package.json files declare a files whitelist and zero of them name content or docs; content/ matches no pnpm-workspace.yaml package glob (the globs are packages/*, packages/apps/*, packages/drivers/*, packages/plugins/*, packages/qa/*, packages/triggers/*, packages/services/*, packages/adapters/*, packages/connectors/*, apps/*, examples/*). Nothing here is published, so this PR declares no release of its own and carries skip-changeset.

Generated by Claude Code


Generated by Claude Code

@github-actionsgithub-actionsBot added size/s documentation Improvements or additions to documentation labels Aug 29, 2026
@os-elonos-elon added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed documentation Improvements or additions to documentation labels Aug 29, 2026 — with Claude
@os-elon
os-elon marked this pull request as ready for review August 29, 2026 07:00
@os-elon
os-elon enabled auto-merge August 29, 2026 07:00
@os-elon
os-elon added this pull request to the merge queueAug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33243814538 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/cli:test: FAIL test/serve-publishes-bound-port.e2e.test.ts > #13062 the non-zero half — nothing an ordinary boot publishes may move > follows the DEV AUTO-SHIFT onto the port it really ↳ 失败原因: @objectstack/cli:test: Error: ENOENT: no such file or directory, open '/tmp/os-bound-port-home-PygxoU/runtime.env_local.json'
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 13 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 339f393Aug 29, 2026
38 checks passed
@os-elon
os-elon deleted the claude/issue-13087-section-action-visible branch August 29, 2026 09:10
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-elon@claude