Skip to content

Commit cd065d3

Browse files
fix(meta)!: the layered view naming no package refuses a name two packages hold, as the package-less writes do (#28583)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XRvSG4VMs8VNwfQoz5Bm3vn)_ Fixes #28337 Clause-②: no (narrowing) — `GET /meta/:type/:name/layers` and the deprecated `?layers=true` refuse a request with no `?package=` for a name whose active rows two or more packages hold, which they answered 200 before. Nothing is widened. Metadata-protocol: none Skills-docs: none — no page under `skills/**` describes the layered view, and the `content/docs` pages that name it (`ui/apps.mdx`, `plugins/adding-a-metadata-type.mdx`, `permissions/system-context.mdx`) say nothing about a name two packages hold, so no sentence there becomes false. **BREAKING** read narrowing at the layered view (`@objectstack/rest`'s shared layered chain, served by `RestServer` and the runtime dispatcher, both spellings). ## 维护者速读 - **改了什么**:同一个名字的元数据被两个(或更多)包各存一行时,不带 `?package=` 的 `GET /meta/:type/:name/layers`(以及已废弃的 `?layers=true`)不再静默挑其中一个包的那份返回 200,而是返回和不带包的保存/删除完全一样的 `409 RESOURCE_CONFLICT`,消息点名所有持有包并给出 `?package=` 处方。 - **为什么改**:卡 #28337(分诊 6539811535):同一个歧义,读接口静默挑一个、写接口 409 拒绝,Studio 和 agent 读到的是哪个包的、还有没有第二个包,都看不出来,接着保存又被拒。分诊裁定:读也照写的方式拒,一个歧义一个答案。 - **风险与回滚**:只影响「两个包都存了同名行、且请求不带 `?package=`」这一种读;带 `?package=` 读任一包、单包持有、只有无包行、包行旁边有无包行,全部照旧。普通条目读、`/published` 和协议的进程内 `getMetaItemLayered`(安全目录回显、权限集锁闸门在用)不变。回滚即 revert。 - **先看**:`packages/rest/src/meta-item-read-gate.ts`(`createMetaLayeredAnswer` 新增的 1c 步,在 gate 之前抛出协议的拒绝);`packages/metadata-protocol/src/protocol.ts`(新方法 `packagelessLayeredReadRefusal`,复用 `packagelessAddress` 与 `packagelessAddressAmbiguityRefusal`);`packages/runtime/src/domains/meta-layers-ambiguous-package.test.ts`(真实 SQLite,两个传输层对照各自的 PUT)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-layers-ambiguous-package.test.ts` → 4/4 通过(需先构建 runtime 依赖闭包)。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes **The judge** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.packagelessLayeredReadRefusal({ type, name, member? })` answers the refusal a layered read naming no package gets, or `undefined`. It reads the same resolution the package-less save and delete read (`packagelessAddress` at `active`, at the addressed bundle member, every row read in full) and, for two or more bindings, returns the writes' own refusal (`packagelessAddressAmbiguityRefusal`, `409 RESOURCE_CONFLICT`, holders sorted, `?package=` prescription first). That function gains one verb, `layered read`, so the sentence reads "Name the package this layered read is for, then send it again: on the REST door add ?package= with the package id, in process pass packageId. Nothing was served. dashboard 'twin_dash' belongs to 2 packages ('app.alpha_demo', 'app.beta_demo'), and a layered read that names none has no single row to show." No code, status or field is minted. **The door** (`@objectstack/rest`, `createMetaLayeredAnswer`, the one chain both transports and both spellings answer the layered view through). A new step 1c, after the absence step and the disabled-package step and BEFORE the per-caller gate: with no `?package=`, ask the new optional source `MetaItemReadGateSources.packagelessLayeredReadRefusal` and THROW what it answers. Thrown, so each transport answers it through the catch its own save door answers the writes' refusal through (`handleRouteError` on `RestServer`, `deps.errorFromThrown` on the dispatcher): one envelope per transport for one ambiguity, measured in the tests by comparing the read's answer with the same door's `PUT`. Before the gate so the answer never depends on which holder the silent pick met. `MetaLayeredRequest` gains an optional `member`, which both transports now pass (the member they read the layers at). **The wiring.** `RestServer.metaItemReadGateSources` and the dispatcher's `metaItemReadGateSources` hand the protocol's judge through when the protocol has one (`RestProtocol` gains it as an optional member; the runtime `MetaDomainProtocol` picks it from `ObjectStackProtocolImplementation`). A protocol without it refuses nothing, the answer before this PR. **Why the door and not `getMetaItemLayered`.** The protocol's layered read has in-process readers that read a name's layers with no package: the security catalog echo, the packaged-permission-set lock gate, and both transports' `/published`. Refusing inside it would have changed all of them; the chain's step 1b already made the same choice for a disabled package ("the protocol's layered read keeps serving every layer … the door answers here"). The ruling names `/layers`; the plain item read and `/published` keep their best-effort first match. **Scope of types.** The writes' refusal is type-agnostic (the direct save's address check runs for every type on a publish-mode authoring save; the active delete's for every type), and so is this one: it is asked of the same rows on every type the layered view serves, and of nothing else. Where the writes take an address — one package, the package-unbound row alone, a package's row with the package-unbound row beside it, nothing stored — the read answers as before (pinned). **Census pins updated.** `protocol.read-verb-canonical-fold.test.ts` derives every `/meta` verb that folds a required `type`; the new verb joins it ("all nineteen"). `execctx-consumer-census.test.ts`'s Proxy protocol answers every unknown method with a truthy object; it now answers the judge in its declared shape (`undefined`), as it already does for `getMetaItemLayered`, so the `/layers` row keeps reaching the answer it measures. ## Tests - **New:** `packages/metadata-protocol/src/protocol.packageless-layered-read-refusal.test.ts` (3: the refusal equals the package-less save's code and status, full sentence pinned, plural spelling folds; no refusal where the writes take an address; the in-process `getMetaItemLayered` still serves a two-holder name, and each `packageId` its own row). `packages/rest/src/meta-layers-ambiguous-package.test.ts` (3, real protocol and registry: both spellings 409 with the `PUT`'s and `DELETE`'s status, code and envelope keys, both holders and `?package=` named, nothing written; `?package=` reads each holder's stack; a one-holder name unchanged). `packages/runtime/src/domains/meta-layers-ambiguous-package.test.ts` (4, real better-sqlite3 store: the same on `RestServer` and through the real `HttpDispatcher.dispatch()` catch-all, each door's read compared with its own `PUT`; the dispatcher serves no item `DELETE`). - **Red first** (`ad08707e`, the REST test alone, with `rest-server.ts` and `meta-item-read-gate.ts` at base `efb1620c`): `expected { status: 200, code: undefined } to deeply equal { status: 409, …(1) }`, 1 failed | 2 passed. The two-holder read answered 200 with one holder's stack. - **Ablation** (`node scripts/ablation-replace.mjs`, anchor `if (ambiguity) throw ambiguity;` → `void ambiguity;` in `meta-item-read-gate.ts`): anchor 1 → 0, blob `ca95d53ac8ff` → `9b5f722a10ee`; `pnpm --filter @objectstack/rest build`, then `ablation-dist-preflight.mjs @objectstack/rest 'throw ambiguity' --absent` ✓ (marker absent from all 6 built files). REST test 1 failed | 2 passed; runtime test 2 failed | 2 passed (both doors' two-holder case; the `?package=` and one-holder controls green). Restore: blob == HEAD, `git diff HEAD` empty; rebuilt, preflight ✓ marker present in 2 built files, tree clean; runtime test 4/4. - **Package suites** (after merging `origin/main`, closure rebuilt): `pnpm --filter @objectstack/metadata-protocol test` Test Files 290 passed | 4 skipped (294), Tests 21897 passed | 26 skipped; `pnpm --filter @objectstack/rest test` Test Files 363 passed (363), Tests 6907 passed | 332 skipped (both at `b8f5998e`). `pnpm --filter @objectstack/runtime test` Test Files 500 passed (500), Tests 8095 passed | 19 skipped (on the tree committed as `913e9905`; `b8f5998e` adds only the engine-double ledger JSON). `typecheck` for the three packages exit 0. The first full run of the two suites, before the census fixes, had one red each: the verb census (`protocol.read-verb-canonical-fold.test.ts`) and the Proxy fake (`execctx-consumer-census.test.ts`, `/layers` answered 500); both are fixed in this PR as described above. ## Acceptance notes - **Consumers (Seam for the ui lane, no `ui/**` edit).** The Studio calls `/layers` without `?package=` from many surfaces (`MetadataClient.layered` in `ui/packages/data-objectstack/src/metadata-client.ts`, called package-less from `StudioDesignSurface.tsx`, `ObjectListViewInspector.tsx`, `PackageOwdOverviewPanel.tsx`, `PermissionMatrixEditor.tsx`, `ResourceEditPage.tsx` when no scope is set). In a single-install flow nothing changes: the refusal needs two packages' active rows of one name. Where two packages do hold the name, those surfaces now get the 409 (thrown by `parseError`, or swallowed where a call has `.catch(() => null)`) instead of one holder's stack; the save they would send next was already refused with the same code. Whether the Studio should pass the owning package there, or show the prescription, is the ui lane's call. `Seam: runtime:createMetaLayeredAnswer step 1c → renderer:MetadataClient.layered (package-less callers)`. - **New public members.** `ObjectStackProtocolImplementation.packagelessLayeredReadRefusal`, optional `RestProtocol.packagelessLayeredReadRefusal`, optional `MetaItemReadGateSources.packagelessLayeredReadRefusal`, optional `MetaLayeredRequest.member`. All additive; the narrowing is the wire answer. - **Main moved during the work** (`#28409`, the `.custom` served override). `packagelessAddress` is unchanged by it, so a base's declared override in its `.custom` pair beside the base's own row is two bindings, refused by the package-less save and now by this read alike. ## Verification All readings on head `b8f5998e` unless stated. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 71 families (the engine-double ledger under `scripts/` widened the first derivation's 63 by 8), each run with its exit code captured before any pipe; all exit 0. `--ran` reconciliation: `✓ dispatch-gates --ran: 71 derived famil(ies) accounted for — 71 run, 0 NOT-MEASURED`. First-read container prerequisites, re-read after meeting them: `check-plugin-teardown-shape.mjs --self-test` exit 3 → `git fetch --depth=1 origin 621a487…` → exit 0; `check:dual-build-cjs-loads` exit 3 → full `pnpm build --concurrency=2` → exit 0. `check:engine-double-contract` exit 1 on the first read (the two new pinned doubles unrecorded) → `--write`, the ledger diff is exactly those two files → exit 0. - **Hand gates:** `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0; `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD` exit 0; `node scripts/check-nul-bytes.mjs` exit 0; `pnpm exec eslint --no-inline-config` over the ten touched source and test files exit 0 (the repo-wide `pnpm lint` is CI's). - **Full dogfood** (owed by the `!` and the `(narrowing)` arm): after the `@objectstack/dogfood^...` closure build, under the lock, `pnpm --filter @objectstack/dogfood test` at `b8f5998e` → Test Files 363 passed | 3 skipped (366), Tests 2932 passed | 28 skipped (2960); no failure. - **Not re-merged:** `origin/main` moved after the last merge (`5c5ee140`); none of the ten later commits touches a file this diff touches. Open PR #28554 also edits `scripts/engine-double-contract.pinned.json` (a ledger `--write` regenerates it on a conflict) and `protocol.ts`. - **CI** families (Test Core, lint farm, type-check lanes) are read off this PR's check-runs by the seat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5o95tvF7A7y85W37iR --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5o95tvF7A7y85W37iR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 617dcf4 commit cd065d3

12 files changed

Lines changed: 796 additions & 7 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(meta)!: the layered view naming no package refuses a name two packages hold with the package-less writes' own `409 RESOURCE_CONFLICT`, instead of serving one holder's stack picked silently (#28337)
8+
9+
Clause-②: no (narrowing) — `GET /meta/:type/:name/layers` and the deprecated `?layers=true` refuse a request with no `?package=` for a name whose active rows two or more packages hold, which they answered `200` before. Nothing is widened.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no stored metadata changes: a read door now answers the refusal the package-less save and delete already answer for the same rows, with the same code, status and holder list. The remedy is the request's (name the package with ?package=), so there is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** read narrowing at the layered view (`@objectstack/rest`'s shared layered chain, served by `RestServer` and the runtime dispatcher, both spellings).
15+
16+
- **Before.** A `sys_metadata` row is keyed by type, name and package, so two installed packages can each hold an item of one name. For such a name, a save or a delete naming no package was refused `409 RESOURCE_CONFLICT` ("Name the package this save is for…", naming the holders), while the layered view naming no package answered `200` with whichever holder's row a package-agnostic lookup met first. The Studio editor and an agent read one holder's layers with no sign that a second holder existed, and the save that followed was refused.
17+
- **Now.** The layered view naming no package answers that same refusal, worded for the read: `409 RESOURCE_CONFLICT`, "Name the package this layered read is for, then send it again: on the REST door add ?package= with the package id…", naming every holder. Each transport answers it in the envelope its own save door answers the writes' refusal in.
18+
- **Unchanged.** `?package=` with any holder's id reads that holder's stack. A name one package holds, a name only the package-unbound row holds, and a package's row with the package-unbound row beside it answer as before. Every type the writes refuse on is refused here, and no other. The plain item read, `/published` and the protocol's in-process `getMetaItemLayered` (read by the security catalog echo and the packaged-permission-set lock gate) still serve a name two packages hold as they did.
19+
- **New export.** `ObjectStackProtocolImplementation.packagelessLayeredReadRefusal(request)` (`@objectstack/metadata-protocol`) answers the refusal or `undefined`; `RestProtocol` and `MetaItemReadGateSources` gain it as an optional member, and `MetaLayeredRequest` gains an optional `member`.
20+
- **Who sees it.** A client that reads `/layers` with no `?package=` for a name two installed packages both hold.
21+
22+
**The remedy.** Send the read with `?package=` naming the package whose stack you want, as the writes already require.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28337, ADR-0048] `packagelessLayeredReadRefusal`: the layered view's door
5+
* refusal for a name two or more packages hold, asked with no package. It is
6+
* the refusal the package-less save and delete answer for the same rows
7+
* (`packagelessAddressAmbiguityRefusal`, `409 RESOURCE_CONFLICT`, the holders
8+
* sorted and the `?package=` prescription), over the same resolution
9+
* (`packagelessAddress` at `active`).
10+
*
11+
* The layered read itself is unchanged: its in-process resolution readers
12+
* (the security catalog echo, the packaged-permission-set lock gate,
13+
* `/published`) read a name's layers with no package and keep the answer they
14+
* had. The door asks the judge (`createMetaLayeredAnswer` in `@objectstack/rest`).
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
19+
import { ObjectStackProtocolImplementation } from './protocol.js';
20+
21+
const PKG_A = 'app.alpha_demo';
22+
const PKG_B = 'app.beta_demo';
23+
24+
type Row = Record<string, any>;
25+
26+
const rowKey = (w: Row) =>
27+
[w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|');
28+
29+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
30+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
31+
? (want as { $in: unknown[] }).$in.includes(actual)
32+
: actual === want;
33+
34+
const matchesWhere = (row: Row, where: Row) =>
35+
Object.entries(where).every(([k, v]) => {
36+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
37+
if (v === null) return row[k] === null || row[k] === undefined;
38+
return whereValueMatches(row[k], v);
39+
});
40+
41+
/** An in-memory `sys_metadata` behind the REAL protocol, with no registry items. */
42+
function makeProtocol() {
43+
const rows = new Map<string, Row>();
44+
let nextId = 0;
45+
const findRow = (w: Row) => {
46+
for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r };
47+
return null;
48+
};
49+
const registry: any = {
50+
getItem: () => undefined,
51+
getArtifactItem: () => undefined,
52+
listItems: () => [],
53+
getObject: () => undefined,
54+
isPackageDisabled: () => false,
55+
isObjectPackageDisabled: () => false,
56+
getAllPackages: () => [{ manifest: { id: PKG_A } }, { manifest: { id: PKG_B } }],
57+
getPackage: (id: string) => ({ manifest: { id } }),
58+
};
59+
const engine: any = {
60+
registry,
61+
async findOne(table: string, o: { where: Row }) {
62+
assertEngineFindOnePredicate(table, o);
63+
if (table !== 'sys_metadata') return null;
64+
return findRow(o.where)?.row ?? null;
65+
},
66+
async find(table: string, o: { where: Row; limit?: number }) {
67+
if (table !== 'sys_metadata') return [];
68+
const matched = Array.from(rows.values()).filter((r) => matchesWhere(r, o.where));
69+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
70+
},
71+
async insert(table: string, data: Row) {
72+
if (table !== 'sys_metadata') return { id: `rec_${++nextId}` };
73+
const row = { id: `r_${++nextId}`, ...data };
74+
rows.set(rowKey(data), row);
75+
return { id: row.id };
76+
},
77+
async update(table: string, data: Row, o: { where: Row }) {
78+
assertEngineUpdateDispatch(data, o);
79+
if (table !== 'sys_metadata') return { id: null };
80+
const found = findRow(o.where);
81+
if (!found) return { id: null };
82+
const merged = { ...found.row, ...data };
83+
rows.delete(found.key);
84+
rows.set(rowKey(merged), merged);
85+
return { id: merged.id };
86+
},
87+
async delete(table: string, o?: Row) {
88+
assertEngineDeleteDispatch(o);
89+
if (table !== 'sys_metadata') return { deleted: 0 };
90+
const found = findRow((o?.where ?? {}) as Row);
91+
if (!found) return { deleted: 0 };
92+
rows.delete(found.key);
93+
return { deleted: 1 };
94+
},
95+
async syncObjectSchema() { /* no physical storage in this double */ },
96+
};
97+
return new ObjectStackProtocolImplementation(engine, undefined, 'env_test');
98+
}
99+
100+
/** Store one dashboard row per package named (`null`: the package-unbound row). */
101+
async function store(protocol: ObjectStackProtocolImplementation, name: string, packages: Array<string | null>) {
102+
for (const pkg of packages) {
103+
await protocol.saveMetaItem({
104+
type: 'dashboard', name, item: { name, label: `${pkg ?? 'local'} copy`, widgets: [] },
105+
...(pkg ? { packageId: pkg } : {}),
106+
} as any);
107+
}
108+
}
109+
110+
/** What a rejected promise threw. */
111+
const thrown = async (p: Promise<unknown>): Promise<any> => {
112+
try {
113+
await p;
114+
} catch (e) {
115+
return e;
116+
}
117+
throw new Error('expected a refusal; the call resolved');
118+
};
119+
120+
describe('[#28337] packagelessLayeredReadRefusal', () => {
121+
it('a name two packages hold: the package-less save\'s refusal, worded for the layered read', async () => {
122+
const protocol = makeProtocol();
123+
await store(protocol, 'twin_dash', [PKG_B, PKG_A]);
124+
const refusal = await protocol.packagelessLayeredReadRefusal({ type: 'dashboard', name: 'twin_dash' });
125+
const saveRefusal = await thrown(protocol.saveMetaItem({
126+
type: 'dashboard', name: 'twin_dash', item: { name: 'twin_dash', label: 'edited', widgets: [] },
127+
} as any));
128+
expect(refusal).toBeInstanceOf(Error);
129+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: saveRefusal.code, status: saveRefusal.status });
130+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'RESOURCE_CONFLICT', status: 409 });
131+
expect(refusal!.message).toBe(
132+
'Name the package this layered read is for, then send it again: on the REST door add ?package= with the '
133+
+ 'package id, in process pass packageId. Nothing was served. dashboard \'twin_dash\' belongs to 2 packages '
134+
+ `('${PKG_A}', '${PKG_B}'), and a layered read that names none has no single row to show.`,
135+
);
136+
// The holder list is the save's, sorted.
137+
expect(saveRefusal.message).toContain(`('${PKG_A}', '${PKG_B}')`);
138+
// The plural URL spelling is the same item.
139+
expect((await protocol.packagelessLayeredReadRefusal({ type: 'dashboards', name: 'twin_dash' }))?.message)
140+
.toBe(refusal!.message);
141+
});
142+
143+
it('no refusal where the package-less writes take an address: one package, the package-unbound row beside it, or none', async () => {
144+
const protocol = makeProtocol();
145+
await store(protocol, 'solo_dash', [PKG_A]);
146+
await store(protocol, 'local_beside', [PKG_A, null]);
147+
await store(protocol, 'local_dash', [null]);
148+
for (const name of ['solo_dash', 'local_beside', 'local_dash', 'no_such_dash']) {
149+
expect(await protocol.packagelessLayeredReadRefusal({ type: 'dashboard', name }), name).toBeUndefined();
150+
}
151+
});
152+
153+
it('the layered read itself still serves its in-process readers a name two packages hold', async () => {
154+
const protocol = makeProtocol();
155+
await store(protocol, 'twin_dash', [PKG_A, PKG_B]);
156+
const layered = await protocol.getMetaItemLayered({ type: 'dashboard', name: 'twin_dash' });
157+
expect(layered.overlay).toMatchObject({ name: 'twin_dash' });
158+
for (const pkg of [PKG_A, PKG_B]) {
159+
const scoped = await protocol.getMetaItemLayered({ type: 'dashboard', name: 'twin_dash', packageId: pkg });
160+
expect(scoped.overlay).toMatchObject({ name: 'twin_dash', label: `${pkg} copy` });
161+
}
162+
});
163+
});

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
210210
}
211211

