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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Retire the `allowRestore` / `allowPurge` columns from the metadata-admin permission matrix by os-sales · Pull Request #6607 · objectstack-ai/objectui · GitHub
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
37 changes: 37 additions & 0 deletions .changeset/6595-retire-allowrestore-allowpurge-columns.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata-admin permission matrix no longer authors the retired object-permission
bits `allowRestore` / `allowPurge` (objectui#6595).

The `Re` and `Pu` columns, their two typed fields, the two preview rows, and the
"Purge (hard delete) granted without Delete" sanity check are gone, together with the
two column tooltips in both locale tables. `allowTransfer` is enforced upstream
(objectstack#3004) and is untouched — it stays a column.

Both removed keys gated `restore` / `purge` ObjectQL operations that **have never
existed**: a dispatched restore/purge is denied unconditionally by the evaluator's
fail-closed destructive-operation backstop. So every tick of those checkboxes wrote a
grant no runtime has ever read, and the preview lint warned about a combination whose
danger was entirely notional. `@objectstack/spec` retired both keys as `retiredKey()`
tombstones (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
recommendation B, ADR-0049 enforce-or-remove), which turns the same checkbox into a save
that hard-fails at publish once the bump carrying that retirement reaches this repo.

**The return path is named in the code, not just here**: both keys come back with the M2
lifecycle initiative, whose restart is recorded upstream on objectstack#1883. The
tombstone on `ObjectPerm` in `permission-slice.ts` states it, and the two `retiredLifecycleKeys`
pins name it again — a future reader who wonders where the columns went finds the answer
at each of the three sites the removal touched.

**A stored legacy value is carried through, not stripped.** It is no longer modelled and
no longer authorable, so it rides through save untouched exactly as any key this editor
does not model does — the record-level index signature on `PermissionSetDraft` states
that rule, and `updateObjectPerm`'s spread applies it per row. Stripping was deliberately
left out: the installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still **accepts**
both keys at permission parse, so a strip today would delete stored data the schema still
honours. Once the bump lands and a carried value becomes a body the schema refuses,
strip-on-load becomes correct — that is objectui#4644's resolution for `indexed`, and it
belongs to the bump PR. The pin that records today's posture says so in its own header,
so the bump replaces it deliberately rather than deleting a red.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Access matrix never AUTHORS the retired lifecycle bits
* `allowRestore` / `allowPurge` (objectui#6595).
*
* Both keys gated `restore` / `purge` ObjectQL operations that have never
* existed — a dispatched restore/purge is denied unconditionally by the
* evaluator's fail-closed destructive-operation backstop — so every tick of
* the `Re` / `Pu` checkboxes was a grant no runtime has ever read.
* `@objectstack/spec` retired both as `retiredKey()` tombstones
* (objectstack#12497; maintainer ruling 2026-08-26 accepting objectstack#1883
* recommendation B, ADR-0049 enforce-or-remove). They return with the M2
* lifecycle initiative, whose restart is recorded on objectstack#1883.
*
* Two directions are pinned, because a missing column is only half of it:
* - the COLUMN SET, not merely the absence of two keys. A set assertion is
* what stops a retired key drifting back in beside a live one, and it is
* the direction that also proves `allowTransfer` — enforced upstream, and
* explicitly out of this removal — is still authorable.
* - what reaches the WIRE. "Grant all" seeds a row from the column list, so
* the key set it writes is the real product of this change; asserting on
* the saved payload catches a column list that drifts back into the seed
* without a header cell to show for it.
*
* ## The one assertion the spec bump is expected to revisit
*
* `carries a stored legacy value through untouched` pins TODAY's posture: the
* installed `@objectstack/spec` (17.2.0, measured 2026-08-27) still ACCEPTS
* both keys at permission parse, so stripping a stored value here would delete
* data the schema still honours. Once the bump carrying the retirement lands,
* a carried-through value becomes a body the schema REFUSES, and strip-on-load
* becomes correct (objectui#4644's resolution for `indexed`). That is the bump
* PR's change to make, deliberately, replacing this assertion — not a red to
* be quietly deleted.
*/

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

/** The object-permission keys the matrix may author, in column order. */
const LIVE_COLUMN_SHORTS = ['C', 'R', 'U', 'D', 'Tr', 'VA', 'MA'];

let clientImpl: any;
let saved: Record<string, any> | null = null;

function makeClient(set: Record<string, unknown>) {
return {
layered: async () => ({ effective: set, code: null, overlay: null, overlayScope: null }),
getDraft: async () => null,
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
save: async (_t: string, _n: string, payload: Record<string, any>) => {
saved = payload;
return payload;
},
} as any;
}

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
vi.mock('@object-ui/fields', () => ({
CapabilityMultiSelectField: () => <div data-testid="cap-picker" />,
parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

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

async function renderSet(objects: Record<string, unknown> = {}) {
clientImpl = makeClient({
name: 'sales_perms',
label: 'Sales',
objects,
fields: {},
});
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name="sales_perms" />
</MemoryRouter>,
);
await screen.findByText('Sales');
}

/** Click Save and return the payload the client was handed. */
async function save() {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(saved).not.toBeNull());
return saved!;
}

describe('PermissionMatrixEditor · retired lifecycle keys (objectui#6595)', () => {
it('offers exactly the live capability columns — no Re, no Pu', async () => {
await renderSet();

const headers = screen
.getAllByRole('columnheader')
.map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Bulk; the capability strip is what this pins.
expect(headers.slice(1, -1)).toEqual(LIVE_COLUMN_SHORTS);
// Stated twice on purpose: the set assertion above is the guard, these two
// name the keys this card retired so a future reader sees them by name.
expect(headers).not.toContain('Re');
expect(headers).not.toContain('Pu');
});

it('offers no Restore / Purge checkbox, and still offers Transfer', async () => {
await renderSet({ a_account: { allowRead: true } });

expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();
// Falsification: `allowTransfer` is enforced upstream and is NOT part of
// this removal — if it vanished too, the assertions above would be passing
// for the wrong reason.
expect(screen.getByLabelText('a_account Transfer ownership')).toBeTruthy();
});

it('"Grant all" writes exactly the live keys — the retired pair cannot ride along', async () => {
await renderSet({ a_account: {} });

fireEvent.click(screen.getAllByRole('button', { name: /^All$/ })[0]);
const payload = await save();

const row = payload.objects.a_account;
expect(Object.keys(row).sort()).toEqual(
[
'allowCreate',
'allowRead',
'allowEdit',
'allowDelete',
'allowTransfer',
'viewAllRecords',
'modifyAllRecords',
].sort(),
);
expect('allowRestore' in row).toBe(false);
expect('allowPurge' in row).toBe(false);
});

it('carries a stored legacy value through untouched rather than authoring it', async () => {
// What an older build of this editor wrote. Read the header note above
// before changing this: it pins today's posture, and the spec bump that
// lands the retirement is the change that replaces it with strip-on-load.
await renderSet({ a_account: { allowRead: true, allowRestore: true, allowPurge: true } });

// Not authorable: no control renders for either key…
expect(screen.queryByLabelText(/restore/i)).toBeNull();
expect(screen.queryByLabelText(/purge/i)).toBeNull();

// …and an unrelated edit does not silently delete them either.
fireEvent.click(screen.getByLabelText('a_account Create'));
const payload = await save();

const row = payload.objects.a_account;
expect(row.allowCreate).toBe(true);
expect(row.allowRestore).toBe(true);
expect(row.allowPurge).toBe(true);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@
* Set / Profile metadata item:
*
* • Top section — object-level CRUD + VAMA (View All / Modify All)
* + lifecycle (Transfer / Restore / Purge).
* + lifecycle (Transfer).
* • Lower section — field-level R/W for the fields of any object
* selected from the table above.
*
Expand DownExpand Up@@ -177,8 +177,11 @@ function getObjectActions(
{ key: 'allowEdit', short: 'U', tip: translate('perm.action.edit', locale) },
{ key: 'allowDelete', short: 'D', tip: translate('perm.action.delete', locale) },
{ key: 'allowTransfer', short: 'Tr', tip: translate('perm.action.transfer', locale) },
{ key: 'allowRestore', short: 'Re', tip: translate('perm.action.restore', locale) },
{ key: 'allowPurge', short: 'Pu', tip: translate('perm.action.purge', locale) },
// No `Re` (allowRestore) / `Pu` (allowPurge) columns: both keys are retired
// (objectui#6595 — see the tombstone on `ObjectPerm` in `permission-slice`
// for the full account and the M2 return path on objectstack#1883). They
// gated ObjectQL operations that have never existed, so every tick was a
// grant no runtime read. `allowTransfer` is enforced upstream and stays.
{ key: 'viewAllRecords', short: 'VA', tip: translate('perm.action.viewAll', locale) },
{ key: 'modifyAllRecords', short: 'MA', tip: translate('perm.action.modifyAll', locale) },
];
Expand DownExpand Up@@ -1013,7 +1016,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,

{/* Column legend — the matrix header cells already carry a native
`title` tooltip per column, but a hover-only affordance on
unfamiliar two-letter abbreviations (Tr/Re/Pu/VA/MA) is easy to
unfamiliar two-letter abbreviations (Tr/VA/MA) is easy to
miss. Spell them out once, up front. */}
<div className="px-6 py-2 border-b flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{OBJECT_ACTIONS.map((a) => (
Expand DownExpand Up@@ -1154,9 +1157,11 @@ function PermissionTable({
onOpenOwd,
}: PermissionTableProps) {
return (
// objectui#2600 B3 — the fixed columns (object + 9 CRUD + bulk) need ~960px;
// objectui#2600 B3 — the fixed columns (object + 7 CRUD + bulk) need ~960px;
// a min-width makes the enclosing overflow-auto container scroll instead of
// squishing the CRUD grid and clipping the Bulk column off the right edge.
// The min-width is deliberately unchanged by the two columns objectui#6595
// retired: it is a floor, so the grid simply has more room to breathe.
<table className="w-full min-w-[960px] text-sm">
<thead className="sticky top-0 bg-background border-b z-10">
<tr>
Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1144,8 +1144,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'perm.action.edit': 'Edit',
'perm.action.delete': 'Delete',
'perm.action.transfer': 'Transfer ownership',
'perm.action.restore': 'Restore deleted records (reserved — deletes are hard today)',
'perm.action.purge': 'Hard delete (purge)',
'perm.action.viewAll': 'View All Records (bypass sharing)',
'perm.action.modifyAll': 'Modify All Records (bypass sharing)',
'perm.col.object': 'Object',
Expand DownExpand Up@@ -3007,8 +3005,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'perm.action.edit': '编辑',
'perm.action.delete': '删除',
'perm.action.transfer': '转移所有者',
'perm.action.restore': '恢复已删除记录(预留 — 当前为硬删除)',
'perm.action.purge': '彻底删除',
'perm.action.viewAll': '查看所有记录(绕过共享规则)',
'perm.action.modifyAll': '修改所有记录(绕过共享规则)',
'perm.col.object': '对象',
Expand Down
24 changes: 22 additions & 2 deletions packages/app-shell/src/views/metadata-admin/permission-slice.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,8 +24,28 @@ export interface ObjectPerm {
allowEdit?: boolean;
allowDelete?: boolean;
allowTransfer?: boolean;
allowRestore?: boolean;
allowPurge?: boolean;
// `allowRestore` / `allowPurge` were REMOVED here (objectui#6595). Both gated
// `restore` / `purge` ObjectQL operations that have never existed — a
// dispatched restore/purge is denied unconditionally by the evaluator's
// fail-closed destructive-operation backstop — so the matrix authored two
// checkboxes no runtime has ever read. `@objectstack/spec` retired both keys
// as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
// 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
// enforce-or-remove).
//
// THE RETURN PATH: both keys come back with the M2 lifecycle initiative,
// whose restart is recorded upstream on objectstack#1883. Restoring them
// means restoring three things together — these fields, the columns in
// `PermissionMatrixEditor`, the rows in `previews/PermissionPreview` — and
// only alongside the operations that make them enforceable.
//
// A value written by an older editor is not modelled here and not authorable.
// It is carried through save untouched, as any key this editor does not model
// is (`PermissionSetDraft`'s index signature below states that rule for the
// record; `updateObjectPerm`'s spread applies it per row). Stripping it is
// NOT this change: the installed spec still accepts both keys, so a strip
// today would delete stored data the schema still honours. That belongs with
// the `@objectstack/spec` bump that lands the retirement.
viewAllRecords?: boolean;
modifyAllRecords?: boolean;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Pins that the Permission preview no longer renders the retired lifecycle
* bits `allowRestore` / `allowPurge`, nor lints over them (objectui#6595).
*
* The preview is the reviewer's read of a permission set, so it carried two
* columns and one sanity check ("Purge (hard delete) granted without Delete")
* for keys whose `restore` / `purge` ObjectQL operations have never existed —
* a dispatched restore/purge is denied unconditionally by the evaluator's
* fail-closed destructive-operation backstop. `@objectstack/spec` retired both
* as `retiredKey()` tombstones (objectstack#12497; maintainer ruling
* 2026-08-26 accepting objectstack#1883 recommendation B, ADR-0049
* enforce-or-remove); they return with the M2 lifecycle initiative, whose
* restart is recorded on objectstack#1883.
*
* The lint is the half worth stating: a warning over a key that can no longer
* be granted cannot fire for a real reason, but it CAN fire for a stored
* legacy value — telling a reviewer to go fix a grant the authoring surface no
* longer offers, with no control to fix it with.
*
* Every assertion here carries its falsification in the same render: the
* capability set is pinned whole (so a live column cannot go missing behind a
* green "no Restore column"), and the retired lint's removal is measured on a
* draft that still trips the lints that stayed.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PermissionPreview } from './PermissionPreview';

afterEach(cleanup);

/** Capability columns, in matrix order — the whole set, not a sample. */
const LIVE_CAPS = ['C', 'R', 'U', 'D', 'E', 'T', 'V*', 'M*'];

function renderPreview(objects: Record<string, unknown>) {
return render(
<PermissionPreview
type="permission"
name="sales_rep"
locale="en-US"
draft={{ name: 'sales_rep', label: 'Sales Rep', objects }}
/>,
);
}

describe('PermissionPreview · retired lifecycle keys (objectui#6595)', () => {
it('renders exactly the live capability columns — Restore and Purge are gone', () => {
renderPreview({ opportunity: { allowRead: true } });

const headers = screen.getAllByRole('columnheader').map((th) => th.textContent?.trim() ?? '');
// Object + capabilities + Scope.
expect(headers.slice(1, -1)).toEqual(LIVE_CAPS);
expect(screen.queryByTitle('Restore')).toBeNull();
expect(screen.queryByTitle('Purge')).toBeNull();
// Falsification: the neighbours of the two removed rows must survive —
// `allowTransfer` is enforced upstream and explicitly stays, and
// `allowExport` sits directly beside it (objectstack#4115 added it).
expect(screen.getByTitle('Transfer')).toBeTruthy();
expect(screen.getByTitle('Export')).toBeTruthy();
});

it('drops the "Purge without Delete" lint while the surviving lints still fire', () => {
// A stored legacy grant, exactly the shape that used to trip the lint:
// purge granted, delete not. Typed loosely because the key is retired —
// once the spec bump lands, `ObjectPermission` will not name it.
renderPreview({
opportunity: { allowRead: true, allowEdit: true, allowPurge: true, modifyAllRecords: true },
});

expect(screen.queryByText(/Purge \(hard delete\) granted without Delete/)).toBeNull();

// Falsification in the same render: the lints that stayed still fire, so
// the assertion above cannot pass merely because the banner is missing.
expect(screen.getByText(/Modify All without View All/)).toBeTruthy();
});

it('renders a stored legacy value as no column at all, not as a granted chip', () => {
renderPreview({ opportunity: { allowRead: true, allowRestore: true, allowPurge: true } });

const row = screen.getByText('opportunity').closest('tr')!;
// Object + 8 capabilities + Scope. A stale key adds no cell: it is not a
// capability this surface knows, so it renders nowhere rather than as an
// unlabelled grant.
expect(row.querySelectorAll('td')).toHaveLength(LIVE_CAPS.length + 2);
});
});
Loading
Loading