Skip to content

Commit 710c2da

Browse files
harden(metadata): a permission set's name is unique across packages (#27535)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk harden(metadata): a permission set's name is unique across packages Dispatched by the W3-C3b QA run security path (detail withheld). Reworked on the contract-tier review FAIL (PR comment 6497768595): the must-fix and all five should-fixes are taken, each listed under What changes. Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — the published skills teach that a grant references a permission set by its machine `name` (`skills/objectstack-data/rules/security.md`); none claims a name may be reused across owners, so this hardening contradicts nothing, and `Metadata-protocol: none` means there is no new authorable capability to teach. This narrows a published accept set (a metadata-door write that answered `200` is now refused), so it owes one contract-review-tier review before the queue. ## 维护者速读 - 改了什么:权限集(permission set)在元数据的保存门(草稿/直接发布)、"草稿→发布"提升门、以及恢复门(回滚 rollback / 提交回退 revert)上,若同名权限集已被另一个所有者(另一个包,或环境本身)持有,一律拒绝写入,且什么都不落库;错误信封与注册表在安装、冷启动时对同一规则的答复一致:`NAMESPACE_CONFLICT` / 422。 - 为什么改:授权按名字引用权限集(一个名字只能对应一套权限,ADR-0131 D4;裁决 #15196 Q4=A、#22307 A:"一个名字一个持有者,每条路径都如此")。安装与冷启动早已拒绝第二个持有者,元数据入口没有,留下一个与已声明契约不符的缺口。本次返工修正了复审指出的问题:所属包自己用"不带包名的草稿保存"编辑自家权限集时被误拒。属安全加固。 - 风险与回滚:只拦"用别人已占用的名字再写一份"这类本就非法的写入;同一所有者改自己的集照常(包括不带包名的草稿保存、以及历史遗留的"包行旁的无包行");已有两个所有者同名的部署,在改名或删除其中一份之前,对该名字的写入(含 `os migrate meta --stored --apply` 重存、包复制 duplicate 中未带前缀的集)会报失败。回滚 = 还原本 PR。 - 先看:`packages/metadata-protocol/src/protocol-save-door-refusals.ts`(判据 `permissionSetNameCollisionRefusal` 与注册表读法 `registryPackageHoldersOf`);`packages/metadata-protocol/src/protocol.ts` 保存门里计算 `landsIn` 的那段(按写入真正落入的包判定,风险最高);`packages/metadata-protocol/src/protocol-authoring-gate.ts` 恢复门的一行调用。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.permission-set-name-cross-package.test.ts` → 预期 17/17 绿(三道门的拒绝 + 各自对照,含"所属包不带包名的草稿编辑可保存"一条)。 - 需要你做的:无 — 席位落地;入队前需一次契约复审档(contract-review)复核,因为本卡 `Clause-②: no (narrowing)`。 ## What changes A permission set's name is its identity across the whole deployment: a grant (`sys_user_permission_set.permission_set`) resolves its set by that name alone (ADR-0131 D4), and positions, permission sets and capabilities each hold one name per deployment (ruling Q4 = A on #15196, record 6050490870). The registry already refuses a second holder at install and at cold boot with `NAMESPACE_CONFLICT` / 422 (ruling A on #22307, record 6063176077: one name, one holder, on every path). The metadata doors keyed a permission-set write by `(type, name, package)` and so let a second owner write a name already held. This PR closes those doors, pulling them back to the already-declared contract (ADR-0086 D4: a package never writes into a record another owns). `permissionSetNameCollisionRefusal` refuses a `permission` write whose name a DIFFERENT owner already holds, nothing written. It is asked at every door a body reaches `active` through: - **the save door** (`saveMetaItem`, draft and publish mode), right after the sibling owner judge; - **the draft→active promotion** (`promoteDraftForPublish`, shared by the per-item publish and `publishPackageDrafts`), on `promoteInto` — a batch publish aborts on it; - **the restore doors** (review should-fix 2): first in `refuseRestoredBodyTheSaveDoorRefuses`, under `rollbackMetaItem` and `revertCommit`, on the binding the restore lands in (the active row's `package_id`, which `put` keeps; `null` when no active row exists and the package-unbound row is written). A version of a deleted set cannot come back as a second holder of a name a package has taken since. The save-door check runs before the create precondition, so `If-None-Match: *` cannot smuggle a cross-owner create. **The owner is the binding the write LANDS IN (review must-fix 1).** The first cut judged the owner judge's binding (`saveWriteBinding`), which reads the store only when the body carries a `packageId`, so the owning package's own plain `?mode=draft` edit (body without `packageId`, no package named) was judged as the environment and refused, although the repository writes that draft into the inherited package (`resolveWriteHead`). The save door now hands the predicate: the named package (for a publish-mode save naming none, the package the package-less address resolved, already on the request); for a draft save naming none, the `into` of the draft address `packagelessDraftAddress` names — the address the door already reads, read here too for the server-stated rewrites it is not read for — and an ambiguous address is refused with the refusal the repository answers it with (`RESOURCE_CONFLICT` / 409, "name the package"); otherwise the package-unbound row (`null`). **The holders: two readings, met.** The stored active rows by `package_id` (a set published through the metadata door is stored and registered in the registry's bare slot without a package, so only the row says whose it is), and the registry's OWN package-held reading for a set a package ships in code or declares in its install claim (review should-fix 5; ruling letter B on #22371, record 6074838935: one reading of who holds a name). `@objectstack/objectql` exports that reading as `findPackageHeldSecurityCatalogNames(registry)`; metadata-protocol cannot import objectql (objectql depends on it), so the predicate asks the same registry member the export asks, as it already asks the registry's `defaultRecordPageHoldersOf` for the default-record-page rule. A new objectql suite drives the doors over the real `ObjectQL` + `SchemaRegistry` composition, so a rename of that member reds there. The first cut's `getItem` reading (one item, no install claims, a bare-slot stamp read as a package) is gone. **A package-less row beside exactly one package's row is that package's (review should-fix 6).** #23165's rule for the write address, applied to ownership: such a row is the residue of the direct save before it was re-addressed, so it is not a second owner. The owning package's edit and a write landing in the residue row are accepted; another package is still refused, and its refusal names the package alone. **Envelope (review should-fix 4).** `NAMESPACE_CONFLICT` / 422, worded as the registry's `SecurityCatalogNameConflictError` ("Security catalog name conflict: … is already held by …", the same rationale sentence), prescription first because a 4xx message is truncated at 500 characters at the REST boundary. The rulings fix this rule's envelope at that code and status on every path, so a client reads one code for the rule whichever door it met. The code is `@objectstack/objectql`'s; `@objectstack/spec`'s `ERROR_CODE_LEDGER` now lists it under `@objectstack/metadata-protocol` too (the `DEFAULT_RECORD_PAGE_CONFLICT` precedent); the `ErrorCode` union is unchanged and no code is minted. **Faces declared (review should-fix 3).** The changeset's "Who sees it" now names `POST /api/v1/packages/:id/duplicate` (a copied set whose name lacks the source namespace prefix is reported in `failed[]`; remedy: prefix it with your own module prefix) and `os migrate meta --stored --apply` (a re-saved row of a name two owners hold is reported failed until one set is renamed or deleted). A `patch` changeset ships it for `@objectstack/metadata-protocol` and `@objectstack/spec`. ## Tests - `packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts` — 17 cases on the storing engine double (doubles recorded in `scripts/engine-double-contract.pinned.json`), each refusal paired with a control through the same door: the save door (both modes, the environment author, the create precondition, an install-claimed name); the owner as the binding the write lands in (the must-fix pin: owner B holds the active set, body without `packageId`, `mode: 'draft'`, no package on the request → `success: true`, draft bound to B; the publish-mode control; the ambiguous draft address); the package-less residue; the promotion (per-item and batch, `failed[].code` = `NAMESPACE_CONFLICT`); the restore door through `rollbackMetaItem` and its control. Shapes a deployment can only hold from before this hardening are staged through the real write path with the predicate switched off for the staging writes alone. - `packages/objectql/src/protocol-permission-set-name-registry-reading.test.ts` — 4 cases over the real composition (`ObjectQLPlugin` binding the real protocol to a real `ObjectQL` and its `SchemaRegistry`, an in-memory driver): the exported reading names the claimant; another installed package and the environment are refused `NAMESPACE_CONFLICT` / 422 naming it, nothing stored; control. <details><summary>Touched packages — typecheck and full suites (verify-lock, exit codes captured before any pipe)</summary> ``` pnpm --filter @objectstack/metadata-protocol typecheck && build -> VERDICT command-exit 0 pnpm --filter @objectstack/metadata-protocol test -> Test Files 280 passed | 4 skipped (284) · Tests 21798 passed | 26 skipped (21824) · VERDICT command-exit 0 pnpm --filter @objectstack/objectql typecheck (incl. check:test-typecheck) && test (project local) -> Test Files 487 passed (487) · Tests 9132 passed (9132) · VERDICT command-exit 0 pnpm --filter @objectstack/spec typecheck && test -> Test Files 752 passed (752) · Tests 22343 passed | 1 todo (22344) · VERDICT command-exit 0 new files alone: metadata-protocol 17/17 · objectql 4/4 ``` `tsc --listFilesOnly` counts the metadata-protocol test in that package's program (1) and the objectql test in `tsconfig.test.json`'s (1), which `check:test-typecheck` compiles. </details> <details><summary>Ablation of must-fix 1 (the draft-address owner branch disabled) — src-resolved, no rebuild</summary> The subject resolves through the package's own `src` (the test imports `./protocol.js`), so the mutation is seen without a rebuild. The fix was committed first; `scripts/ablation-replace.mjs` under a script with `trap` restore against `HEAD`. ``` before: anchor=1 marker=0 ablation-replace: anchor x1 -> x0 · replace x0 -> x1 · blob 00b7328 -> 9092f29eaeb8 (mutation landed) during: anchor=0 marker=1 × a draft save naming no package, body without packageId, over package B's active set is B's own edit Error: Security catalog name conflict: this environment cannot save the permission set "project_editor" — it is already held by package "app.owner_b". … (the reviewed defect, reproduced) × a draft save naming no package over a name two packages already hold … (422 instead of the repository's 409) Test Files 1 failed (1) · Tests 2 failed | 15 passed (17) ablation-replace: ok restored: blob == HEAD (00b7328) and `git diff HEAD` is empty trap: blob 00b7328… == HEAD, git diff HEAD empty ``` Direction observed: red (the expected one); the other 15 cases, controls included, stay green. </details> ## Acceptance notes - `Metadata-protocol: none`: no authorable key, value or type changes; a cross-owner second set was never an authorable capability (the install and cold-boot doors and the Studio dialog already refuse it). The accept-set narrowing is at the metadata HTTP doors, which is why `Clause-②` carries the `(narrowing)` arm while the kind stays `none` and the level `patch`. Re-checked after the envelope change: relative to `main` the doors still go from accepting to refusing, and the ledger row adds no member to the `ErrorCode` union, so the declaration is unchanged. - The registry seam is structural by necessity (the dependency runs objectql → metadata-protocol). A host registry without the reading member answers no package-held name, as a registry without `getItem` did before; on the one production host the objectql suite above holds it. - `security-catalog-namespace.ts` (objectql) states the registry does not judge "an environment-catalog save over a package-held name — that path keeps its own answers". Still true: the metadata doors now give that answer, with the registry's envelope. Its header could point at the metadata-protocol predicate; not touched here (no behaviour, outside this diff's files). - `os migrate security-catalog-overlays` and the cold boot read the bare slot as the environment's; this door reads stored rows by binding. The two meet on the package half (one reading) and differ only on the stored half, which the cold boot cannot see by construction. ## Verification Local gate subset on the diff (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, no paths; change set from the merge base, 8 paths): 93 derived, every one run with its exit code captured before any pipe; `--ran` reconciliation: 93 accounted — 92 run, 1 NOT-MEASURED (`check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: it reads the built output of 72 packages outside this diff's closure; Build Core answers it in CI). `check:lean-entry-closure` first exited 3 for the same reason; after `pnpm --filter @objectstack/objectql build` it measured `exit 0` (2 published conditions from a real load, admitted set held exactly). Every family CI runs is read from the PR's check-runs, not re-run here; the repo-wide `pnpm lint` and the full-build gates are CI's. On head `585e6198` CI reads 49 check-runs, all completed: 39 success, 10 skipped (path-filtered or opt-in), 0 failure — `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core` (8/8 shards), `Build Core`, `Governed Surface Queue Guard`, `Check Changeset`, `Dogfood Regression Gate`, `Type Check UI`, `Test UI`, `Build & E2E UI` and `Live E2E UI` success; `Lint UI` skipped. <details><summary>Key gate verdict lines</summary> ``` check-adr-0087-registration --base origin/main -> exit 0 · 1 declared-breaking changeset, ADR-0087 exemption (no-migration-prescription) check-changeset-no-major --base origin/main -> exit 0 · no major bump check-empty-changeset --base origin/main -> exit 0 · 1 declaring changeset added; none modified or deleted check:engine-double-contract -> exit 0 · OK — 1123 pinned, 120 in the DEBT ledger, 3 exempt check:dispatcher-error-vocabulary -> exit 0 · OK — 56 unregistered code-stamping sites, all classified check:error-status-conformance -> exit 0 · every derivable runtime status is documented, and every documented status is reachable check:error-code-casing · check:doc-authoring · check:issue-citations · check:nul-bytes -> exit 0 check:test-source-alias · check:cross-package-test-inputs · check:durability-log-level -> exit 0 spec: check:api-surface (unchanged) · check:docs (232 generated files in sync) · check:authorable-surface · 14 more spec families -> exit 0 local-only: check:skill-frame-freshness -> exit 0 · check:dev-prereqs -> exit 1 (this worktree is built only for the diff's closure: 35 of 105 packages have no dist; a local-state reading, not a finding) ``` </details> <details><summary>Dogfood battery (Clause-② carries the narrowing arm) — final head</summary> ``` head 585e619 · closure built first: pnpm --workspace-concurrency=2 --filter "@objectstack/dogfood^..." build -> VERDICT command-exit 0 pnpm --filter @objectstack/dogfood test (no OS_TEST_SHARD, no path) -> VERDICT command-exit 0 Test Files 350 passed | 3 skipped (353) · Tests 2817 passed | 20 skipped (2837) · no failure ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk)_ <sub>Body refreshed by the PM seat 2026-10-29T15:22Z from the os-dev rework report.</sub> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85663a5 commit 710c2da

