From 9e9160da1d976982c34ec5634f5f8e8aaa880d02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:58:10 +0000 Subject: [PATCH] docs: correct the `visibleWhen` binding claims per surface, not in bulk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#6010 bound the host predicate scope on the five authored-predicate call sites in objectui's form renderer, so `current_user` (plus the ADR-0068 `user` / `ctx.user` / `os.user` aliases) now resolves on a runtime form FIELD `visibleWhen` / `visibleOn` the way it already did on the page/app-nav node gate and per-option `visibleWhen`. The two hand-written pages that documented the old asymmetry are corrected to the measured binding. Measured per claim rather than swept: the FIELD half moved, the SECTION half did not. `FormSection.visibleWhen` is read by exactly one runtime evaluator in objectui — the console's second form renderer — which still passes `undefined` for the scope, and the object-view chain (ObjectForm / ModalForm / DrawerForm / SplitForm) drops the key onto a `section-divider` pseudo-field that carries no predicate at all. So the binding table's one form row becomes two, and the section row keeps the claim it still earns. Two renderer caveats are stated where the binding is, because promising a binding a live surface does not honour is the failure this page keeps hitting: the console's standalone form routes (`/forms/:name`, public `/f/:slug`) evaluate field predicates unbound (objectui#6110), and section predicates are inert on the object-view chain (objectui#6111). The position-gated example moves to the canonical `{ dialect: 'cel' }` envelope via `P`. A bare string carrying the CEL membership operator is normalized by spec parse for authored metadata, but a schema handed straight to the renderer keeps the bare string, which objectui routes to its legacy evaluator — no `in` operator, rejected, then fail-open (objectui#2661 keeps that routing). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- content/docs/protocol/objectui/layout-dsl.mdx | 46 +++++++++++++++---- content/docs/ui/views.mdx | 2 +- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 0b2e0c53fc..9b3901843b 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -804,7 +804,8 @@ expression evaluates truthy. // e.g. on a PageComponent — `record` and `current_user` are both bound: visibleWhen: "record.account_type == 'premium'" -// e.g. on a FormSection / FormField — `record` is bound, `current_user` is NOT: +// e.g. on a view FormField — `record`, `previous` and (since objectui#6010) +// `current_user` are bound; on a FormSection, `current_user` is not: visibleWhen: "record.status != 'closed'" ``` @@ -813,9 +814,19 @@ The predicate's **binding root** is set by the layer, not the key: | Layer | Predicate binds | |---|---| | Page components (`*.page.ts`) | `record` + `current_user` (plus `page.`) | -| Runtime record form sections/fields (`*.view.ts`) | `record` + `previous` — **not** `current_user` | +| Runtime record form **fields** (`*.view.ts`) | `record` + `previous` + `current_user` (objectui#6010) | +| Runtime record form **sections** (`*.view.ts`) | `record` + `previous` — **not** `current_user` | | Metadata-editing forms (`*.form.ts`) | `data` — the row under edit | +The two form rows were one row until objectui#6010 bound the host predicate scope +on the form renderer's authored-predicate call sites — only the field half moved. +Two measured caveats travel with them: a **field** predicate is still evaluated +with `current_user` unbound on the console's standalone form routes +(`/forms/:name`, and the public `/f/:slug`), which run a second form renderer +(objectui#6110); and an authored **section** predicate is read by that second +renderer alone, because the object-view chain drops the key before any evaluator +sees it (objectui#6111). + The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated` aliases: still accepted and folded into `visibleWhen` at parse time, so existing metadata keeps working. Author new metadata with `visibleWhen`. @@ -844,15 +855,32 @@ aliases: {/* os:check */} ```typescript -// On a PageComponent, an app/nav entry, or a per-option `visibleWhen`: -visibleWhen: "'sales_manager' in current_user.positions" +import { P } from '@objectstack/spec'; + +// On a PageComponent, an app/nav entry, a per-option `visibleWhen`, or a view +// form field. `P` emits the canonical `{ dialect: 'cel' }` envelope: +visibleWhen: P`'sales_manager' in current_user.positions` ``` -Two limits come with it. **First, `current_user` is not bound on form sections and -form fields** — those predicates evaluate against `record` (plus `previous`) only, -so the expression above names an unbound root there instead of gating anything. -**Second, `visibleWhen` is presentation, not access control**: it decides what a -client draws from data it already holds. To stop someone from *reading* something, +Write the predicate as a `P` envelope rather than a bare string wherever the +schema does not go through spec parse. Authored metadata is normalized for you — +`ExpressionInputSchema` turns a bare string into `{ dialect: 'cel', source }` at +parse time — but a component tree handed straight to the renderer keeps the bare +string, and objectui routes bare strings to its legacy expression evaluator, +which has no `in` operator: the membership test above is rejected there +(`Unexpected token "i" at position 16`) and then fails open. That routing is +deliberate and documented (objectui#2661); the envelope is what makes one +predicate text mean one thing on every surface. + +Two limits come with it. **First, the binding is per surface, not per key.** A +view form **field** predicate binds this scope since objectui#6010; a form +**section** predicate does not (objectui#6111), and neither does an object-level +field rule (`Field.*({ visibleWhen })`, ADR-0036) — that one is evaluated by the +server as well as by the client, binds `record` (plus `previous`, `parent`) only, +and naming `current_user` there is refused by `@objectstack/lint` at build time +rather than faulting at runtime. **Second, `visibleWhen` is presentation, not +access control**: it decides what a client draws from data it already holds. To +stop someone from *reading* something, use the permission layer — [field-level security](/docs/permissions/field-level-security) and [permission sets](/docs/permissions/permission-metadata), or [row-level security](/docs/permissions/rls), whose `using` clause accepts the very diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 1de3ae5f71..2acebdacce 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -430,7 +430,7 @@ fields: [ | `colSpan` | `1-4` | Legacy absolute column span — prefer `span` | | `widget` | `string` | Custom widget/component name | | `dependsOn` | `string` | Parent field for cascading | -| `visibleWhen` | `string` | Visibility predicate (CEL); runtime forms bind `record` (+ `previous`, `parent`) — **not** `current_user`, which is unbound at field level and would fault the predicate open (was `visibleOn`, ADR-0089) | +| `visibleWhen` | `string` | Visibility predicate (CEL); runtime form fields bind `record` (+ `previous`, `parent`) and, since objectui#6010, `current_user` — the identity scope page components and per-option predicates already bound (ADR-0089 D1). Two surfaces still evaluate it unbound, where the predicate faults open: the console's standalone form routes `/forms/:name` and `/f/:slug` (objectui#6110), and section-level predicates (objectui#6111). (was `visibleOn`, ADR-0089) | ## Complete Example