Skip to content

fix(permissions): preserve absent-vs-empty systemPermissions through the capability gate - #4724

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4656-capabilities-reported
Aug 15, 2026
Merged

yinlianghui merged 2 commits into
mainfrom
claude/issue-4656-capabilities-reported

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4656

Implements the graded scope verbatim: MePermissionsProvider preserves the
absent-vs-empty distinction on systemPermissions, every hasCapabilities(
consumer's call-site fail-open heuristic retires in favor of the centralized
signal, and HomePage.authoringCapabilityGate.test.tsx (the objectstack#8270
pin) stays green.

Premise: verified, still valid

Read MePermissionsProvider (packages/permissions/src/MePermissionsProvider.tsx)
against origin/main at fec06835e: the collapse is real, at
systemPermissions: data?.systemPermissions ?? [] and the hasCapabilities
closure built from the same default. Grepping hasCapabilities( repo-wide
(excluding tests/CHANGELOGs) turns up exactly three consumers —
HomePage.tsx, UnifiedSidebar.tsx, AppSidebar.tsx — and only HomePage.tsx
carries the named call-site heuristic (useCanAuthorMetadata's
if (!Array.isArray(systemPermissions) || systemPermissions.length === 0) return true;).

Shape chosen: systemPermissions: string[] | undefined (carry undefined through)

The card offered two candidates: an added capabilitiesReported: boolean, or
carrying the array as string[] | undefined. I measured the second is not a
new invention — it is already this repo's established doctrine for the
exact same distinction one layer down, in @object-ui/react's
useCapabilityGate.ts (useHeldCapabilities(): string[] | undefined, whose
own doc comment states: "undefined when the host never supplied them (→
callers must fail open)... An EMPTY array is not unknown"
). Matching that
shape instead of inventing a sibling capabilitiesReported flag:

  • lets hasCapabilities absorb the fail-open decision itself (mirroring
    useCapabilityGate()'s own if (!Array.isArray(held)) return true;), so
    call sites don't need to consult a second field at all — useCanAuthorMetadata
    drops to a one-line return hasCapabilities([AUTHORING_CAPABILITY]); and the
    heuristic retires, not relocates;
  • gives every raw-systemPermissions reader (below) the same undefined-means-
    unknown contract they'd already expect from the sibling mechanism.

The sweep — every hasCapabilities(/systemPermissions consumer, named

Definition sites (packages/permissions/):

  • MePermissionsProvider.tsx — the core fix: systemPermissions forwarded
    as-is (no ?? []); hasCapabilities fails OPEN when it is undefined,
    gates strictly on a reported array (empty included).
  • PermissionProvider.tsx (role-based provider, never fetches
    /me/permissions) — systemPermissions: [] → undefined: it has no real
    answer to give, so it must not claim "reported, holds nothing" either.
    hasCapabilities: () => true unchanged (still fail-open by design).
  • usePermissions.ts's no-context fallback (no provider mounted at all) —
    same [] → undefined correction, same reasoning.
  • PermissionContext.ts — type + doctrine comment on both fields.

Direct hasCapabilities( consumers:

  • HomePage.tsx's useCanAuthorMetadata — local heuristic deleted; now
    asks the centralized signal directly. Outward behavior for the three cases
    the pin file already covered is unchanged (verified below); a fourth case
    the pin file could not previously express — a REPORTED, genuinely empty
    systemPermissions: [] — now correctly gates closed instead of falling
    open (new test, "a REPORTED empty systemPermissions is not confused with
    absent").
  • UnifiedSidebar.tsx / AppSidebar.tsx's checkPerm — no code change.
    Their hasCapabilities([perm]) || can(perm, 'read') is not an
    absent-vs-empty heuristic (it ORs a capability check with an unrelated
    object-read fallback for nav items authored as a plain object name), so
    there is nothing to migrate — but it silently inherits a real fix: before
    this PR, a bare-capability nav item on an unreported backend fell all the
    way to can(perm, 'read'), which reads the capability name as an object
    name and denies for an authenticated session with no matching object entry
    (feat(setup): the datasource list shows the real connect verdict, with the operator-facing reason (framework#3827) #2926 ④) — stripping the nav item on exactly the deployments this doctrine
    protects. Reproduced against the real provider in the new test file
    ("the sidebar nav OR-fallback pattern needs no call-site change").

Two more call sites, found by tracing where systemPermissions itself
(not hasCapabilities) flows, not named in the card's own grep instruction
but the same defect class one layer down
— both feed the ADR-0066 D4
action capability gate (ActionEngine/useCapabilityGate, framework#3923),
which already implements the fail-open-on-undefined doctrine correctly; the
bug was these two call sites re-collapsing the signal to [] before handing
it to that already-correct engine:

  • RecordDetailView.tsx's resolveActionUser — systemPermissions ?? [] →
    forwards as-is. Its own pin test (RecordDetailView.actionUser.test.tsx)
    asserted the OLD, now-wrong premise ("a loaded-but-undefined set" collapses
    to "holds nothing"); that premise held only because the OLD provider could
    never actually produce undefined while loaded. Updated the test's
    assertion and doc comment to the new premise (see "pin equivalence" below).
  • useConsoleActionRuntime.tsx (the shell-level ActionProvider, which most
    action surfaces mount, including EnvironmentListToolbar's
    useCapabilityGate() transitively) — same ?? [] at currentUser
    construction, same fix. Its own comment claimed "fail-open when no
    PermissionProvider is mounted" while the code actually failed CLOSED (an
    array, even empty, is never undefined to the engine's
    Array.isArray(held) check) — a live, previously undiscovered instance of
    this exact bug, now fixed by the same one-line change.

I did not find any other systemPermissions reader (checked every
usePermissions() call site repo-wide); PermissionMatrixEditor.tsx /
PermissionPreview.tsx / permission-slice*.ts read a different
draft.systemPermissions — the metadata-admin's authored PermissionSetDraft,
unrelated to this context value.

Pin equivalence — HomePage.authoringCapabilityGate.test.tsx

All four existing cases stay green, byte-for-byte the same assertions,
because the payloads they use only ever exercise the two ends of the truth
table this fix actually changes the boundary of:

  • "ruled case" / "capability present": systemPermissions reported
    non-empty either way — hasCapabilities takes the same strict branch
    before and after (Array.isArray was already true).
  • "unknown capabilities fail OPEN": eePayload(undefined) omits the
    systemPermissions key entirely — data.systemPermissions is undefined
    in both the old (?? [] default kicks in downstream) and new provider;
    observably the CTAs stay enabled either way, only the mechanism moved
    (was: call-site .length === 0 check after defaulting; now: centralized
    Array.isArray check on the raw value).

None of the four exercises a REPORTED-but-empty payload, which is exactly the
case the OLD heuristic got wrong (.length === 0 cannot tell "empty and
reported" from "absent") — that gap is what the new
"a REPORTED empty systemPermissions is not confused with absent" describe
block fills, using eePayload([]) (truthy in JS, so the key IS present).

RecordDetailView.actionUser.test.tsx's superseded case is not
equivalent — it is corrected, because it pinned the old (now-wrong) premise
directly. Full old→new reasoning is in the test file's own comment; the short
version: resolveActionUser(user, true, undefined) used to assert
systemPermissions collapses to [] on the premise that
usePermissions().systemPermissions was NEVER undefined while loaded. That
premise is gone — the provider now genuinely returns undefined while
loaded, for the unreported case — so the test now asserts the value forwards
unchanged (undefined), matching what ActionEngine's own
Array.isArray(held) check needs to fail open correctly.

Truth table (new tests)

packages/permissions/src/__tests__/MePermissionsProvider.capabilitiesReported.test.tsx
pins the full table against the REAL MePermissionsProvider /
PermissionProvider / no-context fallback:

backend systemPermissions systemPermissions in context hasCapabilities(['x'])
key omitted (pre-ADR-0066) undefined true (fail open)
[] (real, empty grant) [] false (gates strictly)
[] + required: [] [] true (vacuous subset)
non-empty, holds 'x' the array true (positive control)
non-empty, missing 'x' (the objectstack#8270 EE owner's exact set) the array false (negative control)
PermissionProvider (no /me/permissions fetch) undefined true
no provider mounted undefined true

Reverse verification

Direction predicted before running: reverting only MePermissionsProvider.tsx
to its pre-fix ?? [] collapse, with the rest of the fix (including the
now-simplified HomePage.tsx) left in place, should redden the new
"UNREPORTED" truth-table case AND the pre-existing HomePage "unknown
capabilities fail OPEN" case (since useCanAuthorMetadata no longer has its
own fallback to paper over the reverted provider) — everything else stays
green. Observed exactly that: 2 failed | 14 passed across the two files,
both failures on the predicted tests, nothing else moved. Restored via
git checkout claude/issue-4656-capabilities-reported -- packages/permissions/src/MePermissionsProvider.tsx
from the already-committed fix; git diff HEAD after restore was empty
(byte-identical) and the full suite below re-ran green from that state.

Verification — all at 1ff99a4da (the final commit)

pnpm exec vitest run packages/permissions/ packages/app-shell/src/console/home \
    packages/app-shell/src/views/RecordDetailView \
    packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx \
    packages/app-shell/src/layout/__tests__ --maxWorkers=2
  → 49 files, 415 passed

pnpm --filter '@object-ui/app-shell^...' --workspace-concurrency=2 build   → exit 0 (builds @object-ui/permissions too)
pnpm --filter @object-ui/permissions type-check                            → Done
pnpm --filter @object-ui/app-shell type-check                              → Done
node scripts/check-changeset-presence.mjs     → 1 changeset declared for the 2 released packages touched
node scripts/check-control-bytes.mjs          → OK (4245 files)
node scripts/check-phantom-dependencies.mjs   → OK
node scripts/check-changeset-no-major.mjs     → OK
eslint (touched files)                        → 0 errors, 163 pre-existing warnings (unchanged count before/after)

Gate re-derivation note: this repo has no scripts/pm/dispatch-gates.mjs (that
tool lives in the objectstack repo); the local families run above were
chosen from AGENTS.md's own "housekeeping" / "check:*" list against the actual
changed paths — no action-schema, i18n, or spec-symbol surface is touched, so
check:action-forward-parity / check:i18n-* / check:spec-symbols are not
implicated.

Changeset

@object-ui/permissions and @object-ui/app-shell, both patch — see
.changeset/permissions-capabilities-reported.md.

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 15, 2026 17:22
…the capability gate

MePermissionsProvider used to collapse an unreported systemPermissions (a
backend predating ADR-0066) and a genuinely empty one into the same [],
so hasCapabilities consumers could not gate strictly on a real empty
answer without stripping UI from every user on a non-reporting deployment.

hasCapabilities now fails open only when systemPermissions is undefined
(unreported) and gates strictly on a reported [] (holds nothing). The
HomePage.tsx call-site heuristic retires in favor of this centralized
signal, and two more call sites that re-collapsed a loaded-but-undefined
answer to [] before feeding the ADR-0066 D4 action gate (RecordDetailView's
resolveActionUser, useConsoleActionRuntime) now forward it unchanged.

Fixes #4656

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
…site change

UnifiedSidebar/AppSidebar gate a bare requiredPermissions name with
hasCapabilities([perm]) || can(perm, 'read'). Reproduce that exact
expression against the real MePermissionsProvider to show it is fixed
for free by the provider-level change alone.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 15, 2026 5:59pm

Request Review

@github-actions github-actions Bot added the tests label Aug 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-CXsq6Jmv.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
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.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.93KB 108.68KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 156.79KB 43.21KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.89KB 1.22KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 64.44KB 18.28KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.90KB 31.76KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 239.86KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.72KB 27.70KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 192.10KB 51.24KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.14KB 26.92KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

Copy link
Copy Markdown
Collaborator Author

Review verdict: ACCEPT (reviewer of record, PM session session_01RnQd8iMMUwXQEV1crFmQiQ).

Verified against this PR, not the report: eleven changed files with the changeset covering exactly the two released packages touched (independently confirmed on the branch). Fixes #4656 is the only closing keyword. The substance clears the bar on every axis I check hardest: the shape choice is measured rather than invented (it adopts useCapabilityGate's existing string[] | undefined doctrine, quoted from its own doc comment, so the fail-open decision centralizes inside hasCapabilities and the call-site heuristic retires outright); the sweep goes beyond the card's grep instruction by tracing the value flow and catches two re-collapse sites feeding the D4 action gate — including a live comment-vs-code contradiction in useConsoleActionRuntime that claimed fail-open while failing closed; the sidebar no-change verdict is proven with a test rather than asserted, and the silent improvement it inherits is reproduced, not hand-waved. The one rewritten pin (RecordDetailView.actionUser.test.tsx) is the falsified-premise pattern handled correctly — old premise invalidated by this very fix, new assertion matched to the engine's Array.isArray contract, reasoning in both the test file and the body. Truth table carries both controls plus the vacuous-subset row, and the reverse verification is direction-predicted with exactly the two forecast failures. Gates pinned to 1ff99a4da, matching this PR's current head.

Will flip ready + enable auto-merge once Lint, Type Check, and Test shards 1–4 conclude success on this head.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 15, 2026 18:18
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 15, 2026
Merged via the queue into main with commit d8b9259 Aug 15, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4656-capabilities-reported branch August 15, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants