Skip to content

fix(plugin-detail): the related-list toolbar mirrors the ADR-0066 D4 capability gate - #9829

Merged
os-tesla merged 2 commits into
mainfrom
claude/issue-9782-related-toolbar-capability-gate
Sep 18, 2026
Merged

os-tesla merged 2 commits into
mainfrom
claude/issue-9782-related-toolbar-capability-gate

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9782

Clause-②: no

What changed

RelatedList now applies the shared useCapabilityGate once to its
list_toolbar set, in the list's own body, so a header action declaring a
requiredPermissions the 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.deriveActions filters
the child object's actions on locations alone and spreads the rest through, so
the declaration arrived at this header intact and the header evaluated only each
action's visible CEL predicate. The third and last carrier of that one
declaration: 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 useCapabilityGate is untouched.

The measurement that came before the design

The card's premise re-derived on origin/main at 42234b1a2, not taken from the
report:

probe reading
SUBJECT — git grep -nw requiredPermissions in RelatedList.tsx 0 hits (exit 1)
lit control — git grep -nw useCondition, same file 3 hits (exit 0)
sibling that gates EnvironmentListToolbar takes useCapabilityGate() and filters actionRendersAt(a, 'list_toolbar') && mayInvoke(a?.requiredPermissions)
toolbarActions read sites in RelatedList.tsx prop declaration, destructure, and one render site — nothing counts them

⭐ The placement trap objectui#9572 found, measured here first.
useCapabilityGate resolves the held set from the nearest ActionProvider
ABOVE its caller, and a gate on the wrong side of that provider fails open on
every action forever with a green suite. DeclaredActionsBar mounts its own
provider, which is why objectui#9805 had to move the gate into a module-private
inner component. RelatedList mounts no provider at all — git grep Provider over the file returns one docblock mention and no JSX — so its body
and the buttons it draws read one and the same context. Its host chain is
RecordDetailView's ActionProvider (seeded with resolveActionUser's
user.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 an
absent systemPermissions as undefined. Every other case supplies the held
set through that ActionProvider alone (the predicate scope is empty), so
moving the filter to a caller above it, or back out to the host bridge, reds the
detector.

⚠️ ZONE 3's chrome caveat has no read site here: the header's action row also
draws Add/New, so a wholly denied set leaves no orphan divider today. Gating
once over the list rather than inside RelatedToolbarButton is what keeps that
true 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.

run reading
new suite, before the repair 2 failed | 5 passed (7) — the detector and the mixed-set case
new suite, after Test 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-check exit 0, 0 error TS; tsc -p tsconfig.test.json --listFiles puts the new test file inside the program
pnpm --filter @object-ui/plugin-detail lint exit 0 · 1027 problems, 0 errors; the two changed files carry 0 messages inside the added hunk
app-shell consumers of the chain (6 files) Tests 63 passed (63)

