Skip to content

docs(protocol): teach position-gated visibility with the binding it actually has - #11413

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11034-visiblewhen-hasrole-example
Aug 23, 2026
Merged

docs(protocol): teach position-gated visibility with the binding it actually has#11413
os-steve merged 1 commit into
mainfrom
claude/issue-11034-visiblewhen-hasrole-example

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11034

content/docs/protocol/objectui/layout-dsl.mdx taught user.hasRole('admin') inside a CEL visibleWhen. hasRole is not a CEL function, so the predicate faults — and visibility is fail-open, so the gate it illustrates showed the element to everyone. That is the worst possible direction for the feature being demonstrated, and it sat in the same code block as a correct record.* example with nothing to tell a reader them apart.

The deciding measurement

Triage ruled the rewrite had to be decided by measurement — specifically, whether any supported user.* binding reaches the CEL evaluation context. It does, and that changed the answer.

A user.* binding exists. ADR-0068 D1 (packages/spec/src/identity/eval-user.zod.ts) makes current_user canonical with user / ctx.user aliases, and objectui binds all of them for real — app-shell/src/providers/ExpressionProvider.tsx:59,70:

constcontext={current_user: user, user,ctx: { user },os: { user }, app, data, features };

What does not exist is hasRole.EvalUser carries positions: string[] as data, not methods; CEL_STDLIB_FUNCTIONS (packages/formula/src/validate.ts:545) lists 30 functions, pinned by a drift-guard test that every advertised entry resolves at runtime, and hasRole is not among them. That non-empty catalog beside positions/current_user resolving is the control that makes the zero a real zero rather than a broken search. Repo-wide census: hasRole occurs in exactly one file — this doc.

The canonical spelling is a membership test, live at content/docs/ui/pages.mdx:165 and examples/app-showcase/src/data/objects/cascading-select.object.ts:85:

visibleWhen: "'sales_manager' in current_user.positions"

But current_user does not reach every layer — and that is what decided the rewrite.

Sitecurrent_user bound?Evidence (objectui)
Page component, app/nav visibleyesExpressionProvider.tsx:59,70
Per-option visibleWhenyescore/src/evaluator/optionRules.ts:103 forwards scope; resolveCascadingOptions(…, predicateScope)
Form section / field visibleWhennoall three resolveFieldRuleState call sites in components/src/renderers/form/form.tsx (:1201, :1237, :1935) pass undefined for scope

evalFieldPredicate binds { record, previous, extra: scope }, so with scope === undefined a form field's predicate sees record and previous and nothing else — its own fault warning says as much ("Values are bound under 'record.'"). The generated reference already recorded this: content/docs/references/data/field.mdx:333 describes the per-option environment as "wider than field-level visibleWhen, which has no current_user".

The broken example's own label was "on a FormSection / FormField" — precisely the layer with no user binding. 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 visible to everyone, and now looking correct. That is the trap this measurement caught.

What changed

  • The example keeps its layer honest. The FormSection / FormField line is record-only; both lines now say which roots are bound.
  • The binding table is split. The old row claimed Runtime record forms & pages (*.view.ts, *.page.ts) | record + current_user — true for pages, false for form fields. Pages and form sections/fields are now separate rows.
  • A new "Position-gated visibility" subsection keeps the use case and answers it. Per triage, the use case a reader came for is not silently swapped for a second record.* example. It states there is no hasRole(), teaches '<name>' in current_user.positions, names the layers it works on, and names the layer that handles enforcement — field-level security, permission sets, RLS — because a visibleWhen is presentation, never access control.
  • A Callout states the fail-open behaviour, so the failure mode that made this a bug rather than a typo is on the page.
  • Vocabulary follows ADR-0090 D3 (position, not the retired word). check:role-word reddened on the first draft with "NEW use of the reserved word ... 4 occurrence(s)" and is green now — the platform's own vocabulary turned out to be the more accurate wording anyway, since the mechanism is literally current_user.positions.

Both new snippets are inside {/* os:check */} fences and type-check.

Verification

All gates run at 643a9aa (the final commit), serialized through scripts/pm/os-verify-lock.sh. Gate set derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack against the real changed paths (16 families), plus two I derived myself that the path derivation did not name: check:nul-bytes (any edit) and check:skill-examples — whose scope does include content/docs/**, and which is the gate that owns the os:check fence this card is about.

18/18 green. Their own verdict lines:

check-role-word: OK, no new occurrences of the reserved word.
Scanned: 225 .md/.mdx file(s) read across 2 root(s) — content/docs 189, skills 36.
✅ 247 prose examples type-check across 2 surface(s) [check:skill-examples]
✓ doc authoring guard: 389 files clean — no bare metadata literals.
check-nul-bytes: OK (scanned 6389 text file(s) ...; no raw ASCII control bytes).

Two of the three initial reds were build-state, not verdicts: check:doc-security-posture and check:skill-examples need packages/lint / packages/client{,-react} themselves built, and I had built only their dependency closures ('<pkg>^...'). Both green once built.

No changeset: docs-only, so skip-changeset applies — the same disposition as #11275, merged today.

Out of scope, filed not fixed

Neither is addressed here; #11407 and #11411 both remain open.


Generated by Claude Code

…ctually has
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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-steve@claude