diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md
index 2e359383e3..e119a47d9a 100644
--- a/skills/objectui/guides/data-integration.md
+++ b/skills/objectui/guides/data-integration.md
@@ -9,7 +9,7 @@ Connecting schema-driven rendering to a real or mock data backend. The DataSourc
│ Schema-Driven UI Layer │
│ (SchemaRenderer, Plugins) │
├─────────────────────────────┤
-│ SchemaRendererProvider │ ← dataSource prop
+│ SchemaRendererProvider │ ← dataSource prop (the ADAPTER)
├─────────────────────────────┤
│ DataSource Interface │ ← universal API contract
├─────────────────────────────┤
@@ -18,7 +18,15 @@ Connecting schema-driven rendering to a real or mock data backend. The DataSourc
└─────────────────────────────┘
```
-Components never import fetch libraries directly. They access data through `useDataScope(path)` or the DataSource methods from context.
+⚠️ That column is the FETCH channel and nothing else. The **values a page's
+`${…}` expressions and `bind` paths read** arrive on a second, independent
+channel — the ambient scope a host publishes with `PredicateScopeProvider`
+(objectui#9308). `dataSource` publishes no expression root and answers no `bind`
+path; keep the two apart when you wire a page.
+
+Components never import fetch libraries directly. They call the DataSource
+methods from context for CRUD, and read the ambient scope through
+`useDataScope(path)`.
## DataSource interface
@@ -151,9 +159,16 @@ const dataSource = new ObjectStackAdapter({
### Static data (no backend)
-For prototypes or static pages, pass a plain object as dataSource:
+For prototypes or static pages there is no adapter to inject. Publish the values
+as an ambient **scope** instead — that is the channel `bind` and `${…}` read:
+
+
+```tsx
+import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
+import type { BaseSchema } from '@object-ui/types'
+
+declare const schema: BaseSchema
-```typescript
const staticData = {
customers: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
@@ -161,16 +176,26 @@ const staticData = {
],
metrics: { total: 2, active: 1 },
userRole: 'admin',
-};
+}
-
-
-
+function Prototype() {
+ return (
+
+
+
+ )
+}
```
Components that read `bind` (see "Via `bind` + `useDataScope`" below) will then
access `staticData.customers` when given `bind: "customers"`.
+⛔ Passing that same object as `SchemaRendererProvider`'s `dataSource` does
+**not** work and never warns: `dataSource` declares the `DataSource` adapter
+contract, it publishes no expression root, and `useDataScope` stopped walking it
+in objectui#9308. Every `bind` resolves `undefined` and every `${…}` on the
+page renders as its own source characters.
+
## ObjectStackAdapter
The built-in adapter for ObjectStack backends (`packages/data-objectstack`).
@@ -217,8 +242,10 @@ A component reads the `bind` field only if it calls `useDataScope`:
}
```
-Inside the component: `const data = useDataScope("customerNames")` resolves to
-the `customerNames` array from the dataSource.
+Inside the component: `const data = useDataScope("customerNames")` resolves the
+`customerNames` array **from the ambient scope** a host published with
+`PredicateScopeProvider` — not from `SchemaRendererProvider`'s `dataSource`
+(objectui#9308).
`useDataScope` is called by `list` and `tree-view` in `@object-ui/components`,
and by the `object-*` widgets the plugin packages register (`object-grid`,
@@ -254,8 +281,9 @@ through is measured, with its open-question caveat, in
### Via expressions on the node
Computed values go through the expression system. `content` is the text key that
-is both expression-evaluated and read back by the renderer, and the provider's
-`dataSource` is reachable under the `data` root:
+is both expression-evaluated and read back by the renderer. The roots it reads
+are the ones the host published on `PredicateScopeProvider`, so the example below
+assumes a scope carrying `data: { metrics: { total: … } }`:
```json
diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md
index 3afe132c1e..bd2bf7c2fe 100644
--- a/skills/objectui/guides/schema-expressions.md
+++ b/skills/objectui/guides/schema-expressions.md
@@ -105,19 +105,78 @@ When the entire string is a single `${expression}`, the result preserves its typ
## Available scope variables
-When expressions are evaluated, these variables are in scope:
+Expression scope is published by the **host**, through `PredicateScopeProvider`.
+Every key of the `scope` you hand it becomes a root the evaluator can read:
+
+
+```tsx
+import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
+import type { BaseSchema } from '@object-ui/types'
+
+declare const schema: BaseSchema
+
+// Every name here becomes a root this page's expressions can read — `data`
+// included, which is now a name YOU publish rather than one the renderer binds.
+const scope = {
+ users: [{ id: 1, name: 'Ada Lovelace' }],
+ metrics: { total: 42 },
+ data: { fieldName: 'Ada Lovelace' },
+}
+
+function Page() {
+ return (
+
+
+
+ )
+}
+```
| Variable | Source | Example |
|----------|--------|---------|
-| Top-level data fields | `SchemaRendererProvider dataSource` | `${users}`, `${metrics.total}` |
-| `data` | Alias for dataSource root | `${data.fieldName}` |
-| `current_user` / `user` | Host predicate scope | `${current_user.email}` |
+| every key of `scope` | the host's `PredicateScopeProvider` | `${users}`, `${metrics.total}` |
+| `data` | a key of `scope` like any other — every `${data.*}` example on this page assumes a host that published one, as above | `${data.fieldName}` |
+| `current_user` / `user` | the same channel; an app-shell host's `ExpressionProvider` already feeds it | `${current_user.email}` |
+| `record` | the row a record surface is bound to, when there is one | `${record.status}` |
| `page` | Page-local state (`PageSchema.variables`) | `${page.selectedId}` |
That is the whole scope. There is **no `item` and no `index`** — the evaluator
context is built once per node, not once per array element. See "No per-item
template iteration" below.
+> ### ⛔ `dataSource` is not an expression root, and this is not a renaming
+>
+> `SchemaRendererProvider`'s `dataSource` carries the host's `DataSource`
+> **adapter** — the object a renderer calls `find()` on. The renderer used to
+> publish that adapter under the name `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).
+>
+> **Re-check every gate you authored from an older copy of this page: the
+> verdict moved.** A root that is MISSING and a root that is PRESENT-but-empty
+> are not the same thing, and the two layers that read `${…}` answer a missing
+> root differently. Measured on the built evaluator with `data.status == 'draft'`:
+>
+> | what the scope holds | as a predicate (`visible` / `hidden`) | interpolated into a text key (`content`) |
+> |---|---|---|
+> | `data` bound to the adapter, which has no `status` member | `false` | `false` |
+> | `data` present and `undefined` | `false` | `false` |
+> | no `data` root at all — what you get now unless you publish one | **fails soft to `true`** | **the template's own source characters are printed on screen** |
+>
+> Read both columns. The predicate layer fails soft, so
+> `"visible": "${data.status == 'draft'}"` written against the old wiring was
+> **hidden on every row** and is now **shown on every row**; spelled `"hidden"`
+> it flips the other way. The interpolation layer does not fail soft to
+> anything — it hands back the characters you typed, so a `content` built from
+> a missing root renders the literal text `${data.status == 'draft'}` to the
+> user. objectui#5454's reporter warns about the predicate case.
+>
+> ⛔ Re-publishing `data` through `PredicateScopeProvider` restores the old,
+> always-`false` verdict — it does not make the gate work. Give the gate a root
+> that actually holds the row: at the runtime layer that root is **`record`**
+> (ADR-0089 D3, whose `CANONICAL_ROOT_BY_LAYER` puts `record` at the runtime
+> layer and `data` at the metadata layer).
+
### Safe globals (always available)
- `Math` — `${Math.round(price)}`, `${Math.max(a, b)}`
- `JSON` — `${JSON.stringify(obj)}`
@@ -298,11 +357,18 @@ The `bind` field is NOT expression-evaluated. It's a path string resolved by
}
```
-When `SchemaRendererProvider` receives
-`dataSource = { customerNames: ["Ada Lovelace", "Grace Hopper"] }`, `list` calls
-`useDataScope("customerNames")` and renders one entry per array element.
+When the host publishes
+`scope = { customerNames: ["Ada Lovelace", "Grace Hopper"] }` through
+`PredicateScopeProvider`, `list` calls `useDataScope("customerNames")` and
+renders one entry per array element.
+
+⛔ `bind` resolves against that same ambient scope — **not** against
+`SchemaRendererProvider`'s `dataSource`. That prop is the `DataSource` adapter,
+it has no member a `bind` path names, and objectui#9308 retired the walk over
+it. A `bind` on a page with no scope published above it resolves `undefined`,
+and each reader falls back to its own empty state.
-**Nested paths work:** `"bind": "app.settings.users"` resolves `dataSource.app.settings.users`.
+**Nested paths work:** `"bind": "app.settings.users"` resolves `scope.app.settings.users`.
### Which components read `bind`
@@ -390,7 +456,7 @@ section exists to close: binding `list` to ordinary records produces one empty
```jsonc
-// ✅ Bound data, already node-shaped: dataSource = { rows: [{ "content": "Ada" }, { "content": "Linus" }] }
+// ✅ Bound data, already node-shaped: scope = { rows: [{ "content": "Ada" }, { "content": "Linus" }] }
{ "type": "list", "bind": "rows" }
```
@@ -563,7 +629,7 @@ When an expression isn't working:
1. **Which key is it on, and does that type declare the key?** `content` and the predicate keys are evaluated and read on every type. `title` / `label` / `value` / `description` are evaluated **only on the types that declare them** — `statistic` (`label` / `value` / `description`), `card` (`title` / `description`), `button` (`label`) — and read raw everywhere else, including on a namespaced spelling such as `ui:statistic`. A `${...}` inside a `props` envelope is evaluated and then discarded. (A `properties` envelope is the one that is evaluated *and* hoisted onto the node — see [`rules/protocol.md`](../rules/protocol.md) for why that is recorded, not recommended.)
2. Is the `${}` syntax correct? Check for unmatched braces.
-3. Is the data actually available in scope? Check `SchemaRendererProvider dataSource`.
+3. Is the data actually available in scope? Check what the host published on `PredicateScopeProvider` — ⛔ not `SchemaRendererProvider`'s `dataSource`, which publishes no expression root.
4. For conditions: are you using `On` suffix correctly? (`hiddenOn` takes raw expression, `hidden` needs `${}` if it's a string).
5. Does the expression use a blocked pattern? Check for constructors, `eval`, `window`, etc.
6. Is type coercion causing issues? `${0 && "yes"}` returns `0`, not `false`.
diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md
index 9c39edab32..240192a8e3 100644
--- a/skills/objectui/rules/protocol.md
+++ b/skills/objectui/rules/protocol.md
@@ -134,7 +134,10 @@ dropped.** The rule above is about `props`. `properties` is the spec spelling of
the same bag, and `SchemaRenderer` evaluates it and then **hoists every key onto
the node** (`type` / `id` excepted) before the renderer runs — so it is read by
every namespace, not just `element:*`. Measured on `origin/main` `f1c27f037`
-with `dataSource = { label: "Evaluated Title" }`:
+with a host scope carrying `data = { label: "Evaluated Title" }` (published
+through `PredicateScopeProvider`). objectui#9308 retired the `dataSource`
+wiring this was first measured through; the envelope readings below are
+unchanged by that:
| node | rendered card header |
|---|---|
@@ -208,17 +211,27 @@ The `bind` field is NOT expression-evaluated. It's a path string resolved by `us
```jsonc
{
"type": "list",
- "bind": "customerNames" // Resolved as dataSource.customerNames
+ "bind": "customerNames" // Resolved against the ambient scope
}
```
-**Nested paths work:** `"bind": "app.settings.users"` resolves `dataSource.app.settings.users`.
+**Nested paths work:** `"bind": "app.settings.users"` resolves `scope.app.settings.users`.
+
+⛔ **The scope `bind` resolves against is the one a host publishes with
+`PredicateScopeProvider`, not `SchemaRendererProvider`'s `dataSource`.** That
+prop carries the `DataSource` **adapter** — it answers no `bind` path — and
+objectui#9308 retired the walk over it. The same ruling stopped the renderer
+publishing that adapter as the expression root `data`, so a `${data.*}` gate
+authored before it now reads whatever the HOST published under `data`, and
+nothing at all if the host published none. See "Available scope variables" in
+[`../guides/schema-expressions.md`](../guides/schema-expressions.md) for the
+verdict that move flips.
**Readers only.** `list` and `tree-view` (`@object-ui/components`) and the `object-*` plugin widgets call `useDataScope`. `data-table` does NOT: it reads its rows from an inline `data` array on the node, so a `bind` on it is ignored and the table renders its header over an empty body — no error, no warning.
**Provider rows into a `data-table`.** Measured on `origin/main` `f1c27f037`,
-real `SchemaRenderer` inside a `SchemaRendererProvider` holding
-`{ customers: [ 2 records ] }`, identical `columns` in every leg, reading
+real `SchemaRenderer` under a host scope publishing
+`data = { customers: [ 2 records ] }`, identical `columns` in every leg, reading
`tbody td`:
| node | rendered body cells |