212212
describe('#9157 — the population, re-derived from the code rather than from the card', () => {
213-
it('every `/meta` request-boundary verb with a required `type` calls the fold — all eighteen', () => {
213+
it('every `/meta` request-boundary verb with a required `type` calls the fold — all nineteen', () => {
214214
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
215215
// this shape have shipped short before, so the set is DERIVED here and
216216
// the derivation is the pin: a tenth verb arriving unfolded turns this
@@ -326,6 +326,9 @@ describe('#9157 — the population, re-derived from the code rather than from th
326326
// [#20679] The locked-base verdict a second write door asks — it
327327
// takes the type a caller names, so it folds at the producer too.
328328
'packagedBaseRefusal',
329+
// [#28337] The layered view's door refusal for a name several
330+
// packages hold — the door hands it the type the request named.
331+
'packagelessLayeredReadRefusal',
329332
'publishMetaItem',
330333
'rollbackMetaItem',
331334
'saveMetaItem',

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11703,6 +11703,45 @@ export class ObjectStackProtocolImplementation implements
1170311703
};
1170411704
}
1170511705

11706+
/**
11707+
* [#28337, ADR-0048] The layered view's door refusal: what
11708+
* `GET /meta/:type/:name/layers` (and the deprecated `?layers=true`)
11709+
* answers when it names no package and the item's ACTIVE rows are bound
11710+
* to two or more packages. The refusal the package-less save and delete
11711+
* answer for the same rows ({@link packagelessAddressAmbiguityRefusal},
11712+
* `409 RESOURCE_CONFLICT`, the holders sorted and the `?package=`
11713+
* prescription), asked of the same resolution
11714+
* ({@link packagelessAddress} at `active`, at the addressed bundle member,
11715+
* read in full): one answer to one ambiguity. `undefined` for a name one
11716+
* package holds, none does, or only the package-unbound row does, and for
11717+
* every request that names a package (the caller asks only without one).
11718+
*
11719+
* Before, {@link getMetaItemLayered} naming no package took its overlay
11720+
* from {@link packagelessReadAddress}, which answers no package for such
11721+
* a name, so the layer was the first row a package-agnostic lookup met:
11722+
* one holder's stack, picked silently, while the Studio editor that loads
11723+
* this view and the save it then sends were refused for the same name.
11724+
*
11725+
* Asked by the DOOR (the shared layered chain in `@objectstack/rest`,
11726+
* `createMetaLayeredAnswer`), ⛔ never inside {@link getMetaItemLayered}:
11727+
* its in-process resolution readers (the security catalog echo, the
11728+
* packaged-permission-set lock gate, `/published`) read the layers of a
11729+
* name with no package and keep the answer they had.
11730+
*/
11731+
async packagelessLayeredReadRefusal(request: {
11732+
type: string;
11733+
name: string;
11734+
/** The bundle member the layered read addresses ({@link resolveBundleMemberSelector}); omitted, the canonical one. */
11735+
member?: string;
11736+
}): Promise<(Error & { code: 'RESOURCE_CONFLICT'; status: 409 }) | undefined> {
11737+
const { type, name } = canonicalizeMetaRequestType(request);
11738+
const member = resolveBundleMemberSelector(type, request.member);
11739+
const address = await this.packagelessAddress(PLURAL_TO_SINGULAR[type] ?? type, name, member);
11740+
return address.candidates.length > 1
11741+
? packagelessAddressAmbiguityRefusal('layered read', type, name, address.candidates)
11742+
: undefined;
11743+
}
11744+
1170611745
/**
1170711746
* Phase 3a-layered-get: return the 3 layers of a metadata item
1170811747
* separately — `code` (artifact-loaded baseline), `overlay` (per-org

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,12 @@ export function versionNotFoundError(
563563
* the same request in different words. [#23495] And the per-item draft
564564
* publish (`publish`) and the draft discard (`delete`) that name no package,
565565
* whose draft address the protocol resolves as the draft save's
566-
* (`ObjectStackProtocolImplementation.packagelessDraftAddress`).
566+
* (`ObjectStackProtocolImplementation.packagelessDraftAddress`). [#28337] And
567+
* the layered view naming no package (`layered read`: `GET /meta/:type/:name/layers`
568+
* and the deprecated `?layers=true`), which served one holder's overlay,
569+
* picked silently, for the address these writes refuse: its door asks
570+
* `ObjectStackProtocolImplementation.packagelessLayeredReadRefusal`, which
571+
* answers this refusal over the same rows the direct save reads.
567572
*
568573
* A `sys_metadata` row is keyed `(org, type, name, package_id)`, so such a
569574
* write has no one row to write or remove. Before this refusal the save wrote
@@ -590,7 +595,7 @@ export function versionNotFoundError(
590595
* predicate.
591596
*/
592597
export function packagelessAddressAmbiguityRefusal(
593-
verb: 'save' | 'delete' | 'publish',
598+
verb: 'save' | 'delete' | 'publish' | 'layered read',
594599
type: string,
595600
name: string,
596601
packageIds: readonly string[],
@@ -601,7 +606,9 @@ export function packagelessAddressAmbiguityRefusal(
601606
? ['Nothing was written.', 'no single row to write']
602607
: verb === 'publish'
603608
? ['Nothing was published.', 'no single draft to promote']
604-
: ['Nothing was deleted.', 'no single row to remove'];
609+
: verb === 'layered read'
610+
? ['Nothing was served.', 'no single row to show']
611+
: ['Nothing was deleted.', 'no single row to remove'];
605612
const err = new Error(
606613
`Name the package this ${verb} is for, then send it again: on the REST door add ?package= with the package id, `
607614
+ `in process pass packageId. ${nothing} ${type} '${name}' belongs to ${packageIds.length} packages `

‎packages/rest/src/execctx-consumer-census.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ function metaProtocol(doc: any) {
216216
type: doc.type, name: doc.name, code: doc, overlay: null, overlayScope: null, effective: doc,
217217
}));
218218
}
219+
// [#28337] The layered chain's ambiguity judge in its own shape:
220+
// `undefined`, one holder. The generic answer below is a truthy
221+
// non-refusal the chain would throw as a fault (500), so the
222+
// `/layers` row would stop reaching the answer it measures.
223+
if (k === 'packagelessLayeredReadRefusal') return vi.fn(async () => undefined);
219224
return vi.fn(async () => ({ ok: true, rows: [], data: [], items: [doc], total: 1 }));
220225
},
221226
});

0 commit comments

Comments
 (0)