You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
layout-dsl.mdx:824 teaches user.hasRole('admin') inside a CEL visibleWhen, but hasRole exists nowhere in spec or core — the example faults and fail-softs to visible #11034
Filed by the domain:ui execution seat (objectui, seat post objectui#5560), surfaced by the read-only survey dispatched on objectui#5401. Filed unassigned — recording only.
The example
content/docs/protocol/objectui/layout-dsl.mdx, lines 819-825 on origin/main:
// e.g. on a PageComponent:
visibleWhen: "record.account_type == 'premium'"// e.g. on a FormSection / FormField:
visibleWhen: "record.status != 'closed' && user.hasRole('admin')"
user.hasRole(...) is not CEL. Measured: hasRole appears zero times across packages/spec and packages/core on origin/main. On the CEL path the call faults, and the evaluator answers an unresolvable predicate with true (core/src/evaluator/ExpressionEvaluator.ts:211-270, all three paths) — so the gate fail-softs to visible.
The failure mode is therefore the worst-shaped one for a visibility gate taught by example: an author copies the line to hide something from non-admins, the predicate faults, and the element is shown to everyone. Nothing errors and nothing logs at the gate.
The contrast is inside the same code block
Line 821's PageComponent example — visibleWhen: "record.account_type == 'premium'" — is correct, and objectui#5505 (c86185eb5, merged 2026-08-21) is what turned it from inert into actually-evaluated by binding record into the node-level evaluator. So the block now demonstrates a working predicate and a faulting one, side by side, with nothing distinguishing them to a reader.
That is what makes this worth a card rather than a one-line docs nit: the two lines are presented as parallel illustrations of the same feature, and one of them silently does the opposite of what its prose promises.
Note for whoever picks this up
The block is marked {/* os:check */}, so it is inside the snippet-checking fence — and the checker evidently validates the TypeScript shape of the assignment without validating the CEL string. Worth deciding whether that gap is in scope here or is its own card; a snippet checker that green-lights a faulting predicate will green-light the next one too.
Two things a fix has to settle, neither of which I am deciding here:
What the corrected example should say. Is there a supported way to express a role check in a visibleWhen at all (a user.* binding with different member names, a different layer, or "not supported — use a different mechanism")? If the answer is the third, the example should not be silently swapped for a record.* one — the docs would then have quietly dropped the use case a reader came for.
Whether other authored or documented predicates use hasRole. My sweep covered authored page predicates for record.*; it was not a hasRole census.
⚠️ Adjacent, filed separately as #11033: packages/spec/src/ui/component.zod.ts:275-280's user-facing prescription still asserts a hoisted properties visibility key is "evaluated by nothing", which #5505 also falsified.
Triage (half-annotation repair — queued without a lane): routed domain:devx (lands in content/docs/protocol/objectui/layout-dsl.mdx), graded Bug — a shipped doc teaches a visibility gate whose copy fail-softs to visible for everyone, the worst failure direction for the feature it illustrates.
Dispatch shape, premise-first: (1) measure whether any supported user.* binding exists in the CEL evaluator context — the answer decides the rewrite; (2) if none exists, the corrected example must keep the use case and answer it honestly — teach the supported mechanism for role-gated visibility (or state plainly that role checks are not a visibleWhen feature and name the right layer), never silently swap in a second record.* example; (3) the os:check gap (the snippet fence validates TS shape but not the CEL string) is its own gate card — file it as a finding, don't build it here. Companion: #11033 (the spec-side prescription text falsified by the same objectui#5505 landing) is queued to domain:spec this round; different files, no fold.
Claim: PM loop round R2
Session: session_015ahemw8RcTgqtxrj15PEZx
Branch: claude/issue-11034-visiblewhen-hasrole-example
Worktree: objectstack-issue-11034
Domain: domain:devx
File surface: content/docs/protocol/objectui/layout-dsl.mdx. ⛔ packages/spec/** and packages/core/** are out of surface — if the honest answer requires a code or schema change, that is a different card in a different lane. ⛔ Never content/docs/releases/** (stop on breach; explain in the report)
Container & model: S-to-M, mode:subagent, model: opus (--tier content/docs/protocol/objectui/layout-dsl.mdx: "no path-derived mandate". Judgement tier despite being a one-line docs edit — what the corrected example should say is the whole card, and triage explicitly forbade the cheap answer)
Clause-②: no — documentation only. No schema, no accept/reject behaviour, no public surface. ⚠️ If the dev concludes the honest fix needs a spec/core change, that flips the analysis: stop and report, do not proceed.
Serial constraints cleared: fully disjoint from everything in flight.#11148 → packages/sdui-parser + packages/lint/validate-jsx-pages.ts; #10907 → scripts/check-i18n-coverage.mjs (PR #11397, landing). No open PR touches content/docs/protocol/**. Companion #11033 (the spec-side prescription falsified by the same objectui#5505 landing) is a domain:spec card — different file, different lane, ⛔ not folded and not this seat's.
Premise re-measured this round at origin/main
content/docs/protocol/objectui/layout-dsl.mdx:824
visibleWhen: "record.status != 'closed' && user.hasRole('admin')" <- live, line unmoved
hasRole across packages/spec + packages/core: ZERO
CONTROL: `visibleWhen` DOES match in packages/spec -> the grep works, so the zero is real
⭐ One of triage's two open questions is now ANSWERED — hand this to the dev
Triage's note 2 asked "whether other authored or documented predicates use hasRole" and said the card's sweep was not a hasRole census. I ran the census:
$ git grep -ln "hasRole" origin/main
content/docs/protocol/objectui/layout-dsl.mdx
total files: 1
⇒ Exactly one site repo-wide. No authored app, example, test or doc anywhere else uses it. So the blast radius of the fix is this single line, and there is no migration tail to plan. That removes the larger half of the card's uncertainty before a dev spends a minute on it.
Triage's note 1 — what the corrected example should say — remains genuinely open and is the actual work.
{
"issue": 11034,
"status": "done",
"branch": "claude/issue-11034-visiblewhen-hasrole-example",
"pr": "https://github.com/objectstack-ai/objectstack/pull/11413",
"premise_still_valid": true,
"summary": "The card's premise held: layout-dsl.mdx:824 was live and `hasRole` is real-zero. But the PM's DECIDING assumption was falsified in the direction that mattered: a `user.*` binding DOES exist (ADR-0068 D1 canonical `current_user` with `user`/`ctx.user`/`os.user` aliases, bound for real in objectui's ExpressionProvider). What does not exist is `hasRole` — EvalUser carries `positions: string[]` as data, not methods, and it is absent from the 30-entry drift-guarded CEL_STDLIB_FUNCTIONS. So role-gated visibility IS supported, spelled `'<name>' in current_user.positions`. A second measurement then decided the wording: `current_user` does NOT reach the form-section/field layer, which is exactly the layer the broken example was labelled for — so swapping hasRole for positions in place would have moved the example from faulting on a missing method to faulting on an unbound root, still fail-open, still visible to everyone. The rewrite keeps the use case, splits the binding table row that wrongly claimed forms bind `current_user`, teaches the supported spelling with its per-layer availability, and names the permission layer for anything that must be enforced. Vocabulary follows ADR-0090 D3 (`position`), which check:role-word forced and which is the more accurate wording anyway.",
"deciding_measurement": {
"question": "Does any supported `user.*` binding exist in the CEL evaluation context?",
"answer": "YES — and this falsifies the PM's provisional 'probably none'. Not inherited: established across the whole evaluation path, not one file.",
"how_established": [
"CONTRACT: packages/spec/src/identity/eval-user.zod.ts — ADR-0068 D1 docblock declares `current_user` canonical with aliases `user`, `ctx.user`; EvalUserSchema fields are id/name/email/positions/isPlatformAdmin/organizationId. `positions: string[]` is data; there are no methods on the contract.",
"LIVE BINDING (not just prose): objectui packages/app-shell/src/providers/ExpressionProvider.tsx:59 and :70 both build `{ current_user: user, user, ctx: { user }, os: { user }, app, data, features }` — so the `user` root really resolves at runtime.",
"FUNCTION REGISTRY: packages/formula/src/validate.ts:545 CEL_STDLIB_FUNCTIONS lists 30 functions (now/today/abs/upper/matches/size/has/...). `hasRole` is not among them; grep for hasRole across packages/formula/src returns zero.",
"LAYER RESOLUTION (the measurement that decided the wording): objectui packages/components/src/renderers/form/form.tsx passes `undefined` for the `scope` parameter at ALL THREE resolveFieldRuleState call sites (:1201, :1237, :1935). predicateScope is read at :1276 and forwarded ONLY to resolveCascadingOptions (:1428, :2129). core/src/evaluator/fieldRules.ts:158 evalFieldPredicate binds `{ record, previous, extra: scope }`, so form field/section predicates see record+previous only. Its own fault warning text: \"Values are bound under 'record.'\". Corroborated independently by the generated reference content/docs/references/data/field.mdx:333, which calls the per-option env 'wider than field-level visibleWhen, which has no current_user'."
],
"controls_that_make_the_zero_real": [
"hasRole zero sits beside NON-zero siblings in the same search space: `positions` resolves in eval-user.zod.ts, `current_user` resolves in ExpressionProvider.tsx, and CEL_STDLIB_FUNCTIONS is a populated 30-entry catalog pinned by a drift-guard test ('every advertised CEL_STDLIB_FUNCTIONS entry resolves at runtime'). A broken search would have returned zero for those too.",
"Repo-wide hasRole census re-run at 365e334: exactly 1 file (the doc itself) — PM's census reproduced, no migration tail.",
"Positive usage control for the corrected spelling: content/docs/ui/pages.mdx:165 and examples/app-showcase/src/data/objects/cascading-select.object.ts:85 both carry `'<name>' in current_user.positions` live."
]
},
"files_changed": [
"content/docs/protocol/objectui/layout-dsl.mdx"
],
"line_budget": "n/a — no skills/** path in the diff; the published-skill line/token budget clause does not apply. Diff is +38/-4 on one content/docs file.",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; script took its own change set from the merge base). Exit 0. 16 families matched. Header confirmed answer came from objectstack-ai/objectstack at 365e334.",
"self_derived_additions": [
"check:nul-bytes — any edit",
"check:skill-examples — NOT named by the derivation, but packages/spec/scripts/check-skill-examples.ts scopes content/docs (excluding content/docs/references), and it is the gate that owns the {/* os:check */} fence this card is about. Ran it; it went green and now counts 247 marked blocks across 2 surfaces."
],
"run_at_sha": "643a9aa",
"runner": "all runs serialized through bash scripts/pm/os-verify-lock.sh; exit codes captured after redirect, never through a pipe",
"result": "18/18 green",
"verdict_lines_quoted_from_the_gates_themselves": [
"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.",
"check:skill-examples: 247 prose examples type-check across 2 surface(s)",
"check-doc-authoring: doc authoring guard: 389 files clean - no bare metadata literals.",
"check-nul-bytes: OK (scanned 6389 text file(s) -- 6389 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes)."
],
"reds_encountered_and_their_disposition": [
"check:role-word — REAL red caused by my first draft: 'NEW use of the reserved word \"role\" (4 occurrence(s)). ADR-0090 D3: use permission_set / position / business_unit.' Fixed by rewriting the prose to the position vocabulary (NOT by touching scripts/role-word-baseline.json, which the gate marks maintainer-only). Backticked `hasRole()` is exempt and stays. Green after.",
"check:doc-security-posture — build state, not a verdict: packages/lint/dist/index.js missing. I had built only the dependency closure ('<pkg>^...'), not the package itself. Green after building @objectstack/lint.",
"check:skill-examples — build state, not a verdict: the client SDK surface's selfPackages are packages/client and packages/client-react (0 .d.ts each); the guard's remedy text names packages/spec, which was already built with 60 .d.ts, so the message is misleading. Green after building both client packages."
]
},
"deviations": [
"Edited the binding TABLE three lines below the broken snippet, not only the snippet line. In scope by the dispatch's own instruction ('a corrected snippet under prose that still promises the old behaviour is not a fix') — the row read 'Runtime record forms & pages (*.view.ts, *.page.ts) | record + current_user', which my measurement shows is true for pages and false for form fields. Leaving it would have shipped a self-contradicting page.",
"Wording uses `position`, not the word the card and the dispatch used, because check:role-word reds on new prose uses of the reserved word (ADR-0090 D3). This is a vocabulary change relative to the dispatch's suggested route, forced by a gate and independently more accurate (the mechanism is literally current_user.positions).",
"No changeset. Docs-only, so `skip-changeset` applies — same disposition as #11275 (docs-only, merged today with that label and no changeset). Label applied via the additive POST endpoint and READ BACK after the size labeler ran: ['documentation', 'size/s', 'skip-changeset'] — it survived.",
"No repo-wide `pnpm lint` run: it is not in the 16 derived families for this path, so it is not owed. This is a statement about the derivation, not a narrowing I am declaring."
],
"tests": "Gate union run at 643a9aa (the final commit, after the section reorder), all through scripts/pm/os-verify-lock.sh, exit codes captured by redirect-then-capture. 18/18 green: check:cross-package-test-inputs, check:doc-anchors, check:doc-authoring, check:doc-formula-expressions, check:doc-security-posture, check:docs-audit-scope, check:docs-redirects, check:empty-state, check:liveness, check:published-readme-links, check:role-word, check:strictness-ledger, check:variant-docs, scripts/check-cross-package-test-inputs.mjs, scripts/check-doc-frontmatter.mjs, scripts/check-section-landing-index.mjs, check:nul-bytes, check:skill-examples. Verdict lines quoted in `gates.verdict_lines_quoted_from_the_gates_themselves` are the gates' own printed text, not $?. Builds needed to reach a real verdict: dependency closures for spec+lint, then @objectstack/spec, @objectstack/lint, @objectstack/client, @objectstack/client-react themselves. No ablation was run — nothing here needed one; the fix is prose plus two CEL strings, and the 'does the binding exist' question was answered by reading the live binding sites, not by mutating them.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #11407: the {/* os:check */} fence type-checks a visibleWhen CEL string as `string`, so any predicate text passes including calls to functions that exist nowhere — the gate card triage explicitly ruled out of this PR. Names why check-doc-formula-expressions cannot already reach it (its opt-in is Field.* factory calls and type:'formula' literals) and why a gate keyed on the key alone would produce false reds, since the binding root genuinely differs by layer. Unassigned, no labels, left for PM triage.",
"filed as #11411 [finding]: the form-layer binding asymmetry itself — position-gated visibility is expressible on pages, nav entries and per-option predicates but not on form sections/fields, because the form renderer passes `undefined` scope. Code half lands in objectui, so routed to the PM rather than minting a cross-repo card (same disposition as #11258/#11247). Docs half is fixed in this PR. Records both fix shapes and the shipped-behaviour-change risk of the second.",
"dedup: GitHub search API is blocked for this session ('sessions are bound to their configured repositories'), so I enumerated all 320 open issues via the repo-scoped endpoint and filtered on os:check/snippet/skill-examples/CEL/fence/visibleWhen/hasRole/predicate. Nearest neighbours checked and distinct: #10830 (os:check cannot enforce an .optional() attribute convention), #11355 (extraction not fence-aware), #11258 (runtime visibleWhen gaps in objectui), #11256 (spec describe under-names page roots — independently CORROBORATES my alias measurement)."
]
}
ACCEPT — PR #11413 · reviewer of record: devx@objectstack (session_015ahemw8RcTgqtxrj15PEZx)
Checklist passed. This run falsified my deciding assumption and then falsified the obvious fix, which is the whole value of the card.
⭐ My Zone-2 deciding assumption was wrong, in the direction that mattered
I wrote "probably no user.* binding exists" and explicitly told the dev not to inherit my one-file grep. Correct call — a user.* binding does exist: ADR-0068 D1's canonical current_user, with user / ctx.user / os.user aliases, bound for real at objectui ExpressionProvider.tsx:59,:70. What does not exist is hasRole — EvalUser carries positions: string[] as data, not methods, and hasRole is absent from the 30-entry CEL_STDLIB_FUNCTIONS.
⇒ Role-gated visibility is supported, spelled '<name>' in current_user.positions. Had the dev inherited my grep it would have written "not supported, use another layer" — confidently, and wrongly.
⭐⭐ And then the second measurement killed the obvious fix
The tempting repair is to swap hasRole(...) for the supported spelling in place. That would have been just as broken, in exactly the same user-visible way, and only a layer measurement catches it:
form.tsx passes undefined for scope at all threeresolveFieldRuleState call sites (:1201, :1237, :1935); predicateScope is read at :1276 and forwarded only to resolveCascadingOptions. core/src/evaluator/fieldRules.ts:158 binds { record, previous, extra: scope }.
⇒ current_userdoes not reach the form-section/field layer — precisely the layer the broken example was labelled for. The in-place swap would have moved the example from faulting on a missing method to faulting on an unbound root: still fail-open, still visible to everyone, still the exact defect the card was filed about. Corroborated independently by the generated reference (field.mdx:333 calls the per-option env "wider than field-level visibleWhen, which has no current_user").
Controls that make the zeros real
hasRole's zero sits beside non-zero siblings in the same search space — positions resolves in eval-user.zod.ts, current_user resolves in ExpressionProvider.tsx, and CEL_STDLIB_FUNCTIONS is a populated 30-entry catalog pinned by a drift-guard test. A broken search would have returned zero for those too. My repo-wide hasRole census was independently reproduced (1 file). And the corrected spelling carries a positive usage control — I verified both live sites myself: content/docs/ui/pages.mdx:165 and examples/app-showcase/.../cascading-select.object.ts:85.
The rewrite satisfies triage's ruling rather than dodging it
Triage forbade silently swapping in a second record.* example, and required the use case be kept and answered honestly. The diff does exactly that: it teaches the supported spelling, names its two limits (not bound on form sections/fields; and visibleWhen is presentation, not access control), and points at the permission layer — FLS, permission sets, RLS — for anything that must actually be enforced.
⭐ It also adds the insight the card was really about, as a warning callout: "An unresolvable predicate … is fail-open … A gate that faults does not hide the element from anyone; it shows it to everyone." The reader now leaves knowing the failure direction, not just the correct syntax.
Deviations — all four sound
Edited the binding table, not only the snippet. The row claimed "Runtime record forms & pages | record + current_user" — true for pages, false for form fields. It is now split into two rows. In scope by my own instruction that a corrected snippet under prose still promising the old behaviour is not a fix; leaving it would have shipped a self-contradicting page.
position vocabulary, not the word the card and my dispatch both used. Forced by check:role-word (ADR-0090 D3) — and independently more accurate, since the mechanism is literally current_user.positions. ⭐ It hit this as a real red on its own first draft and fixed it by rewriting prose, ⛔ not by touching scripts/role-word-baseline.json, which the gate marks maintainer-only. That is the correct instinct on a gate-vs-diff conflict.
No repo-wide lint — and the framing is precise: "not in the 16 derived families for this path, so it is not owed. This is a statement about the derivation, not a narrowing I am declaring." Correct distinction.
The other two reds were correctly diagnosed as build state, not verdicts, and it noted that check:skill-examples' remedy text is misleading (it names packages/spec, already built, when the real need was the client packages).
Landing
content/docs/protocol/objectui/** — not governed surface ⇒ normal queue path. CI at review time: 19 success · 8 skipped · 4 pending · 0 red. Holding for all-green, then ready → arm → poll the queue ref. pm:dispatched stays until MERGED.
Queued. PR #11413 entered the merge queue at 16:20:54Z (refs/heads/gh-readonly-queue/main/pr-11413-…), behind pr-11347 · pr-11371 · pr-11397 · pr-11402.
Final gate read at head 643a9aa: 24 success · 8 skipped · 0 red · 0 pending. Path fork clean — content/docs/protocol/objectui/** is not governed surface.
Ready-flip then arm, queue membership confirmed by polling the ref (present on the second poll, ~20s after arming — enqueue is asynchronous). ⚠️auto_merge read null even while queued, so that field cannot be used to check arming in either direction; the ref is the only instrument.
pm:dispatched stays until MERGED. Subscribed to the PR's activity.
Activity
huangyiirene commentedon Aug 22, 2026
Triage (half-annotation repair — queued without a lane): routed
domain:devx(lands incontent/docs/protocol/objectui/layout-dsl.mdx), graded Bug — a shipped doc teaches a visibility gate whose copy fail-softs to visible for everyone, the worst failure direction for the feature it illustrates.Dispatch shape, premise-first: (1) measure whether any supported
user.*binding exists in the CEL evaluator context — the answer decides the rewrite; (2) if none exists, the corrected example must keep the use case and answer it honestly — teach the supported mechanism for role-gated visibility (or state plainly that role checks are not avisibleWhenfeature and name the right layer), never silently swap in a secondrecord.*example; (3) theos:checkgap (the snippet fence validates TS shape but not the CEL string) is its own gate card — file it as a finding, don't build it here. Companion: #11033 (the spec-side prescription text falsified by the same objectui#5505 landing) is queued todomain:specthis round; different files, no fold.Generated by Claude Code
os-steve commentedon Aug 23, 2026
Claim: PM loop round R2⚠️ If the dev concludes the honest fix needs a spec/core change, that flips the analysis: stop and report, do not proceed.
Session:
session_015ahemw8RcTgqtxrj15PEZxBranch:
claude/issue-11034-visiblewhen-hasrole-exampleWorktree:
objectstack-issue-11034Domain:
domain:devxFile surface:
content/docs/protocol/objectui/layout-dsl.mdx. ⛔packages/spec/**andpackages/core/**are out of surface — if the honest answer requires a code or schema change, that is a different card in a different lane. ⛔ Nevercontent/docs/releases/**(stop on breach; explain in the report)Container & model:
S-to-M,mode:subagent,model: opus(--tier content/docs/protocol/objectui/layout-dsl.mdx: "no path-derived mandate". Judgement tier despite being a one-line docs edit — what the corrected example should say is the whole card, and triage explicitly forbade the cheap answer)Clause-②: no — documentation only. No schema, no accept/reject behaviour, no public surface.
Serial constraints cleared: fully disjoint from everything in flight. #11148 →
packages/sdui-parser+packages/lint/validate-jsx-pages.ts; #10907 →scripts/check-i18n-coverage.mjs(PR #11397, landing). No open PR touchescontent/docs/protocol/**. Companion #11033 (the spec-side prescription falsified by the same objectui#5505 landing) is adomain:speccard — different file, different lane, ⛔ not folded and not this seat's.Premise re-measured this round at
origin/main⭐ One of triage's two open questions is now ANSWERED — hand this to the dev
Triage's note 2 asked "whether other authored or documented predicates use
hasRole" and said the card's sweep was not ahasRolecensus. I ran the census:⇒ Exactly one site repo-wide. No authored app, example, test or doc anywhere else uses it. So the blast radius of the fix is this single line, and there is no migration tail to plan. That removes the larger half of the card's uncertainty before a dev spends a minute on it.
Triage's note 1 — what the corrected example should say — remains genuinely open and is the actual work.
Generated by Claude Code
claude commentedon Aug 23, 2026
{ "issue": 11034, "status": "done", "branch": "claude/issue-11034-visiblewhen-hasrole-example", "pr": "https://github.com/objectstack-ai/objectstack/pull/11413", "premise_still_valid": true, "summary": "The card's premise held: layout-dsl.mdx:824 was live and `hasRole` is real-zero. But the PM's DECIDING assumption was falsified in the direction that mattered: a `user.*` binding DOES exist (ADR-0068 D1 canonical `current_user` with `user`/`ctx.user`/`os.user` aliases, bound for real in objectui's ExpressionProvider). What does not exist is `hasRole` — EvalUser carries `positions: string[]` as data, not methods, and it is absent from the 30-entry drift-guarded CEL_STDLIB_FUNCTIONS. So role-gated visibility IS supported, spelled `'<name>' in current_user.positions`. A second measurement then decided the wording: `current_user` does NOT reach the form-section/field layer, which is exactly the layer the broken example was labelled for — so swapping hasRole for positions in place would have moved the example from faulting on a missing method to faulting on an unbound root, still fail-open, still visible to everyone. The rewrite keeps the use case, splits the binding table row that wrongly claimed forms bind `current_user`, teaches the supported spelling with its per-layer availability, and names the permission layer for anything that must be enforced. Vocabulary follows ADR-0090 D3 (`position`), which check:role-word forced and which is the more accurate wording anyway.", "deciding_measurement": { "question": "Does any supported `user.*` binding exist in the CEL evaluation context?", "answer": "YES — and this falsifies the PM's provisional 'probably none'. Not inherited: established across the whole evaluation path, not one file.", "how_established": [ "CONTRACT: packages/spec/src/identity/eval-user.zod.ts — ADR-0068 D1 docblock declares `current_user` canonical with aliases `user`, `ctx.user`; EvalUserSchema fields are id/name/email/positions/isPlatformAdmin/organizationId. `positions: string[]` is data; there are no methods on the contract.", "LIVE BINDING (not just prose): objectui packages/app-shell/src/providers/ExpressionProvider.tsx:59 and :70 both build `{ current_user: user, user, ctx: { user }, os: { user }, app, data, features }` — so the `user` root really resolves at runtime.", "FUNCTION REGISTRY: packages/formula/src/validate.ts:545 CEL_STDLIB_FUNCTIONS lists 30 functions (now/today/abs/upper/matches/size/has/...). `hasRole` is not among them; grep for hasRole across packages/formula/src returns zero.", "LAYER RESOLUTION (the measurement that decided the wording): objectui packages/components/src/renderers/form/form.tsx passes `undefined` for the `scope` parameter at ALL THREE resolveFieldRuleState call sites (:1201, :1237, :1935). predicateScope is read at :1276 and forwarded ONLY to resolveCascadingOptions (:1428, :2129). core/src/evaluator/fieldRules.ts:158 evalFieldPredicate binds `{ record, previous, extra: scope }`, so form field/section predicates see record+previous only. Its own fault warning text: \"Values are bound under 'record.'\". Corroborated independently by the generated reference content/docs/references/data/field.mdx:333, which calls the per-option env 'wider than field-level visibleWhen, which has no current_user'." ], "controls_that_make_the_zero_real": [ "hasRole zero sits beside NON-zero siblings in the same search space: `positions` resolves in eval-user.zod.ts, `current_user` resolves in ExpressionProvider.tsx, and CEL_STDLIB_FUNCTIONS is a populated 30-entry catalog pinned by a drift-guard test ('every advertised CEL_STDLIB_FUNCTIONS entry resolves at runtime'). A broken search would have returned zero for those too.", "Repo-wide hasRole census re-run at 365e334: exactly 1 file (the doc itself) — PM's census reproduced, no migration tail.", "Positive usage control for the corrected spelling: content/docs/ui/pages.mdx:165 and examples/app-showcase/src/data/objects/cascading-select.object.ts:85 both carry `'<name>' in current_user.positions` live." ] }, "files_changed": [ "content/docs/protocol/objectui/layout-dsl.mdx" ], "line_budget": "n/a — no skills/** path in the diff; the published-skill line/token budget clause does not apply. Diff is +38/-4 on one content/docs file.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; script took its own change set from the merge base). Exit 0. 16 families matched. Header confirmed answer came from objectstack-ai/objectstack at 365e334.", "self_derived_additions": [ "check:nul-bytes — any edit", "check:skill-examples — NOT named by the derivation, but packages/spec/scripts/check-skill-examples.ts scopes content/docs (excluding content/docs/references), and it is the gate that owns the {/* os:check */} fence this card is about. Ran it; it went green and now counts 247 marked blocks across 2 surfaces." ], "run_at_sha": "643a9aa", "runner": "all runs serialized through bash scripts/pm/os-verify-lock.sh; exit codes captured after redirect, never through a pipe", "result": "18/18 green", "verdict_lines_quoted_from_the_gates_themselves": [ "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.", "check:skill-examples: 247 prose examples type-check across 2 surface(s)", "check-doc-authoring: doc authoring guard: 389 files clean - no bare metadata literals.", "check-nul-bytes: OK (scanned 6389 text file(s) -- 6389 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes)." ], "reds_encountered_and_their_disposition": [ "check:role-word — REAL red caused by my first draft: 'NEW use of the reserved word \"role\" (4 occurrence(s)). ADR-0090 D3: use permission_set / position / business_unit.' Fixed by rewriting the prose to the position vocabulary (NOT by touching scripts/role-word-baseline.json, which the gate marks maintainer-only). Backticked `hasRole()` is exempt and stays. Green after.", "check:doc-security-posture — build state, not a verdict: packages/lint/dist/index.js missing. I had built only the dependency closure ('<pkg>^...'), not the package itself. Green after building @objectstack/lint.", "check:skill-examples — build state, not a verdict: the client SDK surface's selfPackages are packages/client and packages/client-react (0 .d.ts each); the guard's remedy text names packages/spec, which was already built with 60 .d.ts, so the message is misleading. Green after building both client packages." ] }, "deviations": [ "Edited the binding TABLE three lines below the broken snippet, not only the snippet line. In scope by the dispatch's own instruction ('a corrected snippet under prose that still promises the old behaviour is not a fix') — the row read 'Runtime record forms & pages (*.view.ts, *.page.ts) | record + current_user', which my measurement shows is true for pages and false for form fields. Leaving it would have shipped a self-contradicting page.", "Wording uses `position`, not the word the card and the dispatch used, because check:role-word reds on new prose uses of the reserved word (ADR-0090 D3). This is a vocabulary change relative to the dispatch's suggested route, forced by a gate and independently more accurate (the mechanism is literally current_user.positions).", "No changeset. Docs-only, so `skip-changeset` applies — same disposition as #11275 (docs-only, merged today with that label and no changeset). Label applied via the additive POST endpoint and READ BACK after the size labeler ran: ['documentation', 'size/s', 'skip-changeset'] — it survived.", "No repo-wide `pnpm lint` run: it is not in the 16 derived families for this path, so it is not owed. This is a statement about the derivation, not a narrowing I am declaring." ], "tests": "Gate union run at 643a9aa (the final commit, after the section reorder), all through scripts/pm/os-verify-lock.sh, exit codes captured by redirect-then-capture. 18/18 green: check:cross-package-test-inputs, check:doc-anchors, check:doc-authoring, check:doc-formula-expressions, check:doc-security-posture, check:docs-audit-scope, check:docs-redirects, check:empty-state, check:liveness, check:published-readme-links, check:role-word, check:strictness-ledger, check:variant-docs, scripts/check-cross-package-test-inputs.mjs, scripts/check-doc-frontmatter.mjs, scripts/check-section-landing-index.mjs, check:nul-bytes, check:skill-examples. Verdict lines quoted in `gates.verdict_lines_quoted_from_the_gates_themselves` are the gates' own printed text, not $?. Builds needed to reach a real verdict: dependency closures for spec+lint, then @objectstack/spec, @objectstack/lint, @objectstack/client, @objectstack/client-react themselves. No ablation was run — nothing here needed one; the fix is prose plus two CEL strings, and the 'does the binding exist' question was answered by reading the live binding sites, not by mutating them.", "open_questions": [], "out_of_scope_findings": [ "filed as #11407: the {/* os:check */} fence type-checks a visibleWhen CEL string as `string`, so any predicate text passes including calls to functions that exist nowhere — the gate card triage explicitly ruled out of this PR. Names why check-doc-formula-expressions cannot already reach it (its opt-in is Field.* factory calls and type:'formula' literals) and why a gate keyed on the key alone would produce false reds, since the binding root genuinely differs by layer. Unassigned, no labels, left for PM triage.", "filed as #11411 [finding]: the form-layer binding asymmetry itself — position-gated visibility is expressible on pages, nav entries and per-option predicates but not on form sections/fields, because the form renderer passes `undefined` scope. Code half lands in objectui, so routed to the PM rather than minting a cross-repo card (same disposition as #11258/#11247). Docs half is fixed in this PR. Records both fix shapes and the shipped-behaviour-change risk of the second.", "dedup: GitHub search API is blocked for this session ('sessions are bound to their configured repositories'), so I enumerated all 320 open issues via the repo-scoped endpoint and filtered on os:check/snippet/skill-examples/CEL/fence/visibleWhen/hasRole/predicate. Nearest neighbours checked and distinct: #10830 (os:check cannot enforce an .optional() attribute convention), #11355 (extraction not fence-aware), #11258 (runtime visibleWhen gaps in objectui), #11256 (spec describe under-names page roots — independently CORROBORATES my alias measurement)." ] }Generated by Claude Code
os-steve commentedon Aug 23, 2026
ACCEPT — PR #11413 · reviewer of record:
devx@objectstack(session_015ahemw8RcTgqtxrj15PEZx)Checklist passed. This run falsified my deciding assumption and then falsified the obvious fix, which is the whole value of the card.
⭐ My Zone-2 deciding assumption was wrong, in the direction that mattered
I wrote "probably no
user.*binding exists" and explicitly told the dev not to inherit my one-file grep. Correct call — auser.*binding does exist: ADR-0068 D1's canonicalcurrent_user, withuser/ctx.user/os.useraliases, bound for real at objectuiExpressionProvider.tsx:59,:70. What does not exist ishasRole—EvalUsercarriespositions: string[]as data, not methods, andhasRoleis absent from the 30-entryCEL_STDLIB_FUNCTIONS.⇒ Role-gated visibility is supported, spelled
'<name>' in current_user.positions. Had the dev inherited my grep it would have written "not supported, use another layer" — confidently, and wrongly.⭐⭐ And then the second measurement killed the obvious fix
The tempting repair is to swap
hasRole(...)for the supported spelling in place. That would have been just as broken, in exactly the same user-visible way, and only a layer measurement catches it:⇒
current_userdoes not reach the form-section/field layer — precisely the layer the broken example was labelled for. The in-place swap would have moved the example from faulting on a missing method to faulting on an unbound root: still fail-open, still visible to everyone, still the exact defect the card was filed about. Corroborated independently by the generated reference (field.mdx:333calls the per-option env "wider than field-levelvisibleWhen, which has nocurrent_user").Controls that make the zeros real
hasRole's zero sits beside non-zero siblings in the same search space —positionsresolves ineval-user.zod.ts,current_userresolves inExpressionProvider.tsx, andCEL_STDLIB_FUNCTIONSis a populated 30-entry catalog pinned by a drift-guard test. A broken search would have returned zero for those too. My repo-widehasRolecensus was independently reproduced (1 file). And the corrected spelling carries a positive usage control — I verified both live sites myself:content/docs/ui/pages.mdx:165andexamples/app-showcase/.../cascading-select.object.ts:85.The rewrite satisfies triage's ruling rather than dodging it
Triage forbade silently swapping in a second
record.*example, and required the use case be kept and answered honestly. The diff does exactly that: it teaches the supported spelling, names its two limits (not bound on form sections/fields; andvisibleWhenis presentation, not access control), and points at the permission layer — FLS, permission sets, RLS — for anything that must actually be enforced.⭐ It also adds the insight the card was really about, as a warning callout: "An unresolvable predicate … is fail-open … A gate that faults does not hide the element from anyone; it shows it to everyone." The reader now leaves knowing the failure direction, not just the correct syntax.
Deviations — all four sound
record+current_user" — true for pages, false for form fields. It is now split into two rows. In scope by my own instruction that a corrected snippet under prose still promising the old behaviour is not a fix; leaving it would have shipped a self-contradicting page.positionvocabulary, not the word the card and my dispatch both used. Forced bycheck:role-word(ADR-0090 D3) — and independently more accurate, since the mechanism is literallycurrent_user.positions. ⭐ It hit this as a real red on its own first draft and fixed it by rewriting prose, ⛔ not by touchingscripts/role-word-baseline.json, which the gate marks maintainer-only. That is the correct instinct on a gate-vs-diff conflict.skip-changeset, docs-only, with precedent cited (docs: name both causes ofnext: nullin the state-introspection prose #11275, same disposition, merged today) and the label read back after the size labeler ran.The other two reds were correctly diagnosed as build state, not verdicts, and it noted that
check:skill-examples' remedy text is misleading (it namespackages/spec, already built, when the real need was the client packages).Landing
content/docs/protocol/objectui/**— not governed surface ⇒ normal queue path. CI at review time: 19 success · 8 skipped · 4 pending · 0 red. Holding for all-green, then ready → arm → poll the queue ref.pm:dispatchedstays until MERGED.Generated by Claude Code
os-steve commentedon Aug 23, 2026
Queued. PR #11413 entered the merge queue at 16:20:54Z (
refs/heads/gh-readonly-queue/main/pr-11413-…), behindpr-11347 · pr-11371 · pr-11397 · pr-11402.Final gate read at head
643a9aa: 24 success · 8 skipped · 0 red · 0 pending. Path fork clean —content/docs/protocol/objectui/**is not governed surface.Ready-flip then arm, queue membership confirmed by polling the ref (present on the second poll, ~20s after arming — enqueue is asynchronous).⚠️
auto_mergeread null even while queued, so that field cannot be used to check arming in either direction; the ref is the only instrument.pm:dispatchedstays until MERGED. Subscribed to the PR's activity.Generated by Claude Code