Repository navigation
fix(app-shell): DeclaredActionsBar mirrors the ADR-0066 D4 capability gate - #9805
Merged
os-tesla merged 2 commits intoSep 18, 2026
Merged
Conversation
…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
Contributor
✅ Console Performance Budget
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
Size Limits
|
os-tesla
marked this pull request as ready for review
September 18, 2026 06:56
os-tesla
deleted the
claude/issue-9572-declared-actions-bar-capability-gate
branch
September 18, 2026 07:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #9572
Clause-②: no
@objectstack/specdeclaresrequiredPermissionsas "enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide".DeclaredActionsBarfilters its own declared-action list instead of routing throughActionEngine.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, afteraction: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
useCapabilityGateis untouched.The repair
The shared
useCapabilityGateis applied once to the located set, inside the bar's ownActionProvider— the provideruseConsoleActionRuntimeseeds with the sameuser.systemPermissionsthe engine reads, and the same one the dispatch this bar is about to make will carry.⭐ The placement is the repair, not an incidental.
useCapabilityGatereads 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 throughuseConsoleActionRuntime().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
visibleCEL, 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 (avisible-hidden action stays hidden even when the capability IS held).The
can_overridearm (objectui#5178) — re-derived, not inheritedcan_overrideis a per-record viewer flag an action's ownvisibleCEL reads;requiredPermissionsis a per-caller capability list checked againstuser.systemPermissions. They are orthogonal, they are ANDed, andisOverrideDecisionstill 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.toPredicateInputnormalizations drifting, not about thecan_overridearm. Thecan_overridehistory 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 unknownsystemPermissions, fail-OPEN with no user at all, chrome kept when one action survives, and thecan_overrideaffordance 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-objectdiffers from theHEADblob), restored afterwards and verified byte-for-byte (git diff HEADempty, restored blob hash equals theHEADblob 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'ssrc, so no rebuild step sits between the mutation and the measurement.Gates (exit codes captured to a file before reading, never through a pipe):
packages/app-shelltype-check(tsc --noEmit+tsc -p tsconfig.test.json)pnpm exec vitest run packages/app-shell/node scripts/check-changeset-presence.mjscheck-changeset-no-major·check-changeset-overwritecheck-control-bytescheck-vi-mock-specifiers·check-vi-mock-inherit·check-vi-mock-override-shapecheck-test-path-rootscheck-new-cross-file-line-citations --base origin/maincheck-governed-queue-guardsurface membershipThe dependency closure was built first (
turbo run build --filter '@object-ui/app-shell^...', 28/28) — an unbuilt closure makestype-checkreportTS2307on every workspace import, which is a missing prerequisite and not a red gate.Lint — a declared narrowing, measured at
998c8fd. The repo-widepnpm 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:eslint.config.js(files: ['**/*.{ts,tsx}']);packages/app-shellhas no config of its own;--format json: 2 files linted, 0 errors;eslint.config.jssets noparserOptions.projectand noprojectService— 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 (13no-explicit-any+ 1react-hooks/exhaustive-deps, measured against the base revision of the same file): the change adds zero warnings to it. The new test file carries 3no-explicit-anywarnings, all inside test doubles copied verbatim from the two siblingDeclaredActionsBarsuites — 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 (DeclaredActionsToolbaris module-private), no member is added toDeclaredActionsBarPropsor to any published interface, no contract face is touched. The new component's props arePicked from the existingDeclaredActionsBarPropsrather than restated. The change narrows a render set onto already-published contract text.Acceptance notes
Out-of-scope observations, ⛔ none filed, ⛔ none fixed here:
getActionsForLocationhas 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.can_overrideOR to objectui#3314 "referenced in its own docblock". That reference is abouttoPredicateInputnormalization drift; thecan_overridearm is objectui#5178. Noted, not filed, same class. Carrier: this pull request's reader.DeclaredActionsBaris exported from@object-ui/app-shellwith a publicactions?: ActionDef[]prop whose element type declaresrequiredPermissions, so the surface is reachable by construction for any host. In-repo it is mounted only byApprovalsInboxPage, twice, forsys_approval_requestatrecord_section, and it passes noactionsprop — 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.useCapabilityGatehas 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:DeclaredActionsBaris the only file in the tree that both mounts anActionProviderand calls the hook (lit control:ObjectView/RecordDetailViewmount one and do not call it;data-table.tsxcalls 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