Skip to content

Commit ee892d1

Browse files
committed
docs(skills): auth-permissions stops teaching dataSource as the data expression root (objectui#9379)
The 2026-09-13 maintainer ruling on objectui#9308 (option B) stopped `SchemaRenderer` publishing the injected `DataSource` adapter as the expression root `data`. This guide still taught both halves: flags derived "in the dataSource object", a scope table sourcing `data` from "the `dataSource` passed to `SchemaRendererProvider`", and a trap paragraph whose recommended spelling is the one that now fails. The document is what is wrong here, not the runtime: the guide is moved onto the channel that does publish roots, `PredicateScopeProvider`, in the same shape PR #9369 used for `content/docs/guide/schema-rendering.md` and `packages/react/README.md`. - permission flags are published as an ambient scope and read by the names they were published under; - the scope table gains a `record` row (ADR-0089 D3 makes `record` the runtime-layer row root) and states that `data` is a root only when the host publishes one; - the trap paragraph is re-measured on the built evaluator: a name nothing published makes a predicate fail soft to `true` (the button is hidden for every user), while a text key hands back its own source characters. One bounded repair inside the same paragraph: the flag example derived its booleans from `check(...)`, which answers a `{ allowed, … }` object. An object is truthy, so `${!flag}` was permanently `false` and the gate showed the button to everyone. The example now publishes `can(...)`, which answers a boolean. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015h79niBMyoB1xcaQje3uiz
1 parent cf601ff commit ee892d1

1 file changed

Lines changed: 67 additions & 31 deletions

File tree

‎skills/objectui/guides/auth-permissions.md‎

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -267,66 +267,102 @@ function AdminPanel() {
267267

268268
### Schema-level visibility with expressions
269269

270-
Combine permissions with expression-based visibility:
270+
Combine permissions with expression-based visibility. The names an expression can
271+
read are the ones the **host published**: `PredicateScopeProvider` is that
272+
channel, and every key of the `scope` you hand it becomes a root — so the gate
273+
below works on a page whose host published `userRole`, and on no other.
271274

272275
<!-- os:check -->
273276
```json
274277
{
275278
"type": "button",
276279
"label": "Delete Selected",
277-
"hidden": "${data.userRole !== 'admin'}"
280+
"hidden": "${userRole !== 'admin'}"
278281
}
279282
```
280283

281-
For more complex permission checks, derive permission flags in the dataSource object:
284+
For more complex permission checks, publish the derived permission flags on that
285+
same scope. Publish `can(...)`, which answers a **boolean** — `check(...)` answers
286+
a `{ allowed, … }` result object, and an object is truthy, so a flag derived from
287+
it makes `${!flag}` permanently `false` and shows the button to everyone:
282288

283-
```typescript
284-
const permissions = usePermissions();
285-
const dataSource = {
286-
...data,
287-
canEditContacts: permissions.check('contacts', 'update'),
288-
canDeleteContacts: permissions.check('contacts', 'delete'),
289-
};
290-
291-
<SchemaRendererProvider dataSource={dataSource}>
292-
<SchemaRenderer schema={schema} />
293-
</SchemaRendererProvider>
289+
<!-- os:check -->
290+
```tsx
291+
import { usePermissions } from '@object-ui/permissions'
292+
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
293+
import type { BaseSchema } from '@object-ui/types'
294+
295+
declare const schema: BaseSchema
296+
297+
function ContactsPage() {
298+
const permissions = usePermissions()
299+
const scope = {
300+
canEditContacts: permissions.can('contacts', 'update'),
301+
canDeleteContacts: permissions.can('contacts', 'delete'),
302+
}
303+
304+
return (
305+
<PredicateScopeProvider scope={scope}>
306+
<SchemaRenderer schema={schema} />
307+
</PredicateScopeProvider>
308+
)
309+
}
294310
```
295311

296-
Then in schema — note the `data.` root:
312+
Then in schema — read each flag by the name you published it under:
297313
<!-- os:check -->
298314
```json
299315
{
300316
"type": "button",
301317
"label": "Delete",
302-
"hidden": "${!data.canDeleteContacts}"
318+
"hidden": "${!canDeleteContacts}"
303319
}
304320
```
305321

322+
⛔ `SchemaRendererProvider`'s `dataSource` is **not** where those names come from.
323+
It carries the host's `DataSource` **adapter** — the object data renderers call
324+
`find()` on. The renderer used to publish that adapter under the expression root
325+
`data`; an adapter answers no `data.*` path an author would write, so that root
326+
was silently constant for every conformant host, and objectui#9308 removed it
327+
(maintainer ruling 2026-09-13). Inject the adapter for CRUD, publish a scope for
328+
expressions — two channels, on purpose.
329+
306330
### Expression scope: which roots resolve
307331

308-
`SchemaRenderer` evaluates every schema expression against a fixed scope:
332+
`SchemaRenderer` evaluates every schema expression against the scope the host
333+
published, plus the roots this tier answers itself:
309334

310335
| Root | Comes from | Example |
311336
|---|---|---|
312-
| `data` | the `dataSource` passed to `SchemaRendererProvider` | `${data.canDeleteContacts}` |
313-
| `user` / `current_user` | the ambient host scope (app-shell's `ExpressionProvider`) | `${user.id}` |
314-
| `features` | the ambient host scope | `${features.multiOrgEnabled}` |
337+
| every key of `scope` | the host's `PredicateScopeProvider` | `${canDeleteContacts}` |
338+
| `user` / `current_user` | the same channel — app-shell's `ExpressionProvider` already feeds it | `${user.id}` |
339+
| `features` | the same channel, from `ExpressionProvider` | `${features.multiOrgEnabled}` |
340+
| `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}` |
315341
| `page` | `PageSchema.variables`, inside a Page | `${page.selectedId}` |
316342

317343
The ambient roots exist only while a host scope is mounted — `ExpressionProvider`
318344
supplies them (it also aliases the signed-in user as `ctx.user` and `os.user`).
319-
With no host scope mounted, `data` and `page` are all you get.
320-
321-
**Keys of the `dataSource` object are reachable only under the `data.` root —
322-
they are not also spread as bare names.** Writing `${!canDeleteContacts}`
323-
instead of `${!data.canDeleteContacts}` does not fail loudly: the bare name
324-
resolves to `undefined`, `!undefined` is always `true`, and the `hidden`
325-
expression is therefore permanently true — the button disappears for *every*
326-
user, including the ones who do have the permission. Because the result no
327-
longer depends on the flag, flipping the user's permission to test it produces
328-
no change at all, so the most natural way to debug it gives no signal. The same
329-
trap applies to `visible`, `disabled` and any other expression-valued key.
345+
With no host scope mounted, `record` and `page` are all you get. `data` is a root
346+
only when **you** publish one under that name, like any other key of `scope`.
347+
348+
**A name nothing published does not fail loudly — and the two layers that read
349+
`${…}` fail differently.** Measured on the built evaluator (`packages/core/dist`)
350+
over `hidden: "${!canDeleteContacts}"`:
351+
352+
| what the scope holds | `hidden` verdict | button on screen |
353+
|---|---|---|
354+
| `canDeleteContacts: true`, published as a root | `false` | shown |
355+
| nothing published under that name | **fails soft to `true`** | hidden for *every* user |
356+
357+
So a gate whose flag never reached the scope hides the button for everyone,
358+
including the users who do hold the permission. Because the result no longer
359+
depends on the flag, flipping the user's permission to test it produces no change
360+
at all, and the most natural way to debug it gives no signal — a
361+
`… is not defined` console line is the only one, and objectui#5454's reporter
362+
warns. Spelled `visible` the same miss flips the other way and the node is shown
363+
to everyone. A text key does not fail soft at all: `content` hands back the
364+
characters you typed, so a missing root renders the literal `${…}` on screen. The
365+
same applies to `disabled` and any other expression-valued key.
330366

331367
## Multi-tenancy
332368

0 commit comments

Comments
 (0)