From 254cdcf937793c1ec9cc36ca51b48a80fd0346da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:47:07 +0000 Subject: [PATCH 1/7] feat(spec): declare requiredPermissions on record:details / highlights / related_list with one true describe shared with record:quick_actions The three record blocks refused requiredPermissions by name while objectui's renderers read it as an ADR-0066 capability set, fail-closed, at the pinned console. Declare it on all three with record:quick_actions' shape, and give all four ONE describe (seat ruling A): capabilities not object actions, all required, a notice in place of the content, presentation only, and fail-open when the client cannot resolve capabilities. The quick_actions describe was false on object scoping, so its published text changes too. Rewrites the texts that said the key was deliberately not declared, flips the absence pin to accept pins (instruments A/B, lit controls aria/fields), and corrects the pending pair changeset that the declaration makes false. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18159-record-block-field-security-pair.md | 2 +- ...18159-record-block-required-permissions.md | 11 ++ ...ponent-record-block-field-security.test.ts | 152 +++++++++++------- packages/spec/src/ui/component.zod.ts | 101 ++++++++++-- 4 files changed, 194 insertions(+), 72 deletions(-) create mode 100644 .changeset/18159-record-block-required-permissions.md diff --git a/.changeset/18159-record-block-field-security-pair.md b/.changeset/18159-record-block-field-security-pair.md index 9ef401a8f51..5bbee223e94 100644 --- a/.changeset/18159-record-block-field-security-pair.md +++ b/.changeset/18159-record-block-field-security-pair.md @@ -12,6 +12,6 @@ All three blocks are `strictObject`s that declared neither key, while `@object-u - **`redactFields`** (string array) drops the names it lists outright. On `record:related_list` it also reaches the columns the list derives for itself when none are authored. - **The claim is held to what the render path does.** Both are presentation filters, applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's `publicSharing.redactFields`, which removes them server-side. Each `describe()` says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own `requiredPermissions` / `maskingRule` (ADR-0066 D3) and the permission set. - **⚠️ On `record:details`, `redactFields` neighbours the already-declared `hideFields`** and on a well-formed field list the two remove the same rows: `hideFields` is the dedupe channel the renderer also writes to (live `record:highlights` registrations, the page-title field), `redactFields` is the author's deliberate omission and the arm that participates in the renderer's fail-closed fold. Converging them is a contract question this change did not open. -- **⚠️ The third key the same three renderers read — `requiredPermissions` — is deliberately NOT declared**, and stays refused by name on all three. Its read is `perms.can(objectName, name)`, whose second parameter is this package's own closed `PermissionActionSchema` enum, not the ADR-0066 capability set that name means on `action`, `app`, `field` and `bulkAction`. Measured on both shipped permission providers: under the backend-backed one an unmapped name falls through to the object's `allowRead` bit, so a capability the caller does not hold passes for every reader; under the role-based one the same name is denied for everyone whenever the object carries a permission config. Declaring it would mint the ADR-0049 fail-open access gate retired from `app.areas[].requiredPermissions` in 17.0.0. The exit is a ruling, not an omission. +- **The third key the same three renderers read — `requiredPermissions` — is declared in the same release, by its own entry.** It is the block-level ADR-0066 capability gate, not a member of this pair: a capability set read through the permission context's capability path, which hides the whole block and, like this pair, authorises nothing. ⚠️ **Not measured here**: the runtime behaviour of either declared key in a browser, and whether any authored document anywhere writes them. "The schema refused it" is not "nobody writes it"; only the first is measured. diff --git a/.changeset/18159-record-block-required-permissions.md b/.changeset/18159-record-block-required-permissions.md new file mode 100644 index 00000000000..eb1e2fd3fb0 --- /dev/null +++ b/.changeset/18159-record-block-required-permissions.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +`record:details`, `record:highlights` and `record:related_list` accept `requiredPermissions`, the block-level capability gate objectui's detail renderers read, with the same shape and the same describe as `record:quick_actions` — whose published describe changes in this release (#18159). + +Clause-②: yes (widening) + +- **Additive.** All three blocks are `strictObject`s that refused the key by name. It is now declared optional, `z.array(z.string())`, with no schema default, so an absent key stays absent and nothing that parsed before stops parsing. +- **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open); a resolved empty set gates. To keep data from a user, gate the object, the field or the action. +- **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names: a name nobody holds hides the bar for every user whose capabilities are reported. diff --git a/packages/spec/src/ui/component-record-block-field-security.test.ts b/packages/spec/src/ui/component-record-block-field-security.test.ts index 11f69049428..ccea01b37f4 100644 --- a/packages/spec/src/ui/component-record-block-field-security.test.ts +++ b/packages/spec/src/ui/component-record-block-field-security.test.ts @@ -1,7 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// #18159 — the record-block field-security pair, and the key that is -// deliberately NOT beside it. +// #18159 — the three keys objectui reads on the record blocks, all declared. // // `record:details`, `record:highlights` and `record:related_list` are // `strictObject`s, and objectui's `@object-ui/plugin-detail` reads three keys @@ -10,33 +9,23 @@ // refused at parse while the renderer honoured the same document on the raw-node // path — a contract that could not be satisfied by writing it down. // -// This card declares TWO of the three and forks the third, so the pins below -// come in two kinds, and the difference matters: +// The card declared the field-security PAIR first and held the third key until +// its semantics were ruled (#19186 ruling B: an ADR-0066 capability set) and the +// renderers read it that way at the pin. Every pin below is an ACCEPT pin: a +// full `safeParse` success is the assertion (not merely "no +// `unrecognized_keys`"), because the value arm is part of what is being +// declared, and a key-only assertion would stay green over a declaration that +// refused every value. // -// ACCEPT pins — `enforceFieldSecurity` / `redactFields` now parse GREEN on -// all three blocks. A full `safeParse` success is the assertion -// (not merely "no `unrecognized_keys`"): the value arm is part -// of what is being declared, and a key-only assertion would -// stay green over a declaration that refused every value. +// `requiredPermissions` is pinned through the card's two instruments, each with +// its lit controls `aria` / `fields`: // -// ABSENCE pin — `requiredPermissions` is still refused BY NAME on all three. -// That is a deliberate outcome, not an oversight. The renderer -// evaluates it as `perms.can(objectName, name)`, whose second -// parameter is this repo's closed `PermissionActionSchema` enum -// and NOT the ADR-0066 capability set every other -// `requiredPermissions` in this spec names. Measured on the two -// shipped providers: an unmapped name falls to the object's -// `allowRead` bit under the backend-backed one (so a capability -// nobody holds passes for every reader), and is denied for -// everyone under the role-based one whenever the object carries -// a permission config. Declaring it would mint the ADR-0049 -// fail-open access gate this repo retired on -// `app.areas[].requiredPermissions` in 17.0.0. +// A — a parse probe on each block's own legal base document; +// B — an enumeration of the block's public zod `.shape`. // -// ⚠️ This pin is tree-scoped: it records what the contract -// accepts TODAY, not that the key may never be declared. The -// ruling that settles the fork updates this file in the same -// PR — it does not route around it. +// Plus the ruling's own condition: the four record blocks carrying the key +// (the three above and `record:quick_actions`) declare it IDENTICALLY — same +// shape, same describe, word for word. import { describe, expect, it } from 'vitest'; import { @@ -46,7 +35,7 @@ import { RecordQuickActionsProps, RecordRelatedListProps, } from './component.zod'; -import { PermissionActionSchema } from '../kernel/plugin-security-advanced.zod'; +import { z } from 'zod'; /** A document that is legal on its own, per block — the baseline every case adds to. */ const BASE: Record> = { @@ -168,36 +157,98 @@ describe('#18159 — `redactFields` is declared on all three blocks', () => { }); }); -describe('#18159 — `requiredPermissions` is REFUSED on the three blocks (the forked key)', () => { - it('is refused by name on each of the three, with the base document legal on its own', () => { +/** Instrument B: the keys a schema declares, read off zod's public `.shape`. */ +const shapeKeys = (type: string): string[] => + Object.keys((ComponentPropsMap[type as keyof typeof ComponentPropsMap] as { shape?: object }).shape ?? {}); + +/** Instrument B, map-wide: every `ComponentPropsMap` row declaring `key`. */ +const declaring = (key: string) => + Object.entries(ComponentPropsMap) + .filter(([, schema]) => Object.keys((schema as { shape?: object }).shape ?? {}).includes(key)) + .map(([type]) => type) + .sort(); + +/** One lit `aria` value — accepted on all three blocks, so the probe can say yes. */ +const ARIA = { ariaLabel: 'Record block' }; + +describe('#18159 — `requiredPermissions` is declared on the three blocks (instruments A and B)', () => { + it.each(BLOCKS)('A · %s parses GREEN with the key set — the whole document, beside the lit control `aria`', (type) => { + // Lit control first, on the same block and the same base document: a probe + // that cannot say yes to a key known to be declared proves nothing below. + expect(parse(type, { aria: ARIA }).success).toBe(true); + + const r = parse(type, { requiredPermissions: ['crm.manage'] }); + expect(r.success).toBe(true); + expect((r.data as Record).requiredPermissions).toEqual(['crm.manage']); + + // And both at once — the key does not displace its neighbours. + expect(parse(type, { aria: ARIA, requiredPermissions: ['crm.manage'] }).success).toBe(true); + }); + + it.each(BLOCKS)('B · %s lists the key in its `.shape`, beside the lit control `aria`', (type) => { + const keys = shapeKeys(type); + expect(keys).toContain('aria'); + expect(keys).toContain('requiredPermissions'); + }); + + it('B · the census: exactly the three blocks and `record:quick_actions` declare it — lit controls `aria` and `fields` fire on the same instrument', () => { + expect(declaring('requiredPermissions')).toEqual([...BLOCKS, 'record:quick_actions'].sort()); + // Controls: the census can find a key at all, and finds the blocks under + // test when it should. `fields` is declared on two of the three (the related + // list's column key is `columns`), which is itself a discriminating reading. + const aria = declaring('aria'); + for (const type of BLOCKS) expect(aria).toContain(type); + expect(aria.length).toBeGreaterThan(BLOCKS.length); + const fields = declaring('fields'); + expect(fields).toContain('record:details'); + expect(fields).toContain('record:highlights'); + expect(fields).not.toContain('record:related_list'); + }); + + it('names are capabilities, not object actions: any string is a legal element, `read` as much as `crm.manage`', () => { for (const type of BLOCKS) { - const issue = unknownKeyIssue(type, { requiredPermissions: ['crm.manage'] }); - expect(issue).toBeDefined(); - expect(issue!.message).toContain('requiredPermissions'); + const r = parse(type, { requiredPermissions: ['crm.manage', 'read'] }); + expect(r.success).toBe(true); + expect((r.data as Record).requiredPermissions).toEqual(['crm.manage', 'read']); } }); - it('is refused for the CRUD spelling too — the refusal is the key, not the value', () => { + it('accepts the empty list, and carries NO schema default — an absent key stays absent', () => { for (const type of BLOCKS) { - expect(unknownKeyIssue(type, { requiredPermissions: ['read'] })).toBeDefined(); + const empty = parse(type, { requiredPermissions: [] }); + expect(empty.success).toBe(true); + expect((empty.data as Record).requiredPermissions).toEqual([]); + const absent = parse(type, {}); + expect(absent.success).toBe(true); + expect(absent.data as Record).not.toHaveProperty('requiredPermissions'); } }); - it('the sibling `record:quick_actions` DOES declare it — the distinction a whole-file screen gets backwards', () => { - const r = RecordQuickActionsProps.safeParse({ requiredPermissions: ['crm.manage'] }); - expect(r.success).toBe(true); - expect(ComponentPropsMap['record:quick_actions']).toBe(RecordQuickActionsProps); + it('refuses a bare string and a non-string member as TYPE errors, never as unknown keys', () => { + for (const type of BLOCKS) { + for (const bad of ['crm.manage', [1]] as unknown[]) { + const r = parse(type, { requiredPermissions: bad }); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(false); + } + } }); - it('names a vocabulary the renderer\'s evaluator cannot express: `perms.can()` takes the closed PermissionAction enum', () => { - // Why the key forks rather than being declared. `requiredPermissions` means - // ADR-0066 CAPABILITIES everywhere else in this spec (`action`, `app`, - // `field`, `bulkAction`); the renderer routes it into the object-ACTION - // evaluator, whose vocabulary is this enum and nothing else. - expect(PermissionActionSchema.safeParse('read').success).toBe(true); - expect(PermissionActionSchema.safeParse('update').success).toBe(true); - expect(PermissionActionSchema.safeParse('crm.manage').success).toBe(false); - expect(PermissionActionSchema.safeParse('showcase.restricted_ops').success).toBe(false); + it('one key, one text: the four record blocks declare it IDENTICALLY — shape and describe, word for word', () => { + const declaration = (schema: { shape: Record }) => + JSON.stringify(z.toJSONSchema(schema.shape.requiredPermissions!, { io: 'input' })); + const reference = declaration(RecordQuickActionsProps as unknown as { shape: Record }); + for (const schema of [RecordDetailsProps, RecordHighlightsProps, RecordRelatedListProps]) { + expect(declaration(schema as unknown as { shape: Record })).toBe(reference); + } + // The comparison is not vacuous: the shared declaration is an optional + // string array that carries a describe. + const qa = (RecordQuickActionsProps as unknown as { shape: Record }).shape.requiredPermissions!; + expect(qa.safeParse(undefined).success).toBe(true); + expect(qa.safeParse(['a']).success).toBe(true); + expect(qa.safeParse('a').success).toBe(false); + expect(typeof qa.description).toBe('string'); + expect((qa.description ?? '').length).toBeGreaterThan(0); }); }); @@ -213,14 +264,7 @@ describe('#18159 — the pair was declared on THESE blocks only', () => { }); it('exactly three rows in `ComponentPropsMap` declare each key', () => { - const declaring = (key: string) => - Object.entries(ComponentPropsMap) - .filter(([, schema]) => Object.keys((schema as { shape?: object }).shape ?? {}).includes(key)) - .map(([type]) => type) - .sort(); expect(declaring('enforceFieldSecurity')).toEqual([...BLOCKS].sort()); expect(declaring('redactFields')).toEqual([...BLOCKS].sort()); - // The control that the census above can find a key at all. - expect(declaring('requiredPermissions')).toEqual(['record:quick_actions']); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 012616c0f37..6b1263a2451 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -851,6 +851,59 @@ export const PageCardProps = strictObject({ * ---------------------------------------------------------------------- */ +/** + * The ONE describe of the block-level `requiredPermissions` gate, carried word + * for word by `record:details`, `record:highlights`, `record:related_list` and + * `record:quick_actions` (#18159, seat ruling A on #19186's ruling-B semantics: + * one word, one meaning, one text). The shape is `z.array(z.string()).optional()` + * on all four; `component-record-block-field-security.test.ts` holds the four + * declarations identical. + * + * Each clause is read off the objectui pin this repo builds against + * (`.objectui-sha` = `62597c588072`), under `packages/plugin-detail/src/renderers/` + * and `packages/permissions/src/`: + * + * 1. CAPABILITIES, NOT OBJECT ACTIONS. Every block gates through + * `perms.hasCapabilities(required)` — `record-details.tsx:234`, + * `record-highlights.tsx:93`, `record-related-list.tsx:242`, + * `record-quick-actions.tsx:263` — and never `perms.can(objectName, …)`; + * each renderer's docblock states the capability "is not object-scoped" + * (`record-details.tsx:223`, `record-highlights.tsx:77`, + * `record-related-list.tsx:229`, `record-quick-actions.tsx:252`). `read` + * is looked up in the capability set like any other name. + * 2. ALL OF THEM. `MePermissionsProvider.tsx:416` is + * `required.every((p) => held.has(p))`. + * 3. THE OUTCOME. Each block returns a `role="status"` "Insufficient + * permissions to view …" notice instead of its content — + * `record-details.tsx:234-242`, `record-highlights.tsx:151-164`, + * `record-related-list.tsx:242-250`, `record-quick-actions.tsx:263-271`. + * Checks that already withhold the content run first on two of them (no + * record bound, `record-details.tsx:186`; no object, + * `record-related-list.tsx:184`; the related object's read gate, + * `record-related-list.tsx:202`), which is why the text says "wherever it + * would otherwise render" rather than promising the notice unconditionally. + * 4. PRESENTATION ONLY. The gate is renderer code: nothing in this repo's + * server packages reads a page component's `requiredPermissions`. The data + * stays in reach — a gated `record:highlights` registers no field names + * (`record-highlights.tsx:146-149`), so `record:details` no longer + * de-duplicates those fields out of its body. To keep a value from a + * user, gate the object, the field or the action. + * 5. UNRESOLVED CAPABILITIES FAIL OPEN. `hasCapabilities` answers `true` when + * `systemPermissions` was never reported (`MePermissionsProvider.tsx:414`), + * under the role-based provider (`PermissionProvider.tsx:77`) and with no + * provider mounted (`usePermissions.ts:45`); a REPORTED empty array reaches + * the `every` at `:416` and gates. + * + * Unlike the field's own `requiredPermissions` (ADR-0066 D3, enforced by the + * server before the payload leaves it), this key hides a block and authorises + * nothing. + */ +const RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION = + '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. ' + + 'When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. ' + + 'Presentation only: it authorises nothing, and the data API still serves the same data to the same user. ' + + 'A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other.'; + export const RecordDetailsProps = strictObject({ surface: 'this `record:details`', history: PROPS_HISTORY, @@ -1118,18 +1171,14 @@ export const RecordDetailsProps = strictObject({ * field list they remove the same rows. Converging them is a contract * question this card did not open. * - * ⚠️ The THIRD key objectui reads on these three blocks — - * `requiredPermissions` — is deliberately NOT declared here. Its read is - * `perms.can(objectName, name)`, whose second parameter is the closed - * `PermissionActionSchema` enum (`create`/`read`/…/`admin`), not the - * ADR-0066 capability set every other `requiredPermissions` in this spec - * names. Measured: under the backend-backed provider an unmapped name falls - * to the object's `allowRead` bit, so a capability nobody holds passes for - * every reader; under the role-based provider the same name is denied for - * everyone whenever the object carries a permission config. Declaring it - * would mint the ADR-0049 fail-open access gate this repo retired on - * `app.areas[].requiredPermissions` in 17.0.0. The exit is the spec seat's - * to rule. + * ⚠️ The THIRD key objectui reads on these three blocks, + * `requiredPermissions`, is declared below them — a different mechanism + * from this pair and from the field's own `requiredPermissions` above. It + * is the block-level ADR-0066 capability gate, read through the capability + * set and fail-closed, with the one describe it shares word for word with + * `record:quick_actions` ({@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION} + * carries the renderer read points). Like this pair it is presentation + * only: it hides the whole block and authorises nothing. */ enforceFieldSecurity: z.boolean().optional().describe( 'Fold this block\'s field list through the caller\'s FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value.', @@ -1137,6 +1186,12 @@ export const RecordDetailsProps = strictObject({ redactFields: z.array(z.string()).optional().describe( 'Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object\'s `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to.', ), + /** + * Block-level ADR-0066 capability gate (#18159) — same shape and same + * describe as `record:quick_actions.requiredPermissions`; the read points + * behind every clause are on {@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}. + */ + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -1253,8 +1308,8 @@ export const RecordRelatedListProps = strictObject({ }).optional().describe('Add-existing-via-picker config (generic m2m/junction assignment).'), /** * The record-block field-security pair — see the family header on - * `RecordDetailsProps` for what the two keys are, what they are not, and why - * the third key objectui reads on this block is not declared. + * `RecordDetailsProps` for what the two keys are, what they are not, and how + * they differ from the block-level `requiredPermissions` gate declared below. * * On THIS block the pair folds `columns` rather than a field list, and * `redactFields` is additionally handed down to `RelatedList` itself: the @@ -1271,6 +1326,12 @@ export const RecordRelatedListProps = strictObject({ redactFields: z.array(z.string()).optional().describe( 'Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object\'s `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set.', ), + /** + * Block-level ADR-0066 capability gate (#18159) — same shape and same + * describe as `record:quick_actions.requiredPermissions`; the read points + * behind every clause are on {@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}. + */ + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -1354,8 +1415,8 @@ export const RecordHighlightsProps = strictObject({ layout: z.enum(['horizontal', 'vertical']).default('horizontal').describe('Layout orientation for highlight fields'), /** * The record-block field-security pair — see the family header on - * `RecordDetailsProps` for what the two keys are, what they are not, and why - * the third key objectui reads on this block is not declared. + * `RecordDetailsProps` for what the two keys are, what they are not, and how + * they differ from the block-level `requiredPermissions` gate declared below. * * On THIS block the pair folds the normalized `fields` chips. The renderer * expresses the fail-closed arm by dropping unnameable entries BEFORE the @@ -1370,6 +1431,12 @@ export const RecordHighlightsProps = strictObject({ redactFields: z.array(z.string()).optional().describe( 'Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object\'s `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set.', ), + /** + * Block-level ADR-0066 capability gate (#18159) — same shape and same + * describe as `record:quick_actions.requiredPermissions`; the read points + * behind every clause are on {@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION}. + */ + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); @@ -1783,7 +1850,7 @@ export const RecordQuickActionsProps = strictObject({ }, }, { actionNames: z.array(z.string()).optional().describe('Names of actions declared on this object (`actions[]`), in display order. The engine still location-filters named actions. Measured: when omitted (and the host supplies nothing) the bar resolves NO actions and renders its empty placeholder — it does not fall back to "every action at this location", whatever the registration\'s input list claims.'), - requiredPermissions: z.array(z.string()).optional().describe('Hide the whole bar unless the current user holds every named permission on this object.'), + requiredPermissions: z.array(z.string()).optional().describe(RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION), location: ActionLocationSchema.optional().describe('Which declared action location this bar renders (renderer default: `record_header`).'), align: z.enum(['start', 'center', 'end']).optional().describe('Horizontal alignment of the button row (renderer default: `end`).'), inline: z.boolean().optional().describe('Render in the flow instead of pulling up into the record-header band. The page header sets this itself when it hosts the bar in its own action slot.'), From dcef292d4efb065c5b925893d36fcdaaea85f56b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:51:17 +0000 Subject: [PATCH 2/7] chore(spec): regenerate authorable-surface and the component reference for the record-block requiredPermissions declaration gen:schema adds the three ui/Record*Props:requiredPermissions rows; gen:docs adds the three table rows and carries the new shared describe onto record:quick_actions. check:generated: all 15 artifacts up to date. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 5 ++++- packages/spec/authorable-surface/ui.json | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 9219cdc48b9..e8773583709 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1194,6 +1194,7 @@ View filter rule | **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). | | **enforceFieldSecurity** | `boolean` | optional | Fold this block's field list through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value. | | **redactFields** | `string[]` | optional | Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordDetailsProps.sections[number]` @@ -1264,6 +1265,7 @@ Type: `string` | **layout** | `Enum<'horizontal' \| 'vertical'>` | optional (default: `"horizontal"`) | Layout orientation for highlight fields | | **enforceFieldSecurity** | `boolean` | optional | Fold this block's highlight chips through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty chip behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the record is fetched either way and the server still decides every value. | | **redactFields** | `string[]` | optional | Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordHighlightsProps.fields[number]` @@ -1335,7 +1337,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **actionNames** | `string[]` | optional | Names of actions declared on this object (`actions[]`), in display order. The engine still location-filters named actions. Measured: when omitted (and the host supplies nothing) the bar resolves NO actions and renders its empty placeholder — it does not fall back to "every action at this location", whatever the registration's input list claims. | -| **requiredPermissions** | `string[]` | optional | Hide the whole bar unless the current user holds every named permission on this object. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | | **location** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>` | optional | Which declared action location this bar renders (renderer default: `record_header`). | | **align** | `Enum<'start' \| 'center' \| 'end'>` | optional | Horizontal alignment of the button row (renderer default: `end`). | | **inline** | `boolean` | optional | Render in the flow instead of pulling up into the record-header band. The page header sets this itself when it hosts the bar in its own action slot. | @@ -1386,6 +1388,7 @@ Type: `string` | **add** | `{ picker: object; linkField?: string; label?: string \| Record }` | optional | Add-existing-via-picker config (generic m2m/junction assignment). | | **enforceFieldSecurity** | `boolean` | optional | Fold this list's `columns` through the caller's FIELD-read permissions on the RELATED object before rendering (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the rows are fetched either way and the server still decides every value. | | **redactFields** | `string[]` | optional | Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordRelatedListProps.columns[number]` diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f870253db81..6b2a134c159 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1079,6 +1079,7 @@ "ui/RecordDetailsProps:inlineEdit", "ui/RecordDetailsProps:layout [RETIRED]", "ui/RecordDetailsProps:redactFields", + "ui/RecordDetailsProps:requiredPermissions", "ui/RecordDetailsProps:sections", "ui/RecordDetailsProps:showHeader", "ui/RecordHighlightsProps:aria", @@ -1086,6 +1087,7 @@ "ui/RecordHighlightsProps:fields", "ui/RecordHighlightsProps:layout", "ui/RecordHighlightsProps:redactFields", + "ui/RecordHighlightsProps:requiredPermissions", "ui/RecordHistoryProps:emptyText", "ui/RecordHistoryProps:limit", "ui/RecordHistoryProps:unknownUserText", @@ -1112,6 +1114,7 @@ "ui/RecordRelatedListProps:redactFields", "ui/RecordRelatedListProps:relationshipField", "ui/RecordRelatedListProps:relationshipValueField", + "ui/RecordRelatedListProps:requiredPermissions", "ui/RecordRelatedListProps:showViewAll", "ui/RecordRelatedListProps:sort", "ui/RecordRelatedListProps:title", From 7c519e7056b683fe32a2bed5845dca68c012a4f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:28:39 +0000 Subject: [PATCH 3/7] fix(spec): cut the record-block requiredPermissions texts that said the gate hides the block At the pinned console the capability gate renders an insufficient-permissions notice in place of the block's content; it does not hide the block. Deletions only: the pending pair changeset ends at "not a member of this pair", the constant's docblock drops "hides a block and", the family header drops "and fail-closed", the new changeset drops the "hides the bar" clause, and the shared describe ends at "it fails open." (an empty requiredPermissions list sets no gate, so "a resolved empty set gates" misread). Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/18159-record-block-field-security-pair.md | 2 +- .changeset/18159-record-block-required-permissions.md | 2 +- packages/spec/src/ui/component.zod.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/18159-record-block-field-security-pair.md b/.changeset/18159-record-block-field-security-pair.md index 5bbee223e94..d502a9a04c6 100644 --- a/.changeset/18159-record-block-field-security-pair.md +++ b/.changeset/18159-record-block-field-security-pair.md @@ -12,6 +12,6 @@ All three blocks are `strictObject`s that declared neither key, while `@object-u - **`redactFields`** (string array) drops the names it lists outright. On `record:related_list` it also reaches the columns the list derives for itself when none are authored. - **The claim is held to what the render path does.** Both are presentation filters, applied in the browser after the record is fetched: the values are in the page either way, so neither is a data-access control and neither is the object's `publicSharing.redactFields`, which removes them server-side. Each `describe()` says that in the text an author reads, rather than leaving the key names to imply it (Prime Directive #10). The gates that do keep a value from a caller are the field's own `requiredPermissions` / `maskingRule` (ADR-0066 D3) and the permission set. - **⚠️ On `record:details`, `redactFields` neighbours the already-declared `hideFields`** and on a well-formed field list the two remove the same rows: `hideFields` is the dedupe channel the renderer also writes to (live `record:highlights` registrations, the page-title field), `redactFields` is the author's deliberate omission and the arm that participates in the renderer's fail-closed fold. Converging them is a contract question this change did not open. -- **The third key the same three renderers read — `requiredPermissions` — is declared in the same release, by its own entry.** It is the block-level ADR-0066 capability gate, not a member of this pair: a capability set read through the permission context's capability path, which hides the whole block and, like this pair, authorises nothing. +- **The third key the same three renderers read — `requiredPermissions` — is declared in the same release, by its own entry.** It is the block-level ADR-0066 capability gate, not a member of this pair. ⚠️ **Not measured here**: the runtime behaviour of either declared key in a browser, and whether any authored document anywhere writes them. "The schema refused it" is not "nobody writes it"; only the first is measured. diff --git a/.changeset/18159-record-block-required-permissions.md b/.changeset/18159-record-block-required-permissions.md index eb1e2fd3fb0..03690dd0d81 100644 --- a/.changeset/18159-record-block-required-permissions.md +++ b/.changeset/18159-record-block-required-permissions.md @@ -8,4 +8,4 @@ Clause-②: yes (widening) - **Additive.** All three blocks are `strictObject`s that refused the key by name. It is now declared optional, `z.array(z.string())`, with no schema default, so an absent key stays absent and nothing that parsed before stops parsing. - **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open); a resolved empty set gates. To keep data from a user, gate the object, the field or the action. -- **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names: a name nobody holds hides the bar for every user whose capabilities are reported. +- **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6b1263a2451..c7c139bd768 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -895,14 +895,14 @@ export const PageCardProps = strictObject({ * the `every` at `:416` and gates. * * Unlike the field's own `requiredPermissions` (ADR-0066 D3, enforced by the - * server before the payload leaves it), this key hides a block and authorises + * server before the payload leaves it), this key authorises * nothing. */ const RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION = '[ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object\'s read or edit permission. ' + 'When the client has resolved the user\'s capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. ' + 'Presentation only: it authorises nothing, and the data API still serves the same data to the same user. ' - + 'A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other.'; + + 'A client that cannot resolve the user\'s capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open.'; export const RecordDetailsProps = strictObject({ surface: 'this `record:details`', @@ -1175,7 +1175,7 @@ export const RecordDetailsProps = strictObject({ * `requiredPermissions`, is declared below them — a different mechanism * from this pair and from the field's own `requiredPermissions` above. It * is the block-level ADR-0066 capability gate, read through the capability - * set and fail-closed, with the one describe it shares word for word with + * set, with the one describe it shares word for word with * `record:quick_actions` ({@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION} * carries the renderer read points). Like this pair it is presentation * only: it hides the whole block and authorises nothing. From 30fc769272f1ffd49cac14cb24d830e8de11fe49 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:34:20 +0000 Subject: [PATCH 4/7] chore(spec): regenerate the component reference for the shortened record-block requiredPermissions describe gen:docs: the four requiredPermissions rows lose "; a resolved empty set gates like any other" and nothing else. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e8773583709..48cb88d60ef 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1194,7 +1194,7 @@ View filter rule | **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). | | **enforceFieldSecurity** | `boolean` | optional | Fold this block's field list through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value. | | **redactFields** | `string[]` | optional | Field names this block never renders, whatever the permission answer (renderer default: render everything authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. Neighbours `hideFields`, which is the dedupe channel the renderer also writes to. | -| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordDetailsProps.sections[number]` @@ -1265,7 +1265,7 @@ Type: `string` | **layout** | `Enum<'horizontal' \| 'vertical'>` | optional (default: `"horizontal"`) | Layout orientation for highlight fields | | **enforceFieldSecurity** | `boolean` | optional | Fold this block's highlight chips through the caller's FIELD-read permissions before rendering, so a field the permission set denies leaves no empty chip behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the record is fetched either way and the server still decides every value. | | **redactFields** | `string[]` | optional | Field names this block never renders as a chip, whatever the permission answer (renderer default: render every field authored). Presentation only, evaluated in the browser after the record is fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | -| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordHighlightsProps.fields[number]` @@ -1337,7 +1337,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **actionNames** | `string[]` | optional | Names of actions declared on this object (`actions[]`), in display order. The engine still location-filters named actions. Measured: when omitted (and the host supplies nothing) the bar resolves NO actions and renders its empty placeholder — it does not fall back to "every action at this location", whatever the registration's input list claims. | -| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **location** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>` | optional | Which declared action location this bar renders (renderer default: `record_header`). | | **align** | `Enum<'start' \| 'center' \| 'end'>` | optional | Horizontal alignment of the button row (renderer default: `end`). | | **inline** | `boolean` | optional | Render in the flow instead of pulling up into the record-header band. The page header sets this itself when it hosts the bar in its own action slot. | @@ -1388,7 +1388,7 @@ Type: `string` | **add** | `{ picker: object; linkField?: string; label?: string \| Record }` | optional | Add-existing-via-picker config (generic m2m/junction assignment). | | **enforceFieldSecurity** | `boolean` | optional | Fold this list's `columns` through the caller's FIELD-read permissions on the RELATED object before rendering (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — the rows are fetched either way and the server still decides every value. | | **redactFields** | `string[]` | optional | Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. | -| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open; a resolved empty set gates like any other. | +| **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities the user must ALL hold — names that permission sets grant through `systemPermissions`, not object actions: `read` or `update` here is an ordinary capability name, not the object's read or edit permission. When the client has resolved the user's capabilities and any of these is missing, this block does not render its content; wherever it would otherwise render, an insufficient-permissions notice takes its place. Presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities (no permission provider, or one that does not report `systemPermissions`) renders this block as if they were held — it fails open. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordRelatedListProps.columns[number]` From aeb6a574569b7c55ceb24d93ca723eb6b2319ced Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:57:05 +0000 Subject: [PATCH 5/7] fix(spec): finish the record-block requiredPermissions cuts ruled on round 2 Deletions only, per the seat's ruling on the two held questions: the RecordDetailsProps family header drops "hides the whole block and" (it now reads "presentation only: it authorises nothing."), and the new changeset drops "; a resolved empty set gates" so it ends at "(fails open)." like the describe. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .changeset/18159-record-block-required-permissions.md | 2 +- packages/spec/src/ui/component.zod.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/18159-record-block-required-permissions.md b/.changeset/18159-record-block-required-permissions.md index 03690dd0d81..80546d85116 100644 --- a/.changeset/18159-record-block-required-permissions.md +++ b/.changeset/18159-record-block-required-permissions.md @@ -7,5 +7,5 @@ Clause-②: yes (widening) - **Additive.** All three blocks are `strictObject`s that refused the key by name. It is now declared optional, `z.array(z.string())`, with no schema default, so an absent key stays absent and nothing that parsed before stops parsing. -- **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open); a resolved empty set gates. To keep data from a user, gate the object, the field or the action. +- **One key, one meaning, one text, on all four record blocks.** The names are ADR-0066 capabilities (what permission sets grant through `systemPermissions`), not object actions. The user must hold all of them; otherwise the block renders an insufficient-permissions notice in place of its content. It is presentation only: it authorises nothing, and the data API still serves the same data to the same user. A client that cannot resolve the user's capabilities renders the block as if they were held (fails open). To keep data from a user, gate the object, the field or the action. - **⚠️ Published text changes: the describe of `record:quick_actions.requiredPermissions`.** It read "Hide the whole bar unless the current user holds every named permission on this object." Against the renderer the pinned console ships, "on this object" is false — the gate reads the user's capability set and is not object-scoped — and the sentence named no fail-open case. The shape is unchanged; only the text moves. If a page writes object actions there (`read`, `update`), the console reads them as capability names. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index c7c139bd768..a13ce7ea768 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1178,7 +1178,7 @@ export const RecordDetailsProps = strictObject({ * set, with the one describe it shares word for word with * `record:quick_actions` ({@link RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION} * carries the renderer read points). Like this pair it is presentation - * only: it hides the whole block and authorises nothing. + * only: it authorises nothing. */ enforceFieldSecurity: z.boolean().optional().describe( 'Fold this block\'s field list through the caller\'s FIELD-read permissions before rendering, so a field the permission set denies leaves no empty row behind (renderer default: off). Presentation only: it re-applies the same field-read answer the server already enforced (ADR-0066 D3) and never widens access — with it off a denied field still arrives masked or stripped, and with it on the server still decides every value.', From a3075b6ace60666eb138c9a4f2f426b169b6e0ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:06:02 +0000 Subject: [PATCH 6/7] chore(spec): regenerate authorable-surface/ui.json on the merged tree The os-regen driver kept the branch's side of ui.json in the merge of origin/main (66960564d9); main's thirteen DocNavItem keys were the dropped side. gen:schema on the merged tree restores them next to this branch's three record-block requiredPermissions keys, and nothing else. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- packages/spec/authorable-surface/ui.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 6b2a134c159..ed849ec2527 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -351,6 +351,19 @@ "ui/DatasetMeasure:format", "ui/DatasetMeasure:label", "ui/DatasetMeasure:name", + "ui/DocNavItem:badge", + "ui/DocNavItem:badgeVariant", + "ui/DocNavItem:book", + "ui/DocNavItem:doc", + "ui/DocNavItem:icon", + "ui/DocNavItem:id", + "ui/DocNavItem:label", + "ui/DocNavItem:order", + "ui/DocNavItem:requiredPermissions", + "ui/DocNavItem:requiresObject", + "ui/DocNavItem:requiresService", + "ui/DocNavItem:type", + "ui/DocNavItem:visible", "ui/ElementButtonProps:action", "ui/ElementButtonProps:aria", "ui/ElementButtonProps:disabled", From b19ac16c77e812659a5a98d5740b82876b265ebf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 02:08:49 +0000 Subject: [PATCH 7/7] fix(spec): re-measure the record-block requiredPermissions read points at objectui f8a9d0fb0596 origin/main moved `.objectui-sha` from 62597c588072 to f8a9d0fb0596, and the shared docblock of the block-level requiredPermissions gate asserts the pin it was read at. Every one of its 21 anchors was re-read at the new pin: the cited range holds the same text it held at 62597c588072 in all 21. record-details.tsx moved +1 (an import line) and record-related-list.tsx moved +34 (an import block and the props-type docblock), so nine anchors take new numbers: record-details 186/223/234/234-242 -> 187/224/235/235-243 and record-related-list 184/202/229/242/242-250 -> 218/236/263/276/276-284. record-highlights.tsx, record-quick-actions.tsx, MePermissionsProvider.tsx, PermissionProvider.tsx and usePermissions.ts are byte-identical across the hop, so their twelve anchors keep their numbers. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- packages/spec/src/ui/component.zod.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 6c96fe3869f..e3c96047ba1 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -862,27 +862,27 @@ export const PageCardProps = strictObject({ * declarations identical. * * Each clause is read off the objectui pin this repo builds against - * (`.objectui-sha` = `62597c588072`), under `packages/plugin-detail/src/renderers/` + * (`.objectui-sha` = `f8a9d0fb0596`), under `packages/plugin-detail/src/renderers/` * and `packages/permissions/src/`: * * 1. CAPABILITIES, NOT OBJECT ACTIONS. Every block gates through - * `perms.hasCapabilities(required)` — `record-details.tsx:234`, - * `record-highlights.tsx:93`, `record-related-list.tsx:242`, + * `perms.hasCapabilities(required)` — `record-details.tsx:235`, + * `record-highlights.tsx:93`, `record-related-list.tsx:276`, * `record-quick-actions.tsx:263` — and never `perms.can(objectName, …)`; * each renderer's docblock states the capability "is not object-scoped" - * (`record-details.tsx:223`, `record-highlights.tsx:77`, - * `record-related-list.tsx:229`, `record-quick-actions.tsx:252`). `read` + * (`record-details.tsx:224`, `record-highlights.tsx:77`, + * `record-related-list.tsx:263`, `record-quick-actions.tsx:252`). `read` * is looked up in the capability set like any other name. * 2. ALL OF THEM. `MePermissionsProvider.tsx:416` is * `required.every((p) => held.has(p))`. * 3. THE OUTCOME. Each block returns a `role="status"` "Insufficient * permissions to view …" notice instead of its content — - * `record-details.tsx:234-242`, `record-highlights.tsx:151-164`, - * `record-related-list.tsx:242-250`, `record-quick-actions.tsx:263-271`. + * `record-details.tsx:235-243`, `record-highlights.tsx:151-164`, + * `record-related-list.tsx:276-284`, `record-quick-actions.tsx:263-271`. * Checks that already withhold the content run first on two of them (no - * record bound, `record-details.tsx:186`; no object, - * `record-related-list.tsx:184`; the related object's read gate, - * `record-related-list.tsx:202`), which is why the text says "wherever it + * record bound, `record-details.tsx:187`; no object, + * `record-related-list.tsx:218`; the related object's read gate, + * `record-related-list.tsx:236`), which is why the text says "wherever it * would otherwise render" rather than promising the notice unconditionally. * 4. PRESENTATION ONLY. The gate is renderer code: nothing in this repo's * server packages reads a page component's `requiredPermissions`. The data