From 71af10d07c690d612c89cbfe109851e3b4c0b19a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:10:48 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(objectql)!:=20the=20security=20catal?= =?UTF-8?q?og=20holds=20one=20name=20per=20holder=20=E2=80=94=20refuse=20a?= =?UTF-8?q?=20second=20holder=20of=20a=20position,=20permission=20set=20or?= =?UTF-8?q?=20capability=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package door (SchemaRegistry.installPackage, ahead of every mutation) refuses a package whose declared positions, permission sets or capabilities name something an installed package, the environment catalog or a built-in already holds; the item seam (registerItem with a package id) refuses the same for direct package-bound registrations. ADR-0112 envelope: the namespace gate's code, NAMESPACE_CONFLICT, status 422; the message names both holders. Same-package reload stays allowed; no collisionPolicy downgrade; bare-slot (environment) registrations are not judged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/objectql/src/registry.ts | 248 +++++++++++++++++- .../src/security-catalog-namespace.ts | 180 +++++++++++++ 2 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 packages/objectql/src/security-catalog-namespace.ts diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 5b4161ea96b..bb40d6e30ce 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -52,7 +52,18 @@ import { applyProtection } from '@objectstack/spec/shared'; // (`id || name`). The install gate's co-ownership set must name packages by the // same string the artifact loader ordered them by, or a co-owner would be // admitted — or refused — under a key nothing else in the path uses. -import { artifactPackageId } from '@objectstack/core'; +import { artifactPackageId, type SecurityCatalogType } from '@objectstack/core'; +// One name, one holder for positions, permission sets and capabilities — the +// rule, the holders and the one place a declaration is read from (module doc). +import { + BUILT_IN_SECURITY_CATALOG_NAMES, + declaredSecurityCatalogNames, + describeSecurityCatalogHolder, + isSecurityCatalogType, + securityCatalogHolderKey, + securityCatalogTypeLabel, + type SecurityCatalogHolder, +} from './security-catalog-namespace.js'; // [#14553] The ONE resolution of "does this nav group id exist?" and the ONE // wording of the diagnostic when it does not. `os build` calls the same two // (`checkNavContributionGroups`), so the compile-time door and this read-time @@ -1555,6 +1566,82 @@ export class NamespaceConflictError extends Error { } } +/** One second holder {@link SecurityCatalogNameConflictError} refused. */ +export interface SecurityCatalogNameConflict { + /** The catalog type, as the registry keys it. */ + readonly catalogType: SecurityCatalogType; + /** The name both holders claim. */ + readonly name: string; + /** The package whose registration this refusal stopped. */ + readonly incomingPackageId: string; + /** Who already holds the name. */ + readonly existingHolder: SecurityCatalogHolder; +} + +/** + * Raised when a package registers a position, permission set or capability + * whose name another holder already holds — an installed package, the + * environment catalog or a built-in (`security-catalog-namespace.ts` states the + * rule, the holders, and what it deliberately does not judge). + * + * The namespace gate's sibling, in its shape: the ADR-0112 envelope (`code` + + * `status: 422`) so a wire door answers `422`, never the `500` fallback, and + * the code the namespace gate already registers — the condition is the same + * one, "a name in a deployment-wide namespace is already taken", and so is the + * caller's remedy: rename, or uninstall the other holder. ⛔ Not the namespace + * gate's class: that one names a `manifest.namespace` and offers the + * `OS_METADATA_COLLISION=warn` downgrade, neither of which is true here. + * + * Every conflict the registration carries is listed — `conflicts` — so one boot + * reports all of them; the top-level fields repeat the first. + */ +export class SecurityCatalogNameConflictError extends Error { + readonly code = NAMESPACE_CONFLICT_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** Every second holder this registration would have created, in declaration order. */ + readonly conflicts: readonly SecurityCatalogNameConflict[]; + /** The first conflict's catalog type. */ + readonly catalogType: SecurityCatalogType; + /** The first conflict's name. */ + readonly catalogName: string; + /** The package whose registration this refusal stopped. */ + readonly incomingPackageId: string; + /** The first conflict's existing holder. */ + readonly existingHolder: SecurityCatalogHolder; + + constructor(conflicts: readonly SecurityCatalogNameConflict[]) { + const [first] = conflicts; + const lines = conflicts.map( + (c) => + `${securityCatalogTypeLabel(c.catalogType)} "${c.name}" is already held by ` + + describeSecurityCatalogHolder(c.existingHolder), + ); + super( + `Security catalog name conflict: package "${first.incomingPackageId}" cannot register ` + + `${conflicts.length === 1 ? 'a name' : `${conflicts.length} names`} another holder ` + + `already holds — ${lines.join('; ')}. Positions, permission sets and capabilities each ` + + `hold one name per deployment: an assignment names a position or a permission set by ` + + `its bare name, with no package to tell two definitions apart, so with two holders ` + + `which definition grants would depend on registration order. Rename the item in ` + + `"${first.incomingPackageId}"${ + first.existingHolder.kind === 'package' + ? `, rename it in "${first.existingHolder.packageId}", or uninstall one of the two packages` + : first.existingHolder.kind === 'environment' + ? ', or rename or delete the environment\'s item first' + : ' — a built-in name is never available to a package' + }. See ADR-0048.`, + ); + this.name = 'SecurityCatalogNameConflictError'; + this.conflicts = conflicts; + this.catalogType = first.catalogType; + this.catalogName = first.name; + this.incomingPackageId = first.incomingPackageId; + this.existingHolder = first.existingHolder; + } +} + /** * [ADR-0130 D1] What the install gate is told about the artifact now installing. * @@ -2154,6 +2241,125 @@ export class SchemaRegistry { return owners ? Array.from(owners) : []; } + // ========================================== + // Security catalog namespace — one name, one holder + // ========================================== + + /** + * Catalog type → name → the installed package that declared it, recorded by + * {@link installPackage} once its declarations pass + * {@link refuseSecurityCatalogNameConflicts}, forgotten by + * {@link uninstallPackage}. + * + * The registry's item store answers "who holds this name?" for every item it + * registered — but it does not register every catalog item a package + * declares: the engine's collection loop has no `positions` entry, so a + * package's positions reach only the metadata service's in-memory registrars + * (`AppPlugin`'s security block, the artifact door). The claim is how the + * package door remembers them, so a second package declaring one of them is + * refused like any other second holder. + */ + private securityCatalogClaims = new Map>(); + + /** + * Every holder of `(type, name)` other than `exceptPackageId` — the ONE + * predicate both refusals ({@link refuseSecurityCatalogNameConflicts} at the + * package door, the item seam in {@link registerItem}) ask. + * + * Read, in this order: a built-in (only when `builtIns` — see + * {@link BUILT_IN_SECURITY_CATALOG_NAMES} for why the item seam does not ask); + * the bare slot (an override a package bound to itself, else an + * environment-authored item); every composite `:` slot; the + * package claims. A package that holds the name more than one way is one + * holder. A disabled package is still installed, and still holds its names. + */ + private securityCatalogHoldersOtherThan( + type: SecurityCatalogType, + name: string, + exceptPackageId: string | undefined, + opts: { builtIns: boolean }, + ): SecurityCatalogHolder[] { + const found = new Map(); + const add = (holder: SecurityCatalogHolder) => { + if (holder.kind === 'package' && holder.packageId === exceptPackageId) return; + found.set(securityCatalogHolderKey(holder), holder); + }; + if (opts.builtIns && BUILT_IN_SECURITY_CATALOG_NAMES[type].has(name)) add({ kind: 'built-in' }); + const suffix = `:${name}`; + for (const [key, item] of this.metadata.get(type) ?? []) { + const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; + if (key === name) { + add(typeof stamped === 'string' && stamped !== '' ? { kind: 'package', packageId: stamped } : { kind: 'environment' }); + } else if (key.endsWith(suffix)) { + add({ + kind: 'package', + packageId: typeof stamped === 'string' && stamped !== '' ? stamped : key.slice(0, -suffix.length), + }); + } + } + const claimant = this.securityCatalogClaims.get(type)?.get(name); + if (claimant !== undefined) add({ kind: 'package', packageId: claimant }); + return [...found.values()]; + } + + /** + * The package door's half of the rule: refuse a package whose declared + * positions, permission sets or capabilities name something another holder + * already holds — built-ins included — ahead of every mutation + * {@link installPackage} makes, so a refused package leaves no record, no + * namespace ownership and no claim behind (the namespace gate's disposition). + * + * Every conflict is collected, so one refusal reports all of them. A package + * with no identity (`artifactPackageId` answers nothing) registers under no + * package at all and has nothing to hold; it is not judged here. + * + * @throws {SecurityCatalogNameConflictError} ADR-0112 envelope, naming both + * holders of each conflicting name. + */ + private refuseSecurityCatalogNameConflicts(manifest: ObjectStackManifest, selfId: string | undefined): void { + if (selfId === undefined) return; + const conflicts: SecurityCatalogNameConflict[] = []; + const seen = new Set(); + for (const { type, name } of declaredSecurityCatalogNames(manifest)) { + // One package declaring one name twice is one holder (an in-package + // repeat is the package's own business, and not this rule's). + const key = `${type}|${name}`; + if (seen.has(key)) continue; + seen.add(key); + for (const existingHolder of this.securityCatalogHoldersOtherThan(type, name, selfId, { builtIns: true })) { + conflicts.push({ catalogType: type, name, incomingPackageId: selfId, existingHolder }); + } + } + if (conflicts.length > 0) throw new SecurityCatalogNameConflictError(conflicts); + } + + /** + * Record `packageId`'s claims. Additive, like the item store beside it: a + * re-install does not unregister the items an earlier install of the same + * package registered, so it does not release their names either — and a + * re-install whose manifest carries no collections at all (`POST + * /api/v1/packages` stores the manifest alone) must not strip the names the + * package's code still declares. {@link uninstallPackage} is what releases + * them. + */ + private recordSecurityCatalogClaims(packageId: string, manifest: ObjectStackManifest): void { + for (const { type, name } of declaredSecurityCatalogNames(manifest)) { + let byName = this.securityCatalogClaims.get(type); + if (!byName) { + byName = new Map(); + this.securityCatalogClaims.set(type, byName); + } + byName.set(name, packageId); + } + } + + /** Drop every claim `packageId` holds. */ + private forgetSecurityCatalogClaims(packageId: string): void { + for (const byName of this.securityCatalogClaims.values()) { + for (const [name, claimant] of byName) if (claimant === packageId) byName.delete(name); + } + } + // ========================================== // Object Registration (Ownership Model) // ========================================== @@ -3533,6 +3739,28 @@ export class SchemaRegistry { const collection = this.metadata.get(type)!; const baseName = String(item[keyField]); + // The security catalog's item seam: a package registering a position, + // permission set or capability under a name another holder holds is + // refused BEFORE anything below stamps or stores it — the per-item + // cross-package throw ADR-0048 §3.4 retired, kept for these three types + // (maintainer ruling Q4 = A on #15196; `security-catalog-namespace.ts`). + // The package door ({@link installPackage}) has already refused a package's + // declared names, built-ins included; this is the seam every OTHER + // package-bound registration reaches (a plugin declaring items through + // the registry directly), so it asks the registered holders only — the + // platform registers its own built-ins here, under its own package id. + // ⛔ A registration with no `packageId` is the bare slot — `sys_metadata` + // hydration, the metadata write-through — and is never judged here: an + // environment save over a package-held name is outside the ruling. + if (packageId && isSecurityCatalogType(type)) { + const holders = this.securityCatalogHoldersOtherThan(type, baseName, packageId, { builtIns: false }); + if (holders.length > 0) { + throw new SecurityCatalogNameConflictError( + holders.map((existingHolder) => ({ catalogType: type, name: baseName, incomingPackageId: packageId, existingHolder })), + ); + } + } + // ADR-0010 §3.7 — translate the author-facing `protection` block // into the private `_lock` envelope and stamp package provenance. // Centralised with the artifact loader path in metadata/plugin.ts @@ -3606,6 +3834,10 @@ export class SchemaRegistry { // same bare name (e.g. `page/home`) legitimately COEXIST under distinct // composite keys and each caller resolves to its own. What the original // guard flagged as a collision is now the supported marketplace case. + // ⚠️ Except for the three security catalog types, refused at the top of this + // method: an assignment names a position or a permission set with no + // package context, so there a second holder is an ambiguity no caller can + // resolve (maintainer ruling Q4 = A on #15196). // // Same-package re-registration still overwrites (idempotent reload), and a // runtime/DB overlay over a packaged item is the sanctioned ADR-0005 path, @@ -4316,6 +4548,14 @@ export class SchemaRegistry { // behind — the same disposition the namespace gate above has. this.refuseCoOwnedObjectNameCollision(manifest, selfId, coOwners); + // The security catalog's one-name-one-holder rule (ADR-0048 §3.4 narrowed + // for positions, permission sets and capabilities; maintainer ruling Q4 = A + // on #15196) — also ahead of every mutation, for the same reason. No + // co-owner exemption: two packages of one artifact sharing a catalog name + // are as ambiguous to a bare-name assignment as two strangers are, and no + // `collisionPolicy` downgrade (`security-catalog-namespace.ts`). + this.refuseSecurityCatalogNameConflicts(manifest, selfId); + const now = new Date().toISOString(); // [#18877] 「缺省 = 保持,有旗 = 设置」 — an install that was not asked to move @@ -4390,6 +4630,9 @@ export class SchemaRegistry { this.debug(`[Registry] Overwriting package: ${manifest.id}`); } collection.set(manifest.id, pkg); + // The names this package now holds — positions included, which the + // engine's collection loop never registers (see `securityCatalogClaims`). + if (selfId !== undefined) this.recordSecurityCatalogClaims(selfId, manifest); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); return pkg; } @@ -4485,6 +4728,9 @@ export class SchemaRegistry { // process. Runs AFTER the object verb because that one can refuse // (ADR-0029 extenders): a refused uninstall must remove nothing at all. this.unregisterItemsByPackage(id); + // …and the security catalog names it held, so an uninstalled package's + // names are free for the next package to declare. + this.forgetSecurityCatalogClaims(id); // [#18877] The boot seed forgets the id together with the row. A package // that no longer exists has no lifecycle state to preserve, so the next diff --git a/packages/objectql/src/security-catalog-namespace.ts b/packages/objectql/src/security-catalog-namespace.ts new file mode 100644 index 00000000000..05ece663139 --- /dev/null +++ b/packages/objectql/src/security-catalog-namespace.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one holder — the security catalog's namespace rule, and the + * vocabulary {@link SchemaRegistry}'s refusal of a second holder is written in. + * + * ## The rule + * + * The three security catalog types — positions, permission sets and + * capabilities — each hold ONE namespace per deployment. A package whose + * position, permission set or capability bears a name that an installed + * package, the environment catalog or a built-in already holds is refused at + * registration, and the refusal names both holders. (Maintainer ruling Q4 = A + * on #15196, record 6050490870.) + * + * Why these three and nothing else: an assignment carries a BARE name with no + * package context (ADR-0131 D4) — a user holds the position `sales_manager`, + * not "package X's `sales_manager`" — so for the security catalog a shared name + * is a real ambiguity: which definition grants would depend on registration + * order. Measured before this rule, on a booted kernel with two packages + * sharing one name per type: the by-name read resolved the permission set and + * the capability to the FIRST-registered package and the position to the + * LAST-registered one. For every other metadata type ADR-0048 §3.4's + * coexistence stands: a caller there carries its own package, and package-scoped + * resolution disambiguates. This module is that narrowing, and only it. + * + * ## The holders + * + * - **a package** — a name another installed package declares (its claim, + * recorded at install, or an item it registered under its own id, or the + * stored override it bound to itself); + * - **the environment catalog** — an item authored in this environment rather + * than shipped by a package: the registry's bare slot carrying no + * `_packageId`; + * - **a built-in** — {@link BUILT_IN_SECURITY_CATALOG_NAMES}. + * + * ## What it deliberately does not judge + * + * - ⛔ An environment-catalog save over a package-held name. The ruling covers + * installing or registering a PACKAGE, not a metadata author's save; that + * path keeps its own answers (a packaged permission set is already locked + * against an in-place edit, `403`). A bare-slot registration — what every + * `sys_metadata` hydration and write-through performs — carries no package + * and is never refused here. + * - ⛔ A downgrade. `OS_METADATA_COLLISION=warn` softens the ADR-0048 Phase 1 + * namespace gate only; no ruling extends it to this refusal. + * - The same package registering its own name again — an idempotent reload, a + * re-install, a hot reload — is one holder, not two. + */ + +import type { SecurityCatalogType } from '@objectstack/core'; +import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec/identity'; +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; + +/** The three catalog types the rule covers, in the order a refusal lists them. */ +export const SECURITY_CATALOG_NAMESPACE_TYPES: readonly SecurityCatalogType[] = Object.freeze([ + 'position', + 'permission', + 'capability', +]); + +/** Is `type` one of the three security catalog types (as the registry keys it)? */ +export function isSecurityCatalogType(type: unknown): type is SecurityCatalogType { + return typeof type === 'string' && (SECURITY_CATALOG_NAMESPACE_TYPES as readonly string[]).includes(type); +} + +/** + * The stack collection each type is declared under — the keys the engine's + * manifest seam and the two in-memory registrars read (`positions`, + * `permissions`, `capabilities`). + */ +const COLLECTION_KEY: Readonly> = Object.freeze({ + position: 'positions', + permission: 'permissions', + capability: 'capabilities', +}); + +/** + * The names the platform itself holds, whichever package registers first. + * + * - `position` — the four built-in identity names (ADR-0068 D2) and the two + * audience anchors (ADR-0090 D5/D9). + * - `capability` — the curated platform capabilities (ADR-0066 D1), seeded by + * `plugin-security` and registered by no package. + * - `permission` — none here. The platform's permission sets are DECLARED by + * `plugin-security` on its own manifest (configurable through its + * `defaultPermissionSets` option), so they reach the registry as that + * package's items and are held by it like any other package's; a static + * list here would either refuse `plugin-security` its own sets or drift + * from what a deployment configured. + * + * Applied at the package door ({@link declaredSecurityCatalogNames}'s caller), + * never at the registry's own item seam: the platform declares these names to + * the registry itself, under its own package id. + */ +export const BUILT_IN_SECURITY_CATALOG_NAMES: Readonly>> = Object.freeze({ + position: new Set([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]), + permission: new Set(), + capability: new Set(PLATFORM_CAPABILITY_NAMES), +}); + +/** Who holds a security catalog name (module doc, "The holders"). */ +export type SecurityCatalogHolder = + | { readonly kind: 'package'; readonly packageId: string } + | { readonly kind: 'environment' } + | { readonly kind: 'built-in' }; + +/** One `(type, name)` a package declares. */ +export interface DeclaredSecurityCatalogName { + readonly type: SecurityCatalogType; + readonly name: string; +} + +/** + * The name an item is registered under — the engine seam's own derivation for + * a non-view collection (`resolveMetadataItemName`: `name`, else `id`). An + * item with neither is skipped there, so it claims nothing here. + */ +function itemName(item: unknown): string | undefined { + if (!item || typeof item !== 'object') return undefined; + const { name, id } = item as { name?: unknown; id?: unknown }; + if (typeof name === 'string' && name !== '') return name; + if (typeof id === 'string' && id !== '') return id; + return undefined; +} + +/** + * Every `(type, name)` a manifest declares, read from the SAME sources the + * engine's registration seams read: the manifest's own collections and each + * nested `plugins[]` entry's (a nested plugin contributes under its parent + * package — `ObjectQL.registerPlugin`), one level deep, arrays only. + * + * `positions` is read although the engine's collection loop does not register + * it: the in-memory registrars (`AppPlugin`'s security block, the artifact + * door) do, for the very same package, so the package's claim covers it. + * + * ⚠️ A non-array `permissions` is skipped, not misread: at the manifest stage + * that key is the ADR-0025 capability GRANT (`{ services, hooks, … }`), not a + * list of permission sets. + */ +export function declaredSecurityCatalogNames(manifest: unknown): DeclaredSecurityCatalogName[] { + const out: DeclaredSecurityCatalogName[] = []; + const read = (source: unknown) => { + if (!source || typeof source !== 'object') return; + for (const type of SECURITY_CATALOG_NAMESPACE_TYPES) { + const items = (source as Record)[COLLECTION_KEY[type]]; + if (!Array.isArray(items)) continue; + for (const item of items) { + const name = itemName(item); + if (name !== undefined) out.push({ type, name }); + } + } + }; + read(manifest); + const plugins = (manifest as { plugins?: unknown } | null | undefined)?.plugins; + if (Array.isArray(plugins)) for (const plugin of plugins) read(plugin); + return out; +} + +/** How a refusal names a catalog type. */ +export function securityCatalogTypeLabel(type: SecurityCatalogType): string { + return type === 'permission' ? 'permission set' : type; +} + +/** How a refusal names a holder. */ +export function describeSecurityCatalogHolder(holder: SecurityCatalogHolder): string { + switch (holder.kind) { + case 'package': + return `package "${holder.packageId}"`; + case 'environment': + return 'the environment catalog (an item authored in this environment, not shipped by a package)'; + case 'built-in': + return 'the platform, as a built-in name no package can declare'; + } +} + +/** A stable identity for de-duplicating holders. */ +export function securityCatalogHolderKey(holder: SecurityCatalogHolder): string { + return holder.kind === 'package' ? `package:${holder.packageId}` : holder.kind; +} From a2c61434437c76b118b80bea0cb4c9c0c3719ff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:13:56 +0000 Subject: [PATCH 02/11] test(objectql): pin the one-holder refusal door by door; flip the shared-name catalog pin to the refusal Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/security/security-catalog.test.ts | 10 +- .../core/src/security/security-catalog.ts | 21 +- .../protocol-boot-hydration-scoped.test.ts | 102 +++++--- ...egistry-security-catalog-namespace.test.ts | 234 ++++++++++++++++++ packages/objectql/src/registry.ts | 5 +- 5 files changed, 318 insertions(+), 54 deletions(-) create mode 100644 packages/objectql/src/registry-security-catalog-namespace.test.ts diff --git a/packages/core/src/security/security-catalog.test.ts b/packages/core/src/security/security-catalog.test.ts index 716b794eb0c..028b5268ea0 100644 --- a/packages/core/src/security/security-catalog.test.ts +++ b/packages/core/src/security/security-catalog.test.ts @@ -11,10 +11,12 @@ import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; /** * ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers. * - * What the real readers answer (the registry's by-name precedence for a name - * two packages ship, the sets a booted showcase holds) is pinned against the - * real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts` - * and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`. + * What the real readers answer (a name a second package is refused, so every + * reader answers its one holder; the sets a booted showcase holds) is pinned + * against the real readers elsewhere: the `security catalog read — a name two + * packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts` + * (the refusal itself, door by door: `registry-security-catalog-namespace.test.ts` + * beside it) and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`. * Here: the read order, the union, the disabled-package rule, and that a read * which did not happen is never reported as "no such item". */ diff --git a/packages/core/src/security/security-catalog.ts b/packages/core/src/security/security-catalog.ts index 72246cf6226..7da2520996d 100644 --- a/packages/core/src/security/security-catalog.ts +++ b/packages/core/src/security/security-catalog.ts @@ -50,18 +50,19 @@ * misses the platform's own permission sets, so a resolver reading it would * fail the platform administrator anchor closed. * - * ## A name two packages ship — today's answer, pinned until it is ruled + * ## A name two packages ship — ruled: there is only ever one holder * * The by-name read takes no package context, because an assignment carries - * only the name (ADR-0131 D4). So which body a name two installed packages both - * ship resolves to is decided by the registry's own by-name precedence: a - * stored override in the bare slot first (ADR-0005), else the FIRST-registered - * package's body. Measured: with no override every by-name read answers the - * first-registered package; once one package stores an override bound to - * itself, every by-name read answers that override. Whether the catalog should - * refuse a shared name instead is an open maintainer question; until it is - * ruled, the pins beside this module hold today's answer, and a change to it - * is a decision, not a refactor. + * only the name (ADR-0131 D4). So a name two installed packages both shipped + * would resolve by the registry's own precedence — measured before the ruling: + * the FIRST-registered package's body, or whichever package stored an override. + * The maintainer ruled that ambiguity out instead (Q4 = A on #15196): each + * catalog type holds one name per deployment, and the engine registry refuses + * a package registering a name an installed package, the environment catalog + * or a built-in already holds (`@objectstack/objectql`, + * `security-catalog-namespace.ts`). So this read never chooses between two + * packages' bodies; a stored override in the bare slot is the holder's own + * (ADR-0005), and it answers ahead of the holder's shipped body. * * ## What this read does NOT answer * diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index ca9944d6b5c..8d155789d66 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -23,7 +23,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { MetadataManager } from '@objectstack/metadata'; import { createSecurityCatalogReader } from '@objectstack/core'; -import { SchemaRegistry } from './registry.js'; +import { SchemaRegistry, NAMESPACE_CONFLICT_CODE } from './registry.js'; import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; @@ -204,55 +204,69 @@ describe('loadMetaFromDb — ADR-0048 package-scoped protection graft at boot (# /** * ADR-0131 D4 — which body the security catalog read * (`createSecurityCatalogReader`, `@objectstack/core`) resolves for a name two - * installed packages both ship. TODAY'S answer, pinned until the maintainer - * rules on shared catalog names: a change here is that ruling landing, never a - * refactor. + * installed packages both ship. RULED (maintainer, Q4 = A on #15196): there is + * never a second body to choose between. Positions, permission sets and + * capabilities each hold one name per deployment, so the second package to + * register a held name is refused at registration, naming both holders + * (`security-catalog-namespace.ts`; the doors are pinned in + * `registry-security-catalog-namespace.test.ts`). * - * It lives beside the #4624 cases because it is their consequence: the row a - * package stored for itself is hydrated into the bare slot, and the catalog - * read asks the registry's by-name precedence first. An assignment carries - * only the NAME (ADR-0131 D4), so there is no package context to prefer one - * package's body over another's: + * This describe was the S1 stage's pin of the pre-ruling answer — the + * FIRST-registered package's body with no stored override, a stored override + * for every caller with one, and the LATER registration for a position two + * stacks declared — kept "until the maintainer rules on shared catalog names". + * It now pins the ruled answer at the same seams: an assignment carries only + * the NAME, and with one holder every reader answers that holder. * - * - no stored override: the FIRST-registered package's body; - * - an override one package stored, bound to itself and hydrated at boot: - * that override, for every caller. - * - * The metadata door's by-name read (`getMetaItem`) is asserted beside each - * answer, so the pin cannot drift from what the door serves. A name the - * registry does not hold falls to the metadata service, where two stacks - * declaring one position name share ONE in-memory slot: the later - * registration holds it. + * It lives beside the #4624 cases because the override case is their + * consequence: the row the holder stored for itself is hydrated into the bare + * slot, and the catalog read asks the registry's by-name precedence first. The + * metadata door's by-name read (`getMetaItem`) is asserted beside each answer, + * so the pin cannot drift from what the door serves. */ -describe('security catalog read — a name two packages ship (ADR-0131 D4, today\'s answer)', () => { - /** The package whose stored override the second case hydrates. */ - const OVERRIDE_PACKAGE = PKG_B; - +describe('security catalog read — a name two packages ship (ADR-0131 D4, ruled: one holder per name)', () => { /** A body each catalog type's schema accepts, so hydration reports it valid. */ const catalogBody = (type: 'permission' | 'position', name: string, label: string) => type === 'permission' ? { name, label, objects: {} } : { name, label }; - function bootWithSharedName(type: 'permission' | 'position', name: string, rows: Row[]) { + type Refusal = Error & { code?: string; status?: number; incomingPackageId?: string; existingHolder?: unknown }; + const refusalOf = (fn: () => unknown): Refusal | undefined => { + try { + fn(); + return undefined; + } catch (e) { + return e as Refusal; + } + }; + + function bootWithHolder(type: 'permission' | 'position', name: string, rows: Row[]) { const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; - // Package A registers FIRST. + // Package A registers FIRST, and holds the name. registry.registerItem(type, catalogBody(type, name, `${PKG_A} body`), 'name', PKG_A); - registry.registerItem(type, catalogBody(type, name, `${PKG_B} body`), 'name', PKG_B); const protocol = new ObjectStackProtocolImplementation(makeEngine(registry, rows)); const reader = createSecurityCatalogReader({ registry, metadata: new MetadataManager({ formats: ['json'], loaders: [] }), }); - return { protocol, reader }; + return { registry, protocol, reader }; } describe.each(['permission', 'position'] as const)('%s', (type) => { const name = `shared_${type}`; - it('no stored override: the first-registered package\'s body', async () => { - const { protocol, reader } = bootWithSharedName(type, name, []); - expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); + it('a second package registering the name is refused, naming both holders; every reader answers the one holder', async () => { + const { registry, protocol, reader } = bootWithHolder(type, name, []); + const refusal = refusalOf(() => + registry.registerItem(type, catalogBody(type, name, `${PKG_B} body`), 'name', PKG_B), + ); + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.incomingPackageId).toBe(PKG_B); + expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: PKG_A }); + + expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 0, errors: 0, invalid: 0 }); const entry = await reader.resolve(type, name); expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_A }); expect(entry?.definition.label).toBe(`${PKG_A} body`); @@ -263,41 +277,51 @@ describe('security catalog read — a name two packages ship (ADR-0131 D4, today expect(door.item?.label).toBe(`${PKG_A} body`); }); - it('an override one package stored for itself: that override, for every caller', async () => { + it('an override the holder stored for itself: that override, for every caller', async () => { const rows = [ overlayRow({ type, name, - package_id: OVERRIDE_PACKAGE, + package_id: PKG_A, metadata: JSON.stringify(catalogBody(type, name, 'stored override')), }), ]; - const { protocol, reader } = bootWithSharedName(type, name, rows); + const { protocol, reader } = bootWithHolder(type, name, rows); expect(await protocol.loadMetaFromDb()).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); const entry = await reader.resolve(type, name); - expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_B }); + expect(entry).toMatchObject({ name, source: 'registry', packageId: PKG_A }); expect(entry?.definition.label).toBe('stored override'); expect((await reader.list(type)).filter((e) => e.name === name)).toEqual([entry]); const door: any = await protocol.getMetaItem({ type, name }); expect(door.item?.label).toBe('stored override'); - expect(door.item?._packageId).toBe(PKG_B); + expect(door.item?._packageId).toBe(PKG_A); }); }); - it('a position name two stacks declare: one metadata-service slot, the later registration holds it', async () => { + it('a position name two packages declare: the package door refuses the second, so one stack\'s declaration reaches the metadata service', async () => { const registry = new SchemaRegistry({ multiTenant: false }); registry.logLevel = 'silent'; const metadata = new MetadataManager({ formats: ['json'], loaders: [] }); - // What two app stacks declaring the same position name do at boot. + // What two app stacks declaring the same position name do at boot: each + // package is installed (Phase 1, `AppPlugin.init` → `registerApp`) before + // its in-memory registrar runs (Phase 2, `AppPlugin.start`). + const stack = (id: string, label: string) => + ({ id, name: id, version: '1.0.0', type: 'app', positions: [{ name: 'regional_manager', label }] }) as never; + registry.installPackage(stack(PKG_A, 'first stack')); metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'first stack' }); - metadata.registerInMemory('position', 'regional_manager', { name: 'regional_manager', label: 'second stack' }); - const reader = createSecurityCatalogReader({ registry, metadata }); + const refusal = refusalOf(() => registry.installPackage(stack(PKG_B, 'second stack'))); + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.incomingPackageId).toBe(PKG_B); + expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: PKG_A }); + + const reader = createSecurityCatalogReader({ registry, metadata }); const entry = await reader.resolve('position', 'regional_manager'); expect(entry).toMatchObject({ name: 'regional_manager', source: 'metadata' }); - expect(entry?.definition.label).toBe('second stack'); + expect(entry?.definition.label).toBe('first stack'); expect(await reader.list('position')).toEqual([entry]); }); }); diff --git a/packages/objectql/src/registry-security-catalog-namespace.test.ts b/packages/objectql/src/registry-security-catalog-namespace.test.ts new file mode 100644 index 00000000000..0727a9565a5 --- /dev/null +++ b/packages/objectql/src/registry-security-catalog-namespace.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one holder — the security catalog's namespace rule at the engine's + * package door and at the registry's item seam (maintainer ruling Q4 = A on + * #15196; `security-catalog-namespace.ts` states the rule and its holders). + * + * The door under test is the real one: the `manifest` service ObjectQLPlugin + * registers, which every package registration reaches — `AppPlugin.init` at + * boot (an artifact boot included), a hot install (`install-local`), a + * post-start `manifest.register` — through `ObjectQL.registerApp` → + * `SchemaRegistry.installPackage`. + * + * Every refusal is asserted by its ADR-0112 envelope (`code` + `status`) and by + * the two holders it names; the message's prose is not pinned. + * + * The controls are what keep the refusal as narrow as the ruling: a same-package + * reload is one holder; a non-catalog type shared by two packages still + * coexists (ADR-0048 §3.4); an environment registration over a package-held + * name (the bare slot every `sys_metadata` hydration writes) is not judged; + * `collisionPolicy: 'warn'` does not downgrade it. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { SchemaRegistry, NAMESPACE_CONFLICT_CODE } from './registry.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; +type Holder = { kind: string; packageId?: string }; +type Refusal = Error & { + code?: string; + status?: number; + incomingPackageId?: string; + existingHolder?: Holder; + conflicts?: Array<{ catalogType: string; name: string; incomingPackageId: string; existingHolder: Holder }>; +}; + +type CatalogType = 'position' | 'permission' | 'capability'; +const TYPES: CatalogType[] = ['position', 'permission', 'capability']; +const COLLECTION: Record = { position: 'positions', permission: 'permissions', capability: 'capabilities' }; + +/** A body each catalog type's schema accepts. */ +const body = (type: CatalogType, name: string, label: string) => + type === 'permission' ? { name, label, objects: {} } : { name, label }; + +/** A flat package payload — the shape `AppPlugin.init` hands the manifest service. */ +const pkg = (id: string, decl: Partial> = {}, extra: Record = {}) => { + const out: Record = { id, name: id.split('.').pop(), version: '1.0.0', type: 'app', ...extra }; + for (const type of TYPES) { + const names = decl[type]; + if (names) out[COLLECTION[type]] = names.map((n) => body(type, n, `${id} ${n}`)); + } + return out; +}; + +const caught = async (fn: () => unknown): Promise => { + try { + await fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +const kernels: ObjectKernel[] = []; +const boot = async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const manifest = kernel.getService('manifest'); + return { registry: ql.registry, register: (m: unknown) => caught(() => manifest.register(m)) }; +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +/** The envelope, and the two holders, of one refusal. */ +function expectRefusal(err: Refusal | undefined, incoming: string, holder: Holder) { + expect(err, 'the registration was refused').toBeDefined(); + expect(err!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.status).toBe(422); + expect(err!.incomingPackageId).toBe(incoming); + expect(err!.existingHolder).toEqual(holder); + expect(err!.message).toContain(incoming); + if (holder.packageId) expect(err!.message).toContain(holder.packageId); +} + +describe.each(TYPES)('%s — a second package declaring a name another package holds', (type) => { + const name = `shared_${type}`; + + it('is refused at the package door, naming both packages, and leaves no record behind', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + + const err = await register(pkg('com.acme.second', { [type]: [name] })); + expectRefusal(err, 'com.acme.second', { kind: 'package', packageId: 'com.acme.first' }); + expect(err!.conflicts).toEqual([ + { catalogType: type, name, incomingPackageId: 'com.acme.second', existingHolder: { kind: 'package', packageId: 'com.acme.first' } }, + ]); + // Ahead of every mutation: no package record, so nothing to uninstall. + expect(registry.getPackage('com.acme.second')).toBeUndefined(); + expect(registry.getPackage('com.acme.first')).toBeDefined(); + }); + + it('same-package reload is one holder, not two', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + }); + + it('uninstalling the holder releases the name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + expect(registry.uninstallPackage('com.acme.first')).toBe(true); + expect(await register(pkg('com.acme.second', { [type]: [name] }))).toBeUndefined(); + }); + + it('a nested plugin declares under its parent package, and is held to the same rule', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', {}, { plugins: [{ name: 'inner', [COLLECTION[type]]: [body(type, name, 'inner')] }] }))).toBeUndefined(); + const err = await register(pkg('com.acme.second', { [type]: [name] })); + expectRefusal(err, 'com.acme.second', { kind: 'package', packageId: 'com.acme.first' }); + }); + + it('the item seam refuses a package-bound registration over another package\'s name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', { [type]: [name] }))).toBeUndefined(); + const err = await caught(() => registry.registerItem(type, body(type, name, 'direct'), 'name', 'com.acme.direct')); + expectRefusal(err, 'com.acme.direct', { kind: 'package', packageId: 'com.acme.first' }); + }); +}); + +describe('the built-in holders', () => { + it.each([ + ['position', 'everyone'], + ['position', 'org_admin'], + ['capability', 'manage_users'], + ] as const)('%s "%s" is a built-in no package can declare', async (type, name) => { + const { register } = await boot(); + const err = await register(pkg('com.acme.app', { [type]: [name] })); + expectRefusal(err, 'com.acme.app', { kind: 'built-in' }); + }); + + it('the platform registers its own built-in positions at the item seam, under its own package id', async () => { + const { registry } = await boot(); + expect(await caught(() => registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined(); + }); + + // The platform's permission sets are declared by plugin-security on its own + // manifest, so they are held as that package's; which side the refusal stops + // depends on which registers first, and both sides are named either way. + const platform = () => pkg('com.objectstack.plugin-security', { permission: ['admin_full_access'] }); + + it('a permission set the platform declares is refused to an app registering after it', async () => { + const { register } = await boot(); + expect(await register(platform())).toBeUndefined(); + const err = await register(pkg('com.acme.app', { permission: ['admin_full_access'] })); + expectRefusal(err, 'com.acme.app', { kind: 'package', packageId: 'com.objectstack.plugin-security' }); + }); + + it('…and an app that registered first stops the platform\'s registration, naming the app', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.app', { permission: ['admin_full_access'] }))).toBeUndefined(); + const err = await register(platform()); + expectRefusal(err, 'com.objectstack.plugin-security', { kind: 'package', packageId: 'com.acme.app' }); + }); +}); + +describe('the environment catalog as a holder', () => { + it('a package declaring a name an environment-authored item holds is refused', async () => { + const { registry, register } = await boot(); + // The bare slot, with no package — what `sys_metadata` hydration registers. + registry.registerItem('permission', body('permission', 'env_set', 'authored here'), 'name'); + const err = await register(pkg('com.acme.app', { permission: ['env_set'] })); + expectRefusal(err, 'com.acme.app', { kind: 'environment' }); + }); + + it('CONTROL — an environment registration over a package-held name is not judged (outside the ruling)', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.app', { permission: ['pkg_set'] }))).toBeUndefined(); + expect(await caught(() => registry.registerItem('permission', body('permission', 'pkg_set', 'env over package'), 'name'))).toBeUndefined(); + }); + + it('a package\'s own stored override in the bare slot is that package\'s, not a second holder', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.app', { permission: ['pkg_set'] }))).toBeUndefined(); + registry.registerItem('permission', { ...body('permission', 'pkg_set', 'override'), _packageId: 'com.acme.app' }, 'name'); + expect(await register(pkg('com.acme.app', { permission: ['pkg_set'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.other', { permission: ['pkg_set'] })); + expectRefusal(err, 'com.acme.other', { kind: 'package', packageId: 'com.acme.app' }); + }); +}); + +describe('the refusal reports, and only refuses, what the ruling covers', () => { + it('every conflicting name is listed in one refusal, and a refused package claims none of its names', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', { position: ['p1'], permission: ['s1'], capability: ['c1'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.second', { position: ['p1', 'free_position'], permission: ['s1'], capability: ['c1'] })); + expect(err?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.conflicts!.map((c) => `${c.catalogType}/${c.name}`)).toEqual(['position/p1', 'permission/s1', 'capability/c1']); + // The refused package's free name was never claimed. + expect(await register(pkg('com.acme.third', { position: ['free_position'] }))).toBeUndefined(); + }); + + it('CONTROL — a non-catalog type shared by two packages still coexists (ADR-0048 §3.4)', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.first', {}, { pages: [{ name: 'home', label: 'first home', type: 'app' }] }))).toBeUndefined(); + expect(await register(pkg('com.acme.second', {}, { pages: [{ name: 'home', label: 'second home', type: 'app' }] }))).toBeUndefined(); + expect(registry.getItem<{ label: string }>('page', 'home', 'com.acme.second')?.label).toBe('second home'); + }); + + it('collisionPolicy "warn" does not downgrade it', async () => { + const registry = new SchemaRegistry({ collisionPolicy: 'warn' }); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.first', { position: ['p1'] }) as never); + const err = await caught(() => registry.installPackage(pkg('com.acme.second', { position: ['p1'] }) as never)); + expectRefusal(err, 'com.acme.second', { kind: 'package', packageId: 'com.acme.first' }); + }); + + it('a manifest-stage `permissions` grant block is not read as permission sets', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.first', { permission: ['services'] }))).toBeUndefined(); + expect(await register(pkg('com.acme.second', {}, { permissions: { services: ['data'] } }))).toBeUndefined(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index bb40d6e30ce..b96099a1268 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -371,7 +371,10 @@ export interface SchemaRegistryOptions { * explicitly. Same-package reinstall and shareable platform namespaces * (`base`/`system`/`sys`) are never treated as conflicts. (The per-item * cross-package collision throw was retired in ADR-0048 §3.4 — distinct - * package ids are always disambiguable by package-scoped resolution.) + * package ids are always disambiguable by package-scoped resolution — except + * for positions, permission sets and capabilities, whose second-holder + * refusal ({@link SecurityCatalogNameConflictError}) this policy does NOT + * downgrade: no ruling extends `warn` to it.) */ collisionPolicy?: 'error' | 'warn'; From ec4c10d2f9da836925d9427c98a077fe9bf90274 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:29:37 +0000 Subject: [PATCH 03/11] test(objectql): the same-named-capability coexistence pin flips to the one-holder refusal Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/engine-capability-provenance.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/engine-capability-provenance.test.ts b/packages/objectql/src/engine-capability-provenance.test.ts index b60120cd529..95389836bca 100644 --- a/packages/objectql/src/engine-capability-provenance.test.ts +++ b/packages/objectql/src/engine-capability-provenance.test.ts @@ -46,6 +46,7 @@ import { describe, it, expect } from 'vitest'; import { pluralToSingular } from '@objectstack/spec/shared'; import { ObjectQL } from './engine'; +import { NAMESPACE_CONFLICT_CODE } from './registry'; /** * The exact read `bootstrapDeclaredCapabilities` performs on the engine, kept @@ -112,14 +113,27 @@ describe('registerApp — declared capabilities carry registry provenance (#5870 expect(cap?._provenance).toBe(permission?._provenance); }); - it('keeps two packages\' same-named capabilities attributed to their own owner', () => { + // This case used to pin two packages' same-named capabilities COEXISTING, + // each attributed to its own owner (ADR-0048 §3.4's coexistence). The + // maintainer's ruling Q4 = A on #15196 takes the security catalog out of + // §3.4: one capability name, one holder per deployment — the second package + // is refused at registration, and the first keeps its attribution. The + // refusal is pinned door by door in `registry-security-catalog-namespace.test.ts`. + it('refuses a second package\'s same-named capability; the first keeps its attribution', () => { const engine = new ObjectQL(); engine.registerApp({ id: 'com.acme.crm', capabilities: [{ name: 'export_data', label: 'CRM Export' }] }); - engine.registerApp({ id: 'com.acme.hr', capabilities: [{ name: 'export_data', label: 'HR Export' }] }); + let refusal: (Error & { code?: string; status?: number; existingHolder?: unknown }) | undefined; + try { + engine.registerApp({ id: 'com.acme.hr', capabilities: [{ name: 'export_data', label: 'HR Export' }] }); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: 'com.acme.crm' }); expect(engine.registry.getItem('capability', 'export_data', 'com.acme.crm')?.label).toBe('CRM Export'); - expect(engine.registry.getItem('capability', 'export_data', 'com.acme.hr')?.label).toBe('HR Export'); - expect(readDeclaredShape(engine, 'capability')).toHaveLength(2); + expect(readDeclaredShape(engine, 'capability').map((c) => c._packageId)).toEqual(['com.acme.crm']); }); it('stamps capabilities declared by a NESTED plugin too (the second seam)', () => { From bed7f08f6172de524c1a5063577fb3c42586c6a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:53:14 +0000 Subject: [PATCH 04/11] test(runtime): the artifact and door-less boots are refused at the package door; changeset; ADR anchor Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../22135-security-catalog-one-holder.md | 27 +++ ...-stack-security-catalog-one-holder.test.ts | 164 ++++++++++++++++++ ...l__src__security-catalog-namespace.ts.json | 8 + 3 files changed, 199 insertions(+) create mode 100644 .changeset/22135-security-catalog-one-holder.md create mode 100644 packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts create mode 100644 scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md new file mode 100644 index 00000000000..1464601fb38 --- /dev/null +++ b/.changeset/22135-security-catalog-one-holder.md @@ -0,0 +1,27 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders + +Clause-②: no + + + +**BREAKING** — an accept-set narrowing at the package registration door, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. + +**Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged. + +**What is refused, and where.** `SchemaRegistry.installPackage` refuses a package whose declared `positions`, `permissions` (permission sets) or `capabilities` — top level, or on a nested `plugins[]` entry — name something another holder already holds. It refuses ahead of every mutation, so a refused package leaves no record behind. The holders are: + +- another installed package; +- the environment catalog: an item authored in this environment, in the registry's bare slot with no package; +- a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). + +The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. + +**What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. + +**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. + +**Measured producers.** On objectstack `7ef50a4fbb`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. diff --git a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts new file mode 100644 index 00000000000..5b4cfa4b6f4 --- /dev/null +++ b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// One name, one holder for positions, permission sets and capabilities, at the +// two runtime boot paths a package's catalog items arrive through (maintainer +// ruling Q4 = A on #15196; the rule: `@objectstack/objectql`'s +// `security-catalog-namespace.ts`). +// +// ## Why the refusal is asserted at the boot, not inside the in-memory registrars +// +// Positions reach no engine registry slot: `AppPlugin`'s security block +// (`registerInMemory`, the `'app-plugin'` registrar) and the artifact door +// (`MetadataPlugin._registerArtifactBodyCollections`, the `'artifact-door'` +// registrar) write them to the metadata service, in `start()`. But neither runs +// for a package the engine has not installed first: `AppPlugin.init()` registers +// every package of the bundle through the `manifest` service in Phase 1 — a +// multi-package artifact package by package — and the engine's package door +// refuses the second holder there, before any `start()`. So the boot is the +// door, and these cases boot the REAL compositions: the artifact boot +// (`createStandaloneStack`, door + `'artifact-door'` AppPlugin) and the +// door-less one (`new AppPlugin(stack)`, `'app-plugin'` registrar). +// +// Each refusal is asserted by its ADR-0112 envelope (`code` + `status`) and by +// the two holders it names; the control boots the same shapes with distinct +// names and finds each package's position registered by its door, stamped +// with its own package. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NAMESPACE_CONFLICT_CODE } from '@objectstack/objectql'; +import { Runtime } from './runtime.js'; +import { AppPlugin } from './app-plugin.js'; +import { createStandaloneStack } from './standalone-stack.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps the +// boot reaches through a dynamic `import()` at MODULE LOAD, never inside a +// clocked `it` (`scripts/check-test-source-alias.mjs`). +import '@objectstack/metadata'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const BOOT_TIMEOUT = 90_000; + +type Refusal = Error & { code?: string; status?: number; incomingPackageId?: string; existingHolder?: unknown }; + +type Names = { position: string; permission: string; capability: string }; + +/** One name of each catalog type, as a stack declares them. */ +const collections = (id: string, names: Names) => ({ + positions: [{ name: names.position, label: `${id} position` }], + permissions: [{ name: names.permission, label: `${id} set`, objects: {} }], + capabilities: [{ name: names.capability, label: `${id} capability` }], +}); + +const manifestOf = (id: string) => ({ id, name: id.split('.').pop(), type: 'app', version: '1.0.0' }); + +/** An assembled package body (ADR-0130 D4): the manifest's fields, its collections written over them. */ +const body = (id: string, names: Names) => ({ ...manifestOf(id), ...collections(id, names) }); + +/** A single-package stack: `manifest` plus its collections. */ +const stackOf = (id: string, names: Names) => ({ manifest: manifestOf(id), ...collections(id, names) }); + +describe('a package\'s security catalog name another holder holds refuses the boot', () => { + const dirs: string[] = []; + const kernels: any[] = []; + + afterEach(async () => { + for (const k of kernels.splice(0)) { + try { await k.shutdown(); } catch { /* noop */ } + } + for (const d of dirs.splice(0)) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } + } + }); + + async function artifactStack(artifact: unknown) { + const dir = mkdtempSync(join(tmpdir(), 'os-catalog-one-holder-')); + dirs.push(dir); + const artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8'); + return createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }); + } + + /** Boots the plugins; answers the refusal, or `undefined` and the kernel. */ + async function boot(plugins: readonly unknown[]): Promise<{ refusal?: Refusal; kernel: any }> { + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + kernels.push(kernel); + for (const p of plugins) await kernel.use(p as any); + try { + await kernel.bootstrap(); + return { kernel }; + } catch (e) { + return { refusal: e as Refusal, kernel }; + } + } + + function expectRefusal(refusal: Refusal | undefined, incoming: string, holder: unknown) { + expect(refusal, 'the boot was refused').toBeDefined(); + expect(refusal!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal!.status).toBe(422); + expect(refusal!.incomingPackageId).toBe(incoming); + expect(refusal!.existingHolder).toEqual(holder); + } + + it('artifact boot: two packages of one artifact sharing a position, permission set and capability name', async () => { + const shared = { position: 'regional_manager', permission: 'regional_set', capability: 'regional.export' }; + const stack = await artifactStack({ + manifest: manifestOf('com.test.catalog-project'), + packages: [{ manifest: body('com.test.first', shared) }, { manifest: body('com.test.second', shared) }], + }); + expect((stack.plugins.find((p: any) => p?.type === 'app') as AppPlugin).securityMetadataRegistrar).toBe('artifact-door'); + + const { refusal } = await boot(stack.plugins); + expectRefusal(refusal, 'com.test.second', { kind: 'package', packageId: 'com.test.first' }); + // Every conflicting name in one refusal: the position no registry slot holds + // is reported beside the two the engine registers. + expect((refusal as any).conflicts.map((c: any) => `${c.catalogType}/${c.name}`)).toEqual([ + 'position/regional_manager', + 'permission/regional_set', + 'capability/regional.export', + ]); + }, BOOT_TIMEOUT); + + it('artifact boot: a package declaring a built-in position', async () => { + const stack = await artifactStack( + stackOf('com.test.builtin', { position: 'everyone', permission: 'builtin_probe_set', capability: 'builtin_probe.export' }), + ); + const { refusal } = await boot(stack.plugins); + expectRefusal(refusal, 'com.test.builtin', { kind: 'built-in' }); + }, BOOT_TIMEOUT); + + it('door-less boot (`app-plugin` registrar): a second stack declaring a name the first holds', async () => { + const shared = { position: 'shared_position', permission: 'shared_set', capability: 'shared.export' }; + const stack = await artifactStack(stackOf('com.test.first', shared)); + const second = new AppPlugin(stackOf('com.test.second', shared)); + expect(second.securityMetadataRegistrar).toBe('app-plugin'); + + const { refusal } = await boot([...stack.plugins, second]); + expectRefusal(refusal, 'com.test.second', { kind: 'package', packageId: 'com.test.first' }); + }, BOOT_TIMEOUT); + + it('CONTROL: the same two-package artifact with distinct names boots, and the door registers each package\'s position under its own package', async () => { + const stack = await artifactStack({ + manifest: manifestOf('com.test.catalog-project'), + packages: [ + { manifest: body('com.test.first', { position: 'first_position', permission: 'first_set', capability: 'first.export' }) }, + { manifest: body('com.test.second', { position: 'second_position', permission: 'second_set', capability: 'second.export' }) }, + ], + }); + const { refusal, kernel } = await boot(stack.plugins); + expect(refusal).toBeUndefined(); + const metadata = kernel.getService('metadata'); + expect(await metadata.get('position', 'first_position')).toMatchObject({ _packageId: 'com.test.first' }); + expect(await metadata.get('position', 'second_position')).toMatchObject({ _packageId: 'com.test.second' }); + }, BOOT_TIMEOUT); +}); diff --git a/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json b/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json new file mode 100644 index 00000000000..6ab24bf953f --- /dev/null +++ b/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/objectql/src/security-catalog-namespace.ts", + "adrs": [ + "ADR-0048", + "ADR-0131" + ], + "invariant": "ADR-0048 §3.4 retired the per-item cross-package throw because a caller carries its package id; for positions, permission sets and capabilities it does not — ADR-0131 D4 makes every assignment a bare name — so the maintainer's ruling (Q4 = A on #15196) narrows §3.4 to leave these three types out: one name, one holder per deployment. A second holder (an installed package, the environment catalog, a built-in) is refused at registration, naming both holders. ⛔ Do not 'restore' §3.4's coexistence for these three types, do not let OS_METADATA_COLLISION=warn downgrade the refusal, and do not judge a registration with no package (an environment save is outside the ruling)." +} From 053cc2ed01d9e93cce678cf6420ae39da13b64d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:06:19 +0000 Subject: [PATCH 05/11] docs(changeset): name install-local's answer and the census anchor Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22135-security-catalog-one-holder.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md index 1464601fb38..fd6632f45d2 100644 --- a/.changeset/22135-security-catalog-one-holder.md +++ b/.changeset/22135-security-catalog-one-holder.md @@ -18,10 +18,10 @@ Clause-②: no - the environment catalog: an item authored in this environment, in the registry's bare slot with no package; - a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). -The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. +The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. **What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. **What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. -**Measured producers.** On objectstack `7ef50a4fbb`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. +**Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. From 089b1c84b680b8a500f26c095edada45c49c13d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 06:22:06 +0000 Subject: [PATCH 06/11] =?UTF-8?q?test(dogfood):=20each=20fixture=20declare?= =?UTF-8?q?s=20a=20permission=20set=20once=20=E2=80=94=20the=20app's=20set?= =?UTF-8?q?=20is=20not=20also=20handed=20to=20plugin-security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dogfood fixtures declared one permission set under two packages: the app's own `permissions`, and the same set handed to SecurityPlugin's `defaultPermissionSets`, which plugin-security declares on its own manifest. Under one-name-one-holder that boot is refused (NAMESPACE_CONFLICT, both holders named). `os serve` never composes it: it hands the plugin the default's NAME only (`appSecurityPluginOptions`), and the app registers the set. The fixtures now wire it the same way. A runtime pin holds the refused composition. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../authored-row-write-scope.dogfood.test.ts | 10 ++++---- .../test/bulk-widener-probe.dogfood.test.ts | 16 ++++++------ ...lic-read-write-write-floor.dogfood.test.ts | 15 ++++------- ...howcase-d7-default-profile.dogfood.test.ts | 25 ++++++++----------- packages/qa/dogfood/test/showcase-security.ts | 20 +++++++++------ ...-stack-security-catalog-one-holder.test.ts | 18 +++++++++++++ 6 files changed, 60 insertions(+), 44 deletions(-) diff --git a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts index 8b7e92975b1..5233702ec6e 100644 --- a/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts @@ -75,7 +75,7 @@ import { defineStack, definePermissionSet } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; -import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; // ── the two objects under probe ──────────────────────────────────────────── @@ -205,10 +205,10 @@ describe('[#7281] checkAuthoredRowWrite answers the declaration, not the caller beforeAll(async () => { stack = await bootStack(probeApp, { - security: new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, WidenerSet as any, PlainSet as any], - fallbackPermissionSet: 'member_default', - }), + // The two sets are the app's own (`permissions` above); ⛔ not ALSO handed + // to `defaultPermissionSets`, which would declare each under a second + // package and refuse the boot (one holder per permission-set name). + security: new SecurityPlugin({ fallbackPermissionSet: 'member_default' }), }); await stack.signIn(); // dev admin seed bobToken = await stack.signUp('wscope-bob@verify.test'); // holds the widener diff --git a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts index 5455303cbe6..a4290956779 100644 --- a/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts +++ b/packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts @@ -57,7 +57,7 @@ import { defineStack, definePermissionSet } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; -import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; // ── the app under probe ──────────────────────────────────────────────────── @@ -165,13 +165,13 @@ describe('[#6736 PROBE] app-authored RLS wideners on the bulk write path', () => beforeAll(async () => { stack = await bootStack(probeApp, { - // The app's own set must be resolvable alongside the platform seeds; the - // fallback stays the platform `member_default` so nothing about this - // fixture's baseline differs from an ordinary deployment's. - security: new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, ProbeWidenerSet as any], - fallbackPermissionSet: 'member_default', - }), + // The app's own set is registered by the app (`permissions` above), as in + // an ordinary deployment — ⛔ not ALSO handed to `defaultPermissionSets`, + // which would declare it under a second package and refuse the boot (one + // holder per permission-set name). The fallback stays the platform + // `member_default` so nothing about this fixture's baseline differs from an + // ordinary deployment's. + security: new SecurityPlugin({ fallbackPermissionSet: 'member_default' }), }); await stack.signIn(); // seed dev admin (platform admin) bobToken = await stack.signUp('probe-bob@verify.test'); // plain member diff --git a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts index ab13cb62974..1f21fbcd8bc 100644 --- a/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts +++ b/packages/qa/dogfood/test/owd-public-read-write-write-floor.dogfood.test.ts @@ -52,7 +52,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { resolveAuthzContext } from '@objectstack/core'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; -import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { SecurityPlugin } from '@objectstack/plugin-security'; import { assertArmed, principalArmed } from './armed.js'; /** @@ -205,15 +205,10 @@ describe('[#8023] a public_read_write OWD opens row-level writes (and nothing el // users to (ADR-0093 D1), which is the shape a real deployment boots in // and the shape QA run #7637 measured. orgContext: true, - security: new SecurityPlugin({ - defaultPermissionSets: [ - ...securityDefaultPermissionSets, - EditorSet as any, - ViewerSet as any, - ScopedSet as any, - ], - fallbackPermissionSet: 'member_default', - }), + // The three sets are the app's own (`permissions` above); ⛔ not ALSO + // handed to `defaultPermissionSets`, which would declare each under a + // second package and refuse the boot (one holder per permission-set name). + security: new SecurityPlugin({ fallbackPermissionSet: 'member_default' }), }); await stack.signIn(); // dev admin seed (first user) aliceToken = await stack.signUp('owdw-alice@verify.test'); diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 7bc12c69305..102e48b1ee8 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -39,14 +39,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; -import { SecurityPlugin, securityDefaultPermissionSets, appDefaultPermissionSetName } from '@objectstack/plugin-security'; -import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin, appDefaultPermissionSetName } from '@objectstack/plugin-security'; -// Mirror the CLI: pull the app-declared default profile (name + object) off the -// stack metadata via the same helper the CLI uses. +// Mirror the CLI: pull the app-declared default profile's name off the stack +// metadata via the same helper the CLI uses. const stackPerms = ((showcaseStack as { permissions?: unknown[] }).permissions ?? []) as Array<{ name?: string }>; const appDefault = appDefaultPermissionSetName(stackPerms); -const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown; const SYS = { isSystem: true } as const; @@ -56,16 +54,15 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = let ql: any; beforeAll(async () => { - // The full CLI boot loads stack permission sets into the metadata service, so - // `fallbackPermissionSet: ` resolves there. The lightweight harness does - // not seed permission metadata, so we hand the declared default to the plugin - // directly — then wire it by NAME exactly as the CLI's appDefaultPermissionSetName - // path does (constructor uses the explicit name, not its own isDefault scan). + // Wired by NAME exactly as the CLI's appDefaultPermissionSetName path does + // (constructor uses the explicit name, not its own isDefault scan). The set + // itself is the app's: the harness's AppPlugin registers the stack's + // `permissions`, as the CLI boot does. ⛔ Not ALSO handed to the plugin's + // `defaultPermissionSets` — that declares one permission set under two + // packages, and the boot is refused (`NAMESPACE_CONFLICT`, both holders + // named): a permission set holds one name per deployment. stack = await bootStack(showcaseStack, { - security: new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, PermissionSetSchema.parse(declaredDefault) as PermissionSet], - fallbackPermissionSet: appDefault, - }), + security: new SecurityPlugin({ fallbackPermissionSet: appDefault }), }); await stack.signIn(); memberToken = await stack.signUp('d7-showcase-member@verify.test'); diff --git a/packages/qa/dogfood/test/showcase-security.ts b/packages/qa/dogfood/test/showcase-security.ts index 1505e52bd32..930cce0c14d 100644 --- a/packages/qa/dogfood/test/showcase-security.ts +++ b/packages/qa/dogfood/test/showcase-security.ts @@ -23,9 +23,17 @@ // `showcase-d7-default-profile.dogfood.test.ts` pins that wiring; this module // USES it, so fixtures are now more faithful to the running app than they were. // -// The lightweight verify harness does not seed permission metadata, so the -// declared set is handed to the plugin directly — the identical note that test -// carries. +// The app's set is registered by the app's own package, exactly as in the CLI +// boot: `bootStack`'s `AppPlugin` registers the stack's `permissions` in the +// engine registry and the metadata service, so the plain branch below wires the +// default by NAME only — the CLI's `appSecurityPluginOptions` wiring. ⛔ It must +// not ALSO hand the declared set to the plugin's `defaultPermissionSets`: that +// declares one permission set under two packages (the app's and +// `plugin-security`'s), and a permission set holds one name per deployment — +// the boot is refused with `NAMESPACE_CONFLICT`, naming both holders +// (`@objectstack/objectql`, `security-catalog-namespace.ts`). The scaffolding +// branch hands the plugin a set of a DIFFERENT name, which the app never +// declares, so it has one holder. import showcaseStack from '@objectstack/example-showcase'; import { SecurityPlugin, @@ -68,10 +76,8 @@ export function showcaseAppDefaultSecurity(extraObjectGrants?: ObjectGrants): Se } const appDefault = PermissionSetSchema.parse(declaredDefault) as PermissionSet; if (!extraObjectGrants) { - return new SecurityPlugin({ - defaultPermissionSets: [...securityDefaultPermissionSets, appDefault], - fallbackPermissionSet: appDefault.name, - }); + // The CLI wiring: the default named, the set itself the app's own. + return new SecurityPlugin({ fallbackPermissionSet: appDefault.name }); } const baseline = PermissionSetSchema.parse({ ...appDefault, diff --git a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts index 5b4cfa4b6f4..0ad68772926 100644 --- a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts +++ b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts @@ -29,6 +29,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { NAMESPACE_CONFLICT_CODE } from '@objectstack/objectql'; +import { SecurityPlugin, SECURITY_PLUGIN_ID, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { Runtime } from './runtime.js'; import { AppPlugin } from './app-plugin.js'; import { createStandaloneStack } from './standalone-stack.js'; @@ -147,6 +148,23 @@ describe('a package\'s security catalog name another holder holds refuses the bo expectRefusal(refusal, 'com.test.second', { kind: 'package', packageId: 'com.test.first' }); }, BOOT_TIMEOUT); + // The shape the dogfood fixtures used to compose: an app declaring a + // permission set AND the same set handed to `plugin-security`'s + // `defaultPermissionSets`, which declares every entry on that plugin's own + // manifest. One set, two packages: refused, whichever registers second, with + // both named. (`os serve` never composes this — it hands the plugin the + // default's NAME only, `appSecurityPluginOptions`.) + it('a permission set the app declares AND hands to plugin-security\'s defaultPermissionSets: two holders, refused', async () => { + const names = { position: 'dup_position', permission: 'dup_set', capability: 'dup.export' }; + const stack = await artifactStack(stackOf('com.test.dup', names)); + const security = new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, { name: 'dup_set', label: 'handed to the plugin', objects: {} } as never], + }); + const { refusal } = await boot([...stack.plugins, security]); + // The app's `AppPlugin` registers first here, so the plugin's is the second holder. + expectRefusal(refusal, SECURITY_PLUGIN_ID, { kind: 'package', packageId: 'com.test.dup' }); + }, BOOT_TIMEOUT); + it('CONTROL: the same two-package artifact with distinct names boots, and the door registers each package\'s position under its own package', async () => { const stack = await artifactStack({ manifest: manifestOf('com.test.catalog-project'), From f0e9ce43a397250fc9066408507dec8ab63ca06f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:59:00 +0000 Subject: [PATCH 07/11] fix(objectql): the platform's declaration of a built-in name is the built-in holder's own registration At the item seam a built-in position or capability name is held by the platform, and plugin-security's registerBuiltinPositions (SecurityPlugin.start) is that holder declaring it. The environment catalog is no longer asked for a built-in name there: an environment item under a built-in name exists only where an environment save went over the platform's name (outside the ruling), and boot hydration (ObjectQLPlugin.start) registers it before the platform declares, so asking it refused the platform's own declaration and the boot. The stored definition keeps answering first from the bare slot (S2b's shadowing). A second PACKAGE registering a built-in name is still refused, in either order; non-built-in names are judged as before. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...egistry-security-catalog-namespace.test.ts | 31 +++++++++++++++++ packages/objectql/src/registry.ts | 34 +++++++++++++++---- .../src/security-catalog-namespace.ts | 7 +++- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/registry-security-catalog-namespace.test.ts b/packages/objectql/src/registry-security-catalog-namespace.test.ts index 0727a9565a5..7122b94af11 100644 --- a/packages/objectql/src/registry-security-catalog-namespace.test.ts +++ b/packages/objectql/src/registry-security-catalog-namespace.test.ts @@ -155,6 +155,37 @@ describe('the built-in holders', () => { expect(await caught(() => registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined(); }); + // An environment item under a built-in name exists only where an environment + // save went over the platform's name (outside the ruling), and boot hydration + // registers it BEFORE the platform's `start()` declares the built-ins. The + // declaration is the built-in holder's own registration, not a second holder: + // it is admitted, and the stored definition keeps answering first. + it('the platform\'s declaration of a built-in name is admitted over an environment item under that name, which keeps answering first', async () => { + const { registry } = await boot(); + registry.registerItem('position', body('position', 'org_admin', 'stored at the door'), 'name'); + expect(await caught(() => registry.registerItem('position', body('position', 'org_admin', 'Organization Admin'), 'name', 'com.objectstack.plugin-security'))).toBeUndefined(); + expect(registry.getItem<{ label: string }>('position', 'org_admin')?.label).toBe('stored at the door'); + }); + + it('…while a second PACKAGE registering a built-in name at the item seam is refused, in either order', async () => { + const first = await boot(); + first.registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security'); + const after = await caught(() => first.registry.registerItem('position', body('position', 'everyone', 'other'), 'name', 'com.acme.other')); + expectRefusal(after, 'com.acme.other', { kind: 'package', packageId: 'com.objectstack.plugin-security' }); + + const second = await boot(); + second.registry.registerItem('position', body('position', 'everyone', 'other'), 'name', 'com.acme.other'); + const before = await caught(() => second.registry.registerItem('position', body('position', 'everyone', 'Everyone'), 'name', 'com.objectstack.plugin-security')); + expectRefusal(before, 'com.objectstack.plugin-security', { kind: 'package', packageId: 'com.acme.other' }); + }); + + it('CONTROL — for a name that is NOT built in, an environment item still refuses a package-bound registration at the item seam', async () => { + const { registry } = await boot(); + registry.registerItem('position', body('position', 'env_position', 'stored at the door'), 'name'); + const err = await caught(() => registry.registerItem('position', body('position', 'env_position', 'direct'), 'name', 'com.acme.direct')); + expectRefusal(err, 'com.acme.direct', { kind: 'environment' }); + }); + // The platform's permission sets are declared by plugin-security on its own // manifest, so they are held as that package's; which side the refusal stops // depends on which registers first, and both sides are named either way. diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b96099a1268..c60d9bad21b 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2272,15 +2272,16 @@ export class SchemaRegistry { * Read, in this order: a built-in (only when `builtIns` — see * {@link BUILT_IN_SECURITY_CATALOG_NAMES} for why the item seam does not ask); * the bare slot (an override a package bound to itself, else an - * environment-authored item); every composite `:` slot; the - * package claims. A package that holds the name more than one way is one - * holder. A disabled package is still installed, and still holds its names. + * environment-authored item — the latter only when `environment`); every + * composite `:` slot; the package claims. A package that + * holds the name more than one way is one holder. A disabled package is still + * installed, and still holds its names. */ private securityCatalogHoldersOtherThan( type: SecurityCatalogType, name: string, exceptPackageId: string | undefined, - opts: { builtIns: boolean }, + opts: { builtIns: boolean; environment: boolean }, ): SecurityCatalogHolder[] { const found = new Map(); const add = (holder: SecurityCatalogHolder) => { @@ -2292,7 +2293,8 @@ export class SchemaRegistry { for (const [key, item] of this.metadata.get(type) ?? []) { const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; if (key === name) { - add(typeof stamped === 'string' && stamped !== '' ? { kind: 'package', packageId: stamped } : { kind: 'environment' }); + if (typeof stamped === 'string' && stamped !== '') add({ kind: 'package', packageId: stamped }); + else if (opts.environment) add({ kind: 'environment' }); } else if (key.endsWith(suffix)) { add({ kind: 'package', @@ -2329,7 +2331,7 @@ export class SchemaRegistry { const key = `${type}|${name}`; if (seen.has(key)) continue; seen.add(key); - for (const existingHolder of this.securityCatalogHoldersOtherThan(type, name, selfId, { builtIns: true })) { + for (const existingHolder of this.securityCatalogHoldersOtherThan(type, name, selfId, { builtIns: true, environment: true })) { conflicts.push({ catalogType: type, name, incomingPackageId: selfId, existingHolder }); } } @@ -3755,8 +3757,26 @@ export class SchemaRegistry { // ⛔ A registration with no `packageId` is the bare slot — `sys_metadata` // hydration, the metadata write-through — and is never judged here: an // environment save over a package-held name is outside the ruling. + // + // A BUILT-IN name is held by the platform, and the registration of one + // here is the platform declaring its own name (`plugin-security`'s + // `registerBuiltinPositions`, in its `start()`) — the holder's own + // registration, never a second holder. Packages are refused built-in names + // at the package door, and a second package registering one here is still + // refused below (in either order: the other package's slot is a holder). + // What is NOT asked for a built-in name is the environment catalog: an + // environment item under a built-in name exists only because an + // environment save went over the platform's name — outside the ruling — and + // it is hydrated (`ObjectQLPlugin.start`, which every `start()` depending on + // the engine follows) BEFORE the platform declares, so asking it would + // refuse the platform's own declaration and with it the boot. The stored + // definition keeps answering first from the bare slot (ADR-0005); the + // declaration sits beside it. if (packageId && isSecurityCatalogType(type)) { - const holders = this.securityCatalogHoldersOtherThan(type, baseName, packageId, { builtIns: false }); + const holders = this.securityCatalogHoldersOtherThan(type, baseName, packageId, { + builtIns: false, + environment: !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(baseName), + }); if (holders.length > 0) { throw new SecurityCatalogNameConflictError( holders.map((existingHolder) => ({ catalogType: type, name: baseName, incomingPackageId: packageId, existingHolder })), diff --git a/packages/objectql/src/security-catalog-namespace.ts b/packages/objectql/src/security-catalog-namespace.ts index 05ece663139..36cfbd227e2 100644 --- a/packages/objectql/src/security-catalog-namespace.ts +++ b/packages/objectql/src/security-catalog-namespace.ts @@ -91,7 +91,12 @@ const COLLECTION_KEY: Readonly> = Object.fre * * Applied at the package door ({@link declaredSecurityCatalogNames}'s caller), * never at the registry's own item seam: the platform declares these names to - * the registry itself, under its own package id. + * the registry itself, under its own package id, and that declaration is the + * built-in holder's own registration — never a second holder. So the item seam + * asks it only whether another PACKAGE holds the name. It does not ask the + * environment catalog: an environment item under a built-in name exists only + * where an environment save went over the platform's name, which is outside + * the ruling, and it is hydrated before the platform declares. */ export const BUILT_IN_SECURITY_CATALOG_NAMES: Readonly>> = Object.freeze({ position: new Set([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]), From ffa6d519137f80378564a2bd797d277f23632de3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:12:09 +0000 Subject: [PATCH 08/11] docs(changeset): the platform's built-in declaration is the built-in holder's own Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22135-security-catalog-one-holder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md index fd6632f45d2..3b27c35024f 100644 --- a/.changeset/22135-security-catalog-one-holder.md +++ b/.changeset/22135-security-catalog-one-holder.md @@ -18,7 +18,7 @@ Clause-②: no - the environment catalog: an item authored in this environment, in the registry's bare slot with no package; - a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). -The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. +The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. The platform's own declaration of a built-in name there (`@objectstack/plugin-security` declaring the six built-in positions) is the built-in holder's, so it is never refused. An environment item already stored under that name keeps answering first. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. **What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. From f16fcd0c7c1c808a1397887fc4334ce804326aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:08:48 +0000 Subject: [PATCH 09/11] docs(objectql): the claims comments read true with positions in the engine's collection loop With positions registered by ObjectQL.registerApp's collection loop (now on main), three comments this change added said the loop never registers them. The claims doc and the installPackage comment now say what the claims are for on either tree: installPackage itself registers no items, so the claim is how the package door remembers every declared name. The declared-names reader's note and the runtime pin's header no longer say positions reach no engine slot. Comments only; no behaviour change. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/objectql/src/registry.ts | 18 +++++++++--------- .../objectql/src/security-catalog-namespace.ts | 8 +++++--- ...e-stack-security-catalog-one-holder.test.ts | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c60d9bad21b..fc1eac1a80d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -2254,13 +2254,13 @@ export class SchemaRegistry { * {@link refuseSecurityCatalogNameConflicts}, forgotten by * {@link uninstallPackage}. * - * The registry's item store answers "who holds this name?" for every item it - * registered — but it does not register every catalog item a package - * declares: the engine's collection loop has no `positions` entry, so a - * package's positions reach only the metadata service's in-memory registrars - * (`AppPlugin`'s security block, the artifact door). The claim is how the - * package door remembers them, so a second package declaring one of them is - * refused like any other second holder. + * The registry's item store answers who holds a name only for items + * something registered into it, and `installPackage` registers none: a + * package's collections reach the item store through `ObjectQL.registerApp`'s + * collection loop (positions only where that loop carries them), never + * through a bare `installPackage`. The claim is how the package door + * remembers every name a package declared, so a second package declaring one + * of them is refused like any other second holder. */ private securityCatalogClaims = new Map>(); @@ -4653,8 +4653,8 @@ export class SchemaRegistry { this.debug(`[Registry] Overwriting package: ${manifest.id}`); } collection.set(manifest.id, pkg); - // The names this package now holds — positions included, which the - // engine's collection loop never registers (see `securityCatalogClaims`). + // The names this package now holds, recorded whether or not an item store + // entry will carry them (see `securityCatalogClaims`). if (selfId !== undefined) this.recordSecurityCatalogClaims(selfId, manifest); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); return pkg; diff --git a/packages/objectql/src/security-catalog-namespace.ts b/packages/objectql/src/security-catalog-namespace.ts index 36cfbd227e2..e8ef6267b9d 100644 --- a/packages/objectql/src/security-catalog-namespace.ts +++ b/packages/objectql/src/security-catalog-namespace.ts @@ -135,9 +135,11 @@ function itemName(item: unknown): string | undefined { * nested `plugins[]` entry's (a nested plugin contributes under its parent * package — `ObjectQL.registerPlugin`), one level deep, arrays only. * - * `positions` is read although the engine's collection loop does not register - * it: the in-memory registrars (`AppPlugin`'s security block, the artifact - * door) do, for the very same package, so the package's claim covers it. + * `positions` is read like the other two collections. The engine's collection + * loop registers a package's positions under the package + * (`ObjectQL.registerApp`), and the in-memory registrars (`AppPlugin`'s + * security block, the artifact door) register them for the very same package; + * the package's claim covers them either way. * * ⚠️ A non-array `permissions` is skipped, not misread: at the manifest stage * that key is the ADR-0025 capability GRANT (`{ services, hooks, … }`), not a diff --git a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts index 0ad68772926..1825dd2e53e 100644 --- a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts +++ b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts @@ -7,10 +7,10 @@ // // ## Why the refusal is asserted at the boot, not inside the in-memory registrars // -// Positions reach no engine registry slot: `AppPlugin`'s security block +// Positions also reach the metadata service: `AppPlugin`'s security block // (`registerInMemory`, the `'app-plugin'` registrar) and the artifact door // (`MetadataPlugin._registerArtifactBodyCollections`, the `'artifact-door'` -// registrar) write them to the metadata service, in `start()`. But neither runs +// registrar) write them there, in `start()`. But neither runs // for a package the engine has not installed first: `AppPlugin.init()` registers // every package of the bundle through the `manifest` service in Phase 1 — a // multi-package artifact package by package — and the engine's package door From 9e4ed5d53bc4cd3972ecce6f4c8f10ffd4eb16d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:18:12 +0000 Subject: [PATCH 10/11] docs(changeset): grade objectql major on the v18 pre-release line; the cold-boot boundary is open on #22307 The changeset graded objectql minor on the premise that Changesets pre mode was not yet on main. It is: .changeset/pre.json is in next pre mode, so the accept-set narrowing ships as a major on the v18 pre-release line. One sentence now states the cold-boot boundary: a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot (the existing collision warning fires), a hot install of it is, and whether a cold boot should refuse too is open on #22307. The runtime pin's comment no longer says no registry slot holds the position: the first package holds all three conflicting names. Comment only. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22135-security-catalog-one-holder.md | 6 +++--- .../standalone-stack-security-catalog-one-holder.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md index 3b27c35024f..32c58d8436b 100644 --- a/.changeset/22135-security-catalog-one-holder.md +++ b/.changeset/22135-security-catalog-one-holder.md @@ -1,5 +1,5 @@ --- -'@objectstack/objectql': minor +'@objectstack/objectql': major --- feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders @@ -8,7 +8,7 @@ Clause-②: no -**BREAKING** — an accept-set narrowing at the package registration door, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. +**BREAKING** — an accept-set narrowing at the package registration door, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. **Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged. @@ -22,6 +22,6 @@ The platform's own permission sets (`admin_full_access`, `member_default`, …) **What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. -**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. +**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; whether a cold boot should refuse too is open on #22307. **Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. diff --git a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts index 1825dd2e53e..9ae85bc8608 100644 --- a/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts +++ b/packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts @@ -121,8 +121,8 @@ describe('a package\'s security catalog name another holder holds refuses the bo const { refusal } = await boot(stack.plugins); expectRefusal(refusal, 'com.test.second', { kind: 'package', packageId: 'com.test.first' }); - // Every conflicting name in one refusal: the position no registry slot holds - // is reported beside the two the engine registers. + // Every conflicting name in one refusal: the position, the permission set + // and the capability the first package holds. expect((refusal as any).conflicts.map((c: any) => `${c.catalogType}/${c.name}`)).toEqual([ 'position/regional_manager', 'permission/regional_set', From 99fba80b6490f9984eb6cead8b08d21b4d044360 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:53:21 +0000 Subject: [PATCH 11/11] docs(changeset): the cold-boot refusal is ruled and tracked on #22307, which lands separately #22307 was ruled while the previous text was in review, so the changeset no longer calls the cold-boot question open. The measured clauses before it are unchanged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22135-security-catalog-one-holder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md index 32c58d8436b..980162dd16f 100644 --- a/.changeset/22135-security-catalog-one-holder.md +++ b/.changeset/22135-security-catalog-one-holder.md @@ -22,6 +22,6 @@ The platform's own permission sets (`admin_full_access`, `member_default`, …) **What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. -**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; whether a cold boot should refuse too is open on #22307. +**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added to a deployment over a permission-set or position name the environment catalog already holds is not refused at cold boot (the registry's existing collision warning fires), while a hot install of the same package is refused; a cold-boot refusal is ruled and tracked on #22307, which lands separately. **Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED.