Skip to content

Commit 99801d8

Browse files
feat(plugin-security)!: under single, a Setup position create or rename into a name a package or a built-in holds answers the metadata door's refusal (C2 stage S10) (#22582)
Part of #15196 Clause-②: no (narrowing) C2 stage **S10, one namespace for positions** (census `6038897018`, S10 row). #15196 remains open for the other stages (S5c, S8a/S8b, S9); this PR carries no closing keyword. ## What changes Under `single`, a non-system Setup **create** of a `sys_position` row, or a **rename into** a name, that a package or a built-in already holds is now refused. The refusal is the metadata door's own: `403 NOT_OVERRIDABLE`, the same refusal `PUT /api/v1/meta/position/NAME` already gives that name. No row is kept, a renamed row keeps its old name, and the renamed position's definition is untouched. - **Asked of its owner, never re-derived.** The verdict is `ObjectStackProtocolImplementation.packagedBaseRefusal({ type: 'position', name, operation: 'save' })`. That method is the metadata door's own locked-base verdict, without a write. It uses the predicate `saveMetaItem` uses (`isSealedManagedItem`, which reads `SchemaRegistry.getArtifactItem`) and the same emitter. The write-through relays the refusal as the door built it: code, status and sentence. It stamps no code of its own and adds no second predicate. The `/automation` doors ask the same method the same way (`refusePackagedFlowBaseChange`, `packages/runtime/src/domains/automation.ts`). Nothing is written to environment metadata for such a name, whatever the answer. - **After the engine's own checks.** The row is written first, as S7 does for every write. So a refusal the engine already makes keeps its own answer: a reserved identity name stays `400 VALIDATION_FAILED`, and a name already held in the same organization stays `409 UNIQUE_VIOLATION`. Only a row the engine accepted is asked about, and the S7 undo removes it, or restores it on a rename, when the door refuses. - **A verdict the door cannot reach** (it re-raises anything that is not its refusal) undoes the write too, and that failure is the answer. - **Unchanged.** An edit that keeps such a name and a delete still stand down, as Q2 = A ruled. Whether the data door admits those at all is the system-row gate's call. System writes, walled postures and kernels without a metadata door are unchanged. A door that brings no `packagedBaseRefusal` keeps the S7 stand-down, as the `/automation` doors keep theirs. Files (all within the claim's surface, `packages/plugins/plugin-security/`, plus the changeset): - `src/position-write-through.ts`: the refusal, its module note, and the undo-failure log's remedy, which now also says "not a name a package or a built-in already holds". - `src/position-write-through.test.ts`: the S10 pins, and the Q2 = A stand-down test narrowed to edit and delete. Its create-under-`everyone` pass-on case now lives in the S10 block as a refusal. - `src/security-plugin.ts`: the registration comment only, no code. - `.changeset/15196-s10-position-namespace.md`: `minor`, BREAKING narrowing, FROM → TO, and the remedy (rename the position). ## Measured first, per posture, both doors (hypothesis 1) These readings come from a local probe, never committed and deleted after the run. It booted the real showcase composition through `@objectstack/verify` (`bootShowcase`), signed in as the seeded platform admin, and used the HTTP doors. The `group` posture ran with `OS_TENANCY_POSTURE=group` plus `multiTenant: 'posture-only'`, and `isolated` ran with `multiTenant: 'posture-only'`. Both reported `degraded: false`. Under each walled posture the admin created an organization and made it active first. **Before** is `origin/main` `6a3f82efa7`. It was reproduced a second time by the dist ablation below, with the probe reordered so that both renames run before any create. **After** is this branch: first `91177a22`, then re-read at the final head `ecf9164303`, with the same answers. | Door / write | `single` before | `single` after | `group` (before = after) | `isolated` (before = after) | |---|---|---|---|---| | Setup `POST /data/sys_position` `manager` (package `com.example.showcase`) | **201**, a second `manager` row lands in the default organization beside the package's organization-less row | **403 `NOT_OVERRIDABLE`**, no row kept | 409 `UNIQUE_VIOLATION` | 409 `UNIQUE_VIOLATION` | | Setup create `everyone` (built-in anchor) | **201** | **403 `NOT_OVERRIDABLE`** | 409 `UNIQUE_VIOLATION` | 409 `UNIQUE_VIOLATION` | | Setup create `guest` (built-in anchor) | **201** | **403 `NOT_OVERRIDABLE`** | 409 `UNIQUE_VIOLATION` | 409 `UNIQUE_VIOLATION` | | Setup create `org_admin` (reserved identity) | 400 `VALIDATION_FAILED` | 400 `VALIDATION_FAILED` | 400 `VALIDATION_FAILED` | 400 `VALIDATION_FAILED` | | Setup `PATCH` rename of an admin position into `exec` (package) | **200**, and the renamed position's environment definition is **deleted** | **403 `NOT_OVERRIDABLE`**, row and definition keep the old name | 409 `UNIQUE_VIOLATION` | 409 `UNIQUE_VIOLATION` | | Setup rename into `guest` (built-in) | **200** | **403 `NOT_OVERRIDABLE`** | 409 `UNIQUE_VIOLATION` | 409 `UNIQUE_VIOLATION` | | Setup create of a fresh name (control) | 201 | 201 | 201 | 201 | | Studio `PUT /meta/position/auditor` (package) | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | | Studio `PUT /meta/position/everyone` (built-in) | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` | | Studio `PUT /meta/position/s10_studio_lead` (control) | 200 | 200 | 200 | 200 | - **The Studio half was already refused** in every posture, so no metadata-door change was needed (hypothesis 1 holds). - **The gap was the Setup data door under `single` only.** Under `single` the declared and built-in rows are organization-less, while an admin's Setup row is stamped with the Default Organization. The `unique: 'organization'` index (`COALESCE(organization_id, '__global__')`) therefore never collided, and the write-through stood down for the name. - **The walled postures already refuse**, through the organization's own seeded rows, under the uniqueness code rather than the door's. This PR leaves the walled half alone: the write-through's own note gives it to a later stage. See the Acceptance notes. - `GET /api/v1/meta/position` lists the held names (`manager`, `exec`, `everyone`, `guest`, `org_admin`) in all three postures. The changeset's remedy points there. ## Provenance (hypothesis 3) Read off the booted showcase's engine registry, the same in all three postures: | Name | `getArtifactItem('position', NAME)._packageId` | `getItem(...)._packageId` | |---|---|---| | `manager`, `exec` (stack-declared) | `com.example.showcase` | `com.example.showcase` | | `everyone`, `guest`, `org_admin` (built-in) | `com.objectstack.plugin-security` | `com.objectstack.plugin-security` | | a fresh Setup name | none | none | The carrier `6040687107` / `6040744949` reading ("stack-declared positions carry no `_packageId`") was true on 2026-10-07. PR #22262 (`0b997ea4`, 2026-10-08) closed it by registering a stack's `positions` under the owning package. Built-ins are registered under the security plugin's package id (stage S2). Both kinds are therefore held for the door, and the refusal treats them alike: `403 NOT_OVERRIDABLE` from one predicate. The four reserved identity names never reach the door: the engine's rule validator refuses the row first (`400 VALIDATION_FAILED`, the #15972 invariant), and this PR keeps that order. ## The other hypotheses - **2, the provenance read.** The refusal reads `SchemaRegistry.getArtifactItem` through the door's own verdict. `packageHoldsPosition` (the stand-down's read) is untouched, and it still decides the edit and delete stand-downs. - **4, the envelope.** The code is the registered `NOT_OVERRIDABLE` / 403, the door's own. It matches the data door's existing pattern: S7's Q1 = A relays the door's own refusal, `INVALID_REQUEST` / `INVALID_METADATA`. `NAMESPACE_CONFLICT` / 422 is the package-registration refusal (ADR-0048 addendum N.2). N.3 keeps an environment save over a package-held name outside N.2, under overlay precedence, where the door answers `NOT_OVERRIDABLE`. No new code. - **5.** Not every posture refused (`single` admitted), so code was owed. ## Tests All runs were at head `ecf9164303` unless noted, through `scripts/pm/os-verify-lock.sh` with `NODE_OPTIONS=--max-old-space-size=3072` and vitest `--maxWorkers=2`. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2` reported **Test Files 192 passed (192), Tests 4044 passed, 45 skipped (4089)**, exit 0. The run started at 01:49:31Z. It includes the S5 grant-equivalence goldens. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/position-write-through.test.ts` at `ecf9164303` reported **Tests 41 passed (41)**, exit 0. This file was re-run at the final head because the full-suite run began 4 s before that commit was cut. - `pnpm --filter @objectstack/plugin-security typecheck` ran `tsc --noEmit`, then `tsc -p tsconfig.scripts.json`, then `check:test-typecheck` ("0 file(s) / 0 error(s)"). Exit 0. - The HTTP probe (table above) was re-run at `ecf9164303` after `turbo run build --filter=@objectstack/plugin-security`, with the same answers. The data door's refusal body for `manager` is the door's own sentence, with `"code":"NOT_OVERRIDABLE"` and `"object":"sys_position"`. - Build: `turbo run build --filter=@objectstack/dogfood^...` reported 63/63 successful (31 cached), exit 0. The S10 pins are in `position-write-through.test.ts`, under the describe block `[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace`. The engine is real (`better-sqlite3`), the `SecurityPlugin` is real, and so is the metadata door. - A create under a package-declared name, under `everyone` and under `guest`: envelope `{ code: NOT_OVERRIDABLE, status: 403 }`, no row and no definition kept. No message pin, as the census ruled. - The data door's refusal equals the door's own `saveMetaItem` refusal: code, status and message. This is an identity check against the door, not a message pin. - A bulk create carrying one held name is refused whole. - A rename into a package-held name, and into `guest`: refused, and the row and its definition keep the old name. - A verdict the door cannot reach undoes the create and the rename. - Three controls: the reserved identity name keeps the engine's `VALIDATION_FAILED`; an edit that keeps a held name stays a row write; a door without the verdict keeps the stand-down. ## Ablation (remove the check, the write lands, red) The fix was committed first (`91177a22`). The mutation was made with `scripts/ablation-replace.mjs`, which uses an anchor hit, an on-disk count and a blob proof, and carries its own trap restore. In `heldPositionNameRefusal`, the guard line was replaced by one that returns `null` for every real name, carrying the marker `S10_ABLATION_MARKER`. - **Leg 1, unit suite.** The subject is imported relatively from `src/`, so no `dist/` sits on the resolution path. Anchor x1 → x0, blob `1acd4313e68c` → `ba75c2f33e43`. `src/position-write-through.test.ts` ran **7 failed | 33 passed (40)**. The 7 red tests are exactly the S10 refusal pins: three creates, the door-identity test, the bulk create, and two renames. The three controls stayed green. Restore: blob after restore == HEAD (`1acd4313e68c`), and `git diff HEAD` was empty. The direction was the expected one (turned red). - **Leg 2, `dist/` and HTTP.** Same mutation, then `turbo run build --filter=@objectstack/plugin-security` (exit 0). `node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security S10_ABLATION_MARKER` found the marker in `dist/index.js` and `dist/index.mjs` (exit 0). The showcase probe then read the pre-S10 answers back: create `manager` 201, create `everyone` 201, rename into `exec` 200, rename into `guest` 200, and the renamed position's definition deleted. Restore proven as in leg 1. A rebuild followed, and `--absent` gave dist `marker absent from all 6 built files`. Its first tree reading answered exit 3: the untracked local probe file was still in the tree, while the dist reading passed. After the probe was deleted, the rerun answered exit 0 (dist absent, tree clean against HEAD). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths passed, on the real diff at `ecf9164303`: 4 paths, 66 commands. Each was run with its exit captured before any pipe, then reconciled with `--ran`. Result: **66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN; all 66 recorded exit 0**. - **One prerequisite rerun.** `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`, exit 3: 8 unrelated packages had no `dist/`. After `turbo run build` for those 8 (57/57 tasks, all cache hits) it answered exit 0: "107 published require entry point(s) across 66 package(s) load". - **Families beyond the dispatch list,** derived from the actual diff and run: `check-adr-0087-registration` (and its `--self-test`), `check-empty-changeset` (and its `--self-test`), `check-dts-emitted --self-test`, `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - **Not run here:** repo-wide scans such as `pnpm lint`. CI owns those. ## Acceptance notes - **The walled half answers `409 UNIQUE_VIOLATION`, not the door's `403 NOT_OVERRIDABLE`.** Under `group` and `isolated`, every organization holds its own seeded rows of the declared and built-in names, so the uniqueness index refuses first, and the write-through stands down for a walled posture. The name is refused in both walled postures, under another code. Carrier: S8, which carries the walled half under Q3 = A (release `6080018188`). - **Same-name rows that already exist under `single` are not swept.** A Setup row created before this release under a package-held or built-in name (for example a default-organization `manager` beside the package's) keeps working. An edit that keeps its name stays a row write with no definition, and this PR deletes nothing (C7 owns deletions). Carrier: S8b (same-name rows rule, `6075437096`) and S9 (boot report). - **The relayed sentence is the door's.** The door words it for an edit ("sealed against in-place edits … Edit the source artifact and redeploy"), and a create receives the same sentence. For an administrator who wants a position of their own, the useful remedy is to choose another name. The changeset states that remedy. The sentence lives in `managedItemSealedSentence` (`packages/metadata-protocol/src/packaged-base-regime.ts`), outside this claim's file surface, and is reported in the stage report. - **Two refusal codes for built-in names, by order.** Under `single`, a reserved identity name answers `400 VALIDATION_FAILED` (the engine's rule validator, first), and an audience anchor (`everyone`, `guest`) answers `403 NOT_OVERRIDABLE` (the door). This is deliberate: the engine's checks keep precedence, as S7 established. - **S8's metadata-first switch** (`6075437096`) makes the refusal arrive before any row write, and deletes the undo paths this refusal reuses. Nothing in this PR adds undo code. - **The grant-equivalence goldens from S5** (`grant-readers-by-name.golden.test.ts`, `grant-permission-set-name.equivalence.test.ts`) ran inside the full `plugin-security` suite above. - **No dogfood pin is committed.** The claim's surface is `plugin-security` only. The HTTP-level readings above come from the local probe, which was deleted. --- _Generated by [Claude Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b98e984 commit 99801d8

