Skip to content

Commit 63a8eb4

Browse files
feat(spec): declare requiredPermissions on record:details / highlights / related_list with one true describe shared with record:quick_actions (#19913)
Fixes #18159 Clause-②: yes <sub>Rewritten short by the `domain:spec#5` seat (2026-09-23T19:59Z). The dev reports are on #18159 (`5798750532`, `5800008407`, `5800978880`, `5801656924`); the earlier long body is in the edit history.</sub> `requiredPermissions` is now declared on `record:details`, `record:highlights` and `record:related_list`, with the shape `record:quick_actions` already has (`z.array(z.string()).optional()`). Ruling: batch #197 item 2, letter A (`5749268463`), after objectui#10058 became installable at `.objectui-sha` `62597c588072`. ## One describe, four blocks (seat ruling `5798783314`) The ruling asks for the same describe as `record:quick_actions`. That published describe said "…every named permission **on this object**", which is false for the renderer at the pin: the check is `hasCapabilities`, and the renderer's own comment says the capability "is not object-scoped". So all four blocks now share ONE describe, from one constant (`RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION`). **The published `record:quick_actions` describe changes; its shape does not.** The describe states: - the names are ADR-0066 capabilities, not object actions; - the user must hold all of them; - when one is missing, the block shows an insufficient-permissions notice instead of its content; - it is presentation only, and the data API still serves the data; - a client that cannot resolve the user's capabilities renders the block (fails open). The renderer lines behind each clause are in the dev report `5800008407`. ## Other changes - The texts that said the key is "deliberately NOT declared" are rewritten: the `RecordDetailsProps` family header, the related-list and highlights pointers, and the test header. - The absence pins are now accept pins: a parse probe and `.shape` enumeration per block, with `aria` and `fields` as lit controls, plus a pin that the four JSON Schemas of the key are identical. Removing the three declarations turns 11 of 25 pins red. - `authorable-surface/ui.json` and `component.mdx` are regenerated. ## `Check Changeset` is red on purpose The pending note `.changeset/18159-record-block-field-security-pair.md` (from PR #19185) said the third key "is deliberately NOT declared". This PR makes that false, so the PR corrects it. That is a DELIBERATE CORRECTION under `check-empty-changeset.mjs`: the check stays red, and the correction awaits the maintainer's written confirmation on this PR. The line to confirm is quoted verbatim in `5801679846`. `Check Changeset` is not a required context. ## Fix round 2026-09-25: `origin/main` merged, objectui pin re-measured Fix round for `domain:spec#4` (session `session_019c3Hi6ZMU1p6m6aA6Bz45d`), new head `b19ac16c77`. The merge queue dequeued the PR. `Type Check · source gates` failed at `check:objectui-pin-citations`: #20036 (`0bf85eaae6`) moved `.objectui-sha` from `62597c588072` to `f8a9d0fb0596`, and the shared `requiredPermissions` docblock in `component.zod.ts` still asserted the old pin. This round adds three commits and changes nothing else: - `62decd77dd` merges `origin/main` at `66960564d9` with `scripts/pm/os-regen-merge.sh` (merge commit; no rebase or force-push). It had no text conflict. - `a3075b6ace` regenerates `packages/spec/authorable-surface/ui.json` on the merged tree. In that file the merge driver kept this branch's side and dropped main's 13 `DocNavItem` keys. The regenerated file has main's keys plus this PR's three `requiredPermissions` keys, and nothing else. - `b19ac16c77` re-measures the docblock at the new pin. It updates the sha and the anchors together (10 lines replaced, 0 added). ### What was measured objectui at `f8a9d0fb0596f4521076628e2bbfe27e6ce67d52` was read from a scratch fetch of both pins. The seven cited files match the codeload tarball of the same commit byte for byte. All 21 anchors in the docblock were re-read. **At the new pin, each cited range holds the same text it held at `62597c588072`.** No read point changed meaning or disappeared. - `record-details.tsx` shifted +1 (one new import line). `record-related-list.tsx` shifted +34 (an import block and the props-type docblock). Nine anchors get new numbers. - `record-highlights.tsx`, `record-quick-actions.tsx`, `MePermissionsProvider.tsx`, `PermissionProvider.tsx` and `usePermissions.ts` did not change between the two pins, so their twelve anchors keep their numbers. | Anchor, old → new | First line of the range at `f8a9d0fb0596` | |---|---| | `record-details.tsx:234` → `:235` | `if (required.length > 0 && !perms.hasCapabilities(required)) {` | | `record-details.tsx:223` → `:224` | `* capability is not object-scoped, and the old && objectName conjunct was` | | `record-details.tsx:234-242` → `:235-243` | `if (required.length > 0 && !perms.hasCapabilities(required)) {` | | `record-details.tsx:186` → `:187` | `if (!ctx) {` | | `record-related-list.tsx:242` → `:276` | `if (required.length > 0 && !perms.hasCapabilities(required)) {` | | `record-related-list.tsx:229` → `:263` | `* capability is not object-scoped. This site never carried the` | | `record-related-list.tsx:242-250` → `:276-284` | `if (required.length > 0 && !perms.hasCapabilities(required)) {` | | `record-related-list.tsx:184` → `:218` | `if (!objectName) {` | | `record-related-list.tsx:202` → `:236` | `if (perms.isLoaded && !perms.can(objectName, 'read')) {` | | `record-highlights.tsx:93` (unchanged) | `const highlightsAllowed = required.length === 0 \|\| perms.hasCapabilities(required);` | | `record-highlights.tsx:77` (unchanged) | `* capability is not object-scoped, and the old && objectName conjunct was` | | `record-highlights.tsx:151-164` (unchanged) | `if (!highlightsAllowed) {` | | `record-highlights.tsx:146-149` (unchanged) | `useRegisterHighlightFields(` | | `record-quick-actions.tsx:263` (unchanged) | `if (required.length > 0 && !perms.hasCapabilities(required)) {` | | `record-quick-actions.tsx:252` (unchanged) | `* capability is not object-scoped, and the old && objectName guard was a` | | `record-quick-actions.tsx:263-271` (unchanged) | `if (required.length > 0 && !perms.hasCapabilities(required)) {` | | `MePermissionsProvider.tsx:416`, and the later `:416` (unchanged) | `return required.every((p) => held.has(p));` | | `MePermissionsProvider.tsx:414` (unchanged) | `if (!Array.isArray(perms)) return true;` | | `PermissionProvider.tsx:77` (unchanged) | `const ALL_CAPABILITIES: PermissionContextValue['hasCapabilities'] = () => true;` | | `usePermissions.ts:45` (unchanged) | `hasCapabilities: () => true,` | (The inner backticks around `&& objectName` in the three docblock lines are left out of the table.) One observation from the re-read, which changes no anchor. The props-type docblock that `record-related-list.tsx` gained (`:89-92`) says no block the contract maps onto this tag declares `requiredPermissions`, and says not to reopen the type to admit it. This PR makes that sentence stale. That is the objectui half ruled **A** on objectstack-ai/objectui#10281, and it moves to the `domain:ui` seat. The renderer's read of the key (`:240-242`, through a cast) and its gate (`:276`) are unchanged. ### Gates, on head `b19ac16c77` - `check:objectui-pin-citations` exits 0 on `--self-test`, on the ordinary run and on `--verify-anchors`, with objectui at the new pin supplied through `OBJECTUI_ROOT`. The ordinary run prints: "48 asserting objectui pin citation(s) match .objectui-sha (f8a9d0f), 40 historical citation(s) recorded and not checked, across 1555 spec source(s). 7 anchor content assertion(s) verified against objectui at f8a9d0f". CI has no objectui checkout, so it verifies the sha label only. - `pnpm --filter @objectstack/spec build`, then `check:generated`: all 15 generated artifacts are up to date. `typecheck` exits 0. The full spec suite: 535 files, 15727 passed, 2 todo. - `node scripts/pm/dispatch-gates.mjs --commands` derived 107 commands for this change set. All 107 ran, and `--ran` reconciles 107 of 107 with recorded exit codes and 0 NOT MEASURED. Seven of them first exited 3 (no build to read). They were re-run green after building the lint closure, the client-react closure and then every package. 106 exit 0. The one exit 1 is `check-empty-changeset.mjs`. That is the deliberate correction described above, which the maintainer confirmed at head `aeb6a57456` (`5823706830`). - The changesets are byte-identical to that head: `git diff --exit-code aeb6a57 b19ac16 -- .changeset/18159-record-block-field-security-pair.md .changeset/18159-record-block-required-permissions.md` exits 0. The blobs are `d502a9a04c6a` and `80546d851168` on both heads. The test file and `component.mdx` are byte-identical to that head as well. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4c897e commit 63a8eb4

6 files changed

Lines changed: 201 additions & 73 deletions

File tree

‎.changeset/18159-record-block-field-security-pair.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ All three blocks are `strictObject`s that declared neither key, while `@object-u
1212
- **`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.
1313
- **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.
1414
- **⚠️ 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.
15-
- **⚠️ 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.
15+
- **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.
1616

1717
⚠️ **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.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`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).
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **⚠️ 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.

‎content/docs/references/ui/component.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,7 @@ View filter rule
11931193
| **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). |
11941194
| **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. |
11951195
| **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. |
1196+
| **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. |
11961197
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
11971198

11981199
### Nested Shape: `RecordDetailsProps.sections[number]`
@@ -1263,6 +1264,7 @@ Type: `string`
12631264
| **layout** | `Enum<'horizontal' \| 'vertical'>` | optional (default: `"horizontal"`) | Layout orientation for highlight fields |
12641265
| **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. |
12651266
| **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. |
1267+
| **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. |
12661268
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
12671269

12681270
### Nested Shape: `RecordHighlightsProps.fields[number]`
@@ -1334,7 +1336,7 @@ Type: `string`
13341336
| Property | Type | Required | Description |
13351337
| :--- | :--- | :--- | :--- |
13361338
| **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. |
1337-
| **requiredPermissions** | `string[]` | optional | Hide the whole bar unless the current user holds every named permission on this object. |
1339+
| **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. |
13381340
| **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`). |
13391341
| **align** | `Enum<'start' \| 'center' \| 'end'>` | optional | Horizontal alignment of the button row (renderer default: `end`). |
13401342
| **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. |
@@ -1385,6 +1387,7 @@ Type: `string`
13851387
| **add** | `{ picker: object; linkField?: string; label?: string \| Record<string, string> }` | optional | Add-existing-via-picker config (generic m2m/junction assignment). |
13861388
| **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. |
13871389
| **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. |
1390+
| **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. |
13881391
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
13891392