8 files changed

Lines changed: 1098 additions & 1 deletion
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/spec': patch
4+
---
5+
6+
harden(metadata): a permission set's name is unique across packages
7+
8+
Clause-②: no (narrowing) — the metadata doors now refuse a permission-set write whose name a DIFFERENT owner already holds (`PUT /api/v1/meta/permission/:name`, draft or publish; `POST /api/v1/meta/permission/:name/publish` and `POST /api/v1/packages/:id/publish-drafts`; the restore doors `rollbackMetaItem` and `revertCommit`), with `NAMESPACE_CONFLICT` / 422 and nothing written. This pulls the metadata doors back to an already-declared contract: a grant resolves its set by name alone (ADR-0131 D4), a package never writes into a record another owns (ADR-0086 D4), and the security catalog holds one name per deployment on every path — the registry already refuses a second holder of a set name at install and at cold boot with this envelope, and the Studio dialog already refuses the duplicate. Nothing is widened.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, accepted value, metadata type, export or type changes: a write the metadata doors accepted against an already-declared contract (ADR-0086 D4, ADR-0131 D4) is now refused with the registry's own NAMESPACE_CONFLICT envelope, so there is nothing for a migration to rewrite and no tombstone; a deployment already holding one name under two owners clears it by renaming or deleting one of the two sets, which the refusal names. -->
12+
13+
**BREAKING** accept-set narrowing at the metadata doors (`@objectstack/metadata-protocol`), served by the permission-set save door, the draft→active promotion and the restore doors.
14+
15+
- **Before.** The metadata doors keyed a permission-set write by `(type, name, package)`, so a package could save, publish or restore a set under a name another installed package, or the environment, already held. Resolution by bare name then answered one of the two, and the create precondition (`If-None-Match: *`) was evaluated against the keyed row alone, so it did not notice the name was taken by another owner.
16+
- **Now.** A permission-set write whose name a different owner holds is refused — `NAMESPACE_CONFLICT` / 422, the code and status the registry answers for the same rule at install and at cold boot, nothing written — at the save door (draft and publish mode), again at the draft→active promotion (the per-item publish and the package publish), and at the restore doors, so neither a draft authored before another owner took the name nor a version of a deleted set can become a second holder. The check runs before the create precondition, so `If-None-Match: *` cannot smuggle a cross-owner create.
17+
- **Owner.** The owner is the binding the write lands in: the package its row is written under, or the environment for a package-unbound row. A draft save that names no package over a package's set is that package's own edit (the draft inherits the package), and a package-less row stored beside exactly one package's row is that package's, not a second owner. `permission` takes no environment overlay (ADR-0005), so the rule is symmetric on the owner; the owning package, or the environment, re-authoring its own set is unchanged, and the original holder's access is untouched.
18+
- **Holders.** The stored active rows, by binding, and the registry's own reading of which packages hold a name — a set a package ships in code or declares in its install — the same reading the cold boot and `os migrate security-catalog-overlays` read.
19+
- **Who sees it.** A deployment where two owners write one set name: the second write is refused where it used to be accepted. Three faces of the same doors see it too. `POST /api/v1/packages/:id/duplicate` copies each permission set through the save door under the target package, renaming only a name that carries the source package's namespace prefix, so a copied set whose name lacks that prefix is now reported in the response's `failed[]` with this refusal instead of being copied as a second holder — prefix the set with your own module prefix before duplicating. `os migrate meta --stored --apply` re-saves each stored row the conversion chain rewrites through the save door, so on a deployment already holding one name under two owners such a row of either owner is reported failed until one of the two sets is renamed or deleted (a package-less row stored beside exactly one package's row is that package's, and is re-saved).
20+
- **`@objectstack/spec`.** `ERROR_CODE_LEDGER` lists `NAMESPACE_CONFLICT` under `@objectstack/metadata-protocol` too, since that package now stamps it; the `ErrorCode` union is unchanged.
21+
22+
**The remedy.** Rename the set to a name no other holder holds (prefix it with your own module prefix), or rename or delete the other holder's set first; to change the existing set, edit it in the package that holds it and re-publish.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export interface AuthoringGateHost {
7777
hierarchyResolverMounted: ObjectStackProtocolImplementation['hierarchyResolverMounted'];
7878
isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked'];
7979
orgWallEnforced: ObjectStackProtocolImplementation['orgWallEnforced'];
80+
permissionSetNameCollisionRefusal: ObjectStackProtocolImplementation['permissionSetNameCollisionRefusal'];
8081
refuseRestoredBodyTheSaveDoorRefuses: ObjectStackProtocolImplementation['refuseRestoredBodyTheSaveDoorRefuses'];
8182
resolveSduiManifest: ObjectStackProtocolImplementation['resolveSduiManifest'];
8283
resolveWritePackageScope: ObjectStackProtocolImplementation['resolveWritePackageScope'];
@@ -112,13 +113,15 @@ export function restoredBodyWriter(
112113

113114
/** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */
114115
export async function refuseRestoredBodyTheSaveDoorRefuses(
115-
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
116+
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
116117
type: string,
117118
name: string,
118119
body: unknown,
119120
binding: string | null,
120121
source: string,
121122
): Promise<void> {
123+
const nameCollision = await this.permissionSetNameCollisionRefusal(type, name, binding, 'restore');
124+
if (nameCollision) throw nameCollision;
122125
const hookRefusal = runtimeHookWithoutBodyRefusal(type, body, name);
123126
if (hookRefusal) throw hookRefusal;
124127
const defaultPageRefusal = await this.defaultRecordPageSaveRefusal(type, body, name, 'restored');

‎packages/metadata-protocol/src/protocol-save-door-refusals.ts‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,161 @@ export async function defaultRecordPageSaveRefusal(
269269
return err;
270270
}
271271

272+
/**
273+
* The packages the engine registry says hold the permission-set name `name` —
274+
* the registry's OWN reading of who holds a security-catalog name: the package
275+
* half of the reading its cold-boot check refuses with, a name held through an
276+
* item a package registered under its id (its composite slot) or through the
277+
* package's install claim, never through the bare slot, built-in names skipped.
278+
* One reading of "who holds a name", never a second one beside the cold boot's
279+
* (maintainer ruling letter B on #22371, record 6074838935).
280+
*
281+
* `@objectstack/objectql` exports that reading as
282+
* `findPackageHeldSecurityCatalogNames(registry)`, which answers
283+
* `registry.packageHeldSecurityCatalogNames()`. This package cannot import
284+
* `@objectstack/objectql` — it would form a cycle (`host-engine.ts`) — so it
285+
* asks the same member of the host's registry, the way it asks the registry's
286+
* `defaultRecordPageHoldersOf` for the default-record-page rule. The objectql
287+
* suite `protocol-permission-set-name-registry-reading.test.ts` holds the seam
288+
* from the side that sees both: it drives the doors over a real
289+
* `SchemaRegistry`, so a rename of the member reds there. A host registry with
290+
* no such member answers no package-held name, as one without `getItem` did.
291+
*
292+
* Not caught: the reading is in memory, and a throw from it is a fault to
293+
* surface, never "nobody holds the name".
294+
*/
295+
function registryPackageHoldersOf(registry: unknown, name: string): readonly string[] {
296+
const read = (registry as { packageHeldSecurityCatalogNames?: unknown } | null | undefined)
297+
?.packageHeldSecurityCatalogNames;
298+
if (typeof read !== 'function') return [];
299+
const held: unknown = read.call(registry);
300+
if (!Array.isArray(held)) return [];
301+
const holders = new Set<string>();
302+
for (const entry of held as Array<{ catalogType?: unknown; name?: unknown; packageIds?: unknown } | null>) {
303+
if (entry?.catalogType !== 'permission' || entry.name !== name || !Array.isArray(entry.packageIds)) continue;
304+
for (const id of entry.packageIds) if (typeof id === 'string' && id !== '') holders.add(id);
305+
}
306+
return [...holders];
307+
}
308+
309+
/**
310+
* The body of `ObjectStackProtocolImplementation#permissionSetNameCollisionRefusal`;
311+
* the method's docblock there is its contract.
312+
*
313+
* A `permission` set's name is its identity across the whole deployment: a
314+
* grant (`sys_user_permission_set.permission_set`) resolves its set by that
315+
* name alone (ADR-0131 D4), so positions, permission sets and capabilities each
316+
* hold one name per deployment (maintainer ruling Q4 = A on #15196, record
317+
* 6050490870). A write that would make a set of a name ACTIVE under an owner
318+
* other than the one already holding the name is refused here, with nothing
319+
* written — a package never writes into a record another owns (ADR-0086 D4).
320+
*
321+
* ## The owner
322+
*
323+
* `owner` is the binding the write LANDS IN: the package its row is written
324+
* under, or `null` for the package-unbound (environment) row. Each door hands
325+
* the binding its own write resolves, never the request's spelling.
326+
* `permission` takes no environment overlay (`allowEnvironmentOverlay: false`,
327+
* ADR-0005), so a name a package holds is never an environment author's to
328+
* re-use either, and the predicate is symmetric on the owner: a write by the
329+
* owner already holding the name — the owning package, or the environment,
330+
* re-authoring its own set — is untouched.
331+
*
332+
* ## The holders: two readings, met
333+
*
334+
* - **The stored active rows** of the environment (ADR-0131 D6), by
335+
* `package_id`. A set published through the metadata door is stored, and
336+
* the registry holds it in the bare slot with no package (hydration and the
337+
* write-through register a stored row without one), so only the row says
338+
* which package it belongs to — the half the registry's reading cannot see,
339+
* and why this door's reading is wider than the cold boot's.
340+
* - **The registry's package half** ({@link registryPackageHoldersOf}): a set
341+
* a package ships in code, or names in its install claim, read through the
342+
* registry's own reading and never re-derived (ruling letter B on #22371).
343+
*
344+
* A package-less row stored beside the rows of exactly ONE package is that
345+
* package's, not a second owner — #23165's rule for the write address ("a
346+
* package-less row beside the package's row is not a second package", which
347+
* `packagelessAddress` resolves), applied to ownership: the direct save that
348+
* minted such a row was re-addressed into the package. It is read as that
349+
* package's on both sides, so it makes no foreign holder of the package's own
350+
* edit, and a write that lands in it is the package's write. Beside two or
351+
* more packages' rows, or none, a package-less row is the environment's.
352+
*
353+
* An UNPROVISIONED store counts as "no stored sets"; any other read failure
354+
* propagates — answering "no other owner" from a read that never happened
355+
* would accept the very second set this exists to refuse.
356+
*
357+
* ## The envelope
358+
*
359+
* `NAMESPACE_CONFLICT` / 422, worded as the registry's own refusal of a second
360+
* holder is (`SecurityCatalogNameConflictError`, `@objectstack/objectql`): the
361+
* rulings fix this rule's envelope at that code and status on every path a
362+
* second holder can arrive by (ruling A on #22307, record 6063176077: "one
363+
* name, one holder, on every path"), and the metadata doors are such paths. A
364+
* client reads one code for the rule whichever door it met. The prescription
365+
* precedes the reason because a 4xx message crosses the REST boundary bounded
366+
* at 500 characters with its tail truncated.
367+
*/
368+
export async function permissionSetNameCollisionRefusal(
369+
this: Pick<SaveDoorRefusalHost, 'engine' | 'readActiveOverlayRows' | 'rethrowUnlessMetadataStoreUnprovisioned' | 'storedOverlayEntries'>,
370+
singularType: string,
371+
name: string,
372+
owner: string | null | undefined,
373+
verb: 'save' | 'publish' | 'restore',
374+
): Promise<(Error & { code: 'NAMESPACE_CONFLICT'; status: 422 }) | undefined> {
375+
if (singularType !== 'permission') return undefined;
376+
377+
let records: any[] = [];
378+
try {
379+
// [ADR-0131 D6] A write lands environment-wide, so the sets it meets
380+
// are the environment-wide rows.
381+
records = await this.readActiveOverlayRows({ type: singularType });
382+
} catch (error) {
383+
this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata');
384+
}
385+
const storedPackages = new Set<string>();
386+
let storedUnbound = false;
387+
for (const entry of this.storedOverlayEntries({ type: singularType }, records)) {
388+
if (entry.name !== name) continue;
389+
if (typeof entry.packageId === 'string' && entry.packageId !== '') storedPackages.add(entry.packageId);
390+
else storedUnbound = true;
391+
}
392+
// [#23165] A package-less row beside exactly one package's row is that package's.
393+
const residueOf = storedUnbound && storedPackages.size === 1 ? [...storedPackages][0] : undefined;
394+
395+
const holders = new Set<string | null>(storedPackages);
396+
for (const packageId of registryPackageHoldersOf(this.engine?.registry, name)) holders.add(packageId);
397+
if (storedUnbound && residueOf === undefined) holders.add(null);
398+
399+
const written = typeof owner === 'string' && owner !== '' ? owner : residueOf ?? null;
400+
const foreign = [...holders]
401+
.filter((holder) => holder !== written)
402+
// Packages by id, the environment last, so the sentence is the same on every driver.
403+
.sort((a, b) => (a === b ? 0 : a === null ? 1 : b === null ? -1 : a < b ? -1 : 1));
404+
if (foreign.length === 0) return undefined;
405+
406+
const holderPhrase = (holder: string | null) => (holder === null ? 'the environment catalog' : `package "${holder}"`);
407+
const writer = written === null ? 'this environment' : `package "${written}"`;
408+
const releases = foreign
409+
.map((holder) => (holder === null ? 'rename or delete the environment catalog\'s set' : `rename it in package "${holder}"`))
410+
.join(', or ');
411+
const remedy = verb === 'restore'
412+
? `Restore it once the name is free (${releases} first), or save this version's body as a set of a name no other `
413+
+ 'holder holds.'
414+
: `Rename this set to a name no other holder holds (prefix it with your own module prefix), or ${releases} first.`;
415+
const err = new Error(
416+
`Security catalog name conflict: ${writer} cannot ${verb} the permission set "${name}" — it is already held by `
417+
+ `${foreign.map(holderPhrase).join(' and ')}. Nothing was ${verb === 'publish' ? 'published' : 'written'}. ${remedy} `
418+
+ 'Positions, permission sets and capabilities each hold one name per deployment: an assignment names a '
419+
+ 'permission set by its bare name, with no package to tell two definitions apart, so with two holders which '
420+
+ 'definition grants would depend on registration order. See ADR-0048.',
421+
) as Error & { code: 'NAMESPACE_CONFLICT'; status: 422 };
422+
err.code = 'NAMESPACE_CONFLICT';
423+
err.status = 422;
424+
return err;
425+
}
426+
272427
/** The body of `ObjectStackProtocolImplementation#externalBindingSaveRefusal`; the method's docblock there is its contract. */
273428
export async function externalBindingSaveRefusal(
274429
this: Pick<SaveDoorRefusalHost, 'externalBindingDatasourceDefinition' | 'objectsBoundExternallyTo'>,

0 commit comments

Comments
 (0)