4 files changed

Lines changed: 231 additions & 21 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security)!: under `single`, a Setup create of a position, or a rename into a name, that a package or a built-in already holds is refused with the metadata door's own refusal (ADR-0048 addendum N.2/N.3)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export, type or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime write door: under the single posture, a sys_position create, or a rename into a name, that a package or a built-in holds is now refused at the data door, as the metadata door already refuses the same name, and the remedy is a different data value (the position's name), not a rewrite of anyone's code or metadata. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no TypeScript declaration moves (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
Positions hold one name per deployment (ADR-0048 addendum, N.2). A position a package declares, and the platform's built-in positions, are managed items: the metadata door already refused an environment save under their names (`PUT /api/v1/meta/position/:name` answers `403 NOT_OVERRIDABLE`). The Setup data door did not.
14+
15+
**FROM.** Under `single`, a non-system `POST /api/v1/data/sys_position` whose `name` a package or a built-in holds answered `201`. The new row sat beside the package's (or the platform's) position under the same name. A `PATCH` that renamed an administrator's position into such a name answered `200`, and it also deleted that position's own environment definition. Measured on the showcase: a create of `manager` (package-declared), `everyone` or `guest` (built-in audience anchors) answered `201`, and a rename into `exec` answered `200`.
16+
17+
**TO.** Under `single`, the same create, or a rename into such a name, is refused with the metadata door's own refusal, relayed as the door builds it: `403 NOT_OVERRIDABLE`. No row is kept, a renamed row keeps its old name, and its definition is untouched. The door's own verdict is asked; nothing is written to environment metadata for such a name.
18+
19+
- **Unchanged.** The engine's own refusals answer first, as before: a reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`) stays `400 VALIDATION_FAILED`, and a name already held in the same organization stays `409 UNIQUE_VIOLATION`. An edit that keeps such a name, and a delete, behave as before. System writes, walled postures and kernels without a metadata door behave as before. Under a walled posture, each organization already holds its own rows of the declared and built-in names, so the same create answers `409 UNIQUE_VIOLATION`, as before.
20+
- **Remedy.** Rename the position: give it a name no package and no built-in holds (for example `regional_manager` instead of `manager`), then point any assignment at the new name. `GET /api/v1/meta/position` lists the names already held.

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

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,13 @@
1919
* - a create, or a rename, into a name the metadata door refuses answers the
2020
* door's own refusal and keeps nothing (seat re-rule Q1 = A); an edit of a
2121
* row that already carries such a name still lands as a row write;
22-
* - a name a package or a built-in holds stands the write-through down: the
23-
* write is passed on and nothing reaches metadata (Q2 = A), pinned at the
24-
* write-through's own level rather than at the data door's answer;
22+
* - an edit that keeps a name a package or a built-in holds, and a delete of
23+
* such a row, stand the write-through down: the write is passed on and
24+
* nothing reaches metadata (Q2 = A), pinned at the write-through's own level
25+
* rather than at the data door's answer;
26+
* - a create, or a rename into, a name a package or a built-in holds answers
27+
* the metadata door's own locked-base refusal and keeps nothing (C2 stage
28+
* S10, ADR-0048 addendum N.2/N.3); the engine's own refusals keep theirs;
2529
* - a package registering a name a Setup position holds in the environment
2630
* ledger is refused `NAMESPACE_CONFLICT` (Q3 = A, within Q4 = A);
2731
* - controls: a walled posture, a system write and a kernel without a metadata
@@ -393,17 +397,21 @@ describe('[ADR-0131 D3] Q1 = A — a name the metadata door refuses is refused w
393397
});
394398
});
395399

