diff --git a/.changeset/24300-api-binding-rendered.md b/.changeset/24300-api-binding-rendered.md new file mode 100644 index 00000000000..985bc21aefc --- /dev/null +++ b/.changeset/24300-api-binding-rendered.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +A page element's `api` data source is described as rendered, and `visibleWhen` says where `data` is mounted + +Clause-②: no (describe and TSDoc text only: `ElementApiDataSourceSchema` and `PageComponentSchema` accept exactly what they accepted before, and every refusal keeps its issue `code` and `path`) + +- `ElementApiDataSourceSchema`'s describe dropped its `[PLANNED — not yet rendered]` marker. It now says what the console renders: the console reads the binding on every page element and binds the value `path` selects as `data`, on that element and on every element nested inside it. The element's `visibleWhen` is decided once the read answers. While the read is in flight, or when the binding is refused or the read fails, the element draws a loading or error panel instead. +- `PageComponentSchema.visibleWhen` (its describe and its TSDoc) no longer says that no renderer reads the binding. The renderer mounts no `data` root on an element that no `api` data source binds, where a `data.*` read still faults and the gate never gates. On an element that one binds, `data` is the bound value. +- The `page:tabs` item `visibleWhen` describe's parenthetical now reads that the component-node evaluator mounts no `data` root "save on an element an `api` data source binds", where `data` is that binding's value. That surface's own evaluator still binds `data` to the record row. +- The binding's refusal and unknown-key messages no longer name a page element. For example, "`read.url` of a page element's `api` data source must be …" now reads "`read.url` of an `api` data source must be …", and the unknown-key surface is "this `api` data source". The `object` guidance names the object binding only where the `dataSource` takes one. Each message keeps its issue `code` and `path`. diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 017bc144f37..d00d6245501 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -46,9 +46,9 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | ### Nested Shape: `ActionNavItem.actionDef` @@ -385,9 +385,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -416,9 +416,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -450,9 +450,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -481,9 +481,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -923,9 +923,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -955,9 +955,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -988,9 +988,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1021,9 +1021,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1053,9 +1053,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1085,9 +1085,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | ### Nested Shape: `NavigationItem[type='action'].actionDef` @@ -1125,9 +1125,9 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1160,9 +1160,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1207,9 +1207,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1245,9 +1245,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1277,9 +1277,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1308,9 +1308,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- @@ -1340,9 +1340,9 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | --- diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 0dccae03fa7..e3959ff8139 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1543,7 +1543,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **label** | `string \| Record` | ✅ | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **icon** | `string` | optional | Lucide icon name rendered in the tab trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `key` beside it, which no read point takes and which the alias table answers with `value`. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface's own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root) and also spreads the row's bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface's own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root save on an element an `api` data source binds, where `data` is that binding's value) and also spreads the row's bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key. | | **value** | `string` | optional | Stable `?tab=` URL token for this tab (default: index-derived `tab-`, which is not durable across item-list changes) | | **count** | `integer` | optional | Badge count shown next to the tab label (default: derived from `record:related_list` descendants) | | **children** | `any[]` | ✅ | Child components | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 1b9e65a0093..b535d8fda78 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -24,22 +24,22 @@ const result = ElementApiDataSourceSchema.parse(data); ## ElementApiDataSource -[PLANNED — not yet rendered] API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads one same-origin endpoint with the console's signed-in fetch and binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`. The console's generic stat, usage-bar and checklist blocks are the planned readers; until they ship, the binding parses and no block reads it. +API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads one same-origin endpoint with the console's signed-in fetch and binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`. The console renders it on every page element: the value is `data` on that element and on every element nested inside it, the element's `visibleWhen` is decided once the read answers, and while the read is in flight, or when the binding is refused or the read fails, the element draws a loading or error panel instead. The console's generic `stat`, `usage-bar` and `checklist` blocks are built to take it. ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | ### Nested Shape: `ElementApiDataSource.read` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | ✅ | Same-origin API path the element reads — it opens with a single `/`, e.g. `/api/v1/cloud/home`. An absolute URL of any scheme, a protocol-relative `//host`, a backslash read as a slash, a space or control character, and a relative path are refused at parse. No credential is written here: the console reads the endpoint with its own signed-in fetch. | -| **method** | `'GET'` | optional | HTTP method of the read — `GET`, the only method a page element's read is sent with | +| **url** | `string` | ✅ | Same-origin API path the binding reads — it opens with a single `/`, e.g. `/api/v1/cloud/home`. An absolute URL of any scheme, a protocol-relative `//host`, a backslash read as a slash, a space or control character, and a relative path are refused at parse. No credential is written here: the console reads the endpoint with its own signed-in fetch. | +| **method** | `'GET'` | optional | HTTP method of the read — `GET`, the only method the read of an `api` data source is sent with | --- @@ -311,7 +311,7 @@ View filter rule | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -338,13 +338,13 @@ View filter rule ### Nested Shape: `PageComponent.dataSource[option 2]` -[PLANNED — not yet rendered] API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads one same-origin endpoint with the console's signed-in fetch and binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`. The console's generic stat, usage-bar and checklist blocks are the planned readers; until they ship, the binding parses and no block reads it. +API binding of a page element: `{ provider: 'api', read: { url }, path? }` reads one same-origin endpoint with the console's signed-in fetch and binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`. The console renders it on every page element: the value is `data` on that element and on every element nested inside it, the element's `visibleWhen` is decided once the read answers, and while the read is in flight, or when the binding is refused or the read fails, the element draws a loading or error panel instead. The console's generic `stat`, `usage-bar` and `checklist` blocks are built to take it. | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `'api'` | ✅ | Binds the element to one same-origin API endpoint instead of an object | -| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the element binds | -| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response | +| **provider** | `'api'` | ✅ | Binds one same-origin API endpoint — the discriminator of an `api` data source | +| **read** | `{ url: string; method?: 'GET' }` | ✅ | The read request — the same-origin endpoint the binding reads | +| **path** | `string` | optional | Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response | ### Nested Shape: `PageComponent.aria` @@ -483,7 +483,7 @@ Sort field and direction pair | **style** | `Record` | optional | Inline styles or utility classes | | **className** | `string` | optional | CSS class names | | **responsiveStyles** | `{ large?: Record; medium?: Record; small?: Record; xsmall?: Record }` | optional | Per-breakpoint scoped style maps (ADR-0065) | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" | | **visibility** | `never` | optional | [REMOVED] `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. | | **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint | | **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 16e1671f233..01066b94003 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -255,7 +255,7 @@ The API binding's rules: - **Same-origin only.** `read.url` must open with a single `/`. An absolute URL of any scheme (`https:`, `mailto:`, `javascript:`, …), a protocol-relative `//host`, a backslash the browser reads as a slash, a space or control character, and a relative path such as `./home` are all refused when the page is parsed (`definePage()`, `os validate`, `os build`), with the reason. - **No credentials in metadata.** The console reads the endpoint with its own signed-in fetch, so the read block has no `headers`, and a credential key such as `headers`, `credentials` or `token` is refused. - **Reads only.** `read.method` is optional and accepts `GET` alone; there is no `write` block. -- **Not rendered yet.** The console's generic blocks that read an API binding (a stat, a usage bar, a checklist) have not shipped: until they do, the binding parses and no block reads it. +- **Rendered by the console.** The console reads the binding on every page element and binds the selected value as `data`: in the element's expression holes (`${data.used}`), in its `visibleWhen`, and in every element nested inside it. The console's generic `hero`, `stat`, `usage-bar`, `checklist` and `tile-grid` blocks are built to take it as their `dataSource`. While the read is in flight the element shows a loading panel, and a refused binding or a failed read shows an error panel instead. ## Variables diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index 26d4f08ec8c..6a21ec97806 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -1,6 +1,6 @@ { "type": "page", - "_note": "PageSchema (UI). Renderers live in objectui; objectui paths are cited with the `objectui:` realm marker (in `evidence` or `note` prose) and counted, not resolved against this repo (#5623). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. The removed roadmap page types (record_review/blank) and their config fields (recordReview/blankLayout) were hard-removed — no longer authorable. RETIREMENT RECORD (#11027, 2026-08-24): `regions[].components[].responsive` is a retiredKey tombstone (ADR-0049 D2) — measured dead across both repos (zero callers of either objectui consumer implementation); the shared `ResponsiveConfigSchema` left with it. The walk drills one level only, so no component-level key has a row here (`page/regions` stays RECORDED in undrilled-containers.baseline.json — #4956's page-side instance, still open for the other component keys); the checkable record of THIS retirement is the authorable-surface `ui/PageComponent:responsive [RETIRED]` row, RETIRED_KEYS_BY_MAJOR[18], and the `page-component-responsive-removed` conversion. PLANNED RECORD (#23016, 2026-10-12, maintainer ruling in the card body): `regions[].components[].dataSource` gained a second shape, the API binding `ElementApiDataSourceSchema` (`{ provider: 'api', read: { url, method? }, path? }`, `read.url` a same-origin API path refused at parse otherwise, no credential key) — PLANNED, not live: measured at objectui @7578323a544e, the page-element data-source hook admits only a binding with a string `object` (objectui: isElementDataSourceConfig in packages/core/src/data-scope/element-data-source.ts), so no block reads an API binding yet. Carrier: objectstack-ai/objectui#12197 (the generic stat / usage-bar / checklist blocks, re-scoped by the same ruling). No row carries it because the walk never reaches a component-level key (`page/regions` and `page/slots` are recorded undrilled containers) and `childShape()` does not descend the `z.union` the key now is; the checkable record is the authorable-surface `ui/ElementApiDataSource:*` rows and the schema's own `[PLANNED — not yet rendered]` describe. When objectui#12197 lands, that describe marker leaves and this sentence is rewritten. PLANNED RECORD (#23134, 2026-10-13, ruling B on objectstack-ai/objectui#12296, ruling comment 6137226306): `regions[].components[].properties.parentDataSource` on `record:related_list` (`RecordRelatedListProps` in packages/spec/src/ui/component.zod.ts) is the block's PARENT-record binding, the element object binding `ElementDataSourceSchema` by reference, read by the single-record rule (the first row by `sort`; no row is a loud absence, never an empty list); the node-level `dataSource` on the block keeps its child-row meaning (#6953). PLANNED, not live: measured at objectui @f0532d8a, the block reads its parent from the record context only (objectui: packages/plugin-detail/src/renderers/record-related-list.tsx, whose RecordRelatedListBody reads useRecordContext()) and nothing reads the key. Carrier: the related-list leg of objectstack-ai/objectui#12294, dispatched once a spec release carrying the key is installable. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordRelatedListProps:parentDataSource` row and the key's own `[PLANNED — not yet rendered]` describe. When that leg lands, the marker leaves and this sentence is rewritten. PLANNED RECORD (#24266, 2026-10-18, objectstack-ai/cloud#2774 Q2 = B as the card quotes it): `regions[].components[].properties.emptyState` on the three bound record blocks `record:details`, `record:highlights` and `record:quick_actions` (`RecordDetailsProps`, `RecordHighlightsProps`, `RecordQuickActionsProps` in packages/spec/src/ui/component.zod.ts) is the list view's own `EmptyStateSchema` by reference, drawn only when the block's node-level `dataSource` binding answers no row. PLANNED, not live: measured at objectui @79cb8c03e, the three blocks take their binding through objectui: packages/plugin-detail/src/renderers/recordBinding.tsx, which mounts a 0-row answer as a record context with no record, so each draws its own placeholder and nothing reads the key. Carrier: objectstack-ai/objectui#12932, dispatched once a spec release carrying the key is installable. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordDetailsProps:emptyState`, `ui/RecordHighlightsProps:emptyState` and `ui/RecordQuickActionsProps:emptyState` rows and the keys' own `[PLANNED — not yet rendered]` describe. When that card lands, the marker leaves and this sentence is rewritten.", + "_note": "PageSchema (UI). Renderers live in objectui; objectui paths are cited with the `objectui:` realm marker (in `evidence` or `note` prose) and counted, not resolved against this repo (#5623). Seeded from the Studio page-design dogfood (framework#2254/#2261/#2265). Containers (variables/regions/interfaceConfig/slots/aria) classified at top level — one drill level, no divergent sub-statuses. The removed roadmap page types (record_review/blank) and their config fields (recordReview/blankLayout) were hard-removed — no longer authorable. RETIREMENT RECORD (#11027, 2026-08-24): `regions[].components[].responsive` is a retiredKey tombstone (ADR-0049 D2) — measured dead across both repos (zero callers of either objectui consumer implementation); the shared `ResponsiveConfigSchema` left with it. The walk drills one level only, so no component-level key has a row here (`page/regions` stays RECORDED in undrilled-containers.baseline.json — #4956's page-side instance, still open for the other component keys); the checkable record of THIS retirement is the authorable-surface `ui/PageComponent:responsive [RETIRED]` row, RETIRED_KEYS_BY_MAJOR[18], and the `page-component-responsive-removed` conversion. LIVE RECORD (#23016, 2026-10-12, maintainer ruling in the card body; rewritten from its PLANNED record by #24300 once the renderer shipped): `regions[].components[].dataSource` gained a second shape, the API binding `ElementApiDataSourceSchema` (`{ provider: 'api', read: { url, method? }, path? }`, `read.url` a same-origin API path refused at parse otherwise, no credential key) — LIVE: measured at the console pin `.objectui-sha` @0707b4ae3712, which carries the renderer, objectui @c4dd7078ed (objectstack-ai/objectui#12197). The console reads it on every page element (objectui: packages/react/src/SchemaRenderer.tsx calls useElementApiDataSource for each node it renders; the read is objectui: packages/react/src/element-data-source/useElementApiDataSource.ts, which parses the binding with this schema first, sends one GET to `read.url` through the host's signed-in fetch and selects `path`) and binds the selected value as `data` in the element's expression holes, in its `visibleWhen` and in its subtree, drawing a loading or error panel until the value is in; the generic stat / usage-bar / checklist blocks take it as their `dataSource` (objectui: packages/app-shell/src/console/blocks/productBlocks.tsx). No row carries it because the walk never reaches a component-level key (`page/regions` and `page/slots` are recorded undrilled containers) and `childShape()` does not descend the `z.union` the key now is; the checkable record is the authorable-surface `ui/ElementApiDataSource:*` rows and the schema's own describe, which no longer carries the `[PLANNED — not yet rendered]` marker. PLANNED RECORD (#23134, 2026-10-13, ruling B on objectstack-ai/objectui#12296, ruling comment 6137226306): `regions[].components[].properties.parentDataSource` on `record:related_list` (`RecordRelatedListProps` in packages/spec/src/ui/component.zod.ts) is the block's PARENT-record binding, the element object binding `ElementDataSourceSchema` by reference, read by the single-record rule (the first row by `sort`; no row is a loud absence, never an empty list); the node-level `dataSource` on the block keeps its child-row meaning (#6953). PLANNED, not live: measured at objectui @f0532d8a, the block reads its parent from the record context only (objectui: packages/plugin-detail/src/renderers/record-related-list.tsx, whose RecordRelatedListBody reads useRecordContext()) and nothing reads the key. Carrier: the related-list leg of objectstack-ai/objectui#12294, dispatched once a spec release carrying the key is installable. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordRelatedListProps:parentDataSource` row and the key's own `[PLANNED — not yet rendered]` describe. When that leg lands, the marker leaves and this sentence is rewritten. PLANNED RECORD (#24266, 2026-10-18, objectstack-ai/cloud#2774 Q2 = B as the card quotes it): `regions[].components[].properties.emptyState` on the three bound record blocks `record:details`, `record:highlights` and `record:quick_actions` (`RecordDetailsProps`, `RecordHighlightsProps`, `RecordQuickActionsProps` in packages/spec/src/ui/component.zod.ts) is the list view's own `EmptyStateSchema` by reference, drawn only when the block's node-level `dataSource` binding answers no row. PLANNED, not live: measured at objectui @79cb8c03e, the three blocks take their binding through objectui: packages/plugin-detail/src/renderers/recordBinding.tsx, which mounts a 0-row answer as a record context with no record, so each draws its own placeholder and nothing reads the key. Carrier: objectstack-ai/objectui#12932, dispatched once a spec release carrying the key is installable. No row carries it for the reason above (component-level keys sit under the recorded undrilled `page/regions` container); the checkable record is the authorable-surface `ui/RecordDetailsProps:emptyState`, `ui/RecordHighlightsProps:emptyState` and `ui/RecordQuickActionsProps:emptyState` rows and the keys' own `[PLANNED — not yet rendered]` describe. When that card lands, the marker leaves and this sentence is rewritten.", "props": { "source": { "status": "live", diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 754fd8346ef..f76753ce997 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -955,7 +955,11 @@ export const PageTabsProps = strictObject({ * behaviour, NOT contract-guaranteed: * * * **`data` is the record ROW here**; the component-node evaluator - * mounts no `data` root at all (see `PageComponentSchema.visibleWhen`). + * mounts no `data` root save on an element an `api` data source + * binds, where `data` is the value that binding selects (see + * `PageComponentSchema.visibleWhen`). Inside such an element `data` + * stays the row on this surface: the item evaluator writes `data` + * after the ambient scope. * * **The row's bare fields are spread flat**, so `status` resolves as * well as `record.status`. The ambient scope is spread AFTER the row, * so an ambient root (`features`, `user`, …) wins over a record @@ -977,7 +981,7 @@ export const PageTabsProps = strictObject({ * being pointed AT `visibleWhen` is not the same as being accepted). */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe( - 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface\'s own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root) and also spreads the row\'s bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.', + 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface\'s own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root save on an element an `api` data source binds, where `data` is that binding\'s value) and also spreads the row\'s bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.', ), /** * Stable URL token for this tab — the value `?tab=` carries and the diff --git a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts index 8eac727889b..c1f453612c7 100644 --- a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#23036] A page component's `visibleWhen` has no `data` root, and neither of - * its two prose faces may say the shipping renderer mounts one. + * [#23036] A page component's `visibleWhen` has no `data` root of its own — + * the one `data` there is an `api` data source's bound value — and neither of + * its two prose faces may say the shipping renderer mounts one anywhere else. * * ## The fact being pinned * @@ -18,22 +19,33 @@ * refuses it. #17203 corrected the same sentence for `app` * (`expression-scope-app-root.pin.test.ts`); this file holds the `data` half. * + * ## The one `data` that is mounted (#24300) + * + * The API binding (`ElementApiDataSourceSchema`, `{ provider: 'api', … }`) is + * rendered by the console at its pin: `SchemaRenderer` reads it on every node + * (`useElementApiDataSource`) and binds the value its `path` selects as `data` + * in that node's holes and `visibleWhen`, and publishes it to the node's + * subtree through `PredicateScopeProvider`. So `data` IS mounted on an element + * an `api` data source binds, and nowhere else on this surface. + * * ## What is pinned: the claim, not the wording * * 1. The mounted-roots sentence names exactly `features` and `os.user`, on both * faces (the published `.describe()` and the TSDoc an AI author reads). * 2. On the published face, every sentence that names `data` says it is NOT - * mounted here, ties it to the PLANNED API binding, or is about the - * `page:tabs` item evaluator — the three readings that are true today. - * 3. The two texts in this file agree. The `visibleWhen` face names the - * PLANNED API binding as the only `data` carrier, which holds exactly while - * `ElementApiDataSourceSchema`'s own describe still carries - * `[PLANNED — not yet rendered]`. When that binding's renderer ships and its - * describe drops the marker, this pin goes red so the `visibleWhen` face is - * rewritten in the same change, not left behind. + * mounted where no `api` data source binds it, ties it to that binding + * (`provider: 'api'`), or is about the `page:tabs` item evaluator — the + * three readings that are true. A sentence that says `data` is mounted on + * this surface without the binding is refused. + * 3. The three texts agree that the binding is RENDERED: the binding's own + * describe, and both `visibleWhen` faces, carry no planned marker and no + * "not rendered yet" reading, and the `visibleWhen` faces name the binding + * as where `data` is mounted. A text that still reads the binding as + * planned tells an author not to use a binding the console renders. * * Rewording any of these sentences is free; a sentence that tells an author - * the renderer binds `data` on this surface is not. + * the renderer binds `data` on this surface without an `api` data source, or + * that the binding is not rendered, is not. */ import fs from 'node:fs'; @@ -111,14 +123,31 @@ const backticked = (s: string): string[] => [...s.matchAll(/`([^`]+)`/g)].map((m const dataClaimsOutsideTheTrueReadings = (text: string): string[] => sentences(text) .filter((s) => s.includes('`data`')) - .filter((s) => !/\bNO `data`/.test(s) && !s.includes('PLANNED') && !s.includes('`page:tabs`')); + .filter((s) => !/\bNO `data`/.test(s) && !s.includes("provider: 'api'") && !s.includes('`page:tabs`')); /** The sentence shapes the retired claim was written in, on either face. */ const RETIRED_CLAIM_SHAPES = [/binds `data` to the data-source/i, /`data` is the data-source/i] as const; +/** + * The readings that call the API binding unrendered — its old marker and the + * sentences that went with it. Any one of them, on any of the three texts, + * tells an author not to use a binding the console renders. + */ +const PLANNED_READINGS = [ + /PLANNED/, + /not yet rendered/i, + /no renderer reads/i, + /renderer ships/i, + /planned readers?/i, + /until they ship/i, +] as const; + +/** The planned readings a text still carries — returned, so a control can run it on a text that MUST fail. */ +const plannedReadingsIn = (text: string): RegExp[] => PLANNED_READINGS.filter((shape) => shape.test(text)); + const MOUNTED_ROOTS = ['features', 'os.user']; -describe('a page component `visibleWhen` names no `data` root — the shipping renderer mounts none', () => { +describe('a page component `visibleWhen` names no `data` root but the `api` data source\'s — the shipping renderer mounts no other', () => { describe('published face — `PageComponentSchema.visibleWhen`.describe()', () => { it('the mounted-roots sentence names exactly `features` and `os.user`', () => { const s = sentenceAt(visibleWhenDescription(), 'additionally mounts'); @@ -156,6 +185,14 @@ describe('a page component `visibleWhen` names no `data` root — the shipping r expect(doc).toContain('mounts NO `data` root'); for (const shape of RETIRED_CLAIM_SHAPES) expect(doc).not.toMatch(shape); }); + + it('names the `api` data source as where `data` is mounted, and the read point that mounts it', () => { + const doc = visibleWhenDocblock(); + const s = sentenceAt(doc, 'The one `data` this surface has'); + expect(s).toContain("provider: 'api'"); + expect(s).toContain('`useElementApiDataSource`'); + expect(s).toContain('objectui: packages/react/src/SchemaRenderer.tsx'); + }); }); describe('the `page:tabs` item twin — the TSDoc above its `visibleWhen` in `component.zod.ts`', () => { @@ -165,31 +202,37 @@ describe('a page component `visibleWhen` names no `data` root — the shipping r expect(start, 'docblock anchor not found — the pin has drifted off its site').toBeGreaterThan(-1); expect(end, 'property anchor not found after the docblock').toBeGreaterThan(start); const doc = componentSource.slice(start, end).replace(/\s*\n\s*\*\s*/g, ' '); - expect(doc).toContain('the component-node evaluator mounts no `data` root'); + expect(doc).toContain('the component-node evaluator mounts no `data` root save on an element an `api` data source binds'); expect(doc).not.toMatch(/binds it to the data-source ADAPTER/i); + expect(doc).not.toMatch(/mounts no `data` root at all/); for (const shape of RETIRED_CLAIM_SHAPES) expect(doc).not.toMatch(shape); }); }); - describe('the two texts in this file agree — the PLANNED API binding is the only `data` carrier', () => { - it('`visibleWhen` names the PLANNED API binding as where `data` is declared', () => { - const s = sentences(visibleWhenDescription()).find((x) => x.includes('PLANNED')); - expect(s, 'the describe no longer names the planned binding').toBeTruthy(); + describe('the three texts agree — the API binding is rendered, and it is the one `data` carrier', () => { + it('`visibleWhen` names the API binding as where `data` is mounted', () => { + const s = sentences(visibleWhenDescription()).find((x) => x.includes("provider: 'api'")); + expect(s, 'the describe no longer names the API binding').toBeTruthy(); expect(s).toContain('`data`'); - expect(s).toContain("provider: 'api'"); + expect(s).toContain('mounted only on an element'); }); - it('…which holds only while the API binding is still PLANNED and still declares a `data` read in `visibleWhen`', () => { + it('the API binding\'s describe says it is rendered and still declares the `data` read in `visibleWhen`', () => { const api = ElementApiDataSourceSchema.description ?? ''; - expect( - api.startsWith('[PLANNED — not yet rendered]'), - 'The API binding is no longer marked PLANNED, so its renderer has shipped: on an element carrying ' - + 'that binding `data` is now mounted. Rewrite `PageComponentSchema.visibleWhen`\'s `data` sentence ' - + '(describe and docblock) in the same change, then this assertion.', - ).toBe(true); + expect(api).toContain('The console renders it on every page element'); expect(api).toContain('`data`'); expect(api).toContain('`visibleWhen`'); }); + + it('no text reads the binding as planned — not its describe, not either `visibleWhen` face', () => { + for (const [face, text] of [ + ['ElementApiDataSourceSchema.describe()', ElementApiDataSourceSchema.description ?? ''], + ['PageComponentSchema.visibleWhen.describe()', visibleWhenDescription()], + ['the docblock above `visibleWhen`', visibleWhenDocblock()], + ] as const) { + expect(plannedReadingsIn(text).map(String), face).toEqual([]); + } + }); }); describe('probe controls — a pass above is only a reading if these hold', () => { @@ -209,6 +252,37 @@ describe('a page component `visibleWhen` names no `data` root — the shipping r expect(RETIRED_CLAIM_SHAPES.some((shape) => shape.test(replaced))).toBe(true); }); + /** + * DARK. A sentence that mounts `data` on this surface WITHOUT the binding + * — the over-correction this rewrite could make — fails rule (2), so its + * `toEqual([])` above is a reading and not a matcher that admits anything. + */ + it('DARK: a sentence mounting `data` without the `api` data source fails rule (2)', () => { + const overclaim = 'Contract-bound roots: `record`, `current_user`, and page state as `page.`. ' + + 'The shipping renderer also mounts `data` on every element, the value its data source reads.'; + expect(dataClaimsOutsideTheTrueReadings(overclaim)).toHaveLength(1); + }); + + /** + * DARK. The texts this card replaced — the binding's describe and the + * `visibleWhen` describe's `data` sentence verbatim, and the docblock's + * closing sentences with the comment gutter stripped. The planned matcher + * must catch each, or the three-text `toEqual([])` above could be passing + * on a matcher that matches nothing. + */ + it('DARK: the replaced planned texts are caught by the planned-reading matcher', () => { + const replacedApi = '[PLANNED — not yet rendered] API binding of a page element: `{ provider: \'api\', read: { url }, path? }` ' + + 'reads one same-origin endpoint with the console\'s signed-in fetch and binds the selected value, which the ' + + 'element reads as `data` in its expression holes and its `visibleWhen`. The console\'s generic stat, ' + + 'usage-bar and checklist blocks are the planned readers; until they ship, the binding parses and no block reads it.'; + const replacedVisibleWhen = '`data` is declared only for an element whose `dataSource` is the PLANNED API binding ' + + '(`provider: \'api\'`), which reads its bound value as `data` once that binding\'s renderer ships.'; + const replacedDocblock = 'binding\'s renderer ships. No renderer reads that binding yet, so no element has a `data` to read today.'; + expect(plannedReadingsIn(replacedApi).length).toBeGreaterThan(0); + expect(plannedReadingsIn(replacedVisibleWhen).length).toBeGreaterThan(0); + expect(plannedReadingsIn(replacedDocblock).length).toBeGreaterThan(0); + }); + /** DARK. The sentence splitter keeps dotted tokens and "e.g." inside one sentence. */ it('DARK: dotted tokens do not split a sentence', () => { expect(sentences('Mounts `os.user` and `page.`; e.g. "page.x != \'\'". Next one.')).toHaveLength(2); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 448b9617687..209357d037d 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -421,7 +421,7 @@ const ELEMENT_API_URL_CLASSES: ReadonlyArray<{ test: (url: string) => boolean; w ]; /** - * The refusal an element `api` data source's `read.url` raises — the rule, the + * The refusal an `api` data source's `read.url` raises — the rule, the * class the value fell in, and the fix. ⛔ The value itself is never echoed: a * URL an author pasted may carry a credential in its user-info or query. */ @@ -431,14 +431,14 @@ function elementApiReadUrlRefusal(input: unknown): string | undefined { const why = ELEMENT_API_URL_CLASSES.find((c) => c.test(input))?.why ?? 'This value does not open with `/`, so it is a relative path: it resolves against the page\'s ' + 'own address, never the API.'; - return '`read.url` of a page element\'s `api` data source must be a same-origin API path — one that ' + return '`read.url` of an `api` data source must be a same-origin API path — one that ' + 'opens with a single `/`, such as `/api/v1/cloud/home`. ' + why + ' Write the endpoint\'s path ' + 'on this deployment\'s own origin. The console reads it with its own signed-in fetch, so a host, ' + 'a scheme or a credential is never written in metadata.'; } /** - * The dotted path an element `api` data source selects from the response — + * The dotted path an `api` data source selects from the response — * `data.environments` in a `{ success, data }` envelope. Segments of letters, * digits, `_` and `-`, joined by single dots: no brackets, no leading `$`, no * slashes, so a JSONPath or a URL path written here is refused rather than @@ -446,7 +446,7 @@ function elementApiReadUrlRefusal(input: unknown): string | undefined { */ const ELEMENT_API_RESPONSE_PATH_RE = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/; -/** What an author who reached for a credential on an element `api` binding is told. */ +/** What an author who reached for a credential on an `api` data source is told. */ const ELEMENT_API_NO_CREDENTIALS = 'No credential is written in metadata: the console reads the endpoint with its own signed-in ' + 'fetch, so the request already carries the user\'s session. Delete this key.'; @@ -480,16 +480,30 @@ const ELEMENT_API_NO_CREDENTIALS = * overrides the request config's own, so any other value here would declare a * method the renderer never sends. * - * ## Planned, not yet rendered + * ## Rendered by the console, on a page element * - * No renderer reads this binding yet: the page-element - * data-source hook (`useElementDataSource`, `@object-ui/react`) admits only a - * binding with a string `object` (`isElementDataSourceConfig`), so an `api` - * binding reaches no block yet. The generic blocks that read it — stat, usage - * bar, checklist — are objectui#12197, re-scoped by the same ruling and - * sequenced after this shape lands. The liveness record is the - * `liveness/page.json` `_note`: the ledger's walk stops at the undrilled - * `page/regions` container, so no component-level key can carry a row. + * The console reads this binding on every page element, whatever its type + * (objectui: packages/react/src/SchemaRenderer.tsx, which calls + * `useElementApiDataSource` for each node it renders; the read itself is + * objectui: packages/react/src/element-data-source/useElementApiDataSource.ts). + * It parses the binding with this schema before anything else, so a binding + * refused here is refused there, with this schema's own message, and nothing + * is read. + * Otherwise it sends one `GET` to `read.url` through the console's signed-in + * fetch and selects `path` from the JSON answer. The selected value is the + * element's `data`: in its expression holes (`${data.used}`), in its + * `visibleWhen`, and in every element nested inside it, to which it is + * published through the predicate scope. While the read is in flight the + * element draws a loading panel; a refused binding or a failed read (a non-2xx + * answer, a body that is not JSON, a `path` that selects nothing) draws an + * error panel naming which. The element's `visibleWhen` is decided only once + * the value is in. The console's generic `stat`, `usage-bar` and `checklist` + * blocks (objectui: packages/app-shell/src/console/blocks/productBlocks.tsx) + * are built to take it as their `dataSource`. Measured at the console pin + * (`.objectui-sha` `0707b4ae37`), which carries the renderer + * (objectui#12197). The liveness record is the `liveness/page.json` `_note`: + * the ledger's walk stops at the undrilled `page/regions` container, so no + * component-level key can carry a row. * * ## Why `ElementDataSourceSchema` is unchanged * @@ -503,16 +517,17 @@ const ELEMENT_API_NO_CREDENTIALS = * the shared union-branch policy renders. */ export const ElementApiDataSourceSchema = lazySchema(() => strictObject({ - surface: 'this element `api` data source', - history: 'A key an element `api` data source does not declare would otherwise be dropped in silence, ' - + 'and the element would read without the setting.', + surface: 'this `api` data source', + history: 'A key an `api` data source does not declare would otherwise be dropped in silence, ' + + 'and the endpoint would be read without the setting.', aliases: { response: 'path', select: 'path', dataPath: 'path', responsePath: 'path', fetch: 'read', request: 'read' }, guidance: { url: 'Set the URL inside the read block: `read: { url }`.', method: 'Set the method inside the read block: `read: { url, method }`.', - write: 'A page element only reads: an `api` data source declares `read` and no `write`.', - object: 'An `api` data source reads an API endpoint, not an object. To bind an object, drop ' - + '`provider` and `read` and keep `object`; to read an endpoint, drop `object`.', + write: 'An `api` data source only reads: it declares `read` and no `write`.', + object: 'An `api` data source reads an API endpoint, not an object: to read the endpoint, drop ' + + '`object`. Where the `dataSource` also takes the object binding (a page element\'s does), bind ' + + 'the object with that instead: drop `provider` and `read` and keep `object`.', headers: ELEMENT_API_NO_CREDENTIALS, credentials: ELEMENT_API_NO_CREDENTIALS, auth: ELEMENT_API_NO_CREDENTIALS, @@ -521,11 +536,11 @@ export const ElementApiDataSourceSchema = lazySchema(() => strictObject({ apiKey: ELEMENT_API_NO_CREDENTIALS, }, }, { - provider: z.literal('api').describe('Binds the element to one same-origin API endpoint instead of an object'), + provider: z.literal('api').describe('Binds one same-origin API endpoint — the discriminator of an `api` data source'), read: strictObject({ - surface: 'the `read` request of this element `api` data source', + surface: 'the `read` request of this `api` data source', history: 'A key this read request does not declare would otherwise be dropped in silence, and the ' - + 'element would read without the setting.', + + 'endpoint would be read without the setting.', aliases: { href: 'url', endpoint: 'url', path: 'url', verb: 'method' }, guidance: { headers: ELEMENT_API_NO_CREDENTIALS, @@ -535,26 +550,26 @@ export const ElementApiDataSourceSchema = lazySchema(() => strictObject({ token: ELEMENT_API_NO_CREDENTIALS, apiKey: ELEMENT_API_NO_CREDENTIALS, params: 'Write query parameters into the path itself: `read: { url: \'/api/v1/x?limit=5\' }`.', - body: 'A page element\'s read is a `GET` and sends no body.', + body: 'The read of an `api` data source is a `GET` and sends no body.', }, }, { url: z.string() .regex(SAME_ORIGIN_API_PATH_RE, { error: (issue) => elementApiReadUrlRefusal(issue.input) }) - .describe('Same-origin API path the element reads — it opens with a single `/`, e.g. `/api/v1/cloud/home`. An absolute URL of any scheme, a protocol-relative `//host`, a backslash read as a slash, a space or control character, and a relative path are refused at parse. No credential is written here: the console reads the endpoint with its own signed-in fetch.'), + .describe('Same-origin API path the binding reads — it opens with a single `/`, e.g. `/api/v1/cloud/home`. An absolute URL of any scheme, a protocol-relative `//host`, a backslash read as a slash, a space or control character, and a relative path are refused at parse. No credential is written here: the console reads the endpoint with its own signed-in fetch.'), method: z.literal('GET', { - error: () => 'A page element\'s `api` read is sent as `GET`: the console issues every read with ' + error: () => 'The read of an `api` data source is sent as `GET`: the console issues every read with ' + '`GET`, whatever the request declares, so no other method is honoured. Omit `method` or write `\'GET\'`.', - }).optional().describe('HTTP method of the read — `GET`, the only method a page element\'s read is sent with'), - }).describe('The read request — the same-origin endpoint the element binds'), + }).optional().describe('HTTP method of the read — `GET`, the only method the read of an `api` data source is sent with'), + }).describe('The read request — the same-origin endpoint the binding reads'), path: z.string() .regex(ELEMENT_API_RESPONSE_PATH_RE, { - error: () => '`path` of a page element\'s `api` data source is a dotted path into the response, ' + error: () => '`path` of an `api` data source is a dotted path into the response, ' + 'such as `data.environments`: segments of letters, digits, `_` or `-` joined by single dots — ' + 'no brackets, no leading `$`, no slashes.', }) .optional() - .describe('Dotted path selecting the sub-tree of the response the element binds, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the element binds the whole response'), -}).describe('[PLANNED — not yet rendered] API binding of a page element: `{ provider: \'api\', read: { url }, path? }` reads one same-origin endpoint with the console\'s signed-in fetch and binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`. The console\'s generic stat, usage-bar and checklist blocks are the planned readers; until they ship, the binding parses and no block reads it.')); + .describe('Dotted path selecting the sub-tree of the response the binding takes, e.g. `data.environments` in a `{ success, data }` envelope; omitted, the binding takes the whole response'), +}).describe('API binding of a page element: `{ provider: \'api\', read: { url }, path? }` reads one same-origin endpoint with the console\'s signed-in fetch and binds the selected value, which the element reads as `data` in its expression holes and its `visibleWhen`. The console renders it on every page element: the value is `data` on that element and on every element nested inside it, the element\'s `visibleWhen` is decided once the read answers, and while the read is in flight, or when the binding is refused or the read fails, the element draws a loading or error panel instead. The console\'s generic `stat`, `usage-bar` and `checklist` blocks are built to take it.')); /** * Page Component Schema @@ -716,25 +731,32 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * `current_user`, `user`, `ctx.user`, `os.user`, `features`, and nothing * else; `configEvaluationScope` composes this surface's evaluator from it, * adding `record` (only when the page has a row — on an app page, once the - * renderer mounts the page's `record` binding) and `page`. + * renderer mounts the page's `record` binding) and `page`. The one root + * added beside those is the `api` data source's `data`, below. * - * ## No `data` root on this surface + * ## `data` — only where an `api` data source binds it * - * The shipping renderer mounts NO `data` root here. objectui#9308 (ruling - * B, 2026-09-13) removed `data` as a runtime expression root: - * `SchemaRenderer` stopped publishing its injected data source under that - * name, and a data scope reaches a predicate - * only through the predicate-scope channel (`PredicateScopeProvider`), - * whose one shipping feeder, `ExpressionProvider`, publishes no `data`. So a - * `data.*` read here faults, and a faulting `visibleWhen` renders the - * component: `data.total > 0` and `data.total == 0` both show it, a gate - * that never gates. The row the page is about is `record`. + * The shipping renderer mounts NO `data` root on an element that no `api` + * data source binds. objectui#9308 (ruling B, 2026-09-13) removed `data` as + * a runtime expression root: `SchemaRenderer` stopped publishing its + * injected data source under that name, and a data scope reaches a + * predicate only through the predicate-scope channel + * (`PredicateScopeProvider`), whose ambient feeder, `ExpressionProvider`, + * publishes no `data`. So on such an element a `data.*` read faults, and a + * faulting `visibleWhen` renders the component: `data.total > 0` and + * `data.total == 0` both show it, a gate that never gates. The row the page + * is about is `record`. * - * The one `data` this surface declares is the PLANNED API binding's (see - * {@link ElementApiDataSourceSchema}): an element whose `dataSource` is - * `{ provider: 'api', … }` reads its bound value as `data` once that - * binding's renderer ships. No renderer reads that binding yet, so no - * element has a `data` to read today. + * The one `data` this surface has is the API binding's (see + * {@link ElementApiDataSourceSchema}): on an element whose `dataSource` is + * `{ provider: 'api', … }`, `data` is the value the binding selects, in + * that element's own `visibleWhen` and in every element nested inside it + * (objectui: packages/react/src/SchemaRenderer.tsx, which calls + * `useElementApiDataSource` for the node and publishes the value to its + * subtree through `PredicateScopeProvider`). So `data.used < data.limit` + * gates there. That `visibleWhen` is decided once the read has answered: + * while it is in flight, or when the binding is refused or the read fails, + * the element draws the binding's loading or error panel instead. * * ## ⚠️ A `page:tabs` item is a different evaluator * @@ -744,7 +766,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * this surface does not have. See that key's own describe in * `component.zod.ts` rather than assuming either environment carries over. */ - visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. \"page.selectedProjectId != ''\""), + visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root on an element that no `api` data source binds: there a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record` — the record a record page opens, or the row an app page's `record` binding reads (none without one; a block's own `dataSource` binds no `record` for its siblings). A count or a plan is a field on that row (a `summary` roll-up: `record.environment_count > 1`; `record.plan != 'free'`), never `features.*`, which are the deployment's public auth flags, the same for every organisation. `data` is mounted only on an element whose `dataSource` is an `api` data source (`provider: 'api'`): there, and on every element nested inside it, `data` is the value the binding selects, and the element's `visibleWhen` is decided once the read answers. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. \"page.selectedProjectId != ''\""), // `visibility` REMOVED at 18 (#23543 F4, ADR-0089 D2): through 17 it was the // page component's deprecated spelling of `visibleWhen`, folded onto it at // parse by a `.transform` that went with the key. The tombstone carries the @@ -767,7 +789,8 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * so one page can query several objects; * - the API binding ({@link ElementApiDataSourceSchema}) `{ provider: 'api', * read: { url }, path? }`, which reads one same-origin endpoint with the - * console's signed-in fetch (planned in the renderer — see its docblock). + * console's signed-in fetch and binds the value as the element's `data` + * (rendered by the console — see its docblock). * * A plain `z.union`, object arm first: the API arm is told apart by its * required `provider: 'api'`, and the object arm keeps its def byte-identical