Skip to content
21 changes: 21 additions & 0 deletions .changeset/28584-studio-layered-reads-name-owner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@object-ui/app-shell": patch
---

Clause-②: no
Metadata-protocol: none

fix(app-shell): Studio and Setup read an item's layered view at the package that owns it

The layered view (`GET /meta/:type/:name/layers`) naming no package is refused `409 RESOURCE_CONFLICT` for a name two
installed packages hold, naming the holders and the `?package=` remedy. Reads in Studio and Setup still asked
it naming no package, so on such a deployment the list-view inspector, the Interfaces leaf and app, the Data pillar's
object, the Automations pillar's flow, the package OWD overview, the permission-set matrix, the embedded item editor and
the item page's reset, publish and discard re-reads failed, and the permission-set matrix read the refusal as "no set
known".

Each read now names the package the caller already reads and saves at: the Studio pillar's package (a read-only base's
own package, whose read serves its declared override), the permission matrix's package door, or the owner the edit
URL names (`?package=`). Where none is known, the read still names no package, and the server's refusal sentence is
shown as the load's error; the permission matrix no longer reads it as "no set known". No holder is picked on the
client.
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ export interface EmbeddedItemEditorProps {
onSaved?: (item: Record<string, unknown>) => void;
}

/**
* The parent's owning package, as the hosting edit page's URL names it
* (`?package=`, the metadata list's row link). Read when the request is
* issued, as that page's own save binding is. `all` is the list's
* every-package scope, not a package.
*/
function readParentOwnerPackage(): string | undefined {
try {
const p = new URLSearchParams(window.location.search).get('package');
return p && p !== 'all' ? p : undefined;
} catch {
return undefined;
}
}