Reverse verification — the gate is removed, the detector reds. Anchor
permittedToolbarActions.map((a) => ( replaced with the pre-repair
(toolbarActions ?? []).map((a) => (, through ablation-replace.mjs so the write
is proved on disk rather than by an exit code: anchor 1 -> 0, blob 4c265bfe59b0 -> d241d29c3f7c, result 2 failed | 5 passed (7), restored blob == HEAD (4c265bfe59b0) with git diff HEAD empty.

⭐ 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 two
arms it should: shows the same action to a caller who holds the capability and
fails OPEN when nothing reported systemPermissions, while the detector stays
green. Both legs restored byte-identically.

Gates hand-derived from this repository's own package.json and
.github/workflows/ — scripts/pm/dispatch-gates.mjs lives only in
objectstack and refuses for this repo: check:control-bytes 0 ·
check:test-path-roots 0 · check:vi-mock-specifiers 0 ·
check:vi-mock-inherit 0 · check:vi-mock-override-shape 0 ·
check:action-ref-convention 0 · check:changeset-claims 0 ·
check:pending-changeset-literals 0 · check-changeset-presence 0 ·
check-changeset-no-major 0 · check:new-line-citations 0 (0 new citations) ·
check-governed-queue-guard --test 0 (NOT GOVERNED, 3 paths, none matched).

Acceptance notes

  • check:changeset-claims names one pending changeset and it is still true.
    .changeset/plugin-detail-8937-parent-scope-residue.md names RelatedList.tsx,
    which this branch edits. Its paragraph is about the node's filter being
    AND-combined with the parent condition, the arity compiler in
    @object-ui/core's parent-scope seam, and the SQL driver's divergent
    storage rule. This diff touches none of that — it adds a capability filter
    over toolbarActions — so the claim is not falsified. Read, judged, and left
    alone. Noted, not filed.
  • check:new-line-citations is 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.
  • RelatedToolbarButton has two test-only consumers that mount it outside the
    list
    (related-toolbar-visible.test.tsx, RelatedList.iconSeam-5935.test.tsx).
    They pin visible and icon resolution, not capability, so they are correct as
    they 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.
  • ⛔ No class (a) / (b) / (c) finding was measured on this branch, so nothing is
    handed to the triage seat to file.
  • ⚠️ Declared narrowing: the published face of @object-ui/plugin-detail is
    byte-identical (no export added, no prop added, no accept set relaxed —
    requiredPermissions already reaches this component through
    RelatedRowActionDef's [k: string]: unknown index signature), so this
    branch runs its own package's suite plus the six app-shell files that mount
    the related-list chain, and leaves the repository-wide pnpm test shards and
    turbo run lint / turbo run type-check to CI.
  • ⚠️ One deviation from the verification-lock discipline: the single-package
    tsc --noEmit was run unlocked after the shared lock returned exit 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

…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
`RelatedRowActionDef` carries an index signature (`[k: string]: unknown`),
and `useCapabilityGate` takes `unknown` — so `requiredPermissions` reaches the
gate typed without an `any` in the way. AGENTS.md #6 (no `any`), and one less
warning in a file that already carries many.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/plugin-detail-8937-parent-scope-residue.md

  • names RelatedList.tsx → packages/plugin-detail/src/RelatedList.tsx — edited by this change

    • packages/plugin-detail/README.md (it is in files[], so it ships). It said the node's filter is AND-combined with { [relationshipField]: parentId }, full stop. Since objectui#7299 the parent condition is compiled to match the relationship field's arity, so a multi-valued relationship gets { [relationshipField]: { $contains: parentId } } instead. The paragraph now states both spellings and names the arbiter (@objectstack/spec/data's isMultiValueField). - The claim that the SQL driver decides arity on that same predicate. It does not: driver-sql gates the equality family on its own storage question, which reads multiple as truthy on ANY type. The two rules therefore disagree for a type outside MULTI_CAPABLE_TYPES carrying multiple: true. objectui#9184 moved the arity compiler into @object-ui/core's parent-scope seam and carried the claim with it, so the correction is recorded there — the seam now states the driver's measured rule, records the divergence as a divergence, and points at the upstream card that owns which of the two rules is right (objectstack#17469). RelatedList.tsx's pointer comment and the objectui#7299 test header carried the same sentence and are corrected to match.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 26ac50369 (merge-base with origin/main): 2 file(s) changed outside .changeset/, read against 1149 pending declaration(s) that publish a body (1681 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3049.5 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-DHkODJI5.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.99KB 130.71KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 215.98KB 59.97KB
fields (index.js) 249.23KB 62.88KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.61KB 20.05KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.37KB 65.90KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.61KB 57.90KB
plugin-kanban (index.js) 48.10KB 14.94KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.05KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-tesla
os-tesla marked this pull request as ready for review September 18, 2026 08:19
@os-tesla
os-tesla added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit d0f52e4 Sep 18, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9782-related-toolbar-capability-gate branch September 18, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants