From e6be0209c3d3aa66a13d077c35183a48b81980b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:36:07 +0000 Subject: [PATCH 1/2] docs(objectui): repair section-tier and action-tier visibility examples on the protocol pages --- content/docs/protocol/objectui/concept.mdx | 32 ++++++++++++++--- content/docs/protocol/objectui/layout-dsl.mdx | 35 +++++++++++++------ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/content/docs/protocol/objectui/concept.mdx b/content/docs/protocol/objectui/concept.mdx index 23ab6f53d2..528dfdfa5e 100644 --- a/content/docs/protocol/objectui/concept.mdx +++ b/content/docs/protocol/objectui/concept.mdx @@ -583,22 +583,46 @@ layout: ### 2. Contextual Actions -Actions appear based on state: +Actions appear based on state. `visible` and `disabled` are **CEL predicate strings** +(or a plain boolean, or a `{ dialect, source }` envelope) — never filter objects: ```yaml actions: - type: standard_edit label: Edit - visible: { status: { $ne: 'locked' } } # Hide if locked + visible: "has(record.status) && record.status != 'locked'" # Hide while locked - type: workflow_approve label: Approve - visible: { permissions: { canApprove: true } } # Hide if no permission + requiredPermissions: [approve_invoice] # Hidden in the UI AND refused with 403 - type: standard_delete label: Delete confirm: true # Require confirmation - disabled: { has_children: true } # Disable if has child records + disabled: "has(record.has_children) && record.has_children == true" # Disable if has child records ``` + + **Two rules turn the obvious spelling of these into bugs.** + + **1. The predicate is bare CEL, and the bound record is sparse — guard every read with** + **`has()`.** A filter object (`{ status: { $ne: 'locked' } }` — Mongo query syntax) is in + none of the three arms `visible`/`disabled` declare, so it is a parse error. And the + record an action predicate sees is whatever the client already fetched: on `list_item` + that is only what the view's `$select` projected, so an unprojected column is *absent*, + not `null`, and CEL aborts the whole expression at key resolution. Here the abort is + **fail-closed** — the button is simply not offered, which looks exactly like the + predicate having said no, and nothing logs it. (Note the polarity is the opposite of a + form field or section predicate, which faults *open*.) See + [Actions → Visibility & Disabled Rules](/docs/protocol/objectui/actions#visibility--disabled-rules) + for the guard to write per predicate shape. + + **2. Hiding a button is UX, not authorization** — the button is gone, the route is not. + That is why the approve action above declares `requiredPermissions` instead of a + `visible` predicate over some `permissions` map: under [ADR-0066](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0066-unified-authorization-model.md) D4 that one + declaration is dual-surface — the platform action route answers **403** when the caller + lacks the capability (the source of truth) and the UI hides the button from the same + declaration. A `visible` predicate would only do the second half. + + ### 3. Conditional Field Visibility Fields appear based on other field values. The key is **`visibleWhen`**, and its value diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 20f618e1e5..4c8f900a34 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -352,25 +352,40 @@ sections: ### Conditional Sections -Show sections based on field values or permissions: +Show sections based on field values. The key is **`visibleWhen`** and its value is a +**CEL predicate string** — never a `{ field, value }` rule object: ```yaml sections: - label: Basic Info fields: [name, email] - + - label: Billing Information - visible: - field: account_type - value: premium # Only show for premium accounts + visibleWhen: "record.account_type == 'premium'" # Only show for premium accounts fields: [payment_method, billing_address] - - - label: Admin Settings - visible: - permission: admin # Only show to admins - fields: [api_key, rate_limit] ``` + + **There is no section-level `visible` key — and no permission test belongs here.** + `FormSectionSchema` is `.strict()` and declares `visibleWhen` (plus the `@deprecated` + alias `visibleOn`); `visible` is refused *by name*, before any value shape is examined, + and the rejection points you back at `visibleWhen`. Neither a `{ field, value }` rule + object nor a `{ permission }` name is a value `visibleWhen` accepts either — it takes a + CEL predicate string, normalized to a `{ dialect: 'cel', source }` envelope at parse. + + Earlier revisions of this page also showed an `Admin Settings` section gated on + `permission: admin`. It is **removed rather than translated**, because rewriting it as a + `visibleWhen` role test would teach the anti-pattern this page warns about further down + — twice over. Nothing server-side evaluates a form-view section predicate, so a role + test here hides the controls and protects no data: the record still carries every value + in the section, and every other read surface still returns them. Worse, 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. To withhold fields by role, declare + [field-level security](/docs/permissions/field-level-security) on a permission set, or + [row-level security](/docs/permissions/rls) — both enforced on the server. + + ### Section Variants ```yaml From d872a474e17fc823e4c32863f99d1ddd52514baf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:41:05 +0000 Subject: [PATCH 2/2] docs(objectui): avoid the ADR-0090 D3 reserved word in the new section-visibility callout --- content/docs/protocol/objectui/layout-dsl.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 4c8f900a34..d75996606c 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -375,13 +375,13 @@ sections: Earlier revisions of this page also showed an `Admin Settings` section gated on `permission: admin`. It is **removed rather than translated**, because rewriting it as a - `visibleWhen` role test would teach the anti-pattern this page warns about further down - — twice over. Nothing server-side evaluates a form-view section predicate, so a role - test here hides the controls and protects no data: the record still carries every value - in the section, and every other read surface still returns them. Worse, 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. To withhold fields by role, declare + `visibleWhen` position test would teach the anti-pattern this page warns about further + down — twice over. Nothing server-side evaluates a form-view section predicate, so a + position test here hides the controls and protects no data: the record still carries + every value in the section, and every other read surface still returns them. Worse, 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. To withhold a group of fields from some users, declare [field-level security](/docs/permissions/field-level-security) on a permission set, or [row-level security](/docs/permissions/rls) — both enforced on the server.