feat(app-shell): say why a capability-gated action is missing, in the action designer - #9567
Conversation
… action designer `action.requiredPermissions` (ADR-0066 D4) is mirrored as a UI hide, and the mirror is silent by design: a viewer who does not hold every listed capability gets no button, no greyed-out control and no message, at every declared location at once. objectui#7234 was opened by an admin who had configured a set of object-bound buttons, held no permission set yet, opened the list, saw nothing, and read it as a broken feature — nothing in the product said otherwise. Maintainer ruling 2026-09-08, option B: the hide STAYS for end users and the reason becomes visible in an author/admin channel that already exists. Option C (greying the action out with the missing capability named) was rejected because it advertises to end users capabilities they do not have. End-user behaviour is unchanged, and that is pinned rather than promised: case J in ObjectView.objectBoundActions-7234.test.tsx asserts the gated end-user list surface still explains nothing. The channel is the action designer, the panel pair the Studio Data tab's Actions config already uses: - the inspector's Placement section, beside the existing empty-`locations` notice, names the gating capabilities and says the action is hidden rather than disabled — and when the signed-in session is itself missing one, says so in the first person, reading the held set from MePermissionsProvider through usePermissions (the signal useCanAuthorMetadata already consumes, not a second client-side permission derivation). An unreported held set is unknown, and that clause stays silent, mirroring the gate's own fail-open doctrine; - the action preview carries the capability line in its metadata strip and above its "Where it appears" frames, which previously drew the button in every declared location without qualification. content/docs/guide/console.md states the hide and where the reason is shown. Part of #7234 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx
… can pass vacuously `H` compared two reads of the preview's capability notice; with the notice deleted both are `undefined` and the comparison passed for a deleted renderer. `J` asserted only absences, which an empty tree supplies. Each now asserts one positive over the same render first — the asymmetry the ObjectView pin's own ablation note already states for its control cases. Also satisfies `MetadataDefaultInspectorProps` in full (`readOnly`, `locale`) so the package's `tsc -p tsconfig.test.json` pass type-checks the new file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx
✅ 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
|
Contract review — head
|
| judgment | reading on this head |
|---|---|
| new exported symbol in either changed source file | 0 — measured over the added lines of ActionDefaultInspector.tsx (+55) and ActionPreview.tsx (+37): zero added lines contain export |
| new diagnostic code / rule / finding shape | none — no new code constant, no new rule id, no new entry point |
| new import reaching outside the published dependency set | one import added, usePermissions from @object-ui/permissions — an already-published export and an already-declared dependency of @object-ui/app-shell (check:phantom-deps exit 0) |
| new authorable key, or a key newly accepted | none — requiredPermissions is read, never declared or accepted here; no schema, zod mirror or .d.ts shape moves |
packages/spec/** touched |
no — read only, confirmed against the changed-file list |
⇒ the change is copy inside two panels that already exist, plus tests, one doc page and one changeset. Nothing is widened. The conservative yes was correct to declare at claim time and the delivered route came in under it.
② Semver
@object-ui/app-shell: minor, single changeset, check:changeset-no-major exit 0 (this repo forbids major; a break would ship minor with the break spelled out). Minor is right: the author-facing panels gain behaviour, nothing is removed, and no consumer contract narrows. ⛔ Not patch, because a panel that now renders a capability notice is new behaviour an author can see.
③ Boundary flags
- Runtime permission / security behaviour: UNCHANGED, and this is the flag that matters most here. ⇒ ⛔ not the manual-floor security/permission class. The capability gate is untouched at every gate site; the report enumerates five object-bound gate sites and none is edited.
- End-user surface: UNCHANGED, and pinned rather than promised. Case J asserts the gated end-user tree contains neither the declared capability string nor any match for
/capabilit/i,/requiredPermissions/i,/permission/i, over the whole container — with a paired positive that the list surface itself rendered, so an empty tree cannot satisfy it. Ablation leg 3 injects the leak and fires exactly that case (1 failed / 9 passed). - Governed surface: NOT GOVERNED. All 6 changed paths checked by this seat against the five governed surfaces (
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md) — no match, so this PR takes the merge-queue route and ⛔ not the maintainer four-piece route. - Upstream obligation: NONE, verified at source. The report claims ruling part (c)'s "state the hide" half is already correct upstream. ⭐ I verified the exact sentence rather than the claim:
objectstack:packages/spec/src/ui/action.zod.ts:1412declaresrequiredPermissionsas "[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide…". ⇒ ⛔ nodomain:speccard is owed, and the objectui half is the whole remaining obligation.
Independence pair
Implemented-by: claude/issue-7234-requiredpermissions-author-channel
Reviewed-by: session_01KSd9P5u2Mf4p8g4n4SD4Fx
mode:subagent dev has no session of its own, so the implementer identity is the BRANCH. The session field inside the dev's own JSON echoes the dispatching session for that reason and is ⛔ not a self-review tell. The reviewing identity is the seat.
Verdict — PASS
CI on this exact head: 36 checks, 33 success, 3 skipped, 0 non-green; Lint and Type Check read individually and both success, each pinned to head.sha (⛔ not an aggregate reading, ⛔ not the dev's local union). mergeable_state: clean. ⇒ both carriers are stripped by this seat in one stroke, citing this record.
domain:ui execution seat · session_01KSd9P5u2Mf4p8g4n4SD4Fx (os-justin) · reviewed at 2026-09-15T15:17Z
Generated by Claude Code
Contract reviewServed-tier:
① Derived judgmentsJudged from the PR's actual diff, ⛔ not from the claim and ⛔ not from the report's self-account. The card-side declaration was
⇒ copy inside two panels that already exist, plus tests, one doc page, one changeset. Nothing is widened. The conservative ② Semver level
③ Boundary flags
Implemented-by:
CI on this exact head: 36 checks — 33 success, 3 skipped, 0 non-green; VERDICT: PASS
Generated by Claude Code |
Provenance for the carrier clear — objectui#7234 / PR objectui#9567Both
Generated by Claude Code |
Fixes #7234
Ruling B from decision batch #81 (2026-09-08), landed: a declared action whose
requiredPermissionsthe viewer lacks stays invisible to end users, and the reason becomes visible where the person who configures the app looks.The card's premise was already falsified, and stays falsified
The card says the relay drops
objectDef.actions. It does not — PR #8123 measured the relay carrying it, the button drawn, and ADR-0066 D4's capability gate doing the hiding (differential F vs G: identical payload, identical code path, only the held capability set differs). Nothing here re-investigates that, and nothing here changes the gate.Which existing channel, and why this one
The ruling leaves the channel to the implementer, requiring only that it already exist and add no end-user surface. Measured on
origin/mainthis fire:preview/capabilityLint.tsusePublishAllDraftspasses the warnings totoast)metadata-admin/inspectors/ObjectDefaultInspector.tsxaccess.defaultposture and the object's ownrequiredPermissionsaction.requiredPermissionsinspectors/ActionDefaultInspector.tsx+previews/ActionPreview.tsxactions[]Two things settled it. The inspector's Placement section already carries the sibling notice for the other way a declared action never surfaces (empty
locations, from #3142) — so "here is why your button will not appear" is an established job of this panel, not a new one. AndActionPreview's Where it appears frames drew the button in every declared location unconditionally; its ownPlacementPreviewnote rules against exactly that shape, naming the ADR-0078 "declares, 'renders', reports success, and does nothing" harm as the reason the retiredglobal_navframe was removed. A capability-gated action was the surviving instance of it.What changed
MePermissionsProviderthroughusePermissions, the same signaluseCanAuthorMetadataconsumes; this is not a second client-side permission derivation. An unreported held set is unknown, and that clause stays silent rather than guessing — mirroring the gate's own fail-open doctrine.content/docs/guide/console.md. States the hide and where the reason is shown.Ruling part (a) is pinned, not promised
No end-user surface is added, and option C (drawing the action greyed out with the missing capability named) is not implemented. Case J, added to #8123's existing nine-case pin, asserts the gated end-user list surface still explains nothing: no capability name, no notice standing in for the button, paired with a positive that the list itself rendered. An implementer who lands this reason on a running app's list surface turns it red.
Where ruling part (c) landed, and where it did not
action.requiredPermissions's authoritative prose lives in@objectstack/spec— a different repository and thedomain:specseat's lane, fenced out of this one. It was read, not edited, and it owes nothing:requiredPermissionsmember of the spec's action schema describes itself as "Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide".⇒ part (c) is an objectui obligation, and
content/docs/guide/console.mddischarges it. ⛔ No upstream card is owed. ⛔ Neitherpackages/types/src/objectql.tsnor its Zod twin was touched — the sibling card #7804 holds both.Clause-② — the declaration was conservative, and the route came in under it
needs:contract-reviewis hung on this PR as required. Evidence for a re-grade at review, ⛔ not a request to strip it: this route adds no new exported symbol and no new diagnostic code.git diffadds noexportto either changed source file; the only new import isusePermissions, already a published export of@object-ui/permissionsand already a declared dependency of@object-ui/app-shell. The change is copy inside two panels that already exist, plus tests and docs.Gates — each exit code captured to a file before reading
pnpm exec vitest run packages/app-shell/(707 files, 6948 passed, 1 skipped, 0 failed)packages/app-shelltype-check(tsc --noEmitandtsc -p tsconfig.test.json)eslint .over the wholepackages/app-shellpackage: 0 errors, pre-existing warnings onlycheck:changeset-claims·check-changeset-presence·check-changeset-no-majorcheck:control-bytes·check:phantom-deps·check:test-path-rootscheck:i18n-keys·check:i18n-drift·check:i18n-designer-parity·check:designer-field-key-paritycheck:vi-mock-inherit·check:vi-mock-override-shape·check:vi-mock-specifierscheck:new-line-citations·check:doc-fencescheck-governed-queue-guard --testover all six changed pathsThe lint reading is a declared narrowing: the repo-wide
pnpm lintisturbo run lintacross every package and belongs to CI. Run here instead:packages/app-shell's owneslint ., whose population is the file list eslint enumerated from its own config rather than a guess of mine, with the count read from--format json. The narrowing is sound because type-aware linting is not enabled anywhere in the flat config — noprojectService, noparserOptions.project, noproject:— so this diff cannot move a verdict on a file it does not touch.Reverse verification — direction predicted before each run, three legs
Every leg mutated from the committed state, proved the mutation reached disk by blob hash before running anything, restored with
git checkout HEAD -- PATH, and verifiedgit diff HEADempty afterwards. No build ordiststep is involved: both mutations and both pins live insidepackages/app-shelland the tests import them by relative path, so nothing resolves through a package'sexports.ObjectView's list toolbarThe second leg is the one worth reading twice: before this branch hardened case H it compared two reads of the notice, and with the notice deleted both are
undefined, so it passed for a deleted renderer. Case J had the same shape and now asserts a positive over the same render. Both hardenings are in their own commit.The third leg is the pin that matters to the ruling: it is the only leg that fires on the failure this card must not produce, and the only one whose mutation is an addition rather than a deletion.
Out of scope, reported rather than fixed
DeclaredActionsBarapplies no capability gate. It rendersobjectDef.actions[]for a record at a location, filtering onlocationsand thevisibleCEL only — nouseCapabilityGate, unlikeaction:bar,page:header, the grid row menu and the selection bar. The same divergence onBulkActionBarwas treated as a defect when it was found. Not touched here: out of this card's surface, and it needs its own measurement of whether that bar is reachable with gated actions.useCapabilityGatereads onlyuser.systemPermissionsand ignores theisPlatformAdminthatuseConsoleActionRuntimepasses alongside it). Explicitly ⛔ not this card; unmeasurable from this repository.Session:
session_01KSd9P5u2Mf4p8g4n4SD4Fx.🤖 Generated with Claude Code
https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx
Generated by Claude Code