export function EmbeddedItemEditor({
parentType,
parentName,
Expand Down Expand Up @@ -132,9 +147,15 @@ export function EmbeddedItemEditor({
// which the client resolves as every layer `null`. ⛔ That absence is
// never a body: an empty base would PUT a stub of the one item over
// the parent. With nothing readable, the save is refused.
// ADR-0048 — at the parent's owner, the package the hosting edit
// page's URL names (`?package=`); with none, the read names none, and
// a parent two packages hold is refused with the server's sentence,
// which the `catch` below shows.
const ownerPackageId = readParentOwnerPackage();
const layered = await client.layered<Record<string, unknown>>(
parentType,
parentName,
ownerPackageId ? { packageId: ownerPackageId } : {},
);
const published = layered.effective ?? layered.code;
if (!published) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,15 @@ function readObjectDefinition(
return (packageId ? client.withPreviewDrafts(true) : client).get<any>('object', objectName);
}

/**
* ADR-0048 — the layered read's refusal of a name two packages hold, asked
* naming no package: `409 RESOURCE_CONFLICT`, its message naming the holders
* and the `?package=` remedy. A read answers that code for nothing else.
*/
function isPackagelessLayeredRefusal(err: unknown): boolean {
return (err as { status?: unknown } | null)?.status === 409 && errorCodeIs(err, 'RESOURCE_CONFLICT');
}

/**
* Is this item backed by a **code-package artifact**? (objectui#4518, #4526)
*
Expand Down Expand Up @@ -749,6 +758,14 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// could only be refused (400), and there is nobody to list.
const [searchParams] = useSearchParams();
const showAssignments = !embedded && isEnvironmentScope(searchParams) && existsOnServer !== false;
// ADR-0048 — the package whose stack the layered reads below read: the
// door's `packageId`, else the owner the edit URL names (`?package=`, the
// metadata list's row link; `all` is that list's every-package scope, not a
// package). With neither, the read names no package, and a name two
// packages hold is refused with the server's sentence, shown as the load's
// error. ⛔ Never a holder picked here.
const routePackage = searchParams.get('package');
const layeredPackageId = packageId ?? (routePackage && routePackage !== 'all' ? routePackage : undefined);
// Which gate to NAME when the surface is locked (host > artifact > type).
// The artifact tier is the DECIDING one only where the type tier would have
// said yes: with both flags false the honest reason is still "this type has
Expand Down Expand Up @@ -916,7 +933,15 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
? readDraftForSave<{ item?: PermissionSetDraft } | PermissionSetDraft>(client, type, name, packageId)
: Promise.resolve(null);
const draftRead = draftReadForSave.then((read) => read?.answer ?? null);
const readLayered = () => client.layered<PermissionSetDraft>(type, name).catch(() => null);
const readLayered = () =>
client
.layered<PermissionSetDraft>(type, name, layeredPackageId ? { packageId: layeredPackageId } : {})
.catch((err: unknown) => {
// A name two packages hold, read naming neither: the server's
// refusal is this load's error, ⛔ never "no set known".
if (isPackagelessLayeredRefusal(err)) throw err;
return null;
});
// objectui#11799 — a set "+ New" created and nobody published has a
// draft and no layer, and `GET …/layers` answers 404 for it. Under the
// package door the layered envelope is used only without a pending
Expand Down Expand Up @@ -1041,7 +1066,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
return () => {
cancelled = true;
};
}, [client, type, name, packageId, publishNonce, resetDraftBaseline, reloadNonce, recordSetRead, forgetSetVersion]);
}, [client, type, name, packageId, layeredPackageId, publishNonce, resetDraftBaseline, reloadNonce, recordSetRead, forgetSetVersion]);

/* ── Lazy-load fields when an object is expanded ─────────── */
async function ensureFields(objectName: string) {
Expand Down Expand Up @@ -1450,7 +1475,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
let rereadFailed = false;
const [fresh, draftRead] = await Promise.all([
client
.layered<PermissionSetDraft>(type, payload.name)
.layered<PermissionSetDraft>(type, payload.name, { packageId })
// Kept on `.catch` so the rejection can never escape as an
// unhandled one; the refusal is raised below, inside this `try`'s
// own body, and reaches the author on the same error channel a
Expand Down Expand Up @@ -1537,7 +1562,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// write is already done, and refusing anything would be a lie.
let rereadFailed = false;
const lay = await client
.layered<PermissionSetDraft>(type, payload.name)
.layered<PermissionSetDraft>(type, payload.name, layeredPackageId ? { packageId: layeredPackageId } : {})
.catch(() => {
rereadFailed = true;
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#28584 — the permission matrix reads a set two packages hold at
* the package that owns it, and shows the server's refusal where it knows none.
*
* Since objectstack#28337, the layered view naming no package refuses a name
* whose active rows two packages hold: `409 RESOURCE_CONFLICT`, its message
* naming the holders and the `?package=` remedy. The editor read it naming no
* package and caught every failure to `null`, so on such a deployment it either
* failed or read the refusal as "no set known".
*
* The REAL editor over a REAL `MetadataClient`, whose transport is an
* in-memory server holding `sales_perms` in two packages and answering the
* layered read as the framework does: the holder's stack with `?package=`, the
* refusal without it.
*
* Pinned:
* - the package door reads its own package's stack, and shows its grants;
* - the routed page (no door) reads the owner its URL names, and shows that
* holder's grants;
* - with no owner known, the read names no package, and the server's refusal
* sentence is shown, ⛔ never a holder picked on the client.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataClient } from '@object-ui/data-objectstack';

const PKG_A = 'app.alpha';
const PKG_B = 'app.beta';
const SET = 'sales_perms';
const REFUSAL_TEXT =
`Name the package this layered read is for, then send it again: on the REST door add ?package= with the package id. ` +
`permission '${SET}' is held by '${PKG_A}' and '${PKG_B}'.`;

/** Each holder's published set: alpha grants Read, beta grants Create. */
const HOLDERS: Record<string, Record<string, unknown>> = {
[PKG_A]: { name: SET, label: 'Sales (alpha)', objects: { a_account: { allowRead: true, allowCreate: false } }, fields: {} },
[PKG_B]: { name: SET, label: 'Sales (beta)', objects: { a_account: { allowRead: false, allowCreate: true } }, fields: {} },
};

const requests: Array<{ method: string; path: string; search: string; status: number }> = [];

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = new URL(String(input), 'http://studio.test');
const pkg = url.searchParams.get('package');
let status = 200;
let body: unknown = [];
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
if (meta) {
const seg = meta[1].split('/').map(decodeURIComponent);
if (seg[0] === '_drafts') {
body = { drafts: [] };
} else if (seg.length === 1 && seg[0] === 'object') {
body = { items: [{ name: 'a_account' }] };
} else if (seg.length === 1 && seg[0] === 'permission') {
body = { items: pkg && HOLDERS[pkg] ? [HOLDERS[pkg]] : Object.values(HOLDERS) };
} else if (seg.length === 3 && seg[0] === 'permission' && seg[1] === SET && seg[2] === 'layers') {
const row = pkg ? HOLDERS[pkg] : undefined;
if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row };
else if (!pkg) [status, body] = [409, { success: false, error: { code: 'RESOURCE_CONFLICT', message: REFUSAL_TEXT } }];
else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }];
} else if (seg.length === 2 && url.searchParams.get('state') === 'draft') {
[status, body] = [404, { error: { code: 'NO_DRAFT', message: 'absent' } }];
} else {
[status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }];
}
}
requests.push({ method, path: url.pathname, search: url.search, status });
return json(status, body);
}

const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });

vi.mock('./useMetadata', () => ({
useMetadataClient: () => client,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: false, allowRuntimeCreate: true }],
}),
}));
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

/** The layered reads the editor sent: each one's query string and status. */
const layersReads = () =>
requests
.filter((r) => r.method === 'GET' && r.path === `/api/v1/meta/permission/${SET}/layers`)
.map((r) => ({ search: r.search, status: r.status }));

const granted = (action: 'Read' | 'Create') => screen.getByRole('checkbox', { name: `a_account ${action}` });

async function settle() {
await act(async () => {
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
});
}

beforeEach(() => {
requests.length = 0;
});
afterEach(cleanup);