396-
describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands the write-through down', () => {
400+
describe('[ADR-0131 D3] Q2 = A — an edit that keeps, or a delete of, a name a package or a built-in holds stands the write-through down', () => {
397401
// Pinned at the write-through's own level, never at the data door's answer:
398402
// whether the data door admits an edit of a package-declared row is the
399403
// system-row gate's call, decided on the row's provenance stamp, and that
400404
// stamp is another change's. The write-through must stand down whatever the
401405
// stamp says, because it reads the name's holder from the engine registry.
402-
it('the write-through passes a write on a package-held or built-in name to the next step, and writes nothing to metadata', async () => {
406+
// A CREATE under such a name is no stand-down since stage S10 (below).
407+
it('the write-through passes an edit or a delete on a package-held name to the next step, and writes nothing to metadata', async () => {
403408
const b = await boot();
404409
const door = {
405410
saveMetaItem: vi.fn(async (_request: Record<string, unknown>) => undefined),
406411
deleteMetaItem: vi.fn(async (_request: Record<string, unknown>) => undefined),
412+
// The real door's verdict, so the S10 refusal is in force for this double too.
413+
packagedBaseRefusal: vi.fn((request: { type: string; name: string; operation: 'save' | 'delete' }) =>
414+
b.protocol.packagedBaseRefusal(request) as Error | null),
407415
};
408416
const writeThrough = createPositionWriteThrough({
409417
ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger,
@@ -418,9 +426,6 @@ describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands t
418426
const edit = { object: 'sys_position', operation: 'update', data: { id: 'pos_pkg', label: 'Edited', delegatable: true }, context: b.admin };
419427
expect(await passOn(edit, () => b.engine.update('sys_position', { id: 'pos_pkg', label: 'Edited', delegatable: true }, { context: SYS } as any)))
420428
.toBe(1);
421-
// A create under a built-in name (the plugin registers the six under its own package).
422-
const builtIn = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin };
423-
expect(await passOn(builtIn, async () => ({ id: 'pos_builtin', name: 'everyone' }))).toBe(1);
424429
// A delete of the package-declared row.
425430
const del = { object: 'sys_position', operation: 'delete', options: { where: { id: 'pos_pkg' } }, context: b.admin };
426431
expect(await passOn(del, () => b.engine.delete('sys_position', { where: { id: 'pos_pkg' }, context: SYS } as any))).toBe(1);
@@ -446,6 +451,112 @@ describe('[ADR-0131 D3] Q2 = A — a name a package or a built-in holds stands t
446451
});
447452
});
448453

454+
describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create or a rename into a held name answers the door\'s refusal', () => {
455+
/** The metadata door's own envelope for an environment save over a name a package or a built-in holds. */
456+
const DOOR_REFUSAL = { code: 'NOT_OVERRIDABLE', status: 403 };
457+
458+
it.each([
459+
['a package declares', PKG_POSITION],
460+
['the platform declares as an audience anchor', 'everyone'],
461+
['the platform declares as an audience anchor (guest)', 'guest'],
462+
])('a create under a name %s is refused with the door\'s envelope, and keeps no row and no definition', async (_holder, name) => {
463+
const b = await boot();
464+
const before = (await b.rows(name)).map((r) => r.id);
465+
const refusal = await refusalOf(create(b, { name, label: 'Mine' }));
466+
expect(envelope(refusal)).toEqual(DOOR_REFUSAL);
467+
expect((await b.rows(name)).map((r) => r.id), 'the created row was undone').toEqual(before);
468+
expect(await b.envRows(name)).toEqual([]);
469+
});
470+
471+
it('the refusal is the door\'s own, relayed as the door built it — the same verdict a metadata save gets', async () => {
472+
const b = await boot();
473+
const refusal: any = await refusalOf(create(b, { name: PKG_POSITION, label: 'Mine' }));
474+
const door: any = await refusalOf(
475+
b.protocol.saveMetaItem({ type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'Mine' } }),
476+
);
477+
expect({ code: refusal?.code, status: refusal?.status, message: refusal?.message })
478+
.toEqual({ code: door?.code, status: door?.status, message: door?.message });
479+
});
480+
481+
it('a bulk create carrying one held name is refused whole: no row of the write is kept', async () => {
482+
const b = await boot();
483+
const refusal = await refusalOf(b.engine.insert('sys_position', [
484+
{ name: 'bulk_lead', label: 'Bulk lead' },
485+
{ name: PKG_POSITION, label: 'Mine' },
486+
], { context: b.admin } as any));
487+
expect(envelope(refusal)).toEqual(DOOR_REFUSAL);
488+
expect(await b.rows('bulk_lead')).toEqual([]);
489+
expect(await b.envRows('bulk_lead')).toEqual([]);
490+
expect((await b.rows(PKG_POSITION)).map((r) => r.id)).toEqual(['pos_pkg']);
491+
});
492+
493+
it.each([
494+
['a package declares', PKG_POSITION],
495+
['the platform declares', 'guest'],
496+
])('a rename into a name %s is refused; the row and its definition keep the old name', async (_holder, held) => {
497+
const b = await boot();
498+
const created: any = await create(b, { name: 'route_lead', label: 'Route lead' });
499+
const refusal = await refusalOf(patch(b, created.id, { name: held, label: 'Renamed' }));
500+
expect(envelope(refusal)).toEqual(DOOR_REFUSAL);
501+
const [row] = await b.rows('route_lead');
502+
expect({ id: row?.id, label: row?.label }).toEqual({ id: created.id, label: 'Route lead' });
503+
expect((await b.rows(held)).map((r) => r.id)).not.toContain(created.id);
504+
// Before S10 the rename stood down for the new name and then deleted the old name's definition.
505+
expect((await b.envRows('route_lead')).map((r) => r.body)).toEqual([
506+
{ name: 'route_lead', label: 'Route lead', delegatable: false },
507+
]);
508+
expect(await b.envRows(held)).toEqual([]);
509+
});
510+
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+
523+
it('control: the engine\'s own refusals keep their answer — a reserved identity name stays VALIDATION_FAILED', async () => {
524+
const b = await boot();
525+
// The platform holds `org_admin` too, but the engine's rule validator refuses
526+
// the row before the door is asked (the REST layer answers it 400).
527+
const refusal: any = await refusalOf(create(b, { name: 'org_admin', label: 'Mine' }));
528+
expect({ name: refusal?.name, code: refusal?.code }).toEqual({ name: 'ValidationError', code: 'VALIDATION_FAILED' });
529+
expect(await b.rows('org_admin')).toEqual([]);
530+
});
531+
532+
it('control: an edit that keeps a held name stays a row write, with no definition and no refusal', async () => {
533+
const b = await boot();
534+
// A row that predates this stage: an organization's own row under the package's name.
535+
await b.engine.insert('sys_position', {
536+
id: 'pos_twin', name: PKG_POSITION, label: 'Twin', organization_id: ORG,
537+
}, { context: SYS } as any);
538+
await patch(b, 'pos_twin', { label: 'Twin (relabelled)' });
539+
expect((await b.rows(PKG_POSITION)).find((r) => r.id === 'pos_twin')?.label).toBe('Twin (relabelled)');
540+
expect(await b.envRows(PKG_POSITION)).toEqual([]);
541+
});
542+
543+
it('control: a door that brings no locked-base verdict keeps the stand-down', async () => {
544+
const b = await boot();
545+
const door = {
546+
saveMetaItem: vi.fn(async (_request: Record<string, unknown>) => undefined),
547+
deleteMetaItem: vi.fn(async (_request: Record<string, unknown>) => undefined),
548+
};
549+
const writeThrough = createPositionWriteThrough({
550+
ql: b.engine, getProtocol: () => door, getPosture: () => 'single', logger: b.logger,
551+
});
552+
const opCtx: any = { object: 'sys_position', operation: 'insert', data: { name: 'everyone', label: 'Everyone' }, context: b.admin };
553+
const next = vi.fn(async () => { opCtx.result = { id: 'pos_builtin', name: 'everyone' }; });
554+
await writeThrough(opCtx, next);
555+
expect(next).toHaveBeenCalledTimes(1);
556+
expect(door.saveMetaItem).not.toHaveBeenCalled();
557+
});
558+
});
559+
449560
describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup position holds in the environment ledger', () => {
450561
it('the registry item seam refuses it 422 NAMESPACE_CONFLICT, naming both holders', async () => {
451562
const b = await boot();

0 commit comments

Comments
 (0)