Skip to content

fix(app-shell): DeclaredActionsBar mirrors the ADR-0066 D4 capability gate - #9805

Merged
os-tesla merged 2 commits into
mainfrom
claude/issue-9572-declared-actions-bar-capability-gate
Sep 18, 2026
Merged

os-tesla merged 2 commits into
mainfrom
claude/issue-9572-declared-actions-bar-capability-gate

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9572

Clause-②: no

@objectstack/spec declares requiredPermissions as "enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide". DeclaredActionsBar filters its own declared-action list instead of routing through ActionEngine.getActionsForLocation, so the engine's gate never reached it and the key was inert on every action it drew — the last object-bound surface that did not mirror it, after action:bar, the grid row menu, the selection bar and the data-table row menu (objectui#9623).

⛔ This restores a MIRROR, it does not move enforcement into the UI. The server stays the sole authority and still answers 403; the request this bar sends is byte-identical either way. The fail-open-on-unknown doctrine in useCapabilityGate is untouched.

The repair

The shared useCapabilityGate is applied once to the located set, inside the bar's own ActionProvider — the provider useConsoleActionRuntime seeds with the same user.systemPermissions the engine reads, and the same one the dispatch this bar is about to make will carry.

⭐ The placement is the repair, not an incidental. useCapabilityGate reads the nearest provider above its caller. This bar is self-contained: it mounts its own provider and renders the buttons under it. A gate called in the bar's outer body would therefore read a different provider — the host's, if there is one — and in a standalone host would read none at all and fail open on every action forever: a gate that compiles, reviews as correct, and mirrors nothing. The new suite supplies the held set only through useConsoleActionRuntime().actionProviderProps.context, with no ambient provider and no predicate scope above the render, so the outer placement cannot pass it.

Applying it to the list rather than per button is the objectui#3562 invariant: the toolbar chrome and the buttons read one filtered source, so a wholly denied set draws nothing instead of leaving the host an orphan divider and section label.

Why the fail-open / fail-closed boundary composes

The card fenced itself on this, correctly. It resolves by measurement, not argument: the capability gate is ANDed in front of the existing fail-closed visible CEL, so the composition is monotone — it can only ever hide more than before, and can never expose something that is hidden today. That is what makes a fail-OPEN gate safe to put in front of a fail-CLOSED one, and it is pinned in both directions (a visible-hidden action stays hidden even when the capability IS held).

