Skip to content

Commit ecf9164

Browse files
committed
fix(plugin-security): an unreachable namespace verdict undoes the position create or rename it guarded
Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91177a2 commit ecf9164

2 files changed

Lines changed: 31 additions & 13 deletions

File tree

‎packages/plugins/plugin-security/src/position-write-through.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,18 @@ describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create o
508508
expect(await b.envRows(held)).toEqual([]);
509509
});
510510

511+
it('a verdict the door cannot reach is no admission: the create and the rename are undone, and the failure is the answer', async () => {
512+
const b = await boot();
513+
const created: any = await create(b, { name: 'yard_lead', label: 'Yard lead' });
514+
const unreadable = Object.assign(new Error('registry unreadable'), { code: 'METADATA_STORE_UNAVAILABLE', status: 503 });
515+
vi.spyOn(b.protocol, 'packagedBaseRefusal').mockImplementation(() => { throw unreadable; });
516+
expect(await refusalOf(create(b, { name: 'dock_lead', label: 'Dock lead' }))).toBe(unreadable);
517+
expect(await b.rows('dock_lead')).toEqual([]);
518+
expect(await refusalOf(patch(b, created.id, { name: 'gate_lead' }))).toBe(unreadable);
519+
expect((await b.rows('yard_lead')).map((r) => r.id)).toEqual([created.id]);
520+
expect(await b.rows('gate_lead')).toEqual([]);
521+
});
522+
511523
it('control: the engine\'s own refusals keep their answer — a reserved identity name stays VALIDATION_FAILED', async () => {
512524
const b = await boot();
513525
// The platform holds `org_admin` too, but the engine's rule validator refuses

‎packages/plugins/plugin-security/src/position-write-through.ts‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -379,13 +379,17 @@ export function createPositionWriteThrough(
379379
const createdRows: Record<string, unknown>[] = [];
380380
for (const created of written) createdRows.push((await readRowById(ql, created.id)) ?? created);
381381
// [S10] A name a package or a built-in holds is not taken: the door's
382-
// refusal is the answer, asked before any definition is saved.
383-
for (const row of createdRows) {
384-
const refusal = heldPositionNameRefusal(door, row.name);
385-
if (refusal) {
386-
await undoInsert(written);
387-
throw refusal;
382+
// refusal is the answer, asked before any definition is saved. A verdict
383+
// the door could not reach (it re-raises anything but its refusal) is
384+
// no answer either: the rows go, and the failure is the answer.
385+
try {
386+
for (const row of createdRows) {
387+
const refusal = heldPositionNameRefusal(door, row.name);
388+
if (refusal) throw refusal;
388389
}
390+
} catch (e) {
391+
await undoInsert(written);
392+
throw e;
389393
}
390394
const saved: Record<string, unknown>[] = [];
391395
for (const row of createdRows) {
@@ -425,14 +429,16 @@ export function createPositionWriteThrough(
425429
}
426430

427431
// [S10] A rename into a name a package or a built-in holds is not taken:
428-
// the door's refusal is the answer, and the row gets its old values back.
429-
// An edit that keeps such a name stands down below.
430-
for (const { pre, post } of posts) {
431-
const refusal = pre.name !== post.name ? heldPositionNameRefusal(door, post.name) : null;
432-
if (refusal) {
433-
await undoUpdate(targets, patch);
434-
throw refusal;
432+
// the door's refusal (or its failure to answer) is the answer, and the row
433+
// gets its old values back. An edit that keeps such a name stands down below.
434+
try {
435+
for (const { pre, post } of posts) {
436+
const refusal = pre.name !== post.name ? heldPositionNameRefusal(door, post.name) : null;
437+
if (refusal) throw refusal;
435438
}
439+
} catch (e) {
440+
await undoUpdate(targets, patch);
441+
throw e;
436442
}
437443

438444
// Every new definition first; the old names' definitions go only once all landed.

0 commit comments

Comments
 (0)