Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/7611-capability-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/fields': minor
'@object-ui/app-shell': minor
---

The capability select lists the capability registry, and the permission-set editor's provenance badge reads the layered envelope (objectui#7611, objectstack ADR-0131 D3/D7).

- **`CapabilityMultiSelectField` lists the registry.** Its options are the `capability` items of the console metadata store (`GET /api/v1/meta/capability`): the platform's curated capabilities beside every capability a package declares. They are read through `MetadataCtx` `ensureType`, the reader the sharing-rule recipient picker uses for positions. It no longer asks a data source for `sys_capability` rows. Nothing writes such a row any more, so on a fresh database the picker offered only the capabilities the set already granted, and none could be added. No second list is merged in. A granted name the registry does not serve is still shown, selected, under "Other". The props are unchanged; the `dataSource` prop is no longer read. Without a metadata store mounted, the picker offers only the set's own selections and loads nothing. On a framework from before objectstack#22669, the registry serves the package-declared capabilities only, so the platform's curated ones are offered only where the set already grants them.
- **The provenance badge.** In the permission-set editor, a set a code package ships now reads "Package" beside its read-only lock. It read "Custom" in the Setup catalog, because the registry serves no `managedBy` and that scope passes no `packageId`. The badge now also reads the layered envelope's verdict that the lock reads. A set authored in the environment, an organization-authored set and a published organization set still read "Custom".
- **The Capabilities page.** `…/metadata/capability?scope=environment` lists the capability registry read-only, with no create affordance and no active switch, because the type offers no runtime write channel and a capability has no activation. Nothing about that page changes here; `SETUP_CATALOG_TYPES` keeps `capability` out of the switch on purpose, and its documentation now says why.
8 changes: 5 additions & 3 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,11 @@ catalog row. A refusal from the door (`403` for a caller who may not switch, or
for switching off the last administrator who can sign in; `404`; `503`) is
shown in the server's own words. The audience anchors `everyone` and `guest`,
which the door refuses to switch, show a disabled switch and the reason.
Capabilities are not in this scope yet: the Capabilities page and the
capability picker still read `sys_capability` rows, and a capability has no
switch.
Capabilities are listed by the same scope, `…/metadata/capability?scope=environment`:
the capability registry (the platform's curated capabilities beside every
package-declared one), read-only, because the type offers no runtime write
channel. That page has no switch, because a capability has no activation. The
capability picker in the permission-set editor lists the same registry.

### Package-less flows (`/studio/~org/automations`)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7611 (F2) — the identity strip's provenance badge reads the layered
* envelope, so a set a code package SHIPS reads "Package", not "Custom".
*
* ## The defect
*
* The badge read only `draft.managedBy` and the `packageId` prop. The registry
* serves no `managedBy` on a permission set, and the Setup catalog (the
* environment scope) passes no `packageId`, so every packaged set opened there
* read "Custom" — beside the artifact lock that says a code package provides
* it. The editor already holds the fact that answers the question: the layered
* envelope's `code` layer and its ADR-0010 `provenance`, read by
* `isArtifactBackedLayer` into `codeIsArtifact` for the artifact gate. The badge
* now reads that same verdict, so the badge and the lock can never disagree.
*
* ## What discriminates
*
* The first case is the fix. The three after it are the predicate's own
* exclusions (`PermissionMatrixEditor.artifactTier.test.tsx` pins them for the
* lock): a set with no code layer, an organization-authored set read back with
* a code-looking `_packageId`, and the `sys_metadata` save-path sentinel. Each
* must still read "Custom", so the fix cannot be "always Package".
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

/** The layered envelope's `code` slot (see the artifact-tier suite's legend). */
let codeLayer: Record<string, unknown> | null = null;
/** The envelope's ADR-0010 `provenance`; `undefined` = the key absent. */
let provenance: 'package' | 'org' | 'env-forced' | undefined;

/** A set as the registry serves it: no `managedBy`. */
const SET = {
name: 'showcase_contributor',
label: 'Contributor',
objects: { a_account: { allowRead: true } },
fields: {},
} as Record<string, unknown>;

function makeClient() {
return {
layered: async () => ({
effective: SET,
code: codeLayer,
overlay: null,
overlayScope: null,
...(provenance !== undefined ? { provenance } : {}),
}),
getDraft: async () => null,
list: async (type: string) =>
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
get: async (type: string) =>
type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null,
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
} as any;
}

let clientImpl: any;

// The stock-boot `permission` shape: no per-org overlay, runtime authoring open
// — the flags under which a packaged set is locked at the environment scope.
vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: false, allowRuntimeCreate: true }],
}),
}));
// The environment scope mounts who-holds-it under the definition; its reads are
// not this suite's subject.
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(() => {
cleanup();
codeLayer = null;
provenance = undefined;
});