The can_override arm (objectui#5178) — re-derived, not inherited

can_override is a per-record viewer flag an action's own visible CEL reads; requiredPermissions is a per-caller capability list checked against user.systemPermissions. They are orthogonal, they are ANDed, and isOverrideDecision still runs unchanged on every action that survives the filter. Pinned both ways: an override-only viewer keeps the override affordance on an action that declares no capability, and gets no exemption from one that does.

⚠️ One correction to the card's reading: objectui#3314 appears in this file's docblock about two toPredicateInput normalizations drifting, not about the can_override arm. The can_override history is objectui#5178 (the affordance) and objectui#5414 (approver names). Both were read before editing.

Evidence

Reverse verification and ablation are one-shot proofs; no permanent test artifact is left behind.

RED before the repair — the new suite on the unrepaired tree: Tests 5 failed | 6 passed (11). The five reds are the four hide arms plus the orphan-chrome arm. The six greens are the guards that must stay green: capability held, fail-OPEN on unknown systemPermissions, fail-OPEN with no user at all, chrome kept when one action survives, and the can_override affordance preserved.

Placement ablation — the gate moved to the bar's outer body, everything else identical. Mutation proved on disk before the run (injected marker present, removed text absent, git hash-object differs from the HEAD blob), restored afterwards and verified byte-for-byte (git diff HEAD empty, restored blob hash equals the HEAD blob hash, zero marker residue). Result: the same five tests fail, the six guards stay green. Vitest resolves @object-ui/* through the root config's alias map to each package's src, so no rebuild step sits between the mutation and the measurement.

Gates (exit codes captured to a file before reading, never through a pipe):

gate verdict
packages/app-shell type-check (tsc --noEmit + tsc -p tsconfig.test.json) exit 0
pnpm exec vitest run packages/app-shell/ exit 0
node scripts/check-changeset-presence.mjs exit 0
check-changeset-no-major · check-changeset-overwrite exit 0
check-control-bytes exit 0
check-vi-mock-specifiers · check-vi-mock-inherit · check-vi-mock-override-shape exit 0
check-test-path-roots exit 0
check-new-cross-file-line-citations --base origin/main exit 0 — 0 citations added
check-governed-queue-guard surface membership not governed (0 of 5 surfaces matched)

The dependency closure was built first (turbo run build --filter '@object-ui/app-shell^...', 28/28) — an unbuilt closure makes type-check report TS2307 on every workspace import, which is a missing prerequisite and not a red gate.

Lint — a declared narrowing, measured at 998c8fd. The repo-wide pnpm lint (turbo run lint, every package) is CI's run. Locally this was narrowed to the two source files the diff touches, with all three pieces of evidence:

  1. the population is the root eslint.config.js (files: ['**/*.{ts,tsx}']); packages/app-shell has no config of its own;
  2. the file count is read from --format json: 2 files linted, 0 errors;
  3. type-aware linting is not enabled — eslint.config.js sets no parserOptions.project and no projectService — so no rule in this config reads another file's types, and this diff cannot move the verdict on any file it does not contain. The diff also changes no eslint config and adds no inline disable.

The modified source file's warning profile is byte-identical to origin/main's (13 no-explicit-any + 1 react-hooks/exhaustive-deps, measured against the base revision of the same file): the change adds zero warnings to it. The new test file carries 3 no-explicit-any warnings, all inside test doubles copied verbatim from the two sibling DeclaredActionsBar suites — a divergent spelling in one of three sibling doubles would be worse than the warning.

Clause ②

Clause-②: no, and the delivered diff matches the declaration: no exported symbol is added (DeclaredActionsToolbar is module-private), no member is added to DeclaredActionsBarProps or to any published interface, no contract face is touched. The new component's props are Picked from the existing DeclaredActionsBarProps rather than restated. The change narrows a render set onto already-published contract text.

Acceptance notes

Out-of-scope observations, ⛔ none filed, ⛔ none fixed here:

  • The card's probe table reports all four probes at 0 hits on this file. Re-derived: three are 0; getActionsForLocation has exactly one hit, in a prose comment, not a call. The substance of the finding is unchanged. Noted, not filed — a card-accuracy nit, not a code defect. Carrier: this pull request's reader.
  • The card attributes the can_override OR to objectui#3314 "referenced in its own docblock". That reference is about toPredicateInput normalization drift; the can_override arm is objectui#5178. Noted, not filed, same class. Carrier: this pull request's reader.
  • Reachability was flagged unmeasured by the card, and it stays partly unmeasurable from this repository. DeclaredActionsBar is exported from @object-ui/app-shell with a public actions?: ActionDef[] prop whose element type declares requiredPermissions, so the surface is reachable by construction for any host. In-repo it is mounted only by ApprovalsInboxPage, twice, for sys_approval_request at record_section, and it passes no actions prop — the set it draws is whatever the backend declares, which is framework metadata this repository cannot read. So: reachable as a published API, and whether a capability-gated action reaches the console host today is a server-side fact, not an objectui one. This bears on grading, ⛔ not on the correctness of the repair.
  • useCapabilityGate has no variant that takes the held set explicitly, so every surface filtering its own list must be rendered under a provider that carries it — the ambient-context foot-gun this change had to step over. Measured: DeclaredActionsBar is the only file in the tree that both mounts an ActionProvider and calls the hook (lit control: ObjectView / RecordDetailView mount one and do not call it; data-table.tsx calls it and only names the provider in prose). No sibling carries the hazard, so there is nothing to file. Noted, not filed. Carrier: the docblock now states the rule in the file where the next reader will meet it.

🤖 Generated with Claude Code

Generated by Claude Code


Generated by Claude Code

…ty gate

`@objectstack/spec` declares `requiredPermissions` as "enforced with 403 on the
platform action route … and mirrored as a UI hide". This bar filters its own
declared-action list instead of routing through
`ActionEngine.getActionsForLocation`, so the engine's gate never reached it and
the key was inert on every action it drew — the last object-bound surface that
did not mirror it.

The shared `useCapabilityGate` is applied ONCE to the located set, inside the
bar's OWN `<ActionProvider>`. That placement is the repair, not an incidental:
the hook reads the nearest provider above its caller, and this bar is
self-contained, so a gate in its outer body would read the host's provider (or
none) and fail open forever.

A UI mirror only — the server stays the sole authority, unknown capabilities
fail OPEN, an empty held set gates normally, and because the gate is ANDed in
front of the existing fail-closed `visible` CEL the composition can only hide
more, never expose more. The `can_override` override affordance is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
…ctionsBarProps`

One declaration rather than a restatement — and it keeps the file's
`no-explicit-any` warning count byte-identical to `origin/main`'s.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3049.6 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-BHc3e_3n.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.71KB 20.07KB
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.28KB 65.87KB
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 06:56
@os-tesla
os-tesla added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 2daa6c0 Sep 18, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9572-declared-actions-bar-capability-gate branch September 18, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants