Skip to content

Commit b36a6d7

Browse files
claude[bot]claude
andauthored
docs(protocol): teach position-gated visibility with the binding it actually has (#11413)
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 `'<name>' 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. Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35c1ca3 commit b36a6d7

1 file changed

Lines changed: 38 additions & 4 deletions

File tree

content/docs/protocol/objectui/layout-dsl.mdx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -817,18 +817,19 @@ expression evaluates truthy.
817817

818818
{/* os:check */}
819819
```typescript
820-
// e.g. on a PageComponent:
820+
// e.g. on a PageComponent — `record` and `current_user` are both bound:
821821
visibleWhen: "record.account_type == 'premium'"
822822

823-
// e.g. on a FormSection / FormField:
824-
visibleWhen: "record.status != 'closed' && user.hasRole('admin')"
823+
// e.g. on a FormSection / FormField — `record` is bound, `current_user` is NOT:
824+
visibleWhen: "record.status != 'closed'"
825825
```
826826

827827
The predicate's **binding root** is set by the layer, not the key:
828828

829829
| Layer | Predicate binds |
830830
|---|---|
831-
| Runtime record forms & pages (`*.view.ts`, `*.page.ts`) | `record` + `current_user` (pages also expose `page.<var>`) |
831+
| Page components (`*.page.ts`) | `record` + `current_user` (plus `page.<var>`) |
832+
| Runtime record form sections/fields (`*.view.ts`) | `record` + `previous`**not** `current_user` |
832833
| Metadata-editing forms (`*.form.ts`) | `data` — the row under edit |
833834

834835
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
846847
`responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see
847848
`packages/spec/src/ui/responsive.zod.ts`.
848849

850+
#### Position-gated visibility
851+
852+
There is **no `hasRole()`**: neither the CEL stdlib nor the
853+
[`EvalUser`](/docs/references/identity/eval-user) contract defines such a call —
854+
`EvalUser` exposes memberships as data, not as methods. Membership is tested
855+
against `current_user.positions`, the canonical `string[]` of
856+
[position](/docs/permissions/positions) names — ADR-0090 D3 retired the older
857+
vocabulary, and ADR-0068 D1 binds the same object under the `user` and `ctx.user`
858+
aliases:
859+
860+
{/* os:check */}
861+
```typescript
862+
// On a PageComponent, an app/nav entry, or a per-option `visibleWhen`:
863+
visibleWhen: "'sales_manager' in current_user.positions"
864+
```
865+
866+
Two limits come with it. **First, `current_user` is not bound on form sections and
867+
form fields** — those predicates evaluate against `record` (plus `previous`) only,
868+
so the expression above names an unbound root there instead of gating anything.
869+
**Second, `visibleWhen` is presentation, not access control**: it decides what a
870+
client draws from data it already holds. To stop someone from *reading* something,
871+
use the permission layer — [field-level security](/docs/permissions/field-level-security)
872+
and [permission sets](/docs/permissions/permission-metadata), or
873+
[row-level security](/docs/permissions/rls), whose `using` clause accepts the very
874+
same `'…' in current_user.positions` predicate and is enforced on the server.
875+
876+
<Callout type="warn">
877+
An unresolvable predicate — an unbound root, or a call to a function that does not
878+
exist — is **fail-open**: the renderer logs one warning and falls back to its safe
879+
default, which for visibility is *visible*. A gate that faults does not hide the
880+
element from anyone; it shows it to everyone.
881+
</Callout>
882+
849883
## Real-World Examples
850884

851885
### Customer 360 Page

0 commit comments

Comments
 (0)