From d83f36253b2b31af8b28ca8bc8813a3a6ef8002f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 07:54:55 +0000 Subject: [PATCH 1/6] feat(spec): requiredPermissions on ListViewSchema and DashboardSchema, the audience gate, planned until the read gate applies it Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/liveness/dashboard.json | 7 ++ packages/spec/liveness/view.json | 7 ++ .../metadata-form-zod-reconciliation.test.ts | 19 +++ .../ui/audience-required-permissions.test.ts | 114 ++++++++++++++++++ .../src/ui/audience-required-permissions.ts | 49 ++++++++ packages/spec/src/ui/dashboard.zod.ts | 15 +++ packages/spec/src/ui/view.zod.ts | 17 +++ 7 files changed, 228 insertions(+) create mode 100644 packages/spec/src/ui/audience-required-permissions.test.ts create mode 100644 packages/spec/src/ui/audience-required-permissions.ts 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/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 3b9089e2b5f..5597753b096 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 }; @@ -2883,6 +2886,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'), From b9cab15f6f5e8c3ff06c709ea49755ef7a15ced7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:07:02 +0000 Subject: [PATCH 2/6] test(lint): the planned audience-gate rows warn on an authored list-view and dashboard requiredPermissions; regenerate the ui authorable surface Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../lint/src/lint-liveness-properties.test.ts | 57 +++++++++++++++++++ packages/spec/authorable-surface/ui.json | 3 + 2 files changed, 60 insertions(+) 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/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", From c0577f5fd31e24d3b876b1ba8361edc2ccee915d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:10:30 +0000 Subject: [PATCH 3/6] chore(spec): regenerate the docs references and liveness counts for the two planned audience-gate keys Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 ++ content/docs/references/data/object.mdx | 1 + content/docs/references/ui/dashboard.mdx | 1 + content/docs/references/ui/view.mdx | 6 ++++++ packages/spec/liveness/state-counts/dashboard.md | 2 +- packages/spec/liveness/state-counts/view.md | 2 +- 6 files changed, 12 insertions(+), 2 deletions(-) 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 76fc5efec84..0c0a19ba75d 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/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 | From c4265a5d44bf16d384da390985b0f0844f337d67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:11:55 +0000 Subject: [PATCH 4/6] docs(permissions): or is one capability granted twice, and the list-view and dashboard gate is not enforced yet; changeset Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- ...ist-view-dashboard-required-permissions.md | 12 +++++++ content/docs/permissions/capabilities.mdx | 32 +++++++++++++++++++ packages/spec/liveness/README.md | 4 +-- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/22611-list-view-dashboard-required-permissions.md 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/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 | From af81d59c30a3c4100d7a595bf213d0a5d7aa50a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:05:07 +0000 Subject: [PATCH 5/6] chore(spec): regenerate the view reference from the merged tree (main's editInline default beside this branch's audience-gate rows) Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0c0a19ba75d..714612fa927 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1082,7 +1082,7 @@ View filter rule | **rowHeight** | `boolean` | optional (default: `true`) | Allow users to toggle row height/density | | **group** | `boolean` | optional (default: `true`) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level `grouping` block. | | **addRecordForm** | `boolean` | optional (default: `false`) | Add records through a form instead of inline | -| **editInline** | `boolean` | optional (default: `false`) | Allow users to edit records inline — click a cell to edit it with the field's type-aware widget (the same control the form uses). Off by default: the list is read-only unless the author opts in. | +| **editInline** | `boolean` | optional (default: `true`) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare `editInline: false` to make the list read-only in place. | | **hideFields** | `boolean` | optional (default: `false`) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level `hiddenFields` list). Boolean toggle — distinct from the record-details component's `hideFields`, which is an array of field names to omit. Off by default: column hiding is opt-in. | | **rowColor** | `boolean` | optional (default: `false`) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level `rowColor` block. Off by default: row colouring is opt-in. | | **buttons** | `string[]` | optional | Custom action button IDs to show in the toolbar | @@ -1466,7 +1466,7 @@ View filter rule | **rowHeight** | `boolean` | optional (default: `true`) | Allow users to toggle row height/density | | **group** | `boolean` | optional (default: `true`) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level `grouping` block. | | **addRecordForm** | `boolean` | optional (default: `false`) | Add records through a form instead of inline | -| **editInline** | `boolean` | optional (default: `false`) | Allow users to edit records inline — click a cell to edit it with the field's type-aware widget (the same control the form uses). Off by default: the list is read-only unless the author opts in. | +| **editInline** | `boolean` | optional (default: `true`) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare `editInline: false` to make the list read-only in place. | | **hideFields** | `boolean` | optional (default: `false`) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level `hiddenFields` list). Boolean toggle — distinct from the record-details component's `hideFields`, which is an array of field names to omit. Off by default: column hiding is opt-in. | | **rowColor** | `boolean` | optional (default: `false`) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level `rowColor` block. Off by default: row colouring is opt-in. | | **buttons** | `string[]` | optional | Custom action button IDs to show in the toolbar | @@ -1637,7 +1637,7 @@ User action toggles for the view toolbar | **rowHeight** | `boolean` | optional (default: `true`) | Allow users to toggle row height/density | | **group** | `boolean` | optional (default: `true`) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level `grouping` block. | | **addRecordForm** | `boolean` | optional (default: `false`) | Add records through a form instead of inline | -| **editInline** | `boolean` | optional (default: `false`) | Allow users to edit records inline — click a cell to edit it with the field's type-aware widget (the same control the form uses). Off by default: the list is read-only unless the author opts in. | +| **editInline** | `boolean` | optional (default: `true`) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare `editInline: false` to make the list read-only in place. | | **hideFields** | `boolean` | optional (default: `false`) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level `hiddenFields` list). Boolean toggle — distinct from the record-details component's `hideFields`, which is an array of field names to omit. Off by default: column hiding is opt-in. | | **rowColor** | `boolean` | optional (default: `false`) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level `rowColor` block. Off by default: row colouring is opt-in. | | **buttons** | `string[]` | optional | Custom action button IDs to show in the toolbar | From 1b39eebd716d95164adb38b5f508b8390edbc5f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 10:14:10 +0000 Subject: [PATCH 6/6] test(metadata-protocol): dashboard serves 22 top-level properties now that it declares requiredPermissions Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../src/protocol.meta-types-degenerate-derivation.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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, };