diff --git a/.changeset/7611-setup-catalog-registry.md b/.changeset/7611-setup-catalog-registry.md
new file mode 100644
index 0000000000..b3edb7e07a
--- /dev/null
+++ b/.changeset/7611-setup-catalog-registry.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/app-shell': minor
+'@object-ui/fields': minor
+'@object-ui/console': minor
+---
+
+The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (objectui#7611, objectstack ADR-0131 D3/D7).
+
+- **The environment scope (`?scope=environment`).** `…/metadata/position` and `…/metadata/permission` with this parameter list every item the registry serves for the type, including the platform's own sets. Without it, the list shows one project package's items, as Studio does. Every link the pages emit keeps the scope: the item links, the create link and the editor's breadcrumb. `ENVIRONMENT_SCOPE_QUERY` is exported for hosts that route their own URLs onto it.
+- **Gated for Setup.** A caller without `manage_metadata` gets no create affordance on the list and a read-only editor, and the page says why. Under `single`, the reason is that the platform administrator defines these items and the caller's organization assigns them. Under a wall, it is that the operator defines them for every organization. The generic editor and the permission-set editor now apply this caller gate wherever they render. The metadata door refuses that caller's save anyway; this states the refusal before the click.
+- **The active switch.** The catalog list has an active column and an active/inactive filter. Both show the activation ledger's state (`sys_metadata_activation`), which is what the authorization resolver reads; an item with no ledger row is active. The switch writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name` with `{ enabled }`, so switching a set or a position off takes it away from its holders, and switching it on gives it back. It reads and writes no catalog row, so the catalog row's own `active` column is neither shown nor changed. The door's refusals are 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. The door is objectstack's ADR-0131 stage 2c.
+- **Holders.** In the environment scope, the permission-set editor shows who holds the set under its definition, and the position editor shows who holds the position. A set's holders are read by name: grants come from the `permission_set` column, and the positions that distribute the set come from the registry's position definitions (`permissionSets`). They are no longer read from `sys_position_permission_set` or `sys_position` rows, so on a framework from before objectstack's ADR-0131 stage 1, where positions declare no `permissionSets`, a set lists no holders through a position. A new grant still carries `permission_set_id`, because the server does not accept a grant by name alone yet. A position is assigned by its name.
+- **The sharing-rule recipient picker lists the `position` registry** through the console's metadata store. It no longer reads `sys_position` rows. Without a metadata store mounted, the `position` kind falls back to the plain text input.
+- **The console's `system/roles`, `system/positions` and `system/permissions`** now go to the catalog list, not to the object pages.
diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx
index 4dd7d7bf62..18eecb62c5 100644
--- a/apps/console/src/AppContent.tsx
+++ b/apps/console/src/AppContent.tsx
@@ -10,7 +10,7 @@
import { lazy, Suspense, useMemo } from 'react';
import { Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom';
-import { DefaultAppContent, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell';
+import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, RecordDetailView, useAdapter, useMetadata } from '@object-ui/app-shell';
import { MePermissionsProvider } from '@object-ui/permissions';
import { createAuthenticatedFetch } from '@object-ui/auth';
import type { DataSource } from '@object-ui/types';
@@ -129,17 +129,12 @@ function MetadataRedirect() {
* record-scoped `nav_organization`
* needs a runtime `{current_org_id}`
* that a static redirect cannot resolve)
- * roles -> sys_position (ADR-0090 D3 renamed `sys_role` ->
- * `sys_position`; the sidebar's "Roles"
- * and the retired hub's "Positions" were
- * the same surface under old/new
- * vocabulary)
- * positions -> sys_position (`nav_positions`)
- * permissions -> sys_permission_set(`nav_permission_sets`; this one was
- * held back in PR #3673 and is resolved
- * by objectui#3655's decision A — the
- * reasoning is recorded at the route
- * block below)
+ *
+ * Three of the five no longer land on an object at all (objectui#7611):
+ * `roles`, `positions` and `permissions` name the security CATALOG, which
+ * ADR-0131 D3 moves into the environment registry, so they forward onto the
+ * Setup catalog — the metadata-admin list in its environment scope — through
+ * {@link SystemCatalogRedirect} below.
*
* Same shape as `ObjectRedirect` / `MetadataRedirect` above (a legacy URL is
* translated, the page is not resurrected), including their treatment of
@@ -154,6 +149,27 @@ function SystemObjectRedirect({ objectName }: { objectName: string }) {
return ;
}
+/**
+ * Forwards the retired `system/{roles,positions,permissions}` console pages
+ * onto the Setup CATALOG (objectui#7611): the metadata-admin list of the
+ * registry type, in its environment scope (`…/metadata/?scope=environment`,
+ * `catalog-scope.ts` in `@object-ui/app-shell`).
+ *
+ * ADR-0131 D3 gives positions and permission sets one home, the environment
+ * registry, and D7 says Setup lists that registry; the ruling that unblocked
+ * objectui#7611 says the Setup pages are the existing metadata-admin pages,
+ * re-routed. The words map as they always did — `roles` was ADR-0090 D3's old
+ * name for positions, and objectui#3655 decision A bound `permissions` to the
+ * permission-set surface (layer 2), not the capability definitions (layer 1).
+ * Same prefix handling, and the same treatment of `location.search` /
+ * `location.hash`, as {@link SystemObjectRedirect}.
+ */
+function SystemCatalogRedirect({ type }: { type: 'position' | 'permission' }) {
+ const location = useLocation();
+ const prefix = location.pathname.replace(/\/system\/[^/]+\/?$/, '');
+ return ;
+}
+
/**
* The bare `…/system` landing: forwards onto `…/system/settings`, the settings
* hub (objectui#3743).
@@ -238,8 +254,11 @@ export const systemRoutes = (
} />
} />
} />
- {/* Legacy URL redirects → the framework-owned system objects (objectui#3655).
- All five resolve now. `system/permissions` was the one held back in PR
+ {/* Legacy URL redirects → the framework-owned system objects (objectui#3655),
+ and — since objectui#7611 — the Setup catalog for the last three
+ (`SystemCatalogRedirect`: positions and permission sets are registry
+ items, ADR-0131 D3). The history below is why `permissions` means the
+ permission-set surface. All five resolve. `system/permissions` was the one held back in PR
#3673: the framework splits what this console calls "Permissions" into
TWO Setup entries, and picking one on a hunch would have bound every
future click and bookmark to a surface nobody chose.
@@ -269,9 +288,9 @@ export const systemRoutes = (
distinction that actually holds. */}
} />
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
>
);
diff --git a/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx b/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx
index 230b3f878f..ab7e7ccc60 100644
--- a/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx
+++ b/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx
@@ -240,7 +240,9 @@ import { systemRoutes } from '../AppContent';
const chain: string[] = [];
function ChainRecorder() {
const location = useLocation();
- const here = location.pathname;
+ // The query string rides along: the Setup catalog's scope is a query
+ // parameter (objectui#7611), and every other landing here carries none.
+ const here = location.pathname + location.search;
if (chain[chain.length - 1] !== here) chain.push(here);
return null;
}
@@ -269,19 +271,12 @@ beforeEach(() => {
describe('system-hub entries reach the framework system objects (objectui#3655)', () => {
/**
- * All five. `roles` and `positions` converge on ONE object on purpose:
- * ADR-0090 D3 renamed `sys_role` -> `sys_position`, so the sidebar's "Roles"
- * and the retired hub's "Positions" were the same surface in old and new
- * vocabulary.
- * `permissions` -> `sys_permission_set` is objectui#3655's decision A (see
- * the file docblock); it landed one PR after the other four.
+ * The two that still name an OBJECT. The other three name the security
+ * catalog and are measured in the next describe (objectui#7611).
*/
it.each([
['/apps/setup/system/users', '/apps/setup/sys_user', 'sys_user'],
['/apps/setup/system/organizations', '/apps/setup/sys_organization', 'sys_organization'],
- ['/apps/setup/system/roles', '/apps/setup/sys_position', 'sys_position'],
- ['/apps/setup/system/positions', '/apps/setup/sys_position', 'sys_position'],
- ['/apps/setup/system/permissions', '/apps/setup/sys_permission_set', 'sys_permission_set'],
])('%s reaches %s in ONE hop', async (url, target, objectName) => {
renderConsoleAt(url);
@@ -347,6 +342,54 @@ describe('system-hub entries reach the framework system objects (objectui#3655)'
});
});
+describe('the catalog entries reach the Setup catalog (objectui#7611)', () => {
+ /**
+ * `roles`, `positions` and `permissions` name the security CATALOG, which
+ * ADR-0131 D3 moves into the environment registry, so they land on the
+ * metadata-admin list of the registry type in its environment scope. `roles`
+ * and `positions` still converge on ONE surface (ADR-0090 D3 renamed
+ * `sys_role` -> `sys_position`), and `permissions` still means the
+ * permission-set surface (objectui#3655 decision A).
+ */
+ it.each([
+ ['/apps/setup/system/roles', '/apps/setup/metadata/position?scope=environment', 'position'],
+ ['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment', 'position'],
+ ['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment', 'permission'],
+ ])('%s reaches %s in ONE hop', async (url, target, type) => {
+ renderConsoleAt(url);
+
+ expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent(type);
+ expect(chain).toEqual([url, target]);
+ // Never the row page the URL used to land on.
+ expect(screen.queryByTestId('object-view')).not.toBeInTheDocument();
+ });
+
+ it('preserves the app prefix', () => {
+ renderConsoleAt('/apps/my-app/system/permissions');
+
+ expect(chain).toEqual([
+ '/apps/my-app/system/permissions',
+ '/apps/my-app/metadata/permission?scope=environment',
+ ]);
+ });
+
+ /**
+ * The zero-app branch declares the metadata routes too, so the catalog is
+ * reachable on a deployment with no app — unlike the object pages the three
+ * URLs used to land on, which end at the "No Apps Configured" guard there.
+ */
+ it.each([
+ ['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment'],
+ ['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment'],
+ ])('with no apps, %s still reaches the catalog', async (url, target) => {
+ metadataApps = [];
+ renderConsoleAt(url);
+
+ expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument();
+ expect(chain).toEqual([url, target]);
+ });
+});
+
describe('zero-app branch — measured, not asserted away (objectui#3655)', () => {
/**
* On a zero-app deployment this is the branch the sidebar's Users /
@@ -369,9 +412,6 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () =
it.each([
['/apps/setup/system/users', '/apps/setup/sys_user'],
['/apps/setup/system/organizations', '/apps/setup/sys_organization'],
- ['/apps/setup/system/roles', '/apps/setup/sys_position'],
- ['/apps/setup/system/positions', '/apps/setup/sys_position'],
- ['/apps/setup/system/permissions', '/apps/setup/sys_permission_set'],
])('%s still redirects, and the target is the no-apps empty state', async (url, target) => {
metadataApps = [];
renderConsoleAt(url);
diff --git a/content/docs/guide/console-architecture.md b/content/docs/guide/console-architecture.md
index adcc965179..fc93607d08 100644
--- a/content/docs/guide/console-architecture.md
+++ b/content/docs/guide/console-architecture.md
@@ -98,6 +98,14 @@ full list:
Read those two route trees in the source rather than trusting a hand-copied table to stay
current.
+The Setup catalog for positions and permission sets is not a separate route family. It is
+the metadata-admin list and editor with the query parameter `scope=environment`, for
+example `/apps/setup/metadata/permission?scope=environment`. That scope lists every item
+the registry serves for the type and adds the Setup half of an item, which is who holds
+it. The console's legacy `system/roles`, `system/positions` and `system/permissions`
+URLs forward there. The `@object-ui/app-shell` README describes the scope under "The
+Setup catalog".
+
| Route Pattern | Component | Purpose |
|---------------|-----------|---------|
| `/apps/:appName` | Home redirect | Redirects to the first object in navigation |
diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md
index cb1b6433de..66ccc92be4 100644
--- a/packages/app-shell/README.md
+++ b/packages/app-shell/README.md
@@ -478,6 +478,43 @@ enable, or disable packages; direct `/metadata/package` links redirect there.
The Studio sidebar also flattens the root Overview group so Home and package
navigation sit directly under the package selector.
+### The Setup catalog: the environment scope (`?scope=environment`)
+
+Positions and permission sets live in the environment registry (objectstack
+ADR-0131 D3), and Setup lists that registry (D7). Setup does not add a page
+family for them: it uses the same metadata-admin list and editors at
+`…/metadata/position` and `…/metadata/permission`, with the query parameter
+`scope=environment` (`ENVIRONMENT_SCOPE_QUERY`, from
+`views/metadata-admin/catalog-scope.ts`). The scope changes three things:
+
+- **The list** shows every item the registry serves for the type, the
+ platform's own sets included. It is not narrowed to one project package.
+ Every link it emits keeps the scope, and so does the editor's breadcrumb.
+- **The gates.** A caller without `manage_metadata` gets no create affordance
+ and a read-only editor, and the page says why in the deployment's own terms.
+ Under `single` the platform administrator defines these items. Under a wall
+ the operator defines them in Studio. In both cases the organization assigns
+ them. The editors apply the caller gate in every scope, because the metadata
+ door refuses that caller's save in every scope.
+- **The Setup half of an item.** It renders under the definition: a permission
+ set's holders (`AssignedUsersSection`), and a position's holders
+ (`PositionHoldersSection`). Both read and write assignment rows by the
+ item's name.
+
+The list has an active switch and an active/inactive filter, through
+`catalog-activation.ts`. Both show the activation ledger's state
+(`sys_metadata_activation`, read through the data door), which is what the
+authorization resolver reads: an item with no ledger row is active. The switch
+writes through the ledger's door, `POST /api/v1/security/_activation/:type/:name`
+with `{ enabled }` (objectstack's ADR-0131 stage 2c), and reads or writes no
+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.
+
### Package-less flows (`/studio/~org/automations`)
A flow that belongs to no package — a clone of a packaged flow is one, by
diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts
index 8a6f3be1bf..f3deae6925 100644
--- a/packages/app-shell/src/index.ts
+++ b/packages/app-shell/src/index.ts
@@ -425,6 +425,10 @@ export { MetadataQuickFind } from './views/metadata-admin/QuickFind.js';
export { PageShell as MetadataPageShell } from './views/metadata-admin/PageShell.js';
export { SchemaForm } from './views/metadata-admin/SchemaForm.js';
export { LayeredDiff } from './views/metadata-admin/LayeredDiff.js';
+// objectui#7611 — the Setup catalog's URL scope (`?scope=environment`), for a
+// host that routes its own legacy URLs onto the catalog (the console's
+// `system/positions` / `system/permissions`). A dependency-free leaf.
+export { ENVIRONMENT_SCOPE_QUERY } from './views/metadata-admin/catalog-scope.js';
export {
registerMetadataResource,
getMetadataResource,
diff --git a/packages/app-shell/src/services/builtinComponents.tsx b/packages/app-shell/src/services/builtinComponents.tsx
index 634332509b..177b14d0e1 100644
--- a/packages/app-shell/src/services/builtinComponents.tsx
+++ b/packages/app-shell/src/services/builtinComponents.tsx
@@ -197,15 +197,14 @@ registerMetadataResource({
domain: 'security',
EditPage: PermissionMatrixEditPage,
searchableFields: ['name', 'label'],
+ // objectui#7611 — no `managedBy` column: the registry serves no such key on a
+ // permission set (measured on objectstack main), so it read "Custom" for every
+ // set, the platform's own included. Provenance is the list's own Source badge,
+ // read from the served `_packageId` / `_provenance`.
listColumns: [
{ key: 'name', label: 'Name', width: '30%' },
{ key: 'label', label: 'Label', width: '30%' },
- {
- key: 'managedBy',
- label: 'Source',
- width: '15%',
- render: (v) => (v === 'package' ? 'Package' : 'Custom'),
- },
+ { key: 'description', label: 'Description' },
],
});
diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx
index 159fe7528f..75448a25d4 100644
--- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.contractEnvelope-5945.test.tsx
@@ -47,12 +47,17 @@ const asBareArray: Envelope = (rows) => rows;
const asRecords: Envelope = (rows) => ({ records: rows, total: rows.length });
const asItems: Envelope = (rows) => ({ items: rows, total: rows.length });
-/** A directory holding one set with one DIRECT grantee — nothing else. */
+/**
+ * A directory holding one set with one DIRECT grantee — nothing else. The
+ * grant names its set (objectui#7611: holders are read by the set's NAME); no
+ * position distributes it (no metadata store is mounted, so the registry
+ * answers no positions).
+ */
const DIRECTORY: Record[]> = {
sys_permission_set: [{ id: 'ps_1', name: 'showcase_contributor' }],
- sys_user_permission_set: [{ id: 'grant_1', permission_set_id: 'ps_1', user_id: 'u_direct' }],
- sys_position_permission_set: [],
- sys_position: [],
+ sys_user_permission_set: [
+ { id: 'grant_1', permission_set_id: 'ps_1', permission_set: 'showcase_contributor', user_id: 'u_direct' },
+ ],
sys_user_position: [],
sys_user: [{ id: 'u_direct', name: 'Direct Dana', email: 'dana@example.com' }],
};
diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx
index 0eba147355..0c6ad7bb23 100644
--- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx
@@ -5,6 +5,11 @@
// direct-grants-only list told the admin "0 users" for any
// normally-administered set (positions are THE distribution channel in
// ADR-0090), right before they edit or delete it.
+//
+// objectui#7611 — by NAME, against the registry: grants are read by their
+// `permission_set` name column, and the positions that distribute the set are
+// the registry's position definitions whose `permissionSets` names it
+// (ADR-0131 D4) — never `sys_position_permission_set` / `sys_position` rows.
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';
@@ -14,22 +19,25 @@ import { AssignedUsersSection } from './AssignedUsersSection';
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal>()),
useAdapter: () => mockAdapter,
+ useMetadata: () => mockMetadataStore,
}));
vi.mock('@object-ui/fields', async (importOriginal) => ({
...(await importOriginal()),
RecordPickerDialog: () => null,
}));
+// The registry's position definitions — the binding is part of the definition.
+const registryPositions = [
+ { name: 'contributor', label: 'Contributor', permissionSets: ['showcase_contributor'] },
+ { name: 'everyone', label: 'Everyone', permissionSets: ['member_default', 'showcase_contributor'] },
+ { name: 'auditor', label: 'Auditor', permissionSets: ['showcase_auditor'] },
+];
+const mockMetadataStore = { ensureType: vi.fn(async () => registryPositions) };
+
const data: Record = {
sys_permission_set: [{ id: 'ps_1', name: 'showcase_contributor' }],
- sys_user_permission_set: [{ id: 'grant_1', permission_set_id: 'ps_1', user_id: 'u_direct' }],
- sys_position_permission_set: [
- { id: 'bind_1', position_id: 'pos_contrib', permission_set_id: 'ps_1' },
- { id: 'bind_2', position_id: 'pos_everyone', permission_set_id: 'ps_1' },
- ],
- sys_position: [
- { id: 'pos_contrib', name: 'contributor', label: 'Contributor' },
- { id: 'pos_everyone', name: 'everyone', label: 'Everyone' },
+ sys_user_permission_set: [
+ { id: 'grant_1', permission_set_id: 'ps_1', permission_set: 'showcase_contributor', user_id: 'u_direct' },
],
sys_user_position: [
{ id: 'up_1', user_id: 'u_held', position: 'contributor' },
@@ -85,3 +93,27 @@ describe('AssignedUsersSection — effective holders (objectui#2382)', () => {
expect(screen.getAllByLabelText('Remove')).toHaveLength(1);
});
});
+
+describe('AssignedUsersSection — by name, against the registry (objectui#7611)', () => {
+ it('reads grants by the set NAME and the distributing positions from the registry', async () => {
+ mockAdapter.find.mockClear();
+ render();
+ await waitFor(() => expect(screen.getByText('Held Henry')).toBeTruthy());
+
+ const reads = mockAdapter.find.mock.calls.map(([object]) => object);
+ // No catalog row is read to LIST holders.
+ expect(reads).not.toContain('sys_position_permission_set');
+ expect(reads).not.toContain('sys_position');
+ expect(reads).not.toContain('sys_permission_set');
+ const grantQuery = mockAdapter.find.mock.calls.find(([o]) => o === 'sys_user_permission_set')?.[1];
+ expect(grantQuery?.$filter).toEqual({ permission_set: 'showcase_contributor' });
+ expect(mockMetadataStore.ensureType).toHaveBeenCalledWith('position');
+ });
+
+ it('a position whose definition does not name the set contributes nobody', async () => {
+ render();
+ // `auditor` names `showcase_auditor`, but nobody holds it in the fixture,
+ // and the direct grant above is for another set: an empty list.
+ await waitFor(() => expect(screen.getByText(/No users assigned yet/)).toBeTruthy());
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx
index df3254ce5f..0575e37d82 100644
--- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx
+++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx
@@ -14,6 +14,24 @@
* rules on the junction insert (e.g. the AI-seat cap) are caught and shown as a
* friendly, localized inline message — not a raw developer error.
*
+ * ## By name, against the registry (objectui#7611, ADR-0131 D3/D4)
+ *
+ * The set is addressed by its machine NAME throughout:
+ *
+ * - direct grants are the `sys_user_permission_set` rows whose `permission_set`
+ * column names the set — the by-name column the platform's grant readers
+ * key on (a grant with no name grants nothing through them);
+ * - the positions that distribute the set are the registry's `position`
+ * definitions whose `permissionSets` list names it (ADR-0131 D4 — the
+ * binding is part of the position's definition), read through the console's
+ * metadata store, never `sys_position_permission_set` / `sys_position` rows.
+ *
+ * ⚠️ One row read remains, and only on ADD: the grant door still requires
+ * `permission_set_id` and refuses a grant that carries the name alone
+ * (measured on objectstack `main`: 400 `VALIDATION_FAILED` on
+ * `permission_set`). {@link resolveGrantRowId} is that read, pending the
+ * server half that accepts a grant by name.
+ *
* Permission-set-agnostic: every role gets the same UI, and the AI seat
* (`ai_seat`) is just one of them. The generic add-by-picker engine (spec
* RecordRelatedListProps.add) powers the capability; this is the polished
@@ -23,7 +41,7 @@
import * as React from 'react';
import { Button } from '@object-ui/components';
import { RecordPickerDialog } from '@object-ui/fields';
-import { useAdapter } from '@object-ui/react';
+import { useAdapter, useMetadata } from '@object-ui/react';
import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react';
import { useMetadataLocale } from './i18n.js';
@@ -62,6 +80,7 @@ function useCopy() {
direct: '直授',
viaPosition: (p: string) => '经岗位 ' + p,
everyoneNote: '已绑定到 everyone 锚点 — 所有登录成员都持有此权限集。',
+ noGrantRow: '此权限集还没有目录行,服务端暂时无法按名称分配。',
positionHeldHint: '经岗位持有 — 在岗位的指派中移除。',
}
: {
@@ -78,6 +97,7 @@ function useCopy() {
direct: 'direct',
viaPosition: (p: string) => 'via position ' + p,
everyoneNote: 'Bound to the everyone anchor — every signed-in member holds this set.',
+ noGrantRow: 'This set has no catalog row yet, and the server does not accept a grant by name alone yet.',
positionHeldHint: 'Held via a position — remove it on the position’s assignments.',
},
[zh],
@@ -108,11 +128,44 @@ const asArray = (res: any): any[] =>
const personLabel = (u: any): string =>
u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? '');
+/** The audience anchors: implicit memberships, noted rather than enumerated. */
+const ANCHOR_POSITIONS = new Set(['everyone', 'guest']);
+
+/**
+ * The positions whose DEFINITION distributes `setName` — its `permissionSets`
+ * list names the set (ADR-0131 D4; `PositionSchema.permissionSets`).
+ */
+export function positionsDistributing(positions: readonly unknown[], setName: string): string[] {
+ const out: string[] = [];
+ for (const raw of positions) {
+ const p = raw as { name?: unknown; permissionSets?: unknown } | null;
+ if (typeof p?.name !== 'string' || !p.name) continue;
+ if (Array.isArray(p.permissionSets) && p.permissionSets.includes(setName)) out.push(p.name);
+ }
+ return out;
+}
+
+/**
+ * The `sys_permission_set` row id a NEW grant must carry — the one row read
+ * left here, pending the server half: the grant door requires
+ * `permission_set_id` and stamps the name from it (see the module doc).
+ */
+async function resolveGrantRowId(adapter: any, setName: string): Promise {
+ const rows = asArray(
+ await adapter.find('sys_permission_set', { $filter: { name: setName }, $select: ['id'], $top: 1 }),
+ );
+ return rows[0]?.id != null ? String(rows[0].id) : null;
+}
+
export function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps) {
const adapter = useAdapter() as any;
const c = useCopy();
- const [setId, setSetId] = React.useState(null);
+ const metadataStore = useMetadata();
+ // Read through a ref: the store's context value is rebuilt whenever any
+ // cached type settles, and AGENTS.md #10 forbids keying `load` on it.
+ const metadataStoreRef = React.useRef(metadataStore);
+ metadataStoreRef.current = metadataStore;
const [rows, setRows] = React.useState([]);
const [everyoneBound, setEveryoneBound] = React.useState(false);
const [loading, setLoading] = React.useState(true);
@@ -123,43 +176,25 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
const load = React.useCallback(async () => {
setLoading(true);
try {
- const sets = asArray(
- await adapter.find('sys_permission_set', { $filter: { name: permissionSetName }, $top: 1 }),
- );
- const id = sets[0]?.id ? String(sets[0].id) : null;
- setSetId(id);
- if (!id) {
- setRows([]);
- setEveryoneBound(false);
- return;
- }
-
- // Effective holders = direct grants ∪ holders of every position bound to
- // the set (objectui#2382). In the ADR-0090 model positions are THE
- // distribution channel — a direct-grants-only list told the admin
- // "0 users" for any normally-administered set.
+ // Direct grants, by the set's NAME.
const grants = asArray(
- await adapter.find('sys_user_permission_set', { $filter: { permission_set_id: id }, $top: 500 }),
+ await adapter.find('sys_user_permission_set', { $filter: { permission_set: permissionSetName }, $top: 500 }),
);
+ // Effective holders = direct grants ∪ holders of every position that
+ // distributes the set (objectui#2382). In the ADR-0090 model positions
+ // are THE distribution channel — a direct-grants-only list told the
+ // admin "0 users" for any normally-administered set.
let positionNames: string[] = [];
let boundEveryone = false;
try {
- const bindings = asArray(
- await adapter.find('sys_position_permission_set', { $filter: { permission_set_id: id }, $top: 200 }),
- );
- const positionIds = [...new Set(bindings.map((b: any) => b.position_id).filter(Boolean).map(String))];
- if (positionIds.length) {
- const positions = asArray(
- await adapter.find('sys_position', { $filter: { id: { $in: positionIds } }, $top: 200 }),
- );
- const names = positions.map((p: any) => String(p.name ?? '')).filter(Boolean);
- // The audience anchors are implicit memberships — `everyone` is every
- // signed-in member; enumerating them as rows would be noise. Surface
- // a note instead and expand only the explicit positions.
- boundEveryone = names.includes('everyone');
- positionNames = names.filter((n) => n !== 'everyone' && n !== 'guest');
- }
+ const positions = await metadataStoreRef.current.ensureType('position');
+ const names = positionsDistributing(positions, permissionSetName);
+ // The audience anchors are implicit memberships — `everyone` is every
+ // signed-in member; enumerating them as rows would be noise. Surface
+ // a note instead and expand only the explicit positions.
+ boundEveryone = names.includes('everyone');
+ positionNames = names.filter((n) => !ANCHOR_POSITIONS.has(n));
} catch {
/* position expansion is additive — direct grants still render */
}
@@ -218,14 +253,21 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
const addUsers = React.useCallback(
async (records: any[]) => {
- if (!setId) return;
setBusy(true);
setError(null);
try {
+ const setId = await resolveGrantRowId(adapter, permissionSetName);
+ if (!setId) throw new Error(c.noGrantRow);
for (const u of records || []) {
const uid = u?.id != null ? String(u.id) : null;
if (!uid || assignedIds.has(uid)) continue;
- await adapter.create('sys_user_permission_set', { permission_set_id: setId, user_id: uid });
+ // The id is what the grant door requires today; the name rides
+ // beside it and the door refuses a mismatch (module doc).
+ await adapter.create('sys_user_permission_set', {
+ permission_set_id: setId,
+ permission_set: permissionSetName,
+ user_id: uid,
+ });
}
await load();
} catch (err: any) {
@@ -242,7 +284,7 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
setPickerOpen(false);
}
},
- [adapter, setId, assignedIds, load, rows.length, c],
+ [adapter, permissionSetName, assignedIds, load, rows.length, c],
);
const removeUser = React.useCallback(
@@ -271,7 +313,7 @@ export function AssignedUsersSection({ permissionSetName }: AssignedUsersSection
@@ -2461,7 +2484,16 @@ function MetadataResourceEditPageImpl({
this read-only notice is redundant (and its CTA has been
folded into the lock banner). Only render it for the
non-locked read-only cases. */}
- {readOnly && !isLocked && (
+ {readOnly && !isLocked && !canAuthor && (
+
@@ -512,9 +646,14 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
// workspace suffix for runtime/overlay-only rows (no real
// package, or the `sys_metadata` rehydration sentinel).
const rowPkg = (row.item as any)._packageId as string | undefined;
- const rowEditSuffix = rowPkg && rowPkg !== 'sys_metadata'
- ? `?package=${encodeURIComponent(rowPkg)}`
- : pkgSuffix;
+ // The environment scope keeps its own scope instead: a
+ // catalog name has ONE holder per deployment (ADR-0131), so
+ // the editor resolves it by name alone.
+ const rowEditSuffix = envScope
+ ? pkgSuffix
+ : rowPkg && rowPkg !== 'sys_metadata'
+ ? `?package=${encodeURIComponent(rowPkg)}`
+ : pkgSuffix;
const invalid = row.diagnostics?.valid === false;
const errorList = row.diagnostics?.errors ?? [];
const warnList = (row.diagnostics as any)?.warnings ?? [];
@@ -582,6 +721,18 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
);
})}
+ {activation && (
+
+
+
+ )}
{(row.item._lock as string | undefined) && row.item._lock !== 'none' && (
;
+}
+
+/**
+ * objectui#7611 — one item's active switch in the environment scope, showing
+ * the activation ledger's state (see `catalog-activation.ts`). Its non-switch
+ * states are each said, never rendered as a guessed "on": the state is still
+ * loading, or the read failed. An audience anchor shows its state on a
+ * disabled switch with the reason, because the door refuses to switch it.
+ */
+function CatalogActiveCell({
+ itemName,
+ ledger,
+ canSwitch,
+ busy,
+ locale,
+ onToggle,
+}: {
+ itemName: string;
+ ledger: CatalogLedger | 'error' | null;
+ canSwitch: boolean;
+ busy: boolean;
+ locale: string;
+ onToggle: (itemName: string, enabled: boolean) => void;
+}) {
+ if (ledger === null) return …;
+ if (ledger === 'error') {
+ return (
+
+ ?
+
+ );
+ }
+ const enabled = isCatalogItemActive(ledger.states, itemName);
+ const anchor = ledger.anchors.has(itemName);
+ // A switch the caller cannot use states the state, and offers no click; the
+ // page's read-only reason says why.
+ const label = anchor
+ ? t('engine.catalog.active.anchor', locale)
+ : canSwitch
+ ? t(enabled ? 'engine.catalog.active.on' : 'engine.catalog.active.off', locale)
+ : t(enabled ? 'engine.catalog.status.active' : 'engine.catalog.status.inactive', locale);
+ return (
+
+ onToggle(itemName, enabled)}
+ aria-label={label}
+ data-testid={`catalog-active-${itemName}`}
+ />
+
+ );
+}
+
function defaultColumns(primaryKey: string): NonNullable {
return [
{ key: primaryKey, label: primaryKey, width: '30%' },
diff --git a/packages/app-shell/src/views/metadata-admin/catalog-activation.test.ts b/packages/app-shell/src/views/metadata-admin/catalog-activation.test.ts
new file mode 100644
index 0000000000..8849828b13
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/catalog-activation.test.ts
@@ -0,0 +1,115 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#7611 — the Setup catalog's active switch (`catalog-activation.ts`)
+ * reads and writes the activation ledger, never a catalog row. Pinned: which
+ * types have a switch, the ledger read (the resolver's reading of the rows,
+ * and no catalog row read), the door write and its refusals in the server's
+ * own words, and the names the door refuses to switch.
+ */
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity';
+import {
+ audienceAnchorsOf,
+ hasCatalogActivation,
+ isCatalogItemActive,
+ readCatalogActivation,
+ writeCatalogActivation,
+} from './catalog-activation';
+
+interface Call {
+ url: string;
+ method: string;
+ body?: string;
+}
+
+/** Stub `fetch` with one canned answer; returns the calls it saw. */
+function stubFetch(status: number, body: unknown): Call[] {
+ const calls: Call[] = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string, init?: RequestInit) => {
+ calls.push({ url: String(url), method: init?.method ?? 'GET', body: init?.body as string | undefined });
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ }),
+ );
+ return calls;
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('catalog-activation (objectui#7611)', () => {
+ it('offers a switch for permission sets and positions, never for capabilities', () => {
+ expect(hasCatalogActivation('permission')).toBe(true);
+ expect(hasCatalogActivation('position')).toBe(true);
+ // The door refuses `capability` with 400 (ADR-0126 §3: code-only).
+ expect(hasCatalogActivation('capability')).toBe(false);
+ });
+
+ it("reads the type's ledger rows by name, as the resolver reads them, and no catalog row", async () => {
+ const calls = stubFetch(200, {
+ object: 'sys_metadata_activation',
+ records: [
+ { metadata_type: 'permission', name: 'off_set', active: false },
+ // A driver that round-trips booleans as 0/1.
+ { metadata_type: 'permission', name: 'off_by_zero', active: 0 },
+ { metadata_type: 'permission', name: 'on_set', active: true },
+ // Another type's row with a colliding name is not this type's switch.
+ { metadata_type: 'position', name: 'on_set', active: false },
+ { metadata_type: 'flow', name: 'some_flow', active: false },
+ ],
+ total: 5,
+ hasMore: false,
+ });
+ const states = await readCatalogActivation('permission');
+ expect([...states.entries()]).toEqual([
+ ['off_set', false],
+ ['off_by_zero', false],
+ ['on_set', true],
+ ]);
+ // No row means ACTIVE.
+ expect(isCatalogItemActive(states, 'never_switched')).toBe(true);
+ expect(calls).toEqual([{ url: '/api/v1/data/sys_metadata_activation?top=1000', method: 'GET', body: undefined }]);
+ expect(calls.some((c) => /sys_permission_set|sys_position/.test(c.url))).toBe(false);
+ });
+
+ it('reads nothing for a type without a switch', async () => {
+ const calls = stubFetch(200, { records: [] });
+ expect((await readCatalogActivation('capability')).size).toBe(0);
+ expect(calls).toEqual([]);
+ });
+
+ it('rejects a refused read with the server message, and a truncated one, rather than answering "active"', async () => {
+ stubFetch(403, { error: 'You do not have permission to perform this action.', code: 'PERMISSION_DENIED' });
+ await expect(readCatalogActivation('permission')).rejects.toThrow('You do not have permission to perform this action.');
+ stubFetch(200, { records: [], hasMore: true });
+ await expect(readCatalogActivation('position')).rejects.toThrow(/more than 1000 rows/);
+ });
+
+ it('switches through the door with exactly { enabled }, and answers the state the server reports', async () => {
+ const calls = stubFetch(200, { success: true, data: { type: 'position', name: 'auditor', enabled: false } });
+ await expect(writeCatalogActivation('position', 'auditor', false)).resolves.toBe(false);
+ expect(calls).toEqual([
+ { url: '/api/v1/security/_activation/position/auditor', method: 'POST', body: '{"enabled":false}' },
+ ]);
+ });
+
+ it("rejects a door refusal with the server's own sentence, and a type without a switch before any request", async () => {
+ const lastAdmin =
+ "Switching permission set 'admin_full_access' off would leave no administrator who can sign in.";
+ stubFetch(403, { success: false, error: { code: 'PERMISSION_DENIED', message: lastAdmin, httpStatus: 403 } });
+ await expect(writeCatalogActivation('permission', 'admin_full_access', false)).rejects.toThrow(lastAdmin);
+
+ const calls = stubFetch(200, {});
+ await expect(writeCatalogActivation('capability', 'manage_metadata', false)).rejects.toThrow(/capability/);
+ expect(calls).toEqual([]);
+ });
+
+ it("names the spec's audience anchors for positions, and none for permission sets", async () => {
+ expect([...(await audienceAnchorsOf('position'))].sort()).toEqual([...AUDIENCE_ANCHOR_POSITIONS].sort());
+ expect((await audienceAnchorsOf('position')).has('everyone')).toBe(true);
+ expect((await audienceAnchorsOf('permission')).size).toBe(0);
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/catalog-activation.ts b/packages/app-shell/src/views/metadata-admin/catalog-activation.ts
new file mode 100644
index 0000000000..3a87a72ba9
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/catalog-activation.ts
@@ -0,0 +1,152 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The ACTIVE switch of a Setup catalog item — a position's or a permission
+ * set's row in the activation ledger, read and written through the ledger's own
+ * doors (objectui#7611, objectstack ADR-0131 D3).
+ *
+ * ## Where the switch lives
+ *
+ * ADR-0126 §3, as ADR-0131 D6 amends it, makes disabling one `active` bit of
+ * deployment-level state in the tenant-less activation ledger,
+ * `sys_metadata_activation` — one row per `(metadata_type, name)`, where
+ * `metadata_type` is `permission` or `position`. The authorization resolver
+ * reads that ledger and nothing else for these two types
+ * (`readDisabledCatalogNames` in `@objectstack/core`): a row whose `active` is
+ * `false` (or a driver's `0`) switches the item off, and no row means ACTIVE.
+ * A catalog row's own `active` column (`sys_permission_set.active`,
+ * `sys_position.active`) no longer decides a grant, so this module reads and
+ * writes no catalog row. ⛔ There is no fallback onto that column either: the
+ * cutover builds nothing for a state it removes (objectstack#22601 → B).
+ *
+ * - **The state shown** is the resolver's reading of the same rows, through
+ * the data door's list of `sys_metadata_activation` — the read that object
+ * declares open (`apiMethods: ['get', 'list']`) and that Setup › Packaged
+ * automation already makes for actions. The record and truncation readers
+ * are that page's (`packagedActions.ts`), imported, not copied.
+ * - **The write** is the door, `POST /security/_activation/:type/:name` with
+ * `{ enabled }` (objectstack#15204 stage 2c). It writes one ledger row and
+ * nothing else.
+ *
+ * ## What the door refuses, and how each refusal reaches the page
+ *
+ * Every refusal is the server's own sentence, read by `actionErrorDetail` and
+ * shown as sent — ⛔ never a silent no-op, ⛔ never re-worded here:
+ *
+ * - `403` — the caller lacks `manage_metadata`, a walled deployment's caller is
+ * not the platform operator, or the switch-off would leave no administrator
+ * who can sign in (the last-admin guard on `admin_full_access`);
+ * - `404` — the name has no definition; `503` / `501` — the catalog or the
+ * ledger cannot be read or written here;
+ * - `400` — a type without a switch, or an audience anchor. Neither is offered
+ * as a live switch: `capability` has no activation column at all
+ * ({@link hasCatalogActivation}), and the anchors render disabled with their
+ * reason ({@link audienceAnchorsOf}).
+ */
+
+import { actionErrorDetail } from '@object-ui/core';
+import { apiBase } from '../../utils/apiBase.js';
+import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js';
+import { ledgerPageTruncated, readDataRecords } from '../setup/packagedActions.js';
+import { SETUP_CATALOG_TYPES } from './catalog-scope.js';
+
+/**
+ * Item name → switched on, for every item of the type the ledger holds a row
+ * for. An item with no entry is ACTIVE (the ledger's own contract).
+ */
+export type CatalogActivation = ReadonlyMap;
+
+/** How many ledger rows one read asks for; a truncated answer is refused. */
+const LEDGER_PAGE_SIZE = 1000;
+
+const JSON_HEADERS = { Accept: 'application/json' } as const;
+
+/** Does Setup offer an active switch for this catalog type at all? */
+export function hasCatalogActivation(type: string): boolean {
+ return (SETUP_CATALOG_TYPES as readonly string[]).includes(type);
+}
+
+/** Is `name` switched on, by the ledger's reading (no row ⇒ active)? */
+export function isCatalogItemActive(states: CatalogActivation, name: string): boolean {
+ return states.get(name) ?? true;
+}
+
+/**
+ * The ledger's rows of `type`, read the way the resolver reads them: one row
+ * per name, `false` / `0` is off and anything else on. Rows of every other
+ * type (flows and actions share the table) are skipped.
+ */
+export function readCatalogActivationRows(
+ records: readonly Record[],
+ type: string,
+): Map {
+ const out = new Map();
+ for (const row of records) {
+ if (row?.metadata_type !== type) continue;
+ const name = row?.name;
+ if (typeof name !== 'string' || !name) continue;
+ out.set(name, !(row.active === false || row.active === 0));
+ }
+ return out;
+}
+
+/**
+ * The activation state of `type`'s items, from `sys_metadata_activation`. An
+ * empty map for a type without a switch, and no request. A refused read, or a
+ * page the server reports as truncated (a missing row reads as ACTIVE, so a
+ * partial page would show a switched-off item as on), rejects: the caller
+ * shows the state as unknown, never as "active".
+ */
+export async function readCatalogActivation(type: string, signal?: AbortSignal): Promise {
+ if (!hasCatalogActivation(type)) return new Map();
+ const res = await fetch(`${apiBase()}/data/sys_metadata_activation?top=${LEDGER_PAGE_SIZE}`, {
+ credentials: 'include',
+ headers: JSON_HEADERS,
+ cache: 'no-store',
+ signal,
+ });
+ const json = await res.json().catch(() => null);
+ if (!res.ok || envelopeRefused(json)) throw new Error(actionErrorDetail(json, `HTTP ${res.status}`));
+ if (ledgerPageTruncated(json)) {
+ throw new Error(`the activation ledger answered more than ${LEDGER_PAGE_SIZE} rows`);
+ }
+ return readCatalogActivationRows(readDataRecords(json), type);
+}
+
+/**
+ * The names of `type` the door refuses to switch in either direction: the
+ * audience anchors `everyone` and `guest` (ADR-0090 D5/D9), which carry the
+ * authenticated and the anonymous baseline of every principal. The list is the
+ * spec's own `AUDIENCE_ANCHOR_POSITIONS`, the constant the door itself reads.
+ * Imported lazily, as `clientValidation.ts` imports the same module: this file
+ * is in the console's eager closure and that module is not.
+ */
+export async function audienceAnchorsOf(type: string): Promise> {
+ if (type !== 'position') return new Set();
+ const { AUDIENCE_ANCHOR_POSITIONS } = await import('@objectstack/spec/identity');
+ return new Set(AUDIENCE_ANCHOR_POSITIONS);
+}
+
+/**
+ * Switch one item on or off through the door. Resolves to the state the server
+ * reports (the requested one when it reports none). A refusal rejects with the
+ * server's own sentence; a type without a switch is refused before any request.
+ */
+export async function writeCatalogActivation(type: string, name: string, enabled: boolean): Promise {
+ if (!hasCatalogActivation(type)) throw new Error(`No activation for metadata type '${type}'`);
+ const res = await fetch(
+ `${apiBase()}/security/_activation/${encodeURIComponent(type)}/${encodeURIComponent(name)}`,
+ {
+ method: 'POST',
+ credentials: 'include',
+ headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' },
+ // ⛔ Exactly the one key the activation body declares; the door refuses
+ // any other by name.
+ body: JSON.stringify({ enabled }),
+ },
+ );
+ const json = await res.json().catch(() => null);
+ if (!res.ok || envelopeRefused(json)) throw new Error(actionErrorDetail(json, `HTTP ${res.status}`));
+ const reported = envelopeData(json).enabled;
+ return typeof reported === 'boolean' ? reported : enabled;
+}
diff --git a/packages/app-shell/src/views/metadata-admin/catalog-scope.ts b/packages/app-shell/src/views/metadata-admin/catalog-scope.ts
new file mode 100644
index 0000000000..4c62f3f2db
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/catalog-scope.ts
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The ENVIRONMENT scope of the metadata-admin pages — the Setup catalog
+ * (objectui#7611, objectstack ADR-0131 D3/D7).
+ *
+ * ## Why a scope and not a page family
+ *
+ * ADR-0131 D3 gives positions and permission sets one home, the environment
+ * registry, and D7 says the Setup pages list that registry. The ruling that
+ * unblocked objectui#7611 says how: "Build it by reuse, not by new pages" — the
+ * Setup catalog pages are the console's existing metadata-admin list and
+ * editors, re-routed and re-gated for Setup. So the catalog is the same
+ * `…/metadata/:type` route with one URL parameter, `scope=environment`:
+ *
+ * - the LIST stops scoping to one project package (the Studio rule, under
+ * which a platform-shipped set such as `admin_full_access` is never
+ * listed) and lists every item the registry serves for the type, with its
+ * provenance;
+ * - the EDITORS add the Setup half beside the definition: who holds a
+ * permission set, who holds a position.
+ *
+ * The scope is addressable state (AGENTS.md #8), so it lives in the URL and
+ * every link the pages emit carries it on: the item links, the create link,
+ * the breadcrumb back to the list. The Setup navigation and the console's
+ * legacy `system/*` URLs land on it.
+ *
+ * ⛔ It is a VIEW scope, never an authority: the server decides every write,
+ * and the read-only gates read the caller's capabilities (`useCanAuthorMetadata`)
+ * and the deployment's posture, not this parameter.
+ */
+
+/** The query parameter carrying the scope. Page-local to metadata-admin. */
+export const CATALOG_SCOPE_PARAM = 'scope';
+
+/** The one value it takes: the environment catalog Setup administers. */
+export const ENVIRONMENT_SCOPE = 'environment';
+
+/** Read-only view of a URL's query, as `useSearchParams` hands it out. */
+interface QueryLike {
+ get(name: string): string | null;
+}
+
+/** Is this URL the environment (Setup catalog) scope? */
+export function isEnvironmentScope(search: QueryLike): boolean {
+ return search.get(CATALOG_SCOPE_PARAM) === ENVIRONMENT_SCOPE;
+}
+
+/** `scope=environment`, ready to join into a query string. */
+export const ENVIRONMENT_SCOPE_QUERY = `${CATALOG_SCOPE_PARAM}=${ENVIRONMENT_SCOPE}`;
+
+/**
+ * The query suffix a link on an environment-scope page appends so the next
+ * page stays in that scope — `?scope=environment`, or `''` outside it.
+ */
+export function environmentScopeSuffix(search: QueryLike): string {
+ return isEnvironmentScope(search) ? `?${ENVIRONMENT_SCOPE_QUERY}` : '';
+}
+
+/**
+ * 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`.
+ */
+export const SETUP_CATALOG_TYPES = ['permission', 'position'] as const;
diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts
index df8e191159..7c170aec0f 100644
--- a/packages/app-shell/src/views/metadata-admin/i18n.ts
+++ b/packages/app-shell/src/views/metadata-admin/i18n.ts
@@ -267,6 +267,23 @@ const ENGINE_STRINGS_EN: Record = {
'engine.list.createHint': 'Click "New" above to create the first {type}.',
'engine.list.readOnlyHint':
'This type is read-only — instances are defined by code artifacts in packages.',
+ // objectui#7611 — the environment scope (Setup catalog). See `catalog-scope.ts`.
+ 'engine.catalog.col.active': 'Active',
+ 'engine.catalog.status.all': 'All',
+ 'engine.catalog.status.active': 'Active',
+ 'engine.catalog.status.inactive': 'Inactive',
+ 'engine.catalog.active.on': 'Active — click to deactivate',
+ 'engine.catalog.active.off': 'Inactive — click to activate',
+ 'engine.catalog.active.anchor':
+ 'An audience anchor: it carries the baseline every principal of the deployment holds, so it cannot be switched off. Change what it grants through the permission sets bound to it.',
+ 'engine.catalog.active.unknown': 'Activation state could not be read.',
+ 'engine.catalog.active.failed': 'The switch was not saved: {message}',
+ 'engine.catalog.readOnly.single':
+ 'Read-only for your account. {type} items are defined by the platform administrator (the `manage_metadata` capability); your organization assigns them.',
+ 'engine.catalog.readOnly.walled':
+ 'Read-only for organization administrators. On this deployment the platform operator defines {type} items for every organization in Studio; your organization assigns them.',
+ 'engine.edit.capabilityReadOnly':
+ 'Read-only for your account: changing this item requires the `manage_metadata` capability, which the platform administrator holds.',
'engine.edit.save': 'Save',
'engine.edit.publish': 'Publish',
'engine.edit.publishBlockedDirty': 'Save first, then publish.',
@@ -3684,6 +3701,22 @@ const ENGINE_STRINGS_ZH: Record = {
'engine.list.emptyQuery': '没有匹配 "{query}" 的结果',
'engine.list.createHint': '点击上方"新建"创建第一个 {type}。',
'engine.list.readOnlyHint': '此类型为只读 — 实例由包内的代码工件定义。',
+ 'engine.catalog.col.active': '启用',
+ 'engine.catalog.status.all': '全部',
+ 'engine.catalog.status.active': '已启用',
+ 'engine.catalog.status.inactive': '已停用',
+ 'engine.catalog.active.on': '已启用 — 点击停用',
+ 'engine.catalog.active.off': '已停用 — 点击启用',
+ 'engine.catalog.active.anchor':
+ '受众锚点:它承载此部署中每个主体都持有的基线,因此不能停用。请通过绑定到它的权限集来调整它授予的内容。',
+ 'engine.catalog.active.unknown': '无法读取启用状态。',
+ 'engine.catalog.active.failed': '开关未保存:{message}',
+ 'engine.catalog.readOnly.single':
+ '您的账号只能查看。{type}由平台管理员定义(需要 `manage_metadata` 能力);您的组织负责分配。',
+ 'engine.catalog.readOnly.walled':
+ '组织管理员只能查看。此部署中由平台运营方在 Studio 中为所有组织定义{type};您的组织负责分配。',
+ 'engine.edit.capabilityReadOnly':
+ '您的账号只能查看:修改此条目需要 `manage_metadata` 能力,该能力由平台管理员持有。',
'engine.edit.save': '保存',
'engine.edit.publish': '发布',
'engine.edit.publishBlockedDirty': '请先保存再发布。',
diff --git a/packages/fields/src/widgets/RecipientPickerField.test.tsx b/packages/fields/src/widgets/RecipientPickerField.test.tsx
index 17d532f8bb..aa9b3757ea 100644
--- a/packages/fields/src/widgets/RecipientPickerField.test.tsx
+++ b/packages/fields/src/widgets/RecipientPickerField.test.tsx
@@ -22,6 +22,7 @@ import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
+import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
import { RecipientPickerField } from './RecipientPickerField';
const USERS = [
@@ -64,7 +65,6 @@ describe('RecipientPickerField — candidate query', () => {
['team', 'sys_team'],
['business_unit', 'sys_business_unit'],
['unit_and_subordinates', 'sys_business_unit'],
- ['position', 'sys_position'],
] as const) {
const ds = mockDataSource([]);
const { unmount } = renderPicker(type, ds);
@@ -155,3 +155,74 @@ describe('RecipientPickerField — localizable copy', () => {
expect(screen.getByText('Select a recipient type first.')).toBeInTheDocument();
});
});
+
+/**
+ * objectui#7611 (ADR-0131 D3/D7) — a position recipient is picked from the
+ * REGISTRY: the console metadata store's `position` items, never `sys_position`
+ * rows, and never a merge of the two.
+ */
+describe('RecipientPickerField — position candidates come from the registry (objectui#7611)', () => {
+ const POSITIONS = [
+ { name: 'sales_manager', label: 'Sales Manager' },
+ { name: 'account_exec', label: 'Account Executive' },
+ ];
+
+ function metadataStore(items: unknown[]): MetadataContextValue & { ensureType: ReturnType } {
+ return {
+ apps: [], objects: [], dashboards: [], reports: [], pages: [],
+ loading: false, error: null,
+ refresh: async () => {}, invalidate: () => {},
+ ensureType: vi.fn().mockResolvedValue(items),
+ getItem: async () => null,
+ getItemsByType: () => [],
+ } as any;
+ }
+
+ function renderInStore(store: MetadataContextValue | null, ds: any, value = '', onChange = vi.fn()) {
+ const picker = (
+
+ );
+ return render(store ? {picker} : picker);
+ }
+
+ it('lists the position items of the registry and never queries sys_position', async () => {
+ const store = metadataStore(POSITIONS);
+ const ds = mockDataSource([{ id: 'row_1', name: 'row_only_position' }]);
+ renderInStore(store, ds);
+
+ await waitFor(() => expect(store.ensureType).toHaveBeenCalledWith('position'));
+ fireEvent.click(screen.getByRole('combobox'));
+ expect(await screen.findByText('Sales Manager')).toBeInTheDocument();
+ expect(screen.getByText('Account Executive')).toBeInTheDocument();
+ // One source: a row the registry does not hold is not offered, and the
+ // data source is never asked.
+ expect(screen.queryByText('row_only_position')).not.toBeInTheDocument();
+ expect(ds.find).not.toHaveBeenCalled();
+ });
+
+ it('stores the chosen item by its machine name', async () => {
+ const store = metadataStore(POSITIONS);
+ const onChange = vi.fn();
+ renderInStore(store, mockDataSource([]), '', onChange);
+
+ await waitFor(() => expect(store.ensureType).toHaveBeenCalled());
+ fireEvent.click(screen.getByRole('combobox'));
+ fireEvent.click(await screen.findByText('Account Executive'));
+ expect(onChange).toHaveBeenCalledWith('account_exec');
+ });
+
+ it('degrades to the text input when no metadata store is mounted', () => {
+ const ds = mockDataSource([]);
+ renderInStore(null, ds, 'sales_manager');
+ // No reader of the registry ⇒ no list to offer; the stored name stays
+ // editable instead of an empty list that can never fill.
+ expect(screen.getByDisplayValue('sales_manager')).toBeInTheDocument();
+ expect(ds.find).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx
index a90a0e95f3..a7306a8b90 100644
--- a/packages/fields/src/widgets/RecipientPickerField.tsx
+++ b/packages/fields/src/widgets/RecipientPickerField.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Combobox, EmptyValue, cn } from '@object-ui/components';
-import { SchemaRendererContext } from '@object-ui/react';
+import { MetadataCtx, SchemaRendererContext } from '@object-ui/react';
import type { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import { useFieldTranslation } from './useFieldTranslation.js';
@@ -20,8 +20,13 @@ import { useFieldTranslation } from './useFieldTranslation.js';
* team → sys_team, store `id`
* business_unit → sys_business_unit, store `id`
* unit_and_subordinates → sys_business_unit, store `id`
- * position → sys_position, store `name` (matched against
- * sys_user_position.position at evaluation time)
+ *
+ * One kind lists CATALOG ITEMS instead of records (objectui#7611, ADR-0131
+ * D3/D7): its candidates are the `position` items of the environment registry,
+ * read through the console's metadata store, and the picker stores the item's
+ * `name` (matched against `sys_user_position.position` at evaluation time):
+ *
+ * position → the `position` registry, store `name`
*
* One kind is NOT a record picker and therefore has no row above:
*
@@ -51,7 +56,32 @@ const TYPE_TO_OBJECT: Record = {
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' },
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' },
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' },
- position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
+};
+
+/** A recipient kind whose candidates are catalog items of one metadata type. */
+interface RegistryRecipientMapping {
+ /** The metadata type listed — the registry's `GET /api/v1/meta/`. */
+ type: string;
+ /** Catalog items are referenced by machine name (ADR-0131 D4). */
+ storeField: 'name';
+ /** Candidate display-label keys, in preference order. */
+ labelFields: string[];
+ /** i18n key for the "choose one" placeholder (see {@link RecipientMapping}). */
+ placeholderKey: string;
+}
+
+/**
+ * The recipient kinds whose candidates come from the registry (objectui#7611).
+ *
+ * ADR-0131 D3 gives positions one home, the environment registry, and D7 says
+ * a picker LISTS that registry: one source, with no second list of
+ * `sys_position` rows merged in. The read goes through the console's metadata
+ * store (`useMetadata().ensureType`), the same reader that serves apps and
+ * objects, so the list is fetched once per session and shared with every
+ * other registry consumer.
+ */
+const TYPE_TO_REGISTRY: Record = {
+ position: { type: 'position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
};
/**
@@ -148,7 +178,15 @@ export function RecipientPickerField({
const disabled = props.disabled;
const dependentValues: Record = (props as any).dependentValues ?? {};
const recipientType = String(dependentValues.recipient_type ?? '');
- const mapping = TYPE_TO_OBJECT[recipientType];
+ const recordMapping = TYPE_TO_OBJECT[recipientType];
+ // The console's metadata store, or `null` outside a ``.
+ // Read raw rather than through `useMetadata()`, whose no-provider fallback
+ // answers every type with `[]`: an empty registry and an absent reader are
+ // different facts, and only the second degrades to the text input below.
+ const metadataStore = React.useContext(MetadataCtx);
+ const registryMapping = metadataStore ? TYPE_TO_REGISTRY[recipientType] : undefined;
+ const mapping: { storeField: 'id' | 'name'; labelFields: string[]; placeholderKey: string } | undefined =
+ recordMapping ?? registryMapping;
const objectName = String(dependentValues.object_name ?? '');
const isFieldRecipient = recipientType === FIELD_RECIPIENT_TYPE;
// Only this data source can answer "which columns does that object declare?".
@@ -181,7 +219,7 @@ export function RecipientPickerField({
React.useEffect(() => {
setRecords(null);
- if (!dataSource || !mapping || typeof dataSource.find !== 'function') return;
+ if (!dataSource || !recordMapping || typeof dataSource.find !== 'function') return;
let cancelled = false;
(async () => {
try {
@@ -189,7 +227,7 @@ export function RecipientPickerField({
// supported again (objectstack#3821 fixed ApiDataSource walking it
// character by character), but the structured form can't regress that
// way for any data source.
- const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } });
+ const res = await dataSource.find(recordMapping.object, { $top: 500, $orderby: { name: 'asc' } });
const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
if (!cancelled) setRecords(Array.isArray(list) ? list : []);
} catch {
@@ -199,7 +237,37 @@ export function RecipientPickerField({
return () => {
cancelled = true;
};
- }, [dataSource, mapping?.object]);
+ }, [dataSource, recordMapping?.object]);
+
+ // The registry kinds' load (objectui#7611): the type's catalog items, from
+ // the metadata store. Sorted by machine name, the order the record kinds
+ // ask the server for. Keyed on the TYPE alone: the store's context value is
+ // rebuilt whenever any type it caches settles, and AGENTS.md #10 forbids an
+ // effect that keys on that identity, so the store is read through a ref.
+ const metadataStoreRef = React.useRef(metadataStore);
+ metadataStoreRef.current = metadataStore;
+ const registryType = registryMapping?.type;
+ React.useEffect(() => {
+ const store = metadataStoreRef.current;
+ if (!registryType || !store) return;
+ setRecords(null);
+ let cancelled = false;
+ store
+ .ensureType(registryType)
+ .then((items) => {
+ if (cancelled) return;
+ const list = (Array.isArray(items) ? items : []).filter(
+ (it: any) => typeof it?.name === 'string' && it.name,
+ );
+ setRecords([...list].sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))));
+ })
+ .catch(() => {
+ if (!cancelled) setRecords([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [registryType]);
// The `field` mode's own load: the SHARED object's schema, keyed on the
// sibling `object_name`. Re-run when the admin switches object, so the
diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx
index 8b993d7698..23499993c7 100644
--- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx
+++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx
@@ -645,6 +645,10 @@ const WRITE_SITES: Record = {
unmapped("Setup's permission-set assignment creates sys_user_permission_set rows and reads no grant on that object"),
'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > removeUser :: delete':
unmapped("Setup's permission-set assignment deletes sys_user_permission_set rows and reads no grant on that object"),
+ 'packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx :: PositionHoldersSection > addHolders :: create':
+ unmapped("Setup's position assignment creates sys_user_position rows and reads no grant on that object"),
+ 'packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx :: PositionHoldersSection > removeHolder :: delete':
+ unmapped("Setup's position assignment deletes sys_user_position rows and reads no grant on that object"),
// ── core ──
'packages/core/src/actions/TransactionManager.ts :: TransactionManager > executeBatch :: batch':
door('the action engine\'s transaction executor runs the operations its caller hands it'),