/** Mounts the editor at the environment scope (no `packageId`). */
async function renderMatrix() {
clientImpl = makeClient();
render(
<MemoryRouter initialEntries={['/apps/setup/metadata/permission/showcase_contributor?scope=environment']}>
<PermissionMatrixEditPage type="permission" name="showcase_contributor" />
</MemoryRouter>,
);
await screen.findByText('Account');
}

const badgeText = () => screen.getByTestId('perm-provenance-badge').textContent;

describe('the provenance badge reads the layered envelope (objectui#7611, F2)', () => {
it('a set a code package ships reads "Package" beside its lock, not "Custom"', async () => {
codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' };
provenance = 'package';
await renderMatrix();
expect(badgeText()).toBe('Package');
// The lock it sits beside: the artifact gate, read off the same envelope.
expect(screen.getByText(/^Read-only \(/)).toBeInTheDocument();
});

it('a set with no code layer (authored in this environment) still reads "Custom"', async () => {
await renderMatrix();
expect(badgeText()).toBe('Custom');
});

it('an organization-authored set read back with a code-looking _packageId still reads "Custom"', async () => {
codeLayer = { _packageId: 'com.example.showcase', name: 'showcase_contributor' };
provenance = 'org';
await renderMatrix();
expect(badgeText()).toBe('Custom');
});

it('the sys_metadata save-path sentinel still reads "Custom"', async () => {
codeLayer = { _packageId: 'sys_metadata', name: 'showcase_contributor' };
await renderMatrix();
expect(badgeText()).toBe('Custom');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,9 @@ export interface PermissionMatrixEditPageProps {
export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, publishNonce, onOpenOwd, onDirtyChange, readOnly = false, embedded = false }: PermissionMatrixEditPageProps) {
const navigate = useNavigate();
const client = useMetadataClient();
// Data adapter (records) — the capability picker reads the live sys_capability
// registry (ADR-0056 P2). The metadata `client` handles the draft; capability
// rows are data, fetched like AssignedUsersSection does.
// Data adapter (records) — read by "Clone to customize" below. The capability
// picker takes no data source: it lists the capability registry through the
// console metadata store (objectui#7611, `CapabilityMultiSelectField`).
const adapter = useAdapter();
// objectui#5987 — the three seams "Clone to customize" dispatches through:
// the console's shared action runner (`useAction()` under
Expand Down Expand Up @@ -1332,11 +1332,16 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
so the summary carries the human label — no need to repeat it. */}
<span className="font-medium truncate">{draft.label || draft.name}</span>
{/* [A4 framework#2920] Provenance tri-state — platform / package /
admin(custom) — mirrors the unified sys_* `managed_by` vocab. */}
<Badge variant="outline" className="text-[10px] shrink-0">
admin(custom) — mirrors the unified sys_* `managed_by` vocab.
objectui#7611 (F2): "Package" also reads the layered envelope
this editor already fetches (`codeIsArtifact`, the artifact
gate's own input). The registry serves no `managedBy`, and at
the environment scope there is no `packageId`, so a set a code
package ships read "Custom" beside its own lock. */}
<Badge variant="outline" className="text-[10px] shrink-0" data-testid="perm-provenance-badge">
{draft.managedBy === 'platform'
? t('perm.badge.platform')
: draft.managedBy === 'package' || packageId
: draft.managedBy === 'package' || packageId || codeIsArtifact
? t('perm.badge.package')
: t('perm.badge.custom')}
</Badge>
Expand Down Expand Up @@ -1449,8 +1454,8 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t('perm.field.systemCapabilitiesHelp')}
</p>
{/* objectui#3332 — the capability chip wall grows with the live
sys_capability registry (44+ nav capabilities → 20+ rows). It is
{/* objectui#3332 — the capability chip wall grows with the
capability registry (44+ nav capabilities → 20+ rows). It is
a non-shrinking sibling of the flex-1 matrix, so unbounded it
squeezes the object list to ~0px with no page scrollbar. Cap it
to a viewport share and scroll internally; the matrix keeps the
Expand All @@ -1465,7 +1470,6 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
setDraft((p) => ({ ...p, systemPermissions: parseCapabilityNames(v) }))
}
field={{ name: 'system_permissions' } as any}
dataSource={adapter as any}
readonly={!writable}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
* - the active switch: per item, the activation ledger's state (no row is
* active) with no catalog row read, written through the activation door by
* name, the door's refusal shown in its own words, disabled for a caller
* who cannot author, and disabled with the reason on an audience anchor.
* who cannot author, and disabled with the reason on an audience anchor;
* - the Capabilities page (row 1b): the same scope lists the capability
* registry read-only, with no switch, no ledger read and no row read — the
* Setup target objectstack's `nav_capabilities` moves onto.
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react';
Expand All @@ -37,6 +40,13 @@ const POSITIONS: Record<string, unknown>[] = [
{ name: 'auditor', label: 'Auditor', _packageId: PROJECT_PKG, _provenance: 'package' },
];

/** The capability registry as a fresh showcase serves it: curated beside package-declared. */
const CAPABILITIES: Record<string, unknown>[] = [
{ name: 'manage_users', label: 'Manage Users', scope: 'platform', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' },
{ name: 'setup.access', label: 'Setup Access', scope: 'platform', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' },
{ name: 'showcase.export_data', label: 'Export Showcase Data', scope: 'org', _packageId: PROJECT_PKG, _provenance: 'package' },
];

const ITEMS: Record<string, unknown>[] = [
{ name: 'admin_full_access', label: 'Administrator', _packageId: 'com.objectstack.plugin-security', _provenance: 'package' },
{ name: 'showcase_auditor', label: 'Showcase Auditor', _packageId: PROJECT_PKG, _provenance: 'package' },
Expand All @@ -52,16 +62,20 @@ const CLIENT = {
? ITEMS
: type === 'position'
? POSITIONS
: [],
: type === 'capability'
? CAPABILITIES
: [],
};

const ENTRY = { type: TYPE, label: 'Permission Set', allowOrgOverride: false, allowRuntimeCreate: true };
const POSITION_ENTRY = { type: 'position', label: 'Position', allowOrgOverride: false, allowRuntimeCreate: true };
// The served `capability` entry: no runtime write channel at all (code-only, ADR-0126 §3).
const CAPABILITY_ENTRY = { type: 'capability', label: 'Capability', allowOrgOverride: false, allowRuntimeCreate: false };

vi.mock('./useMetadata', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadataClient: () => CLIENT,
useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY, POSITION_ENTRY] }),
useMetadataTypes: () => ({ loading: false, error: null, entries: [ENTRY, POSITION_ENTRY, CAPABILITY_ENTRY] }),
}));

vi.mock('./i18n', async (importOriginal) => ({
Expand Down Expand Up @@ -247,3 +261,21 @@ describe('the active switch (the activation ledger, through its door)', () => {
expect(requests.some((r) => r.url.includes('sys_metadata_activation'))).toBe(false);
});
});

describe('the Capabilities catalog page: the registry, read-only, with no switch (objectui#7611, row 1b)', () => {
it('lists every registry capability with no create, no status filter and no switch, and reads no ledger and no sys_capability row', async () => {
const { container } = mount('?scope=environment', 'capability');
await waitFor(() => expect(linkTo(container, 'manage_users')).not.toBeNull());
for (const name of ['manage_users', 'setup.access', 'showcase.export_data']) {
expect(linkTo(container, name)?.getAttribute('href')).toMatch(/\?scope=environment$/);
}
// Read-only: the type offers no runtime write channel, whoever the caller is.
expect(screen.queryByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) })).toBeNull();
// No switch: a capability has no activation (the door refuses the type).
expect(screen.queryByTestId('catalog-status-filter')).toBeNull();
for (const name of ['manage_users', 'setup.access', 'showcase.export_data']) {
expect(screen.queryByTestId(`catalog-active-${name}`)).toBeNull();
}
expect(requests.some((r) => /sys_metadata_activation|sys_capability/.test(r.url))).toBe(false);
});
});
22 changes: 13 additions & 9 deletions packages/app-shell/src/views/metadata-admin/catalog-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,18 @@ export function environmentScopeSuffix(search: QueryLike): string {
}

