Skip to content

Commit 09ef77c

Browse files
fix(plugin-security): the permission-set echo names its runtime package, so an isDefault set records its everyone suggestion (#23258)
Refs #23233 (the environment-kernel echo; the host-kernel producer remains) Clause-②: no — the projection keeps the package owner the protocol read already serves (ADR-0090 D5: packages suggest, admins confirm); no published schema or export moves. ## What this does A permission set marked `isDefault: true` and published into a writable runtime package with no `packageId` in its body now records its `everyone` suggestion on an environment kernel. The security catalog and the protocol's by-name read now name the same owner for it. ADR-0090 D5, verbatim: > **Packages suggest, admins confirm.** `isDefault: true` on a package permission set produces an install-time prompt ("CRM suggests adding `crm_readonly` to Everyone — accept?"). It is **never** auto-bound: installing a package must not silently widen every tenant user's access. - **`permission-set-projection.ts`.** The projection's in-memory echo of a tenant-authored set (lock verdict `org`) now carries `_packageId`, the package its stored row is bound to. The value is the `_packageId` the protocol's by-name read (`getMetaItem`) serves, and nothing here derives an owner. A `packaged` or `unknown` verdict carries none, as before. - **One read of the binding.** `readServedPackageBinding` is a new module-level function. The data door's update leg (#21861) already made this exact read inline, and it now calls the same function. Its behaviour is unchanged. - **A failed read** registers the echo unowned, which is today's behaviour. One `warn` names what is missing and what restores it. - **Untouched:** the suggestion collector and `security-catalog.ts`. The catalog takes its owner from `_packageId`, and the collector reads the catalog. ### Hypotheses, as measured - **H1 is half right.** `stripDecorations` does remove `_packageId`, but the body it is handed (the layered read's `overlay` layer, the stored document) never carried one. Keeping `_packageId` through the strip would change nothing. The owner has to be read from the protocol, which is what the by-name read serves. - **H2 holds on an environment kernel only.** There, the protocol hydrates no stored row into the engine registry (`applyRegistryWriteThrough` returns before the hydrator when the kernel has an `environmentId`). So the catalog reads the metadata service, whose only body for the set is the echo. On a host kernel, the catalog reads the engine registry's hydrated copy first. See the first acceptance note. - **H3:** the publish door's resolved binding reaches the projection only through the protocol: the projector event carries no package. `getMetaItem` stamps it from the served row's `package_id`, the same row `getMetaItemLayered` serves as `overlay`. - **H5 holds.** A code package's set has no overlay row, so it has no echo. The catalog serves its artifact from the engine registry with the artifact's owner. This is pinned below. ## H4 — the row keeps `managed_by: 'admin'` and no `package_id` This deviates from triage's direction text, which carries the binding "into `package_id` and the managed-by marker". The suggestion pin and the owner-agreement pin do not need the row, and writing the row moves edit doors. Read from the tree at `06dbc4e59d`: - **`assertPackageManagedWriteGate` (`security-plugin.ts`)** refuses `restore`, `transfer` and `purge` on a `managed_by: 'package'` row, by id or by filter. Today those succeed on a runtime-package set's admin row. Writing the marker would narrow them. - **`retirePermissionSetRecord`** leaves a package-owned record in place on a metadata delete ("left to the package door"). Deleting a set from a runtime package through the metadata door would leave a record with no definition behind it, until the package is uninstalled. Today that record is retired. - **`upsertEnvPermissionSet`** stamps `customized: true` on a package row backed by an overlay. A runtime-package set's definition IS its stored row, so Setup would badge it "customized" and offer a reset. - **The C3 stage 6b-2 landing (`c8a9783eca`)** left no writer of `managed_by: 'package'` rows. Writing the marker here would bring that writer class back. So the edit surface is unchanged, and the pins measure it: the layered read still reports `lock: 'none'`, `editable: true` and `deletable: true`; the lock classifier still answers `org` with the owner-carrying echo as its code layer (the echo marker keeps it from reading as an artifact); an edit through the metadata door still lands; and the row is still `managed_by: 'admin'` with a null `package_id`. ## Measured on a booted showcase, before and after This was a scratch probe, not committed. It boots the showcase, saves an `isDefault: true` draft into `app.probe_NAME` with no body `packageId`, runs `publishPackageDrafts`, then reads. The environment kernel was emulated by setting the protocol's `environmentId` before the publish (the tenant shape). `main` is `06dbc4e59d`. | read | `main` | this branch | |---|---|---| | `getMetaItem` item `_packageId` | the package | the package | | `getMetaItemLayered` `packageId` | absent | the package | | catalog, environment kernel | source `metadata`, no owner | source `metadata`, the package | | suggestions, environment kernel | none | one, `pending`, under the package (written by the post-publish reconcile) | | catalog, host kernel, right after the publish | source `registry`, no owner | unchanged (first acceptance note) | | `sys_permission_set` row | `managed_by: admin`, `package_id: null` | unchanged | | `editable` / `deletable` / `lock` | `true` / `true` / `none` | unchanged | ## The pins: `permission-set-echo-package-owner.test.ts` The harness is a REAL `ObjectStackProtocolImplementation` (aliased to source) over a fake engine, constructed with an `environmentId`. The package door is real (`saveMetaItem` draft, then `publishPackageDrafts`), as are `createSecurityCatalogReader` and `syncAudienceBindingSuggestions`. - Publish a runtime package's `isDefault` set without a body `packageId`: one pending `everyone` suggestion, under that package. Anti-vacuity: the engine registry holds no copy of the set. - The catalog (`resolve` and `list`, source `metadata`) and `getMetaItem` name the same owner. - The edit surface does not move (H4). - **Controls:** a code package's set is unchanged; a set saved into no package stays unowned and records no suggestion; a failed binding read leaves the echo unowned and warns once. ## Negative-pin ablation (at `2f35a44309`) The owner carry was replaced by a comment through `scripts/ablation-replace.mjs`, which traps the restore. The anchor went 1 → 0 and the blob went `251b44ecf505` → `5c7759d56da9`. Then the pin file ran: ```text × records ONE pending everyone suggestion, under the package the publish door bound it to × the security catalog and the protocol's by-name read name the same owner × the owner-carrying echo is still not a shipped artifact: the lock answers org, the item stays editable, and an edit lands AssertionError: expected [] to deeply equal [ ObjectContaining{…} ] AssertionError: expected { type: 'permission', …(3) } to match object { source: 'metadata', …(1) } AssertionError: expected { …(6) } to match object { …(2) } Tests 3 failed | 3 passed (6) ablation-replace: ok restored: blob == HEAD (251b44e) and `git diff HEAD` is empty ``` The three controls stayed green. The direction is the expected one: red. ## What remains on #23233 A host kernel (no `environmentId`) still serves a runtime package's freshly published `isDefault` set as unowned until a `getMetaItems` list read. The cause is a second producer of the same unowned read, in `metadata-protocol`: `applyRegistryWriteThrough` → `hydrateOverlayIntoRegistry` registers the published body in the engine registry without stamping its row's package binding as `_packageId`, while `getMetaItems` stamps it before hydrating. The security catalog reads the engine registry first. That half lands in another package and lane, so the seat routes it as the card's remainder. This PR covers the measured topology of the card's report: the environment kernel's echo. ## Acceptance notes - **Host kernel: a second producer drops the binding (finding, not fixed here).** On a kernel with no `environmentId`, the publish's registry write-through (`applyRegistryWriteThrough`, `@objectstack/metadata-protocol`) hydrates the stored row into the engine registry WITHOUT stamping its `package_id` as `_packageId`. The list read (`getMetaItems`) does stamp it before it hydrates. Until the next `permission` list read, the catalog (registry first) serves the set unowned and no suggestion is recorded. The probe measured `created: 0` right after the publish and `created: 1` after one `getMetaItems`. That file is outside this card's surface and is reported for filing. - **A body `packageId` that disagrees with the binding.** The door accepts it: the probe published `packageId: 'app.probe_other'` into `app.probe_bound` with outcome `published`. Before this change, on an environment kernel, the suggestion went to the body's package while `getMetaItem` named the binding. With this change it goes to the binding, which the host kernel already did after a list read. A suggestion that was recorded under the body's package is pruned while pending, and withheld from `everyone`, with its reason, if it was accepted. That follows from the collector's and the derivation's code and was not measured end to end. The door accepting the disagreement is reported as a finding. - **Boot cost.** Reconciliation now makes one `getMetaItem` per env-authored overlay name, beside the `getMetaItemLayered` it already made. A live save or publish makes one per mutation. NOT MEASURED against a remote database. - **A disabled runtime package.** The catalog does not serve an owned item whose package the engine registry reports disabled. On an environment kernel that rule now reaches runtime-package sets, as it already did on a host kernel after a list read. NOT MEASURED: whether a runtime package can be disabled in the registry's package map. ## Verification - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 189 files, 3869 passed, 45 skipped. - `pnpm --filter @objectstack/plugin-security typecheck` exits 0, and the test layer compiles under `tsconfig.test.json` with an empty debt ledger. - **Gate union at `2f35a44309`:** 75 families derived by `node scripts/pm/dispatch-gates.mjs --commands` (no paths), all 75 run, each `:: exit 0`; `--ran` reconciliation: "75 run, 0 NOT-MEASURED (a DERIVED zero)". `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (eight unrelated packages had no `dist/`); they were restored from the turbo cache and it then passed. The derivation warns the tree is behind `origin/main` on `scripts/pm/ci-failure.mjs`, which this diff does not touch. - `pnpm exec eslint --no-inline-config --format json` over the two changed TypeScript files: 2 files, 0 errors, 0 warnings. Both resolve a config (`--print-config`), and `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. - `check:engine-double-contract` asked for the new pin file's three pinned doubles to be recorded. `scripts/engine-double-contract.pinned.json` gains those three rows and nothing else (`--write`: "0 added or grown, 0 lost"). --- _Generated by [Claude Code](https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7958bdf commit 09ef77c

4 files changed

Lines changed: 388 additions & 7 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a permission set published into a runtime package names that package to the security catalog, so its `isDefault: true` records an `everyone` suggestion
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** A writable runtime package's permission set marked `isDefault: true`, published through the package door with no `packageId` in its body, recorded no audience-binding suggestion on an environment kernel, so ADR-0090 D5's "packages suggest, admins confirm" never happened for it. On that kernel the protocol hydrates no stored row into the engine registry, so the security catalog finds the set in the metadata service. The only body there is the permission-set projection's in-memory copy, and that copy carried no `_packageId`. The catalog served the set as unowned while `GET /api/v1/meta/permission/NAME` named the package, and the suggestion collector skipped it.
10+
- **What changed.** The projection's copy of a tenant-authored set now names the package its stored row is bound to. It reads that binding from the protocol's by-name read (the `_packageId` that read serves) and derives none of its own. The catalog, the by-name read and the layered read (`?layers=true`, whose `packageId` comes from the same copy) now name the same owner. `GET /api/v1/security/suggested-bindings` lists one pending suggestion under that package. A `manage_metadata` holder still has to accept it: the publish grants nothing.
11+
- **When the body's `packageId` disagrees.** A set whose body declares a `packageId` other than the package it is published into is now owned, for the catalog and the suggestions, by the package it is published into, which is what the by-name read already served. On an environment kernel, a suggestion recorded under the body's package is pruned at the next sync while pending, and withheld from `everyone` with its reason logged if it was accepted. A pending suggestion under the publishing package replaces it.
12+
- **Unchanged.** The `sys_permission_set` row (still `managed_by: 'admin'`, with no `package_id`), the packaged-permission-set lock and every edit door. A code package's set, whose owner the catalog reads from its artifact. A set saved into no package, which stays unowned and records no suggestion. A copy of a code-shipped set's legacy overlay, which carries no owner, as before.
13+
- **If the binding cannot be read**, the copy is registered without an owner, as before, and one warning says so. It still enforces the new body, and the next save, publish or boot reads the binding again.
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23233 — the permission-set projection's registry echo names the package
5+
* its stored row is bound to, so a set published into a writable runtime
6+
* package records its `everyone` suggestion (ADR-0090 D5: "packages suggest,
7+
* admins confirm"), and the security catalog and the protocol's by-name read
8+
* name the same owner.
9+
*
10+
* ## The topology, and why it is the one under test
11+
*
12+
* A REAL `ObjectStackProtocolImplementation` (aliased to the producer's source
13+
* — see `vitest.config.ts`) over a fake engine, constructed with an
14+
* `environmentId`: an ENVIRONMENT kernel, the shape a hosted tenant runs. On
15+
* that topology the protocol hydrates no stored row into the engine registry
16+
* (`applyRegistryWriteThrough` returns before the hydrator when the kernel has
17+
* an environment), so the security catalog finds the set in the metadata
18+
* service — and the only body the metadata service holds for it is the
19+
* projection's echo. The catalog takes its owner from `_packageId` alone
20+
* (`ownerOf`, `@objectstack/core`); the echo used to arrive without one, so
21+
* the set was served unowned and the suggestion collector skipped it.
22+
*
23+
* The publish is the real package door (`publishPackageDrafts`) over a draft
24+
* saved into the runtime package, with no `packageId` in the body — the input
25+
* the card measured on a tenant kernel. The catalog is the real
26+
* `createSecurityCatalogReader`, and the suggestion pass is the real
27+
* `syncAudienceBindingSuggestions`.
28+
*/
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { bindSecurityCatalogReader, createSecurityCatalogReader } from '@objectstack/core';
32+
import {
33+
assertEngineDeleteDispatch,
34+
assertEngineFindOnePredicate,
35+
assertEngineUpdateDispatch,
36+
} from '@objectstack/metadata-core';
37+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
38+
import { projectPermissionMutation, registerPermissionSetProjection } from './permission-set-projection.js';
39+
import { syncAudienceBindingSuggestions } from './suggested-audience-bindings.js';
40+
import { declareEveryoneBaseline } from './builtin-positions.js';
41+
import { classifyPackagedPermissionSet } from './packaged-permission-set-lock.js';
42+
43+
/** A writable runtime package — never installed as code, so nothing ships its sets. */
44+
const RUNTIME_PKG = 'app.crm_workspace';
45+
/** The runtime package's default set: `isDefault: true`, no `packageId` in the body. */
46+
const RUNTIME_SET = 'workspace_member_default';
47+
/** An installed code package, and the default set its artifact ships. */
48+
const CODE_PKG = 'com.example.crm';
49+
const CODE_SET = 'crm_readonly';
50+
const SECURITY_PACKAGE = 'com.objectstack.plugin-security';
51+
const DEFAULT_ORG = 'org_default';
52+
53+
type Row = Record<string, any>;
54+
55+
/** Does `row` satisfy `where`? Equality (null matches absent), `$in`, and `$and`/`$or`. */
56+
function matches(row: Row, where: Record<string, any> | undefined): boolean {
57+
return Object.entries(where ?? {}).every(([key, want]) => {
58+
if (key === '$and') return (want as any[]).every((w) => matches(row, w));
59+
if (key === '$or') return (want as any[]).some((w) => matches(row, w));
60+
if (want && typeof want === 'object' && !Array.isArray(want)) {
61+
if (Array.isArray(want.$in)) return want.$in.includes(row[key]);
62+
if ('$ne' in want) return (row[key] ?? null) !== want.$ne;
63+
throw new Error(`fake engine: unsupported operator on '${key}': ${Object.keys(want).join(',')}`);
64+
}
65+
return want === null ? (row[key] ?? null) === null : row[key] === want;
66+
});
67+
}
68+
69+
/**
70+
* The fake engine, which is also the `ql` the security plugin projects
71+
* through. Its engine registry holds the code package's artifact and the
72+
* positions; on an environment kernel no stored row is hydrated into it, so
73+
* `registered` records anything else that tries to land there.
74+
*/
75+
function makeEngine() {
76+
const tables: Record<string, Row[]> = {
77+
sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }],
78+
};
79+
const codeArtifacts: Row[] = [
80+
{ name: CODE_SET, label: 'CRM read-only', isDefault: true, objects: {}, _packageId: CODE_PKG, _provenance: 'package' },
81+
];
82+
const positions = new Map<string, Row>();
83+
const registered: Array<{ type: string; name: unknown }> = [];
84+
const registry = {
85+
registered,
86+
listItems: (type: string) => (type === 'permission' ? codeArtifacts : type === 'position' ? [...positions.values()] : []),
87+
getItem: (type: string, name: string) => registry.listItems(type).find((item) => item.name === name),
88+
getArtifactItem: (type: string, name: string) =>
89+
(type === 'permission' ? codeArtifacts.find((item) => item.name === name) : undefined),
90+
isPackageDisabled: () => false,
91+
getPackage: () => undefined,
92+
getAllPackages: () => [],
93+
registerItem: (type: string, item: Row, _key: string, packageId?: string) => {
94+
if (type === 'position') positions.set(String(item.name), { ...item, _packageId: packageId });
95+
else registered.push({ type, name: item?.name });
96+
},
97+
registerObject: () => {},
98+
unregisterItem: () => {},
99+
};
100+
let seq = 0;
101+
const table = (name: string) => (tables[name] ??= []);
102+
const engine = {
103+
tables,
104+
registry,
105+
async find(object: string, query?: { where?: Record<string, any>; limit?: number }) {
106+
const rows = table(object).filter((row) => matches(row, query?.where));
107+
return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows;
108+
},
109+
async findOne(object: string, query: { where?: Record<string, any> }) {
110+
assertEngineFindOnePredicate(object, query);
111+
return table(object).find((row) => matches(row, query?.where)) ?? null;
112+
},
113+
async insert(object: string, data: Row, options?: { context?: { tenantId?: string } }) {
114+
const row: Row = { id: data.id ?? `${object}_${++seq}`, ...data };
115+
// A tenant-scoped insert is stamped with the context's organization, as the driver does.
116+
if (object === 'sys_audience_binding_suggestion') row.organization_id = options?.context?.tenantId ?? null;
117+
table(object).push(row);
118+
return { ...row };
119+
},
120+
async update(object: string, data: Row, options?: { where?: Record<string, any>; multi?: boolean }) {
121+
const dispatch = assertEngineUpdateDispatch(data, options);
122+
const { id: _id, ...patch } = data;
123+
const hits = dispatch.kind === 'by-id'
124+
? table(object).filter((row) => row.id === (data.id ?? options?.where?.id))
125+
: table(object).filter((row) => matches(row, options?.where));
126+
for (const row of hits) Object.assign(row, patch);
127+
return hits.length;
128+
},
129+
async delete(object: string, options?: { where?: Record<string, any>; multi?: boolean }) {
130+
const dispatch = assertEngineDeleteDispatch(options);
131+
const rows = table(object);
132+
const doomed = dispatch.kind === 'by-id'
133+
? rows.filter((row) => row.id === options?.where?.id)
134+
: rows.filter((row) => matches(row, options?.where));
135+
for (const row of doomed) rows.splice(rows.indexOf(row), 1);
136+
return doomed.length;
137+
},
138+
async transaction<T>(cb: (ctx: unknown, info: { owned: boolean }) => Promise<T>): Promise<T> {
139+
return cb(undefined, { owned: true });
140+
},
141+
};
142+
return engine;
143+
}
144+
145+
/** The kernel `metadata` service as the projection and the catalog use it: registry-first, in memory. */
146+
function makeMetadataService() {
147+
const store = new Map<string, Row>();
148+
return {
149+
store,
150+
registerInMemory: (_type: string, name: string, data: Row) => { store.set(name, data); },
151+
get: (_type: string, name: string) => store.get(name),
152+
list: (_type: string) => [...store.values()],
153+
unregister: (_type: string, name: string) => { store.delete(name); },
154+
};
155+
}
156+
157+
/** One environment kernel: the protocol, the projection wired as the security plugin wires it, the catalog bound. */
158+
function boot() {
159+
const engine = makeEngine();
160+
const metadata = makeMetadataService();
161+
const warnings: Array<{ message: string; meta?: Record<string, any> }> = [];
162+
const logger = { warn: (message: string, meta?: Record<string, any>) => { warnings.push({ message, meta }); } };
163+
const protocol = new ObjectStackProtocolImplementation(
164+
engine as never,
165+
() => new Map<string, unknown>([['metadata', metadata]]),
166+
'env_tenant', // an ENVIRONMENT kernel — see the module doc
167+
) as any;
168+
expect(registerPermissionSetProjection(protocol, { ql: engine, metadata, logger })).toBe(true);
169+
const catalog = createSecurityCatalogReader({ registry: engine.registry, metadata });
170+
bindSecurityCatalogReader(engine, catalog);
171+
declareEveryoneBaseline(engine.registry, SECURITY_PACKAGE, ['member_default']);
172+
return { engine, metadata, protocol, catalog, warnings };
173+
}
174+
175+
/** Publish `body` into `packageId` through the package door: a draft, then the batch publish. */
176+
async function publishThroughPackageDoor(protocol: any, packageId: string, body: Row) {
177+
await protocol.saveMetaItem({ type: 'permission', name: body.name, item: body, packageId, mode: 'draft' });
178+
const published = await protocol.publishPackageDrafts({ packageId, actor: 'system' });
179+
expect(published, JSON.stringify(published)).toMatchObject({ outcome: 'published', failed: [] });
180+
}
181+
182+
const suggestionRows = (engine: ReturnType<typeof makeEngine>, set: string) =>
183+
(engine.tables.sys_audience_binding_suggestion ?? []).filter((row) => row.permission_set_name === set);
184+
185+
const runtimeDefault = () => ({ name: RUNTIME_SET, label: 'Workspace member', isDefault: true, objects: {} });
186+
187+
describe('#23233 — a runtime package\'s isDefault set, published without a body packageId', () => {
188+
it('records ONE pending everyone suggestion, under the package the publish door bound it to', async () => {
189+
const { engine, protocol } = boot();
190+
const body = runtimeDefault();
191+
expect(body, 'precondition: the body names no package').not.toHaveProperty('packageId');
192+
193+
await publishThroughPackageDoor(protocol, RUNTIME_PKG, body);
194+
// Anti-vacuity: the topology under test — the engine registry holds no
195+
// stored row, so the catalog can only find the set in the metadata service.
196+
expect(engine.registry.getItem('permission', RUNTIME_SET)).toBeUndefined();
197+
expect(engine.registry.registered.filter((r) => r.name === RUNTIME_SET)).toEqual([]);
198+
199+
await syncAudienceBindingSuggestions(engine);
200+
201+
expect(suggestionRows(engine, RUNTIME_SET)).toEqual([
202+
expect.objectContaining({
203+
package_id: RUNTIME_PKG, anchor: 'everyone', status: 'pending', deployment_decision: false,
204+
organization_id: DEFAULT_ORG,
205+
}),
206+
]);
207+
});
208+
209+
it('the security catalog and the protocol\'s by-name read name the same owner', async () => {
210+
const { protocol, catalog } = boot();
211+
await publishThroughPackageDoor(protocol, RUNTIME_PKG, runtimeDefault());
212+
213+
const served = await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET });
214+
const resolved = await catalog.resolve('permission', RUNTIME_SET);
215+
const listed = (await catalog.list('permission')).find((entry) => entry.name === RUNTIME_SET);
216+
217+
expect(served.item._packageId).toBe(RUNTIME_PKG);
218+
// The catalog read the echo (anti-vacuity), and the echo names the owner.
219+
expect(resolved).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG });
220+
expect(listed).toMatchObject({ source: 'metadata', packageId: RUNTIME_PKG });
221+
expect(resolved?.definition).toMatchObject({ _provenance: 'org', isDefault: true });
222+
});
223+
});
224+
225+
describe('#23233 — the edit surface does not move', () => {
226+
// The projected `sys_permission_set` row keeps `managed_by: 'admin'` and no
227+
// `package_id`: the owner rides on the echo the catalog reads, and nothing
228+
// that gates an admin's edit reads the echo's `_packageId`.
229+
it('the owner-carrying echo is still not a shipped artifact: the lock answers org, the item stays editable, and an edit lands', async () => {
230+
const { engine, protocol } = boot();
231+
await publishThroughPackageDoor(protocol, RUNTIME_PKG, runtimeDefault());
232+
233+
const layered = await protocol.getMetaItemLayered({ type: 'permission', name: RUNTIME_SET });
234+
// Anti-vacuity: the layered read's code layer IS the echo, and it carries the owner.
235+
expect(layered.code).toMatchObject({ _packageId: RUNTIME_PKG, _envProjection: true });
236+
expect(classifyPackagedPermissionSet(RUNTIME_SET, engine, { status: 'read', envelope: layered })).toEqual({ status: 'org' });
237+
expect(layered).toMatchObject({ lock: 'none', editable: true, deletable: true });
238+
239+
await protocol.saveMetaItem({
240+
type: 'permission', name: RUNTIME_SET, packageId: RUNTIME_PKG, item: { ...runtimeDefault(), label: 'Edited' },
241+
});
242+
expect((await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET })).item).toMatchObject({ label: 'Edited', _packageId: RUNTIME_PKG });
243+
expect((engine.tables.sys_permission_set ?? []).filter((row) => row.name === RUNTIME_SET)).toEqual([
244+
expect.objectContaining({ label: 'Edited', managed_by: 'admin' }),
245+
]);
246+
expect(engine.tables.sys_permission_set.find((row) => row.name === RUNTIME_SET)?.package_id ?? null).toBeNull();
247+
});
248+
});
249+
250+
describe('#23233 — controls: the owner is carried, never invented', () => {
251+
it('a code package\'s default set is unchanged: the catalog serves its artifact, its owner and its suggestion', async () => {
252+
const { engine, metadata, catalog } = boot();
253+
254+
await syncAudienceBindingSuggestions(engine);
255+
256+
expect(suggestionRows(engine, CODE_SET)).toEqual([
257+
expect.objectContaining({ package_id: CODE_PKG, status: 'pending' }),
258+
]);
259+
expect(await catalog.resolve('permission', CODE_SET)).toMatchObject({ source: 'registry', packageId: CODE_PKG });
260+
// No overlay row, so the projection registered no echo of it at all.
261+
expect(metadata.store.has(CODE_SET)).toBe(false);
262+
});
263+
264+
it('a set saved with no package stays unowned — no binding, no owner, no suggestion (ADR-0086 D3)', async () => {
265+
const { engine, protocol, catalog } = boot();
266+
await protocol.saveMetaItem({ type: 'permission', name: 'org_member_default', item: { ...runtimeDefault(), name: 'org_member_default' } });
267+
268+
expect((await protocol.getMetaItem({ type: 'permission', name: 'org_member_default' })).item._packageId).toBeUndefined();
269+
const resolved = await catalog.resolve('permission', 'org_member_default');
270+
expect(resolved).toMatchObject({ source: 'metadata' });
271+
expect(resolved).not.toHaveProperty('packageId');
272+
273+
await syncAudienceBindingSuggestions(engine);
274+
expect(suggestionRows(engine, 'org_member_default')).toEqual([]);
275+
});
276+
277+
it('a binding read that fails leaves the echo registered and unowned, as before, and says so once', async () => {
278+
const { engine, metadata, warnings } = boot();
279+
// The layered read answers an overlay; the by-name read is refused.
280+
const protocol = {
281+
getMetaItemLayered: async () => ({ type: 'permission', name: RUNTIME_SET, code: null, overlay: runtimeDefault(), effective: runtimeDefault() }),
282+
getMetaItem: async () => { throw new Error('metadata store unavailable'); },
283+
};
284+
285+
const out = await projectPermissionMutation(protocol, { ql: engine, metadata, logger: { warn: (m: string, meta?: any) => warnings.push({ message: m, meta }) } }, {
286+
type: 'permission', name: RUNTIME_SET, state: 'active',
287+
});
288+
289+
expect(out?.seeded).toBe(1);
290+
const echo = metadata.store.get(RUNTIME_SET);
291+
expect(echo).toMatchObject({ name: RUNTIME_SET, _provenance: 'org', _envProjection: true });
292+
expect(echo).not.toHaveProperty('_packageId');
293+
expect(warnings.filter((w) => w.meta?.name === RUNTIME_SET)).toHaveLength(1);
294+
});
295+
});

0 commit comments

Comments
 (0)