Repository navigation
spec(ui): record:details / record:highlights / record:related_list declare enforceFieldSecurity and redactFields — requiredPermissions forks on a measured fail-open gate - #19185
Conversation
`record:details`, `record:highlights` and `record:related_list` are `strictObject`s that declared none of `enforceFieldSecurity` / `redactFields`, while `@object-ui/plugin-detail` reads and honours both on each of the three — an author who wrote either was refused at parse while the renderer honoured the same document on the raw-node path. Declare both on all three blocks, with each `describe()` held to what the render path actually does: they are presentation filters over the field list the block draws, applied in the browser after the record is fetched, never a data-access control (Prime Directive #10). The third key the same renderers read, `requiredPermissions`, is NOT declared here — its evaluator's vocabulary is the closed `PermissionActionSchema` enum, not the ADR-0066 capability set the name means everywhere else in this spec. That exit is the spec seat's to rule. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
… pair `gen:api-surface-declarations` and `gen:docs`, as `check:generated` proved stale; `authorable-surface/ui.json` is the build's own regeneration. Six additions in the authorable surface and nothing else — the two keys on the three record blocks. The changeset is `minor` and carries `Clause-②: yes (widening)`: the three keys were refused at parse before this change, so declaring two of them widens the accept set on a published authoring surface. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a2205833766a573701979a99b8f88479b65fcab8 && git checkout a2205833766a573701979a99b8f88479b65fcab8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eeaa882459edffd077c1cda2972af06d1f10550b 676d44d593a8e0d8546bac212e2cb335b7aa22ea && git checkout -B drift-repro eeaa882459edffd077c1cda2972af06d1f10550b && git merge --no-ff 676d44d593a8e0d8546bac212e2cb335b7aa22ea
node scripts/docs-audit/affected-docs.mjs --json eeaa882459edffd077c1cda2972af06d1f10550b
|
Contract reviewServed-tier: Isolated at-tier contract review of PR #19185 ( ① Derived judgmentsThe accept-set delta, measured. My own parse probe (self-tested first: strict refuses nonsense / strict accepts a declared key / loose refuses nothing, 3 of 3) run inside each tree, and the PR's own suite
Every schema is the exported one ( Item by item, against the renderer (
② Semver level
③ Boundary flags
Checks on the judged head, read at 2026-09-19T09:38Z: 39 check runs, all completed, 34 Not measured by this review: the runtime behaviour of either key in a browser (no dogfood run was made), and whether any authored document anywhere writes these keys. The Implemented-by: VERDICT: PASS Generated by Claude Code |
Clause-② provenance — carriers cleared on an at-tier PASS; ⛔ auto-merge deliberately NOT armed yet
Record adopted: comment 5740810186, a Tier, measured rather than taken from the reviewer's word. Rendered by an isolated at-tier subagent fed only the card, its rulings and this PR — ⛔ never the dispatch order, ⛔ never this seat's conclusions, and told in terms that seat comments and the Carriers cleared in one act, both read back at 2026-09-19T09:40Z: card #18159 →
⭐ The review's substantive finding, recorded because it is the one that matters for this repo's North Star: it read the three objectui renderers, both permission providers and the evaluator at the pinned sha and at objectui One finding it recorded rather than failed, carried here so it is not lost: ⛔ Why auto-merge is NOT armed, and what will arm itEvery landing precondition is met — PASS of record on this head, both carriers cleared, Measured this hour on a sibling PR of this seat: #19183 entered the queue at 2026-09-19T09:10Z and was ejected at 2026-09-19T09:27Z with nothing red anywhere — because ⇒ Arming now would very likely spend a fleet merge group to reproduce that, and 「合并队列是共享串行资源」. So this seat holds the arm on one cheap and decisive test it is already watching: the group formed at 09:27:31Z for another PR is on the same path. If that group lands, the arm goes on immediately. If it is ejected the same way, #19189 is confirmed with n=2 and this PR waits for it. ⛔ Nothing else about this PR is outstanding. Generated by Claude Code |
…s / related_list with one true describe shared with record:quick_actions (objectstack-ai#19913) Fixes objectstack-ai#18159 Clause-②: yes <sub>Rewritten short by the `domain:spec#5` seat (2026-09-23T19:59Z). The dev reports are on objectstack-ai#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 objectstack-ai#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 objectstack-ai#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`: objectstack-ai#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 (f8a9d0fb0), 40 historical citation(s) recorded and not checked, across 1555 spec source(s). 7 anchor content assertion(s) verified against objectui at f8a9d0fb0". 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>
Part of #18159
Clause-②: yes (widening)
Part of, not a closing keyword, and deliberately so. The card routes three keys here and asks for a per-key exit. Two are declared below. The third,requiredPermissions, returns a fork that only a ruling can settle — merging this PR must not close the card, because the half it leaves open is the half with a measured fail-open gate behind it.What this declares
record:details,record:highlightsandrecord:related_listarestrictObjects that declared neitherenforceFieldSecuritynorredactFields, while@object-ui/plugin-detailreads and honours both on each of the three. An author who wrote either was refused at publish and the same document was honoured on the raw-node path. Both keys are now declared on all three blocks — optional, no schema default, so an absent key stays absent rather than becoming "the author asked for off".enforceFieldSecurity(boolean)fieldsandsections, the highlight chips, the related list'scolumns— through the caller's field-read permissions (checkField, the platform's own answer) before rendering.redactFields(string array)record:related_listit is also handed down toRelatedList, because that component derives its own columns when none are authored — filtering the authored array alone let a redacted field return through the derivation (objectui#9053).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. Eachdescribe()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 ownrequiredPermissions/maskingRule(ADR-0066 D3) and the permission set.What this deliberately does NOT declare —
requiredPermissionsforksThe card's Zone 3 instruction was to stop rather than declare a key whose honouring is itself the defect. That condition is met for
requiredPermissions, and it is met on a measurement, not a reading of intent.The renderers evaluate it as
perms.can(objectName, name)(record-details.tsx,record-highlights.tsx,record-related-list.tsx, each writingname as any). That second parameter is typedPermissionAction— this package's own closed enum,packages/spec/src/kernel/plugin-security-advanced.zod.ts:create·read·update·delete·execute·manage·configure·share·export·import·admin. It is not the ADR-0066 capability set, which is whatrequiredPermissionsmeans onaction,app,fieldandbulkAction, and which objectui evaluates through a different function entirely (hasCapabilities, oversystemPermissions).Both shipped providers were run, each with self-tests and controls that fire in both directions:
MePermissionsProvider(the backend-backed one,/me/permissions). Itscheckmaps eight spellings to a permission bit and falls through:const k = map[action as string] ?? 'allowRead'. Driven withallowRead: true, every write bitfalse, andsystemPermissions: []— reported, holding nothing:PermissionProvider(the role-based one), through its realevaluatePermission:So one authored
requiredPermissions: ['crm.manage']passes for every reader who can read the object under one provider, is denied to every reader under the other, and consults the caller's actual capability set under neither. Declaring it would mint the ADR-0049 fail-open access gate this repo retired fromapp.areas[].requiredPermissionsin 17.0.0 — whose own conversion-registry entry says it in the same words: "FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user".⭐ objectui's own pin battery for this gate (
record-quick-actions.requiredPermissions-gate.test.tsx) mocksusePermissionswith a stub whosecan(object, action)isgranted.has(action). It pins that the renderer callscan()with the right arguments; it cannot see whatcan()answers, which is where the defect lives.The question for the seat is in the report on the card. In short: (A) declare it as object ACTIONS (
PermissionActionSchema), which is whatrecord-highlights.tsx's own comment says the author should write — "record:* may declare requiredPermissions like ['read','update']" — and what the backend-backed provider's map can honour; (B) declare it as ADR-0066 capabilities and repair the renderer to evaluate it throughhasCapabilities; (C) rule it host-composition surface and retire the reads, the ADR-0049 precedent. ⛔ Not decided here.Also measured, and left alone
record:quick_actionsalready declaresrequiredPermissions(z.array(z.string()), described as "unless the current user holds every named permission on this object") and its renderer performs the identicalperms.can(objectName, p as any)read. That declaration is on a published surface and predates this branch; it is not touched here and is reported for filing rather than fixed on a card that does not name it.Evidence
One instrument, self-tested before it was pointed at the tree, run on the same tree before and after the change. Two positive controls, both unmoved:
Named refusals on
record:related_list, base document legal on its own:packages/spec/authorable-surface/ui.jsonmoved by exactly six lines, all additions, and nothing else.Verification
Run on
676d44d593, the head this PR carries.pnpm --filter @objectstack/spec exec vitest run src/ui/component-record-block-field-security.test.ts— 18 tests, exit 0. With the three sibling component suites: 4 files / 397 tests, exit 0.pnpm --filter @objectstack/spec typecheck— exit 0 (test layer: 54 files / 259 errors / 144 pinned signatures held, unmoved).pnpm --filter @objectstack/spec check:generated— exit 0.gen:api-surface-declarationsandgen:docswere regenerated after the check proved them stale;authorable-surface/ui.jsonis the build's own output.authorable-surface.base.jsonis untouched.MANIFEST=$PWD/sdui.manifest.json pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict— exit 0, aslint.ymlspells it.REACT_RECORD_BLOCK_ALTERNATIVES), so nospec-onlysignal arises here — andspec-onlyis a reported, never-ratcheted signal in any case.pnpm lint— the whole tree,eslint . --no-inline-config, exit 0. No narrowing was needed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE— 107 derived families accounted for: 106 run, 1 NOT MEASURED. The one ispnpm check:dual-build-cjs-loads, which exits 3 (PREREQUISITE NOT MET) because ~34 packages have nodist/in this worktree; it needs a whole-workspace build, which CI'sBuild Coreprovides. Six other families first exited 3 or 1 on the same prerequisite class and were re-run green after building@objectstack/lint,@objectstack/formula,@objectstack/client-reactand@objectstack/driver-turso.Acceptance notes
Out of scope for this card, noted and not filed from here:
record:quick_actions.requiredPermissionscarries the same defect on a published surface (z.array(z.string()), described as a capability gate, evaluated against the closed action enum). Reported for filing; deliberately not fixed on a card that does not name it. Successor: the same ruling that settles this card's fork.redactFieldsneighbourshideFieldsonrecord:details, and on a well-formed field list the two remove the same rows. They are different channels —hideFieldsis the dedupe list the renderer also writes to (liverecord:highlightsregistrations, the page-title field),redactFieldsis the author's deliberate omission and the arm that participates in the fail-closed fold — and the TSDoc says so. Converging them is a contract question this card did not open. Successor: whoever next editsRecordDetailsProps.packages/spec/src/ui/react-blocks.tswas in the dispatched file surface and needed no edit. All three blocks are withdrawn from the react tier because they take their record from context, so they carryREACT_RECORD_BLOCK_ALTERNATIVESrows rather than published props. Carrier: none — stated so the absence reads as measured rather than skipped.ComponentPropsMapentries for the three key names; the renderer side was read only for@object-ui/plugin-detail.Generated by Claude Code