From 643a9aa4c724b16dbc4da04bf0f5adfc964a1f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 16:05:44 +0000 Subject: [PATCH] docs(protocol): teach position-gated visibility with the binding it actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `visibleWhen` example taught `user.hasRole('admin')`. `hasRole` is not a CEL function: it is in no stdlib registry and on no contract, so the predicate faults and — visibility being fail-open — the gate it illustrates shows the element to everyone. Worst possible direction for the feature being demonstrated. Measured before rewriting, since the answer decides the wording: - A `user.*` binding DOES exist. ADR-0068 D1 makes `current_user` canonical with `user` / `ctx.user` aliases, and objectui's ExpressionProvider binds all of them. What does not exist is `hasRole` — `EvalUser` carries `positions: string[]` as data, not methods, and `CEL_STDLIB_FUNCTIONS` (30 entries, drift- guarded) has no such call. - The canonical membership test is `'' in current_user.positions`, live in `content/docs/ui/pages.mdx` and in the showcase example app. - But `current_user` does not reach every layer. Page components, app/nav entries and per-option predicates bind it; form sections and fields do not — all three `resolveFieldRuleState` call sites in objectui's form renderer pass `undefined` for the scope argument, so those predicates see `record` and `previous` only. The example's own label was "on a FormSection / FormField" — the one layer with no user binding at all. So swapping `hasRole` for `positions` in place would have moved it from faulting on a missing method to faulting on an unbound root, still fail-open, still shown to everyone. Instead: the example keeps `record`-only on the form layer, the binding table splits the row that claimed forms bind `current_user`, and a new subsection teaches the supported spelling, names the layers it works on, and points anything that must be enforced at the permission layer rather than at a visibility gate. Vocabulary follows ADR-0090 D3 (`position`, not the retired word), which `check:role-word` enforces. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- content/docs/protocol/objectui/layout-dsl.mdx | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 2426b780a6..73642ff923 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -817,18 +817,19 @@ expression evaluates truthy. {/* os:check */} ```typescript -// e.g. on a PageComponent: +// e.g. on a PageComponent — `record` and `current_user` are both bound: visibleWhen: "record.account_type == 'premium'" -// e.g. on a FormSection / FormField: -visibleWhen: "record.status != 'closed' && user.hasRole('admin')" +// e.g. on a FormSection / FormField — `record` is bound, `current_user` is NOT: +visibleWhen: "record.status != 'closed'" ``` The predicate's **binding root** is set by the layer, not the key: | Layer | Predicate binds | |---|---| -| Runtime record forms & pages (`*.view.ts`, `*.page.ts`) | `record` + `current_user` (pages also expose `page.`) | +| Page components (`*.page.ts`) | `record` + `current_user` (plus `page.`) | +| Runtime record form sections/fields (`*.view.ts`) | `record` + `previous` — **not** `current_user` | | Metadata-editing forms (`*.form.ts`) | `data` — the row under edit | The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated` @@ -846,6 +847,39 @@ Breakpoint-based show/hide is handled separately via the component's `responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see `packages/spec/src/ui/responsive.zod.ts`. +#### Position-gated visibility + +There is **no `hasRole()`**: neither the CEL stdlib nor the +[`EvalUser`](/docs/references/identity/eval-user) contract defines such a call — +`EvalUser` exposes memberships as data, not as methods. Membership is tested +against `current_user.positions`, the canonical `string[]` of +[position](/docs/permissions/positions) names — ADR-0090 D3 retired the older +vocabulary, and ADR-0068 D1 binds the same object under the `user` and `ctx.user` +aliases: + +{/* os:check */} +```typescript +// On a PageComponent, an app/nav entry, or a per-option `visibleWhen`: +visibleWhen: "'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, +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 +same `'…' in current_user.positions` predicate and is enforced on the server. + + +An unresolvable predicate — an unbound root, or a call to a function that does not +exist — is **fail-open**: the renderer logs one warning and falls back to its safe +default, which for visibility is *visible*. A gate that faults does not hide the +element from anyone; it shows it to everyone. + + ## Real-World Examples ### Customer 360 Page