diff --git a/skills/objectui/guides/auth-permissions.md b/skills/objectui/guides/auth-permissions.md
index dd4e463306..71b599f6a3 100644
--- a/skills/objectui/guides/auth-permissions.md
+++ b/skills/objectui/guides/auth-permissions.md
@@ -267,66 +267,102 @@ function AdminPanel() {
### Schema-level visibility with expressions
-Combine permissions with expression-based visibility:
+Combine permissions with expression-based visibility. The names an expression can
+read are the ones the **host published**: `PredicateScopeProvider` is that
+channel, and every key of the `scope` you hand it becomes a root — so the gate
+below works on a page whose host published `userRole`, and on no other.
```json
{
"type": "button",
"label": "Delete Selected",
- "hidden": "${data.userRole !== 'admin'}"
+ "hidden": "${userRole !== 'admin'}"
}
```
-For more complex permission checks, derive permission flags in the dataSource object:
+For more complex permission checks, publish the derived permission flags on that
+same scope. Publish `can(...)`, which answers a **boolean** — `check(...)` answers
+a `{ allowed, … }` result object, and an object is truthy, so a flag derived from
+it makes `${!flag}` permanently `false` and shows the button to everyone:
-```typescript
-const permissions = usePermissions();
-const dataSource = {
- ...data,
- canEditContacts: permissions.check('contacts', 'update'),
- canDeleteContacts: permissions.check('contacts', 'delete'),
-};
-
-
-
-
+
+```tsx
+import { usePermissions } from '@object-ui/permissions'
+import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
+import type { BaseSchema } from '@object-ui/types'
+
+declare const schema: BaseSchema
+
+function ContactsPage() {
+ const permissions = usePermissions()
+ const scope = {
+ canEditContacts: permissions.can('contacts', 'update'),
+ canDeleteContacts: permissions.can('contacts', 'delete'),
+ }
+
+ return (
+
+
+
+ )
+}
```
-Then in schema — note the `data.` root:
+Then in schema — read each flag by the name you published it under:
```json
{
"type": "button",
"label": "Delete",
- "hidden": "${!data.canDeleteContacts}"
+ "hidden": "${!canDeleteContacts}"
}
```
+⛔ `SchemaRendererProvider`'s `dataSource` is **not** where those names come from.
+It carries the host's `DataSource` **adapter** — the object data renderers call
+`find()` on. The renderer used to publish that adapter under the expression root
+`data`; an adapter answers no `data.*` path an author would write, so that root
+was silently constant for every conformant host, and objectui#9308 removed it
+(maintainer ruling 2026-09-13). Inject the adapter for CRUD, publish a scope for
+expressions — two channels, on purpose.
+
### Expression scope: which roots resolve
-`SchemaRenderer` evaluates every schema expression against a fixed scope:
+`SchemaRenderer` evaluates every schema expression against the scope the host
+published, plus the roots this tier answers itself:
| Root | Comes from | Example |
|---|---|---|
-| `data` | the `dataSource` passed to `SchemaRendererProvider` | `${data.canDeleteContacts}` |
-| `user` / `current_user` | the ambient host scope (app-shell's `ExpressionProvider`) | `${user.id}` |
-| `features` | the ambient host scope | `${features.multiOrgEnabled}` |
+| every key of `scope` | the host's `PredicateScopeProvider` | `${canDeleteContacts}` |
+| `user` / `current_user` | the same channel — app-shell's `ExpressionProvider` already feeds it | `${user.id}` |
+| `features` | the same channel, from `ExpressionProvider` | `${features.multiOrgEnabled}` |
+| `record` | the row a record surface is bound to, when there is one — ADR-0089 D3 makes `record` the runtime-layer row root | `${record.status}` |
| `page` | `PageSchema.variables`, inside a Page | `${page.selectedId}` |
The ambient roots exist only while a host scope is mounted — `ExpressionProvider`
supplies them (it also aliases the signed-in user as `ctx.user` and `os.user`).
-With no host scope mounted, `data` and `page` are all you get.
-
-**Keys of the `dataSource` object are reachable only under the `data.` root —
-they are not also spread as bare names.** Writing `${!canDeleteContacts}`
-instead of `${!data.canDeleteContacts}` does not fail loudly: the bare name
-resolves to `undefined`, `!undefined` is always `true`, and the `hidden`
-expression is therefore permanently true — the button disappears for *every*
-user, including the ones who do have the permission. Because the result no
-longer depends on the flag, flipping the user's permission to test it produces
-no change at all, so the most natural way to debug it gives no signal. The same
-trap applies to `visible`, `disabled` and any other expression-valued key.
+With no host scope mounted, `record` and `page` are all you get. `data` is a root
+only when **you** publish one under that name, like any other key of `scope`.
+
+**A name nothing published does not fail loudly — and the two layers that read
+`${…}` fail differently.** Measured on `@object-ui/core`'s built evaluator
+over `hidden: "${!canDeleteContacts}"`:
+
+| what the scope holds | `hidden` verdict | button on screen |
+|---|---|---|
+| `canDeleteContacts: true`, published as a root | `false` | shown |
+| nothing published under that name | **fails soft to `true`** | hidden for *every* user |
+
+So a gate whose flag never reached the scope hides the button for everyone,
+including the users who do hold the permission. Because the result no longer
+depends on the flag, flipping the user's permission to test it produces no change
+at all, and the most natural way to debug it gives no signal — a
+`… is not defined` console line is the only one, and objectui#5454's reporter
+warns. Spelled `visible` the same miss flips the other way and the node is shown
+to everyone. A text key does not fail soft at all: `content` hands back the
+characters you typed, so a missing root renders the literal `${…}` on screen. The
+same applies to `disabled` and any other expression-valued key.
## Multi-tenancy