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
13 changes: 13 additions & 0 deletions .changeset/12753-perm-provenance-no-managedby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': patch
---

The permission set editor's provenance badge decides from `packageId` and the package artifact only (objectui#12753). Spec 18 retired the authored `managedBy` on a permission set (objectstack#23891), so no served or saved body carries it. The badge used to read it for a third answer, "Platform", and as one input to "Package". No served datum names a platform set: the layered read serves `_packageId` and `_provenance` (`package` or `org`), so a set the platform ships through a package reads "Package", like any other packaged set.

- The badge now has two answers. "Package" means a package ships the set: it is opened under the package door's `packageId`, or its code layer is a package artifact. "Custom" means it was authored in this environment.
- The routed page's badge and the Studio Access pillar's kind chip read one derivation, so they cannot disagree.
- A draft that still carries a stray `managedBy` (an old stored row) no longer moves the answer. `managedBy: 'platform'` used to read "Platform", and `managedBy: 'package'` used to read "Package" for a set no package ships. Both now read "Custom".

Clause-②: no

The metadata-admin string table loses `perm.badge.platform` (en and zh), the "Platform" label only that badge read. The table is module-local: it is not exported from the package entry, so no consumer can address the key. The local `PermissionSetDraft` type loses its `managedBy` member; that type is not exported from the package entry either. No export, prop or exported type member changes.
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A4 (framework#2920) — the provenance badge in the Studio permission matrix is
* a TRI-STATE (platform / package / admin-custom), mirroring the unified sys_*
* `managed_by` vocabulary. This guards each branch of the ternary.
* The provenance badge in the permission set editor has two answers: "Package"
* (a package ships the set: the package door's `packageId`, or the layered
* envelope's code artifact) and "Custom" (authored in this environment).
*
* objectui#12753 — spec 18 retired the authored `managedBy` on a permission set
* (objectstack#23891). The badge used to read it for a third answer,
* "Platform", and for "Package" too. No served datum names a platform set, so
* the badge reads `packageId` and the artifact only. A draft that still carries
* a stray `managedBy` (an old stored row) must not move the answer.
*
* Both sites are pinned: the routed page's badge, and the Studio Access
* pillar's kind chip (`studio`). They read one derivation, so they agree.
*/

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

/** The layered envelope's `code` slot; `null` = no code layer. */
let codeLayer: Record<string, unknown> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
layered: async () => ({
effective: set,
code: codeLayer,
overlay: null,
overlayScope: null,
...(codeLayer ? { provenance: 'package' } : {}),
}),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,
} as any;
Expand All @@ -34,44 +53,65 @@ vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

function baseSet(extra: Record<string, unknown>) {
return { name: 'sales_perms', label: 'Sales', isProfile: false, objects: {}, fields: {}, ...extra };
}

async function renderWith(extra: Record<string, unknown>) {
type Site = 'page' | 'studio';

async function renderWith(site: Site, extra: Record<string, unknown>, packageId?: string) {
clientImpl = makeClient(baseSet(extra));
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
{site === 'studio' ? (
<PermissionMatrixEditPage
type="permission"
name="sales_perms"
packageId={packageId}
embedded
studio={{ tab: 'objects', onTabChange: () => {} }}
/>
) : (
<PermissionMatrixEditPage type="permission" name="sales_perms" packageId={packageId} />
)}
</MemoryRouter>,
);
// Wait for the load to settle. The identity strip is collapsed by default
// (objectui#2600 B1), so the label shows as summary text, not an input.
await screen.findByText('Sales');
// Wait for the load to settle: the object list arrives with the set.
await screen.findByText('Account');
}

describe('PermissionMatrixEditPage — provenance tri-state badge (A4 framework#2920)', () => {
it('managedBy "platform" renders the Platform badge', async () => {
await renderWith({ managedBy: 'platform' });
expect(screen.getByText('Platform')).toBeInTheDocument();
expect(screen.queryByText('Custom')).not.toBeInTheDocument();
const badgeText = () => screen.getByTestId('perm-provenance-badge').textContent;

describe.each<Site>(['page', 'studio'])('the provenance badge (%s site)', (site) => {
it('a set opened under a packageId reads "Package"', async () => {
await renderWith(site, {}, 'com.example.sales');
expect(badgeText()).toBe('Package');
});

it('a set whose code layer is a package artifact reads "Package"', async () => {
codeLayer = { _packageId: 'com.example.sales', name: 'sales_perms' };
await renderWith(site, {});
expect(badgeText()).toBe('Package');
});

it('managedBy "package" renders the Package badge', async () => {
await renderWith({ managedBy: 'package' });
expect(screen.getByText('Package')).toBeInTheDocument();
it('a set with neither reads "Custom"', async () => {
await renderWith(site, {});
expect(badgeText()).toBe('Custom');
});

it('managedBy "admin" renders the Custom badge', async () => {
await renderWith({ managedBy: 'admin' });
expect(screen.getByText('Custom')).toBeInTheDocument();
it('a stray managedBy "platform" on an old stored row does not read "Platform"', async () => {
await renderWith(site, { managedBy: 'platform' });
expect(badgeText()).toBe('Custom');
expect(screen.queryByText('Platform')).not.toBeInTheDocument();
});

it('legacy managedBy "user" still falls through to Custom (read compat)', async () => {
await renderWith({ managedBy: 'user' });
expect(screen.getByText('Custom')).toBeInTheDocument();
it('a stray managedBy "package" does not make an environment-authored set read "Package"', async () => {
await renderWith(site, { managedBy: 'package' });
expect(badgeText()).toBe('Custom');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,12 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// opt-in would be a client copy of the seal rule. `undefined` keeps the
// flag's answer, as before.
const artifactTierApplies = !packageId && codeIsArtifact;
// The set's provenance, for the page badge and the Studio kind chip alike:
// a package ships it (the package door's `packageId`, or the layered
// envelope's code artifact), else it was authored in this environment. Spec
// 18 retired the authored `managedBy` (objectstack#23891), and no served
// datum names a "platform" set, so there are two answers (objectui#12753).
const shippedByPackage = !!packageId || codeIsArtifact;
const artifactArmOpen = !!entry?.allowEnvironmentOverlay && servedEditable !== false;
const typeTierOpen = !!(entry?.allowEnvironmentOverlay || entry?.allowRuntimeCreate);
const canWriteByType = artifactTierApplies ? artifactArmOpen : typeTierOpen;
Expand Down Expand Up @@ -1354,19 +1360,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
);
const provenanceBadgesEl = (
<>
{/* [A4 framework#2920] Provenance tri-state — platform / package /
admin(custom) — mirrors the unified sys_* `managed_by` vocab.
{/* Provenance: Package or Custom, from `shippedByPackage`.
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. */}
gate's own input). 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 || codeIsArtifact
? t('perm.badge.package')
: t('perm.badge.custom')}
{shippedByPackage ? t('perm.badge.package') : t('perm.badge.custom')}
</Badge>
{!!draft.isDefault && (
<Badge variant="secondary" className="text-[10px] shrink-0">{t('perm.badge.default')}</Badge>
Expand Down Expand Up @@ -1601,13 +1602,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
chips={
<>
{/* The provenance chip in the artboard's kind-chip look; same
test id and the same three answers as the page's badge. */}
test id and the same answer as the page's badge. */}
<span className={STUDIO_KIND_CHIP} data-testid="perm-provenance-badge">
{draft.managedBy === 'platform'
? t('perm.badge.platform')
: draft.managedBy === 'package' || packageId || codeIsArtifact
? t('perm.badge.package')
: t('perm.badge.custom')}
{shippedByPackage ? t('perm.badge.package') : t('perm.badge.custom')}
</span>
{!!draft.isDefault && <span className={STUDIO_KIND_CHIP}>{t('perm.badge.default')}</span>}
{/* A host-locked package says so in the pillar's note; the
Expand Down
2 changes: 0 additions & 2 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2413,7 +2413,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.basics.editHint': 'Edit name / label',
'perm.cap.none': 'No system capabilities',
'perm.cap.add': 'Add capability',
'perm.badge.platform': 'Platform',
'perm.badge.package': 'Package',
'perm.badge.custom': 'Custom',
'perm.badge.default': 'Default for new users',
Expand Down Expand Up @@ -5812,7 +5811,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.basics.editHint': '编辑名称 / 标签',
'perm.cap.none': '无附加系统能力',
'perm.cap.add': '添加能力',
'perm.badge.platform': '平台内置',
'perm.badge.package': '包内置',
'perm.badge.custom': '本环境自定义',
'perm.badge.default': '新用户默认',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,7 @@ export interface FieldPerm {
export interface PermissionSetDraft {
name: string;
label?: string;
isDefault?: boolean; // install-time suggestion (ADR-0090 D5); managedBy/packageId are provenance
managedBy?: string;
isDefault?: boolean; // install-time suggestion (ADR-0090 D5); packageId is provenance
objects: Record<string, ObjectPerm>;
fields?: Record<string, FieldPerm>;
systemPermissions?: string[];
Expand Down
Loading