diff --git a/.changeset/22611-list-view-dashboard-required-permissions.md b/.changeset/22611-list-view-dashboard-required-permissions.md
new file mode 100644
index 00000000000..c5923114a3f
--- /dev/null
+++ b/.changeset/22611-list-view-dashboard-required-permissions.md
@@ -0,0 +1,12 @@
+---
+"@objectstack/spec": minor
+---
+
+feat(spec): a list view and a dashboard take `requiredPermissions`, the audience gate, declared now and not enforced yet
+
+Clause-②: yes (widening)
+
+- **What is new.** `ListViewSchema` and `DashboardSchema` each accept `requiredPermissions`: a list of capability names (the names permission sets grant through `systemPermissions`) a user must ALL hold. It is the same key, with the same shape (`string[]`, optional, no default) and the same meaning, as `requiredPermissions` on an app, a navigation item and an action. Before this release both schemas refused the key as an unrecognized key. Because a list view's shape is shared, the key reaches every list-view door: a view container's `list` and `listViews` entries, an object's `listViews`, and a view item's `config`.
+- **Not enforced yet.** No server reads either key in this release. A gated list view or dashboard is still served to every user who can read it, so do not rely on the key to keep a view or a dashboard from an audience yet. The key's description says so, and `os lint` / `os validate` warn (`liveness-planned-property`) when a dashboard or a view container's `list` sets it. Enforcement on the `/meta` reads of `view` and `dashboard` (the list read and the by-name read) is the separate enforcing change (#22639).
+- **"Or" is a capability, not a new shape.** There is no any-of form: an object such as `{ anyOf: [...] }` is refused. To serve one view or dashboard to two audiences, declare one capability (for example `clm_legal_workbench.view`), grant it to both permission sets, and list that one name. `content/docs/permissions/capabilities.mdx` shows the pattern.
+- **Nothing to migrate.** Every document that parsed before parses the same way; only a document that sets the new key, which was refused before, now parses.
diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx
index b205cab3bca..f08012ffd35 100644
--- a/content/docs/permissions/capabilities.mdx
+++ b/content/docs/permissions/capabilities.mdx
@@ -159,6 +159,38 @@ You cannot take a platform name. A declaration whose `name` collides with a
curated platform capability (`manage_users`, `manage_metadata`,
`setup.access`, …) is refused loudly at boot — those are platform-owned.
+## "Or" is one capability, granted twice
+
+`requiredPermissions` is the one way to require a capability on an entry — an
+app, a navigation item, an action, a record block, and a list view or a
+dashboard — and everywhere it means the same thing: a list of names the user
+must **all** hold. There is no any-of form. To show an entry to two audiences —
+the legal team **or** an administrator — declare one capability for the entry
+and grant it to both permission sets:
+
+```typescript
+capabilities: [
+ defineCapability({ name: 'clm_legal_workbench.view', label: 'Open the legal workbench' }),
+],
+permissions: [
+ { name: 'clm_legal', systemPermissions: ['clm_legal_workbench.view'] },
+ { name: 'clm_admin', systemPermissions: ['clm_legal_workbench.view'] },
+],
+// …and on the entry itself: requiredPermissions: ['clm_legal_workbench.view']
+```
+
+Listing each audience's own capability instead —
+`requiredPermissions: ['clm_legal.access', 'clm_admin.access']` — requires both,
+so it shuts out everyone who holds only one of them.
+
+
+**A list view's and a dashboard's `requiredPermissions` are not enforced yet.**
+The key is declared, so it validates and is stored, but no server reads it
+today: the view or the dashboard is still served to every user who can read
+it. Do not rely on it to keep a view or a dashboard from an audience until the
+platform enforces it.
+
+
## What the seeder does, and what it refuses
`bootstrapDeclaredCapabilities` runs on `kernel:ready` and is idempotent: it
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index 4c84cdf0246..0ec60e17f70 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -1694,6 +1694,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
@@ -1779,6 +1780,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx
index 3c6f1ce61a1..96c7d0e10e8 100644
--- a/content/docs/references/data/object.mdx
+++ b/content/docs/references/data/object.mdx
@@ -391,6 +391,7 @@ const result = ApiMethod.parse(data);
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx
index 33a530f1d65..18af55968ef 100644
--- a/content/docs/references/ui/dashboard.mdx
+++ b/content/docs/references/ui/dashboard.mdx
@@ -39,6 +39,7 @@ const result = DashboardSchema.parse(data);
| **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. 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. |
| **dateRange** | `{ field?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Global dashboard date range filter configuration |
| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. |
diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx
index 982391d7691..714612fa927 100644
--- a/content/docs/references/ui/view.mdx
+++ b/content/docs/references/ui/view.mdx
@@ -791,6 +791,7 @@ Map view configuration
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
@@ -1183,6 +1184,7 @@ View filter rule
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
@@ -1766,6 +1768,7 @@ Tab configuration for multi-tab view interface
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
@@ -1851,6 +1854,7 @@ Tab configuration for multi-tab view interface
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
@@ -2092,6 +2096,7 @@ This schema accepts one of the following structures:
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
@@ -2270,6 +2275,7 @@ This schema accepts one of the following structures:
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. 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. |
| **description** | `string \| Record` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
+| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. [PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it. |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
| **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value |
diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts
index 15f7f89c4d1..9b83b3e243d 100644
--- a/packages/lint/src/lint-liveness-properties.test.ts
+++ b/packages/lint/src/lint-liveness-properties.test.ts
@@ -830,6 +830,63 @@ describe('lintLivenessProperties', () => {
});
});
+ // ── #22611: a list view's and a dashboard's audience gate ships spec-first —
+ // `planned` + `authorWarn` until the `/meta` read gate applies it (#22639).
+ // REAL LEDGER: contract tests on the two shipped rows. The enforcing change
+ // flips both rows `live` and drops `authorWarn`, which turns the two warn pins
+ // below red ON PURPOSE — re-subject them to the silence in that change.
+ // (`authorWarn` reaches a container's `list` and every dashboard; the walk
+ // flattens one level of `children`, so a named `listViews` entry is not
+ // walked — the key's describe carries the not-enforced clause for that door.)
+ describe('list-view and dashboard audience gate (#22611 — planned until the read gate enforces it)', () => {
+ const GATE = ['clm_legal_workbench.view'];
+ const viewWith = (list: Record) =>
+ ({ object: 'clm_contract', list: { type: 'grid', columns: ['name'], ...list } });
+ const boardWith = (extra: Record) =>
+ ({ name: 'legal_workbench', label: 'Legal Workbench', widgets: [], ...extra });
+ const shippedRow = (type: 'view' | 'dashboard') => {
+ const ledger = JSON.parse(readFileSync(join(shippedLedgerDir(), `${type}.json`), 'utf8'));
+ return type === 'view' ? ledger.props.list.children.requiredPermissions : ledger.props.requiredPermissions;
+ };
+
+ for (const [type, path, stack] of [
+ ['view', 'list.requiredPermissions', { views: [viewWith({ requiredPermissions: GATE })] }],
+ ['dashboard', 'requiredPermissions', { dashboards: [boardWith({ requiredPermissions: GATE })] }],
+ ] as const) {
+ it(`warns on an authored \`${path}\` (${type}) — the planned rule id and the row's own authorHint`, () => {
+ const findings = lintLivenessProperties(stack);
+ const f = findings.find((x) => x.message.includes(`sets \`${path}\``));
+ expect(f).toBeDefined();
+ expect(f!.rule).toBe('liveness-planned-property');
+ const row = shippedRow(type);
+ // Anti-vacuity: the row is the shape this pin exists for.
+ expect(row.status).toBe('planned');
+ expect(row.authorWarn).toBe(true);
+ expect(f!.hint).toBe(row.authorHint);
+ expect(f!.hint).not.toMatch(/#\d+/);
+ expect(f!.hint).not.toContain('Remove it');
+ });
+ }
+
+ it('stays silent on a view and a dashboard that author no gate — in a call that still warns', () => {
+ // One stack, two halves: the gated dashboard is the lit control that
+ // proves the ledgers loaded, so the ungated view's silence is a reading.
+ const viewQuiet = lintLivenessProperties({
+ views: [viewWith({})],
+ dashboards: [boardWith({ requiredPermissions: GATE })],
+ });
+ expect(ruleOf(viewQuiet, 'requiredPermissions')).toBe('liveness-planned-property');
+ expect(ruleOf(viewQuiet, 'list.requiredPermissions')).toBeUndefined();
+
+ const boardQuiet = lintLivenessProperties({
+ views: [viewWith({ requiredPermissions: GATE })],
+ dashboards: [boardWith({})],
+ });
+ expect(ruleOf(boardQuiet, 'list.requiredPermissions')).toBe('liveness-planned-property');
+ expect(ruleOf(boardQuiet, 'requiredPermissions')).toBeUndefined();
+ });
+ });
+
// ── #11288: `stack.translations` is a locale-keyed BUNDLE, not an item ──────
//
// Every other collection in `TYPE_COLLECTIONS` is a flat array of items whose
diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts
index b0d90394cc6..3954d5dc631 100644
--- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts
+++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts
@@ -311,9 +311,11 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
* same way when it gained `dueLike` and `settledWhen` (#22227, a date's
* deadline and when it is settled), and 77 → 78 when it gained
* `conditionalFormatting` (#22228, a field's cell formatting rules).
+ * `dashboard` moved 21 → 22 the same way when it gained
+ * `requiredPermissions` (#22611, the audience gate).
*/
const CARD_PROPERTY_COUNTS: Record = {
- agent: 26, app: 30, dashboard: 21, dataset: 16, field: 78, flow: 23,
+ agent: 26, app: 30, dashboard: 22, dataset: 16, field: 78, flow: 23,
hook: 22, object: 45, page: 25, position: 12, report: 21, skill: 17, tool: 14,
};
diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json
index a3aaf5ca157..c355fb6d9f6 100644
--- a/packages/spec/authorable-surface/ui.json
+++ b/packages/spec/authorable-surface/ui.json
@@ -326,6 +326,7 @@
"ui/Dashboard:protection",
"ui/Dashboard:refreshInterval [RETIRED]",
"ui/Dashboard:refreshIntervalSeconds",
+ "ui/Dashboard:requiredPermissions",
"ui/Dashboard:widgets",
"ui/DashboardHeader:actions",
"ui/DashboardHeader:showDescription",
@@ -757,6 +758,7 @@
"ui/ListView:pageName [RETIRED]",
"ui/ListView:pagination",
"ui/ListView:performance [RETIRED]",
+ "ui/ListView:requiredPermissions",
"ui/ListView:resizable",
"ui/ListView:responsive [RETIRED]",
"ui/ListView:rowActions",
@@ -943,6 +945,7 @@
"ui/ObjectListView:pageName [RETIRED]",
"ui/ObjectListView:pagination",
"ui/ObjectListView:performance [RETIRED]",
+ "ui/ObjectListView:requiredPermissions",
"ui/ObjectListView:resizable",
"ui/ObjectListView:responsive [RETIRED]",
"ui/ObjectListView:rowActions",
diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md
index 24a2da91580..08d61a088b1 100644
--- a/packages/spec/liveness/README.md
+++ b/packages/spec/liveness/README.md
@@ -967,9 +967,9 @@ marker where the Notes cell goes, never a guess at what belongs there.
| skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) |
| dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) |
| page | live, plus the one dead tombstone below, plus the `print` declaration's eleven `planned` rows (#22158, 2026-10-08: the container, its eight keys and the four `margins` sides): card ① of the #8346 ruling (B′) declares the print page spec-first, so the declaration's REFUSALS are live (the parse's `checkPagePrintComposition`, lint's `validatePrintPageBlocks`) while what its keys configure has no reader until the console's print rendering, card ②, lands. The container row carries `authorWarn`, so an author who writes `print` is told the layout is not yet applied. Its earlier `planned` row, `requires`, flipped `live` 2026-10-02 (#20871): the save door refuses an html page whose written list disagrees with its source and stores the derived one, and boot hydration reports a stored page whose list names a plugin the deployment's SDUI manifest does not carry. Dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked |
-| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead because authoring it changes nothing — an authored value restates the key the container already registers under or contradicts it — and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the metadata door itself stamps the save name into every saved view body (`normalizeViewMetadata`) and its overlay paths key on that copy, so tombstoning `name` would reject the platform's own saves (re-measured 2026-10-02, #20301 stage 2: the earlier attribution to artifact-shipped containers and the metadata-validation sweep was the door's stamp misread; neither carries one). `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) |
+| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead because authoring it changes nothing — an authored value restates the key the container already registers under or contradicts it — and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the metadata door itself stamps the save name into every saved view body (`normalizeViewMetadata`) and its overlay paths key on that copy, so tombstoning `name` would reject the platform's own saves (re-measured 2026-10-02, #20301 stage 2: the earlier attribution to artifact-shipped containers and the metadata-validation sweep was the door's stamp misread; neither carries one). `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) **#22611** (2026-10-10): `list.requiredPermissions` is this type's only `planned` row (the `list.map` precedent above flipped `live`) — the audience gate (a list of capabilities, all required), declared spec-first under the ruling of record 6095014058, letter A, with `authorWarn` and an `authorHint` because no server reads it until the `/meta` read gate applies it (#22639, the carrier). The warning reaches a container's `list` only: the lint flattens one level of `children`, so a named `listViews` entry is not walked, and the key's describe carries the not-enforced clause for that door |
| report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) — live on non-joined reports only: a `joined` report's container `chart` is refused and `blocks[].chart` was removed (#20161, 2026-09-27; nothing drew either) |
-| dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority |
+| dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority **#22611** (2026-10-10): `requiredPermissions` is this type's only `planned` row — the audience gate (a list of capabilities, all required) shared word for word with the list view's, declared spec-first under the ruling of record 6095014058, letter A, with `authorWarn` and an `authorHint` because no server reads it until the `/meta` read gate applies it (#22639, the carrier) |
| query | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate resolves 1 experimental at the depth this ledger drills; the 7 marker-experimental search affordances sit one level deeper, below what this ledger declares (the walk recurses since #17424, but only where a `children` map is written, and none is written here) — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2). **#6815** adds the 5th dead: `aggregations[].distinct` REMOVED 2026-08-09 (live → dead, `-1` live). It is the one member of this ledger the #4286 sweep could not have caught with the question it asked — that sweep looked for keys NO executor reads, and this one had a reader: the objectql in-memory fallback deduplicated before applying the function while all five other faces (driver-sql, driver-turso, driver-mongodb, driver-memory, service-analytics' `AGGREGATE_SQL`) ignored it, so one query answered two plausible NUMBERS depending on which backend served it. The lesson for the next audit is the question, not the key: a per-key `live` verdict is only as good as the count of faces it was measured across, and this row's 2026-07-31 evidence (`in-memory-aggregation.ts:167,204-206`) was TRUE and still the wrong verdict. `count_distinct` is the surviving spelling (enforce leg, #6409) |
| datasource | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) **批 A CLOSED 2026-08-02 (#4583)**: the `capabilities` block — 11 flags, every one dead and authorWarn'd — was REMOVED rather than bridged; pushdown comes from the runtime driver's own `supports.*`, so there was nothing to connect it to. Its rows are deleted (strict-removal route), which is why dead falls 20 → 9. `readOnly` was the reason the audit was worth doing: it read as a safety switch, gated nothing, and had already been MOVED twice toward somewhere it might be enforced (#4410, #4465) — the shipped CRM example called a datasource a read replica on the strength of it while the datasource took writes. Removing it does NOT hand the author a working alternative: `external.allowWrites` only gates FEDERATED datasources, so a managed one has no read-only gate at all (#4584). Remaining 9 = healthCheck ×3 + retryPolicy ×4 + external ×2, batches B/C/D of #4583 **BATCHES B/C/D CLOSED 2026-08-02 — datasource now has ZERO dead properties**, down from the 20 it was seeded with (the highest dead ratio of any governed type). `retryPolicy` ×4 and `healthCheck` ×3 went as whole blocks, `external.label` / `external.requirePermission` as keys. None was bridgeable: each already had a different LIVE mechanism doing the job — the boot policy, the driver handle's on-demand `ping()`/`checkHealth()`, the top-level `label`, and ordinary permission sets + RLS. The `retryPolicy` rejection deliberately refuses to offer a rename: `hook`/`job` retryPolicy ARE enforced but spell the delay `backoffMs`, and that inconsistency is itself the evidence nothing read the datasource one (#4488's sharpest trap) |
| webhook | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING |
diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json
index 87a2a068baf..ff6c4d3ff19 100644
--- a/packages/spec/liveness/dashboard.json
+++ b/packages/spec/liveness/dashboard.json
@@ -272,6 +272,13 @@
"status": "live",
"note": "objectui: DashboardRenderer.tsx:279,988 — dashboard-level date-range filter (framework#2501). Supersedes the 2026-06 audit's DEAD finding."
},
+ "requiredPermissions": {
+ "status": "planned",
+ "verifiedAt": "2026-10-10",
+ "authorWarn": true,
+ "authorHint": "Keep it, but know it hides nothing yet: no server reads `requiredPermissions` on a list view or a dashboard today, so the item is still served to every user who can read it. Do not rely on it to keep a view or a dashboard from an audience until the platform enforces it. For several audiences, declare one capability and grant it to each of their permission sets; there is no any-of form.",
+ "note": "PLANNED (#22611, the spec half of the ruling of record 6095014058, letter A): the audience gate, declared spec-first with the app / navigation-item / action shape and meaning: a list of capabilities, all required, no any-of form (letter B was not taken). CARRIER #22639 (the rest half, `Blocked-by: #22611`): the `/meta` read gate serves a gated dashboard only to a caller holding all of its capabilities, on the list read and the by-name read (packages/rest/src/meta-item-read-gate.ts). No reader in either repo when declared: framework origin/main 1b99388505 (every `requiredPermissions` read under packages/rest and packages/runtime is an app, navigation-item, action, object or field read) and objectui at the `.objectui-sha` pin 20c6d351ad74 (no read under plugin-list, plugin-view or plugin-dashboard; the navigation and record-block readers are other carriers). `authorWarn` reaches every `dashboards[]` item (a top-level row; no schema default materializes the key, so only an authored list warns). Flip to `live` citing the gate symbol, drop `authorWarn` / `authorHint`, and delete the describe's `[PLANNED — not enforced yet]` clause (packages/spec/src/ui/audience-required-permissions.ts) in that same edit."
+ },
"aria": {
"status": "dead",
"verifiedAt": "2026-07-30",
diff --git a/packages/spec/liveness/state-counts/dashboard.md b/packages/spec/liveness/state-counts/dashboard.md
index 1be1bf5c734..a6b11ac48a2 100644
--- a/packages/spec/liveness/state-counts/dashboard.md
+++ b/packages/spec/liveness/state-counts/dashboard.md
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
-| `dashboard` | 42 | 0 | 0 | 13 | 0 | 55 |
+| `dashboard` | 42 | 0 | 0 | 13 | 1 | 56 |
diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md
index 2b76c6cdc40..f3a2b4753df 100644
--- a/packages/spec/liveness/state-counts/view.md
+++ b/packages/spec/liveness/state-counts/view.md
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
-| `view` | 78 | 0 | 0 | 11 | 0 | 89 |
+| `view` | 78 | 0 | 0 | 11 | 1 | 90 |
diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json
index 02fa2030d16..b94eba94639 100644
--- a/packages/spec/liveness/view.json
+++ b/packages/spec/liveness/view.json
@@ -138,6 +138,13 @@
"evidence": "objectui: packages/plugin-list/src/ListView.tsx:2544 gates the toolbar sharing badge on schema.sharing.type, :2549 renders its tooltip from the same value and :2573 counts it when laying out the toolbar's left side; objectui: packages/plugin-list/src/__tests__/ListView.test.tsx:2428 pins the lockedBy tooltip — measured objectui @11c1e71e",
"note": "RE-CITED 2026-08-10 (#7133/#7142): the old citation was the TEST ALONE, at ListView.test.tsx:2276. The test survives with its content intact — it is now packages/plugin-list/src/__tests__/ListView.test.tsx:2428, 'should display lockedBy in sharing tooltip when set' — so this was line drift, not deletion. A test is the weaker half of a liveness claim, so the production reader is cited first and the test kept as the pin. Verdict unchanged: LIVE. Supersedes the 2026-06 audit's 'sharing.lockedBy dead' line."
},
+ "requiredPermissions": {
+ "status": "planned",
+ "verifiedAt": "2026-10-10",
+ "authorWarn": true,
+ "authorHint": "Keep it, but know it hides nothing yet: no server reads `requiredPermissions` on a list view or a dashboard today, so the item is still served to every user who can read it. Do not rely on it to keep a view or a dashboard from an audience until the platform enforces it. For several audiences, declare one capability and grant it to each of their permission sets; there is no any-of form.",
+ "note": "PLANNED (#22611, the spec half of the ruling of record 6095014058, letter A): the audience gate, declared spec-first with the app / navigation-item / action shape and meaning: a list of capabilities, all required, no any-of form (letter B was not taken). CARRIER #22639 (the rest half, `Blocked-by: #22611`): the `/meta` read gate serves a gated view only to a caller holding all of its capabilities, on the list read and the by-name read (packages/rest/src/meta-item-read-gate.ts). No reader in either repo when declared: framework origin/main 1b99388505 (every `requiredPermissions` read under packages/rest and packages/runtime is an app, navigation-item, action, object or field read) and objectui at the `.objectui-sha` pin 20c6d351ad74 (no read under plugin-list, plugin-view or plugin-dashboard; the navigation and record-block readers are other carriers). `authorWarn` reaches `views[].list.requiredPermissions` only: the lint flattens one level of `children`, so a container's named `listViews` entries and an object's `listViews` are not walked, and the describe's own not-enforced clause is what reaches those doors. Flip to `live` citing the gate symbol, drop `authorWarn` / `authorHint`, and delete the describe's `[PLANNED — not enforced yet]` clause (packages/spec/src/ui/audience-required-permissions.ts) in that same edit."
+ },
"rowHeight": {
"status": "live",
"note": "objectui: ObjectGrid.tsx (audit L15)."
diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
index a22537dc26a..2c99327ff74 100644
--- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
+++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
@@ -416,6 +416,25 @@ const LEDGER: ReadonlyArray = [
key: 'externalSharingModel',
why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0090 D11: validated at authoring time only; the audience-aware evaluator branch that would honour it is scheduled, not built). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
},
+ // #22611 — the audience gate on a list view and a dashboard lands spec-first:
+ // `planned` + `authorWarn` in the liveness ledger, read by no server until the
+ // `/meta` read gate applies it (#22639). The app and action forms offer their
+ // `requiredPermissions` because those gates are enforced; offering these two
+ // first would be the UI half of declared-not-enforced.
+ {
+ kind: 'omit',
+ type: 'dashboard',
+ path: ROOT_PATH,
+ key: 'requiredPermissions',
+ why: "declared, not enforced yet — liveness verdict `planned` with `authorWarn`: the dashboard's audience gate (a list of capabilities, all required) parses and is stored, but no server reads it until the `/meta` read gate serves a gated dashboard only to a holder. A form control offered today would let an author set a gate that hides nothing; whether to offer it then is a ruling for the enforcement, not for this gate",
+ },
+ {
+ kind: 'omit',
+ type: 'view',
+ path: ROOT_PATH,
+ key: 'requiredPermissions',
+ why: "declared, not enforced yet — liveness verdict `planned` with `authorWarn`: a list view's audience gate (a list of capabilities, all required) parses and is stored, but no server reads it until the `/meta` read gate serves a gated view only to a holder. A form control offered today would let an author set a gate that hides nothing; whether to offer it then is a ruling for the enforcement, not for this gate",
+ },
// Renderer-owned vocabulary — a key is admitted here only when all three
// hold: its `describe()` says the spec declares no vocabulary for its value;
diff --git a/packages/spec/src/ui/audience-required-permissions.test.ts b/packages/spec/src/ui/audience-required-permissions.test.ts
new file mode 100644
index 00000000000..2158c0717a9
--- /dev/null
+++ b/packages/spec/src/ui/audience-required-permissions.test.ts
@@ -0,0 +1,114 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// #22611 — a list view and a dashboard each take the platform's one audience
+// key, `requiredPermissions` (ruling of record 6095014058, letter A): a list of
+// capabilities, all required, with the app / navigation-item / action shape and
+// meaning, and ONE describe shared by the two.
+//
+// Every accept pin is a full `safeParse` success (the value arm is part of what
+// is declared, so "no `unrecognized_keys`" alone would stay green over a
+// declaration that refused every value), and every refuse pin asserts the issue
+// CODE and PATH, never a bare failure: before this change the key was refused
+// as `unrecognized_keys` on both schemas, and a refusal that only says "false"
+// could not tell that old refusal from the type refusal pinned here.
+
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+
+import { AppSchema } from './app.zod';
+import { AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION } from './audience-required-permissions';
+import { DashboardSchema } from './dashboard.zod';
+import { ListViewSchema, ObjectListViewSchema, ViewItemSchema, ViewSchema } from './view.zod';
+
+const GATE = ['clm_legal_workbench.view'];
+
+/** A document legal on its own, per door — the baseline every case adds the key to. */
+const DOORS: Record }> = {
+ 'ListViewSchema': { schema: ListViewSchema, base: { type: 'grid', columns: ['name'] } },
+ 'ObjectListViewSchema (an object\'s `listViews` entry)': { schema: ObjectListViewSchema, base: { type: 'grid', columns: ['name'] } },
+ 'DashboardSchema': { schema: DashboardSchema, base: { name: 'legal_workbench', label: 'Legal Workbench', widgets: [] } },
+};
+
+const withKey = (base: Record, value: unknown) => ({ ...base, requiredPermissions: value });
+
+describe('#22611 — `requiredPermissions` on a list view and a dashboard', () => {
+ for (const [door, { schema, base }] of Object.entries(DOORS)) {
+ describe(door, () => {
+ it('accepts a list of capabilities and keeps it verbatim', () => {
+ const r = schema.safeParse(withKey(base, GATE));
+ expect(r.success).toBe(true);
+ expect((r.data as Record).requiredPermissions).toEqual(GATE);
+ });
+
+ it('accepts the empty list, and carries NO schema default — an absent key stays absent', () => {
+ const empty = schema.safeParse(withKey(base, []));
+ expect(empty.success).toBe(true);
+ expect((empty.data as Record).requiredPermissions).toEqual([]);
+ const absent = schema.safeParse(base);
+ expect(absent.success).toBe(true);
+ expect(absent.data as Record).not.toHaveProperty('requiredPermissions');
+ });
+
+ it('refuses a non-array, a non-string member and an any-of object as TYPE errors at the key, never as unknown keys', () => {
+ for (const bad of ['clm_legal_workbench.view', [1], { anyOf: GATE }] as unknown[]) {
+ const r = schema.safeParse(withKey(base, bad));
+ expect(r.success).toBe(false);
+ const issues = r.error!.issues;
+ expect(issues.some((i) => i.code === 'unrecognized_keys')).toBe(false);
+ expect(issues.some((i) => i.code === 'invalid_type' && i.path[0] === 'requiredPermissions')).toBe(true);
+ }
+ });
+ });
+ }
+
+ it('reaches the view container\'s `list` and named `listViews`, and the view item\'s `config`', () => {
+ const container = ViewSchema.safeParse({
+ list: { type: 'grid', columns: ['name'], requiredPermissions: GATE },
+ listViews: { legal_queue: { type: 'grid', columns: ['name'], requiredPermissions: GATE } },
+ });
+ expect(container.success).toBe(true);
+ const c = container.data as { list: { requiredPermissions?: string[] }; listViews: Record };
+ expect(c.list.requiredPermissions).toEqual(GATE);
+ expect(c.listViews.legal_queue.requiredPermissions).toEqual(GATE);
+
+ const item = ViewItemSchema.safeParse({
+ name: 'clm_contract.legal_queue',
+ object: 'clm_contract',
+ viewKind: 'list',
+ config: { type: 'grid', data: { provider: 'object', object: 'clm_contract' }, columns: ['name'], requiredPermissions: GATE },
+ });
+ expect(item.success).toBe(true);
+ expect((item.data as { config: { requiredPermissions?: string[] } }).config.requiredPermissions).toEqual(GATE);
+ });
+
+ it('one key, one text: both declarations are IDENTICAL — shape and describe, word for word — and carry the shared constant', () => {
+ const view = ListViewSchema.shape.requiredPermissions;
+ const dashboard = DashboardSchema.shape.requiredPermissions;
+ const json = (s: z.ZodType) => JSON.stringify(z.toJSONSchema(s, { io: 'input' }));
+ expect(json(view)).toBe(json(dashboard));
+ expect(view.description).toBe(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION);
+ expect(dashboard.description).toBe(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION);
+ });
+
+ it('the same SHAPE as the app\'s `requiredPermissions`: their JSON Schemas differ in the describe only', () => {
+ const shapeOf = (s: z.ZodType) => {
+ const { description: _describe, ...rest } = z.toJSONSchema(s, { io: 'input' }) as Record;
+ return rest;
+ };
+ const app = shapeOf(AppSchema.shape.requiredPermissions);
+ expect(shapeOf(ListViewSchema.shape.requiredPermissions)).toEqual(app);
+ expect(shapeOf(DashboardSchema.shape.requiredPermissions)).toEqual(app);
+ // Not vacuous: the shared shape is a string array.
+ expect(app).toMatchObject({ type: 'array', items: { type: 'string' } });
+ });
+
+ it('the describe carries the machine-read `[PLANNED` marker for the not-enforced window, and no tracker number', () => {
+ // Wording is not pinned; the marker is, because a consumer parses it:
+ // `markerStatus` in `scripts/liveness/check-liveness.mts` grades a row-less
+ // property `planned` off `[planned`. The enforcing change deletes the clause
+ // when both liveness rows flip to `live`.
+ expect(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION).toMatch(/\[planned/i);
+ // Runtime text: no tracker number reaches an author.
+ expect(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION).not.toMatch(/#\d+/);
+ });
+});
diff --git a/packages/spec/src/ui/audience-required-permissions.ts b/packages/spec/src/ui/audience-required-permissions.ts
new file mode 100644
index 00000000000..b5b9874d4e4
--- /dev/null
+++ b/packages/spec/src/ui/audience-required-permissions.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The audience gate of a list view and of a dashboard, declared once: the ONE
+ * describe of `ListViewSchema.requiredPermissions` and
+ * `DashboardSchema.requiredPermissions` (#22611, ruling of record 6095014058,
+ * letter A).
+ *
+ * The ruling, verbatim: "`ListViewSchema` and `DashboardSchema` each gain
+ * `requiredPermissions` with the same shape and semantics as the app,
+ * navigation-item, action (ADR-0066 D4) and record-block (#18159) keys: a list
+ * of capabilities, all required." So the shape is the one those four carry —
+ * `z.array(z.string()).optional()`, no default (an unauthored key never
+ * materializes) — and the two keys share this text word for word;
+ * `audience-required-permissions.test.ts` holds both declarations identical to
+ * each other and shape-identical to `AppSchema.requiredPermissions`.
+ *
+ * Three clauses, each load-bearing:
+ *
+ * 1. ALL OF THEM. Several names are AND-ed, as on the app and the navigation
+ * item (`filterAppForUserWithReason`, `every`). Absent or empty is no gate.
+ * 2. NO ANY-OF FORM. The ruling did not take an `anyOf` shape (its letter B):
+ * "or" is one capability the application declares and grants to each
+ * audience's permission set, the way a capability is already composed for
+ * every other carrier of this key. Saying so here is what keeps an author
+ * from reaching for a disjunction the schema refuses.
+ * 3. NOT ENFORCED YET — and the text says so, because declared is enforced.
+ * This is the spec half; the `/meta` read gate that serves a gated item
+ * only to a holder is the rest half (#22639, `Blocked-by: #22611`). Until
+ * it lands no server reads either key, the liveness rows are `planned` with
+ * `authorWarn` (`liveness/view.json` `list.requiredPermissions`,
+ * `liveness/dashboard.json` `requiredPermissions`), and this sentence is
+ * the one channel that reaches every door — the lint's warning reaches a
+ * container's `list` and a dashboard, never a named `listViews` entry. The
+ * change that enforces the gate deletes the clause in the same edit that
+ * flips both rows to `live`.
+ *
+ * A module of its own, and outside the `ui` barrel, so the two carriers share
+ * one declaration without it becoming published API (the `./view-history.ts`
+ * and `./list-view-export-options.ts` precedent). Nothing here names a
+ * tracker number an author would read: the describe is runtime text.
+ */
+export const AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION =
+ '[ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that '
+ + 'permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation '
+ + 'item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several '
+ + 'audiences (legal OR admin), declare one capability and grant it to each of their permission sets. '
+ + '[PLANNED — not enforced yet] No server reads this key today, so the item is still served to every user who can '
+ + 'read it; do not rely on it to keep a view or a dashboard from an audience until the `/meta` read gate applies it.';
diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts
index 0b9247ea909..6e7bebcde6c 100644
--- a/packages/spec/src/ui/dashboard.zod.ts
+++ b/packages/spec/src/ui/dashboard.zod.ts
@@ -31,6 +31,9 @@ import { I18nLabelSchema } from './i18n.zod';
*/
import { lazySchema } from '../shared/lazy-schema';
import { retiredKey } from '../shared/retired-key';
+// [#22611] The audience gate's ONE describe, shared with `ListViewSchema` and
+// declared outside the `ui` barrel.
+import { AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION } from './audience-required-permissions';
export const WidgetColorVariantSchema = lazySchema(() => z.enum([
'default',
'blue',
@@ -1792,6 +1795,18 @@ export const DashboardSchema = lazySchema(() => strictObject({
/** Global Filters */
globalFilters: z.array(GlobalFilterSchema).optional().describe('Global filters that apply to all widgets in the dashboard'),
+ /**
+ * [#22611] The audience gate — the platform's one capability key, with the
+ * app / navigation-item / action shape and meaning: a list of capabilities,
+ * all required (ruling 6095014058, letter A). Distinct from a widget's
+ * `requiresObject` / `requiresService`, which gate one widget on what is
+ * INSTALLED, never on who is asking. One describe with
+ * `ListViewSchema.requiredPermissions` — see
+ * {@link AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION} for its clauses,
+ * including why it says the gate is not enforced yet.
+ */
+ requiredPermissions: z.array(z.string()).optional().describe(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION),
+
// `aria` / `performance` REMOVED (#3896 audit close-out): authorable and
// inert — no DashboardRenderer path applied either (ledger: dead; the
// report twins were removed in the report-liveness close-out).
diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts
index 6979ade6158..9f2808af568 100644
--- a/packages/spec/src/ui/view.zod.ts
+++ b/packages/spec/src/ui/view.zod.ts
@@ -97,6 +97,9 @@ import {
ListViewExportFormatSchema,
ListViewExportOptionsSchema,
} from './list-view-export-options';
+// [#22611] The audience gate's ONE describe, shared with `DashboardSchema`
+// and declared outside the `ui` barrel.
+import { AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION } from './audience-required-permissions';
export { HttpMethodSubsetSchema, HttpRequestSchema };
@@ -2892,6 +2895,20 @@ const ListViewShapeSchema = lazySchema(() => strictObject({
description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'),
sharing: ViewSharingSchema.optional().describe('View sharing and access configuration'),
+ /**
+ * [#22611] The audience gate — the platform's one capability key, with the
+ * app / navigation-item / action shape and meaning: a list of capabilities,
+ * all required (ruling 6095014058, letter A). `sharing` above is ownership
+ * and locking, NOT audience, and stays as it is. One describe with
+ * `DashboardSchema.requiredPermissions` — see
+ * {@link AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION} for its clauses, including
+ * why it says the gate is not enforced yet. Declared on the SHAPE, so it
+ * reaches every list-view door built from it: `ListViewSchema`,
+ * `ObjectListViewSchema` (a container's `list` / `listViews`, an object's
+ * `listViews`), the view item's `config` and the flattened overlay arm.
+ */
+ requiredPermissions: z.array(z.string()).optional().describe(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION),
+
/** Row Height / Density (Airtable-style) */
rowHeight: RowHeightSchema.optional().describe('Row height / density setting'),