Uh oh!
There was an error while loading. Please reload this page.
docs(protocol): teach position-gated visibility with the binding it actually has - #11413
Merged
Merged
Conversation
…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_015ahemw8RcTgqtxrj15PEZxos-steve
marked this pull request as ready for review
August 23, 2026 16:20
This was referenced Aug 23, 2026
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#11034
content/docs/protocol/objectui/layout-dsl.mdxtaughtuser.hasRole('admin')inside a CELvisibleWhen.hasRoleis 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 correctrecord.*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) makescurrent_usercanonical withuser/ctx.useraliases, and objectui binds all of them for real —app-shell/src/providers/ExpressionProvider.tsx:59,70:What does not exist is
hasRole.EvalUsercarriespositions: 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, andhasRoleis not among them. That non-empty catalog besidepositions/current_userresolving is the control that makes the zero a real zero rather than a broken search. Repo-wide census:hasRoleoccurs in exactly one file — this doc.The canonical spelling is a membership test, live at
content/docs/ui/pages.mdx:165andexamples/app-showcase/src/data/objects/cascading-select.object.ts:85:visibleWhen: "'sales_manager' in current_user.positions"But
current_userdoes not reach every layer — and that is what decided the rewrite.current_userbound?visibleExpressionProvider.tsx:59,70visibleWhencore/src/evaluator/optionRules.ts:103forwardsscope;resolveCascadingOptions(…, predicateScope)visibleWhenresolveFieldRuleStatecall sites incomponents/src/renderers/form/form.tsx(:1201,:1237,:1935) passundefinedforscopeevalFieldPredicatebinds{ record, previous, extra: scope }, so withscope === undefineda form field's predicate seesrecordandpreviousand 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:333describes the per-option environment as "wider than field-level visibleWhen, which has nocurrent_user".The broken example's own label was "on a FormSection / FormField" — precisely the layer with no user binding. So swapping
hasRoleforpositionsin 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
FormSection / FormFieldline isrecord-only; both lines now say which roots are bound.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.record.*example. It states there is nohasRole(), 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 avisibleWhenis presentation, never access control.Calloutstates the fail-open behaviour, so the failure mode that made this a bug rather than a typo is on the page.position, not the retired word).check:role-wordreddened 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 literallycurrent_user.positions.Both new snippets are inside
{/* os:check */}fences and type-check.Verification
All gates run at
643a9aa(the final commit), serialized throughscripts/pm/os-verify-lock.sh. Gate set derived withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the real changed paths (16 families), plus two I derived myself that the path derivation did not name:check:nul-bytes(any edit) andcheck:skill-examples— whose scope does includecontent/docs/**, and which is the gate that owns theos:checkfence this card is about.18/18 green. Their own verdict lines:
Two of the three initial reds were build-state, not verdicts:
check:doc-security-postureandcheck:skill-examplesneedpackages/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-changesetapplies — the same disposition as #11275, merged today.Out of scope, filed not fixed
os:checkfence type-checks avisibleWhenCEL string asstring— any predicate text passes, including calls to functions that exist nowhere #11407 — theos:checkfence type-checks avisibleWhenCEL string asstring, so any predicate text passes, including calls to functions that exist nowhere. This is the gate gap triage explicitly ruled out of this PR ("its own gate card — file it as a finding, don't build it here"). The card names why the sibling gatecheck-doc-formula-expressionscannot already reach it, and why a naive gate keyed on the key alone would produce false reds.visibleWhenbinds nocurrent_user, so position-gated visibility is expressible on pages but not on forms — the asymmetry was undocumented and the docs asserted the opposite (lands in objectui) #11411 —[finding]the form-layer binding asymmetry itself: position-gated visibility is expressible on pages but not on form fields. The docs half is fixed here; whether the missing binding in objectui is intended or a gap is left for triage to route, with both fix shapes and the behaviour-change risk spelled out.Neither is addressed here; #11407 and #11411 both remain open.
Generated by Claude Code