13901393
### Nested Shape: `RecordRelatedListProps.columns[number]`

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,13 +1090,15 @@
10901090
"ui/RecordDetailsProps:inlineEdit",
10911091
"ui/RecordDetailsProps:layout [RETIRED]",
10921092
"ui/RecordDetailsProps:redactFields",
1093+
"ui/RecordDetailsProps:requiredPermissions",
10931094
"ui/RecordDetailsProps:sections",
10941095
"ui/RecordDetailsProps:showHeader",
10951096
"ui/RecordHighlightsProps:aria",
10961097
"ui/RecordHighlightsProps:enforceFieldSecurity",
10971098
"ui/RecordHighlightsProps:fields",
10981099
"ui/RecordHighlightsProps:layout",
10991100
"ui/RecordHighlightsProps:redactFields",
1101+
"ui/RecordHighlightsProps:requiredPermissions",
11001102
"ui/RecordHistoryProps:emptyText",
11011103
"ui/RecordHistoryProps:limit",
11021104
"ui/RecordHistoryProps:unknownUserText",
@@ -1123,6 +1125,7 @@
11231125
"ui/RecordRelatedListProps:redactFields",
11241126
"ui/RecordRelatedListProps:relationshipField",
11251127
"ui/RecordRelatedListProps:relationshipValueField",
1128+
"ui/RecordRelatedListProps:requiredPermissions",
11261129
"ui/RecordRelatedListProps:showViewAll",
11271130
"ui/RecordRelatedListProps:sort",
11281131
"ui/RecordRelatedListProps:title",

0 commit comments

Comments
 (0)