describe('PermissionMatrixEditPage — a set two packages hold (objectstack#28584)', () => {
it('the package door reads its own package\'s stack and shows that holder\'s grants', async () => {
render(
<MemoryRouter>
<PermissionMatrixEditPage type="permission" name={SET} packageId={PKG_B} embedded />
</MemoryRouter>,
);
await screen.findByText('a_account', undefined, { timeout: 8000 });
await settle();

expect(layersReads()).toEqual([{ search: `?package=${PKG_B}`, status: 200 }]);
expect(granted('Read')).toHaveAttribute('aria-checked', 'false');
expect(granted('Create')).toHaveAttribute('aria-checked', 'true');
expect(screen.queryByText(REFUSAL_TEXT)).toBeNull();
});

it('the routed page reads the owner its URL names and shows that holder\'s grants', async () => {
render(
<MemoryRouter initialEntries={[`/metadata/permission/${SET}?package=${PKG_A}`]}>
<PermissionMatrixEditPage type="permission" name={SET} />
</MemoryRouter>,
);
await screen.findByText('a_account', undefined, { timeout: 8000 });
await settle();

expect(layersReads()).toEqual([{ search: `?package=${PKG_A}`, status: 200 }]);
expect(granted('Read')).toHaveAttribute('aria-checked', 'true');
expect(granted('Create')).toHaveAttribute('aria-checked', 'false');
expect(screen.queryByText(REFUSAL_TEXT)).toBeNull();
});

it('with no owner known, the read names no package and the server\'s refusal sentence is shown', async () => {
render(
<MemoryRouter initialEntries={[`/metadata/permission/${SET}?package=all`]}>
<PermissionMatrixEditPage type="permission" name={SET} />
</MemoryRouter>,
);
expect(await screen.findByText(REFUSAL_TEXT, undefined, { timeout: 8000 })).toBeInTheDocument();
await settle();

// `?package=all` is the list's every-package scope: no package is named,
// and the server, not the client, says which packages hold the set.
expect(layersReads()).toEqual([{ search: '', status: 409 }]);
expect(screen.queryByRole('checkbox', { name: 'a_account Read' })).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,11 @@ function MetadataResourceEditPageImpl({
// the canonical member and for a type with no members.
const bundleMember = searchParams.get(BUNDLE_MEMBER_PARAM) || undefined;
const memberScope = bundleMember ? { member: bundleMember } : {};
// ADR-0048 — every layered re-read below asks the stack the load read: at
// the owning package, when the URL names it. A layered read naming no
// package is refused for a name two packages hold, and the refusal is shown
// as the step's error.
const layeredScope = { ...(ownerPackageId ? { packageId: ownerPackageId } : {}), ...memberScope };
const client = useMetadataClient();
// objectstack#28680 — a page or dashboard this editor publishes for the
// first time, or deletes, writes app navigation here, at the site.
Expand Down Expand Up @@ -2179,7 +2184,7 @@ function MetadataResourceEditPageImpl({
void navigationWrites.deleted(type, name, deletedBody);
}
if (resetSemantic) {
const lay = await client.layered<any>(type, name, memberScope);
const lay = await client.layered<any>(type, name, layeredScope);
setLayered(lay);
const fresh = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
setDraft(fresh);
Expand Down Expand Up @@ -2225,7 +2230,7 @@ function MetadataResourceEditPageImpl({
// objectui#11773 — the draft is re-read at the address the next save
// writes, so its answer pins that save (see the load effect).
const [lay, draftRead] = await Promise.all([
client.layered<any>(type, name, memberScope),
client.layered<any>(type, name, layeredScope),
readDraftForSave<any>(client, type, name, activePackage, bundleMember),
]);
const draftResp = draftRead.answer;
Expand Down Expand Up @@ -2297,7 +2302,7 @@ function MetadataResourceEditPageImpl({
forgetDraftVersion();
// objectui#11799 — a discard makes no layer: an item this page loaded
// with no layer still has none, and `/layers` would answer 404.
const lay = layered && holdsNoLayer(layered) ? layered : await client.layered<any>(type, name, memberScope);
const lay = layered && holdsNoLayer(layered) ? layered : await client.layered<any>(type, name, layeredScope);
setLayered(lay);
const fresh = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
setDraft(fresh);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,12 +577,17 @@ export function useObjectListViewDraft({
!draftChanges.some((c) => c.path.length === 0);
// A failure of that added read costs the element marks only, never the
// editor: the draft is the buffer either way.
// ADR-0048 — the published layer is read at this package, the view's
// owner here: a layered read naming no package is refused for a name
// two packages hold. On a read-only base that is the base, whose read
// serves its declared override (ADR-0146 D8).
const layeredAt = { packageId };
const lay = objectDraftOnly
? null
: !draft
? await client.layered<Row>('view', viewId)
? await client.layered<Row>('view', viewId, layeredAt)
: marksNeedPublished
? await client.layered<Row>('view', viewId).catch(() => undefined)
? await client.layered<Row>('view', viewId, layeredAt).catch(() => undefined)
: null;
if (cancelled) return;
const published = (lay as { effective?: unknown; code?: unknown } | null)?.effective ?? (lay as { code?: unknown } | null)?.code;
Expand Down
Loading
Loading