…ated-list toolbar
`@objectstack/spec` declares `requiredPermissions` as "enforced with 403 on the
platform action route (script/flow/modal + MCP) and mirrored as a UI hide".
`RelatedList` drew its `list_toolbar` header buttons from the set
`RelatedRecordActionsBridge.deriveActions` hands it, and that bridge filters on
`locations` alone — so the toolbar evaluated each action's `visible` CEL
predicate and nothing else, and an action declaring a capability the caller
does not hold rendered anyway.
The shared `useCapabilityGate` is now applied once to the toolbar set, in the
list's own body. Placement is the point: the hook resolves the held set from the
nearest `ActionProvider` above its caller, and this component mounts no provider
of its own, so the body and the buttons it draws read the same one — the
provider `RecordDetailView` seeds with the `user.systemPermissions` the action
engine reads. Measured at the mount before the repair was designed, not assumed.
A UI mirror of a decision the server still enforces, and nothing more: the route
still answers 403, the dispatch is byte-identical either way, and no enforcement
moves into the renderer. Unknown capabilities fail open, an empty held set gates
normally, and the gate is ANDed in front of the fail-closed `visible` predicate
so the composition is monotone.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Fixes #9782
Clause-②: no
What changed
RelatedListnow applies the shareduseCapabilityGateonce to itslist_toolbarset, in the list's own body, so a header action declaring arequiredPermissionsthe caller does not hold is no longer drawn.@objectstack/spec(packages/spec/src/ui/action.zod.ts) declares the key as"enforced with 403 on the platform action route (script/flow/modal + MCP) and
mirrored as a UI hide".
RelatedRecordActionsBridge.deriveActionsfiltersthe child object's actions on
locationsalone and spreads the rest through, sothe declaration arrived at this header intact and the header evaluated only each
action's
visibleCEL predicate. The third and last carrier of that onedeclaration: the data-table row menu landed on objectui#9623, and the
declared-actions bar is in flight on objectui#9805.
⛔ A UI mirror of a decision the server still enforces, and nothing more. The
route still answers 403, the dispatch this toolbar makes is byte-identical
either way, and ⛔ no enforcement moves into the renderer. The fail-open-on-unknown
doctrine in
useCapabilityGateis untouched.The measurement that came before the design
The card's premise re-derived on
origin/mainat42234b1a2, not taken from thereport:
git grep -nw requiredPermissionsinRelatedList.tsxgit grep -nw useCondition, same fileEnvironmentListToolbartakesuseCapabilityGate()and filtersactionRendersAt(a, 'list_toolbar') && mayInvoke(a?.requiredPermissions)toolbarActionsread sites inRelatedList.tsx⭐ The placement trap objectui#9572 found, measured here first.
useCapabilityGateresolves the held set from the nearestActionProviderABOVE its caller, and a gate on the wrong side of that provider fails open on
every action forever with a green suite.
DeclaredActionsBarmounts its ownprovider, which is why objectui#9805 had to move the gate into a module-private
inner component.
RelatedListmounts no provider at all —git grep Providerover the file returns one docblock mention and no JSX — so its bodyand the buttons it draws read one and the same context. Its host chain is
RecordDetailView'sActionProvider(seeded withresolveActionUser'suser.systemPermissions) →RelatedRecordActionsBridge→SchemaRenderer→here. ⇒ the outer body IS under the held set, and the gate belongs there.
That is asserted rather than asserted-about: the first case of the new suite
resolves
useHeldCapabilities()at the mount and pins[]as[]and anabsent
systemPermissionsasundefined. Every other case supplies the heldset through that
ActionProvideralone (the predicate scope is empty), somoving the filter to a caller above it, or back out to the host bridge, reds the
detector.
draws Add/New, so a wholly denied set leaves no orphan divider today. Gating
once over the list rather than inside
RelatedToolbarButtonis what keeps thattrue if something starts counting.
Verification
Commands run from the repository root, exit codes captured to a file before
being read. Ablation numbers are from the final head
3f191693.2 failed | 5 passed (7)— the detector and the mixed-set caseTest Files 1 passed (1)·Tests 7 passed (7)pnpm exec vitest run packages/plugin-detail/Test Files 184 passed (184)·Tests 1763 passed (1763)pnpm --filter @object-ui/plugin-detail type-checkerror TS;tsc -p tsconfig.test.json --listFilesputs the new test file inside the programpnpm --filter @object-ui/plugin-detail lintTests 63 passed (63)Reverse verification — the gate is removed, the detector reds. Anchor
permittedToolbarActions.map((a) => (replaced with the pre-repair(toolbarActions ?? []).map((a) => (, throughablation-replace.mjsso the writeis proved on disk rather than by an exit code:
anchor 1 -> 0, blob 4c265bfe59b0 -> d241d29c3f7c, result2 failed | 5 passed (7), restoredblob == HEAD (4c265bfe59b0)withgit diff HEADempty.⭐ The fail-OPEN arm is a real detector, not decoration. A careless repair
that reads "declares a capability" as "hide" — the filter replaced with
!(Array.isArray(a?.requiredPermissions) && a.requiredPermissions.length)—lands on disk (
blob 4c265bfe59b0 -> 426bd123ab34) and reds exactly the twoarms it should: shows the same action to a caller who holds the capability and
fails OPEN when nothing reported
systemPermissions, while the detector staysgreen. Both legs restored byte-identically.
Gates hand-derived from this repository's own
package.jsonand.github/workflows/—scripts/pm/dispatch-gates.mjslives only inobjectstackand refuses for this repo:check:control-bytes0 ·check:test-path-roots0 ·check:vi-mock-specifiers0 ·check:vi-mock-inherit0 ·check:vi-mock-override-shape0 ·check:action-ref-convention0 ·check:changeset-claims0 ·check:pending-changeset-literals0 ·check-changeset-presence0 ·check-changeset-no-major0 ·check:new-line-citations0 (0 new citations) ·check-governed-queue-guard --test0 (NOT GOVERNED, 3 paths, none matched).Acceptance notes
check:changeset-claimsnames one pending changeset and it is still true..changeset/plugin-detail-8937-parent-scope-residue.mdnamesRelatedList.tsx,which this branch edits. Its paragraph is about the node's
filterbeingAND-combined with the parent condition, the arity compiler in
@object-ui/core'sparent-scopeseam, and the SQL driver's divergentstorage rule. This diff touches none of that — it adds a capability filter
over
toolbarActions— so the claim is not falsified. Read, judged, and leftalone. Noted, not filed.
check:new-line-citationsis 0 new citations but its corpus leg reads"nothing to judge". The gate itself prints that this is ⛔ not the same
answer as a clean one, so it is recorded here as what it is rather than folded
into the green count. Noted, not filed.
RelatedToolbarButtonhas two test-only consumers that mount it outside thelist (
related-toolbar-visible.test.tsx,RelatedList.iconSeam-5935.test.tsx).They pin
visibleand icon resolution, not capability, so they are correct asthey stand; recording it because the gate now lives one level above them and
neither would notice it moving. Successor: the next change to the toolbar
button. Noted, not filed.
handed to the triage seat to file.
@object-ui/plugin-detailisbyte-identical (no export added, no prop added, no accept set relaxed —
requiredPermissionsalready reaches this component throughRelatedRowActionDef's[k: string]: unknownindex signature), so thisbranch runs its own package's suite plus the six
app-shellfiles that mountthe related-list chain, and leaves the repository-wide
pnpm testshards andturbo run lint/turbo run type-checkto CI.tsc --noEmitwas run unlocked after the shared lock returnedexit 99(queue-timeout, NOT MEASURED) twice on the same kept slot. A one-package
type-check is outside AGENTS.md's "heavy verification" list (full
vitest run, timing measurement, whole-repo build, whole suite); the package suite,the dependency-closure build and the consumer run all went through the lock.
🤖 Generated with Claude Code
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Generated by Claude Code