/**
* The catalog types Setup administers through this scope. Each name is also
* the activation ledger's `metadata_type` and the activation door's `:type`
* (see `catalog-activation.ts`). `capability` is absent: the Capabilities page
* and the capability picker still read `sys_capability` rows, and moving them
* onto the registry is objectui#7611's follow-up. (The registry serves the
* platform's own capabilities since objectstack#22669, #15204 stage 6b-1a;
* before it, it served the package-declared ones only.) A capability has no
* switch either: ADR-0126 §3 makes it code-only, and the activation door
* refuses the type with `400`.
* The catalog types Setup administers through this scope with an active
* switch. Each name is also the activation ledger's `metadata_type` and the
* activation door's `:type` (see `catalog-activation.ts`).
*
* `capability` is absent ON PURPOSE, and it is a catalog all the same. The
* Capabilities page is this scope's list of the type,
* `…/metadata/capability?scope=environment`: the capability registry (the
* platform's curated capabilities, served since objectstack#22669, beside
* every package-declared one), read-only because the served type entry offers
* no runtime write channel. The capability picker lists the same registry
* (objectui#7611). What a capability lacks is the switch: ADR-0126 §3 makes it
* code-only, and the activation door refuses the type with `400`, so naming it
* here would put a switch on that page that every click refuses.
*/
export const SETUP_CATALOG_TYPES = ['permission', 'position'] as const;
Loading
Loading