diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/22135-security-catalog-one-holder.md index 980162dd16f..fddd17ded72 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; a cold-boot refusal is ruled and tracked on #22307, which lands separately. +**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 this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release). **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/.changeset/22307-cold-boot-catalog-refusal.md b/.changeset/22307-cold-boot-catalog-refusal.md new file mode 100644 index 00000000000..627ed100d2d --- /dev/null +++ b/.changeset/22307-cold-boot-catalog-refusal.md @@ -0,0 +1,31 @@ +--- +'@objectstack/objectql': major +--- + +feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does + +Clause-②: no + + + +**BREAKING** — an accept-set narrowing at boot, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose environment catalog holds a position or permission-set name that a configured package also declares booted before this release and is refused at boot after it. + +**Why.** Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot did not refuse it: every package registers in the kernel's first phase, before the environment catalog loads from `sys_metadata` in the engine plugin's `start()`, so the package door could not see the environment's name. The stored row loaded over the package's definition, the registry printed a `[Registry] Collision` warning, and the by-name read served the environment's definition in place of the package's. The maintainer ruled that the cold boot refuses too, so that a cold boot, a hot install and an artifact boot answer alike (ADR-0048 addendum N.3). + +**What is refused, and where.** Right after `sys_metadata` hydration in `ObjectQLPlugin.start()`, before any plugin that depends on the engine starts, the engine checks every package-held position and permission-set name against the environment catalog's items. One such name fails the boot. Every conflict is listed in one refusal. The check reads the two types an environment can author: the runtime metadata API refuses to create a capability (`403`, a code-only type), so the environment catalog holds none. The hydration write itself is still not judged. + +**What an operator sees.** The kernel reports `Plugin com.objectstack.engine.objectql failed to start`, and the cause is the package door's envelope: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE` is exported), `status: 422`, and `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder: { kind: 'environment' } }`. The message names the package that declares each name and the environment catalog that holds it. + +**The upgrade shape.** A deployment fails to boot after this release when an active, environment-wide `sys_metadata` row of type `permission` or `position` (or the legacy plural `permissions` / `positions`, which the boot's load folds to the same types) has the name of a permission set or position that a configured package declares. That includes a row saved over a package-held name before the packaged locks refused such saves, whether or not the row was bound to the package, and a row over one of the platform security plugin's own permission sets (`member_default`, `admin_full_access` and the rest it declares). + +**The one-line fix: rename the item in the package, or rename or delete the environment's item, then restart.** + +- **Before upgrading, for a permission set.** On the release you run now, a boot whose environment catalog overlays a package-declared permission set logs at `kernel:ready`: `[security] N package-declared permission set(s) are being shadowed by an environment overlay`, with the set names. Those are the permission sets this release refuses at boot. The audited **Discard Overlay** action on the set's record in Setup (`POST /api/v1/security/permission-sets//discard-overlay`, documented under "Declared ≠ enforced" on the Permission Sets page) removes the overlay and resyncs the set to the package's definition. So does `DELETE /api/v1/meta/permission/`, which answers "Customization overlay deleted … reset to artifact default". Either works for the platform security plugin's own sets too, and neither needs direct database access. Positions have no such reading and no such action. +- **After upgrading, for a package you can leave out.** Boot once without the package in the configuration, rename or delete the environment's item through the metadata API (`DELETE /api/v1/meta/permission/`, `DELETE /api/v1/meta/position/`), then add the package back. +- **After upgrading, for a name the platform security plugin declares, or for any row the metadata API does not reach.** Back the database up, then delete the row in it. The rows that refuse the boot are the active, environment-wide ones of that name: `organization_id IS NULL` and `state = 'active'`, whatever their `package_id`, under the type or its legacy plural: `DELETE FROM sys_metadata WHERE organization_id IS NULL AND state = 'active' AND type IN ('permission', 'permissions') AND name = '';` (for a position, `type IN ('position', 'positions')`). A draft row and an organization-scoped row are not loaded at boot and do not refuse it. + +`DELETE /api/v1/meta/permission/` and `DELETE /api/v1/meta/position/` reach a row stored under `permission` or `position` only, one row per call. A row stored under the legacy plural `permissions` / `positions` is not reached: the call answers `200` that nothing was found and removes nothing. Where a name has two active rows, for example one bound to no package and one bound to the package, each call removes one. A plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by the SQL above after upgrading, for any name. + +No `os` command deletes a `sys_metadata` row offline: `os meta delete` and `os data delete` call a running server. Nothing renames or removes either item automatically. + +**What is NOT refused.** A stored definition under a built-in position name (`org_admin`, `everyone` and the other four): the platform declares its built-in positions itself, after this check, and the stored definition keeps answering first. The same package restarting with its own names. A package whose names the environment catalog does not hold, booting beside the environment's own items. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index bfd9350dc7c..c4e37ef1fc9 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -368,7 +368,9 @@ either can be live on one row, and they need different remedies: current artifact immediately. It refuses on any set that no installed code package ships (a set created in this environment, a clone, or a set saved into a writable runtime package), so it can never destroy a genuinely - environment-authored set. + environment-authored set. **Discard it before you upgrade:** from the + release that adds ADR-0048's cold-boot check, a deployment that still holds + such an overlay does not boot, so the action can no longer reach it. - **Provenance skip.** The record's `managed_by` column predates package provenance tracking (a legacy insert without `managed_by: 'package'` — typically a database first initialized on an older release line), so boot diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 7c7485a1d4a..9b18363e90e 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -35,6 +35,9 @@ import { collectManifestPicklistReferences, describeUnresolvedPicklistReferences, } from './picklist-resolution.js'; +// [ADR-0048 N.3] The security catalog's one-holder envelope, raised here by the +// cold-boot check (see `refuseEnvironmentHeldSecurityCatalogNames`). +import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js'; export type { Plugin, PluginContext }; @@ -935,6 +938,13 @@ export class ObjectQLPlugin implements Plugin { ctx.logger.info('Project kernel — skipping sys_metadata hydration (metadata sourced from artifact)'); } + // [ADR-0048 N.3, ruling letter A on #22307] The environment catalog is in + // the registry now, and every package registered before it: a package-held + // position or permission-set name the environment already holds refuses the + // boot here, before any other plugin starts. See + // {@link refuseEnvironmentHeldSecurityCatalogNames}. + this.refuseEnvironmentHeldSecurityCatalogNames(); + // Phase 3: Sync any new schemas that were just hydrated from the DB // (e.g. CRM objects seeded via template — they must have tables before use). await this.installRegisteredSchemas(ctx); @@ -2106,6 +2116,43 @@ export class ObjectQLPlugin implements Plugin { } } + /** + * [ADR-0048 N.3 — maintainer ruling letter A on #22307, record 6063176077] + * The cold boot refuses a package-held position or permission-set name the + * environment catalog already holds, as a hot install does. + * + * At a cold boot every package registers in the kernel's first phase, through + * the package door, BEFORE `sys_metadata` hydrates into the registry's bare + * slot ({@link restoreMetadataFromDb}, just above in `start()`), so the door + * could not see the environment's names. The hydration write itself stays + * unjudged; this judges each package's claim against what it wrote, with the + * door's envelope (`SecurityCatalogNameConflictError`: `422` + * `NAMESPACE_CONFLICT`, every conflict listed, both holders named). + * + * Placed right after hydration and before Phase 3's schema sync, which is + * before `kernel:ready` and before every plugin that depends on the engine + * starts. A registration made after this point meets the environment's items + * at the registry's own package door or item seam, so between them every + * package registration of the boot is judged. Called whether or not this + * kernel hydrated: without hydration the bare slot holds only what a + * package-less registration put there, judged the same way, and usually + * nothing. + * + * The reading is the registry's, kept off the public surface + * ({@link findEnvironmentHeldSecurityCatalogNames}: which items are the + * environment's, and the built-in carve-out). + * + * @throws {SecurityCatalogNameConflictError} with `door: 'cold-boot'`, which + * fails `start()` and with it the boot. + */ + private refuseEnvironmentHeldSecurityCatalogNames(): void { + const registry = this.ql?.registry; + if (!registry) return; + const conflicts = findEnvironmentHeldSecurityCatalogNames(registry); + if (conflicts.length === 0) return; + throw new SecurityCatalogNameConflictError(conflicts, { door: 'cold-boot' }); + } + /** * Bridge all SchemaRegistry objects to the metadata service. * diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 8d155789d66..1129f4cb55a 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -19,13 +19,15 @@ * and the write-through. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { MetadataManager } from '@objectstack/metadata'; -import { createSecurityCatalogReader } from '@objectstack/core'; +import { createSecurityCatalogReader, ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; import { SchemaRegistry, NAMESPACE_CONFLICT_CODE } from './registry.js'; import { assertEngineUpdateDispatch } from './engine-update-dispatch.js'; import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; const PKG_A = 'com.acme.a'; const PKG_B = 'com.acme.b'; @@ -325,3 +327,136 @@ describe('security catalog read — a name two packages ship (ADR-0131 D4, ruled expect(await reader.list('position')).toEqual([entry]); }); }); + +/** + * ADR-0048 addendum N.3 — the cold boot (maintainer ruling letter A on #22307, + * record 6063176077). Every package registers in the kernel's first phase, + * BEFORE `sys_metadata` hydrates into the registry's bare slot in + * `ObjectQLPlugin.start`, so the package door cannot see an environment-held + * name at a cold boot. The hydration write stays unjudged; right after it, the + * engine plugin judges every package-held position and permission-set name + * against what it wrote, and refuses the boot with the package door's envelope. + * + * These cases boot a real kernel: `ObjectQLPlugin`, a package registered + * through the real `manifest` service in Phase 1 (what `AppPlugin.init` does), + * and the real protocol hydrating the stored rows in Phase 2 — over this + * file's engine double, so the rows are this file's `Row` shape. The refusal + * leaves `start()`, so the kernel wraps it and the envelope is the wrapper's + * `cause`. The real composition, restarted on one database, is pinned in + * `packages/qa/dogfood` and the artifact boot in `packages/runtime`. + */ +describe('cold boot — a package-held catalog name the environment catalog holds refuses the boot (ADR-0048 N.3)', () => { + type CatalogType = 'permission' | 'position'; + type Envelope = Error & { + code?: string; + status?: number; + conflicts?: Array<{ catalogType: string; name: string; incomingPackageId: string; existingHolder: unknown }>; + }; + const PKG = 'com.acme.coldboot'; + const catalogBody = (type: CatalogType, name: string, label: string) => + type === 'permission' ? { name, label, objects: {} } : { name, label }; + const storedRow = (type: CatalogType, name: string, packageId: string | null = null) => + overlayRow({ type, name, package_id: packageId, metadata: JSON.stringify(catalogBody(type, name, 'saved in the environment')) }); + /** A package declaring `decl` — the flat shape `AppPlugin.init` hands the `manifest` service. */ + const pkgOf = (decl: Partial>) => ({ + id: PKG, + name: 'coldboot', + version: '1.0.0', + type: 'app', + ...(decl.position ? { positions: decl.position.map((n) => catalogBody('position', n, 'shipped')) } : {}), + ...(decl.permission ? { permissions: decl.permission.map((n) => catalogBody('permission', n, 'shipped')) } : {}), + }); + + const kernels: ObjectKernel[] = []; + afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + try { await kernel.shutdown(); } catch { /* a refused boot is already stopped */ } + } + }); + + /** + * Boot with `pkg` registered in Phase 1 and `rows` in `sys_metadata`. The + * second hook lets a case register into the registry in Phase 1 the way a + * plugin's own `init()` would. + */ + async function boot( + pkg: unknown, + rows: Row[], + beforeHydration?: (ql: ObjectQL) => void, + ): Promise<{ refusal?: Envelope; ql: ObjectQL }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + let ql!: ObjectQL; + await kernel.use(new ObjectQLPlugin({ registerProtocol: false })); + const storedRowsAndPackage: Plugin = { + name: 'test.stored-rows-and-package', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + ql = ctx.getService('objectql'); + ql.registry.logLevel = 'silent'; + ctx.registerService('protocol', new ObjectStackProtocolImplementation(makeEngine(ql.registry, rows))); + await ctx.getService<{ register(m: unknown): Promise | void }>('manifest').register(pkg); + beforeHydration?.(ql); + }, + }; + await kernel.use(storedRowsAndPackage); + try { + await kernel.bootstrap(); + return { ql }; + } catch (e) { + return { refusal: ((e as { cause?: unknown }).cause ?? e) as Envelope, ql }; + } + } + + const environmentConflict = (type: CatalogType, name: string) => + ({ catalogType: type, name, incomingPackageId: PKG, existingHolder: { kind: 'environment' } }); + + describe.each(['permission', 'position'] as const)('%s', (type) => { + const name = `coldboot_${type}`; + + it('a package-held name the environment catalog already holds refuses the boot, naming both holders', async () => { + const { refusal } = await boot(pkgOf({ [type]: [name] }), [storedRow(type, name)]); + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.status).toBe(422); + expect(refusal?.conflicts).toEqual([environmentConflict(type, name)]); + }); + + it('a stored row bound to the package itself is the environment\'s too (a hot install refuses it alike)', async () => { + const { refusal } = await boot(pkgOf({ [type]: [name] }), [storedRow(type, name, PKG)]); + expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal?.conflicts).toEqual([environmentConflict(type, name)]); + }); + + it('CONTROL — the environment\'s own names and the package\'s distinct names: the boot comes up, each answers its own', async () => { + const { refusal, ql } = await boot(pkgOf({ [type]: [`${name}_shipped`] }), [storedRow(type, `${name}_saved`)]); + expect(refusal).toBeUndefined(); + expect(ql.registry.getItem<{ _packageId?: string }>(type, `${name}_shipped`)?._packageId).toBe(PKG); + expect(ql.registry.getItem<{ label?: string }>(type, `${name}_saved`)?.label).toBe('saved in the environment'); + }); + }); + + it('every conflict is listed in one refusal, positions first, each name once', async () => { + const { refusal } = await boot( + pkgOf({ position: ['coldboot_both_position'], permission: ['coldboot_both_set', 'coldboot_free_set'] }), + [storedRow('permission', 'coldboot_both_set'), storedRow('position', 'coldboot_both_position')], + ); + expect(refusal?.conflicts).toEqual([ + environmentConflict('position', 'coldboot_both_position'), + environmentConflict('permission', 'coldboot_both_set'), + ]); + }); + + it('CONTROL — a built-in name the platform declares beside a stored definition boots (the item seam\'s carve-out)', async () => { + // The platform's own declaration of a built-in position, made at the + // item seam under its own package id, here in Phase 1 so it is + // package-held when the check runs; and a stored definition under the + // same name, which shadows it at read (ADR-0005). + const { refusal, ql } = await boot(pkgOf({}), [storedRow('position', 'org_admin')], (engine) => { + engine.registry.registerItem('position', { name: 'org_admin', label: 'Organization Admin' }, 'name', 'com.objectstack.plugin-security'); + }); + expect(refusal).toBeUndefined(); + expect(ql.registry.getItem<{ label?: string }>('position', 'org_admin')?.label).toBe('saved in the environment'); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index be201f2e469..f3b9e430e66 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -59,6 +59,7 @@ import { BUILT_IN_SECURITY_CATALOG_NAMES, declaredSecurityCatalogNames, describeSecurityCatalogHolder, + ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES, isSecurityCatalogType, securityCatalogHolderKey, securityCatalogTypeLabel, @@ -1609,6 +1610,31 @@ export interface SecurityCatalogNameConflict { readonly existingHolder: SecurityCatalogHolder; } +/** + * The cold-boot wording of {@link SecurityCatalogNameConflictError}: every + * package registered before the environment catalog hydrated, so each line + * names the package that declares the name and the holder it meets, and the + * remedy is stated for an operator restarting a deployment, not for an install. + */ +function coldBootConflictMessage(conflicts: readonly SecurityCatalogNameConflict[]): string { + const lines = conflicts.map( + (c) => + `package "${c.incomingPackageId}" declares the ${securityCatalogTypeLabel(c.catalogType)} "${c.name}", ` + + `already held by ${describeSecurityCatalogHolder(c.existingHolder)}`, + ); + return ( + `Security catalog name conflict at boot: ${conflicts.length === 1 ? 'a name' : `${conflicts.length} names`} ` + + `a configured package declares ${conflicts.length === 1 ? 'is' : 'are'} already held by another holder — ` + + `${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, so with two holders the environment's ` + + `stored item would be served in place of the package's definition. The environment catalog loads from ` + + `sys_metadata before this check, so the boot is refused. Rename the item in the package, or rename or ` + + `delete the environment's item (its environment-wide sys_metadata row: through the metadata API on a boot ` + + `that leaves the package out of the configuration, or in the database), then restart. ` + + `See ADR-0048.` + ); +} + /** * Raised when a package registers a position, permission set or capability * whose name another holder already holds — an installed package, the @@ -1625,6 +1651,12 @@ export interface SecurityCatalogNameConflict { * * Every conflict the registration carries is listed — `conflicts` — so one boot * reports all of them; the top-level fields repeat the first. + * + * `door: 'cold-boot'` is the same refusal raised by the engine plugin after + * `sys_metadata` hydration ({@link findEnvironmentHeldSecurityCatalogNames}): + * the packages registered first, so the message says which package declares + * each name the environment catalog holds, and that the boot is what is refused. + * The envelope is unchanged. */ export class SecurityCatalogNameConflictError extends Error { readonly code = NAMESPACE_CONFLICT_CODE; @@ -1642,7 +1674,7 @@ export class SecurityCatalogNameConflictError extends Error { /** The first conflict's existing holder. */ readonly existingHolder: SecurityCatalogHolder; - constructor(conflicts: readonly SecurityCatalogNameConflict[]) { + constructor(conflicts: readonly SecurityCatalogNameConflict[], options: { door?: 'cold-boot' } = {}) { const [first] = conflicts; const lines = conflicts.map( (c) => @@ -1650,19 +1682,21 @@ export class SecurityCatalogNameConflictError extends Error { 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.`, + options.door === 'cold-boot' + ? coldBootConflictMessage(conflicts) + : `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; @@ -1673,6 +1707,21 @@ export class SecurityCatalogNameConflictError extends Error { } } +/** + * Every package-held position and permission-set name the environment catalog + * also holds, read off `registry` — the conflicts the engine plugin's cold-boot + * check refuses the boot with (`ObjectQLPlugin`, right after `sys_metadata` + * hydration; the reading itself is the registry's private + * `environmentHeldSecurityCatalogConflicts`, documented there). + * + * A module-level function rather than a public method so the reading stays off + * the public surface: `index.ts` and `core.ts` re-export named lists from this + * module, and this name is on neither. + */ +export function findEnvironmentHeldSecurityCatalogNames(registry: SchemaRegistry): SecurityCatalogNameConflict[] { + return registry['environmentHeldSecurityCatalogConflicts'](); +} + /** * [ADR-0130 D1] What the install gate is told about the artifact now installing. * @@ -2414,6 +2463,74 @@ export class SchemaRegistry { } } + /** + * The cold-boot half of the rule (`security-catalog-namespace.ts`, "The cold + * boot"; maintainer ruling letter A on #22307, record 6063176077): every + * package-held position and permission-set name the environment catalog also + * holds, as the conflicts {@link SecurityCatalogNameConflictError} reports — + * the package that declares the name as the incoming side, the environment + * catalog as the holder. Read-only: it records nothing and refuses nothing; + * the engine plugin asks it once `sys_metadata` has hydrated, and refuses the + * boot on any answer. + * + * - **The environment's names** are the bare-slot items of the two types the + * environment can author ({@link ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES}). + * Every bare-slot item is the environment's whatever `_packageId` it wears: + * only a registration with no package writes the bare slot, and at a cold + * boot hydration grafts the package's envelope onto the stored row (the + * protocol's artifact-protection merge), so the stamp names the very + * package the row collides with. + * - **A package holds a name** through an item registered under it (its + * composite slot) or its install claim — never through the bare slot. + * - **Built-in names are skipped**, as the item seam skips the environment + * holder for them: the platform declares its built-in positions itself + * (after this check, in its own `start()`), and an environment item under a + * built-in name is a stored definition that shadows the declaration at + * read (ADR-0005) — a legitimate shadow, not a second holder. + * + * Sorted by type, then name, so two boots of one database report alike. + * + * Private, like the rest of this rule's registry half, so the public surface + * does not grow; the engine plugin, which owns the boot sequence it is asked + * in, reaches it through the module-level {@link findEnvironmentHeldSecurityCatalogNames}, + * which the package entries do not re-export. + */ + private environmentHeldSecurityCatalogConflicts(): SecurityCatalogNameConflict[] { + const conflicts: SecurityCatalogNameConflict[] = []; + for (const type of ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES) { + const collection = this.metadata.get(type); + if (!collection) continue; + const environmentNames = [...collection.keys()] + .filter((key) => !key.includes(':') && !BUILT_IN_SECURITY_CATALOG_NAMES[type].has(key)) + .sort(); + for (const name of environmentNames) { + for (const packageId of this.securityCatalogPackageHolders(type, name)) { + conflicts.push({ catalogType: type, name, incomingPackageId: packageId, existingHolder: { kind: 'environment' } }); + } + } + } + return conflicts; + } + + /** + * The packages holding `(type, name)` through an item registered under them + * (a composite `:` slot) or an install claim — the package + * half of {@link securityCatalogHoldersOtherThan}'s reading, without the bare + * slot, whose stamp is not a claim (see {@link environmentHeldSecurityCatalogConflicts}). + */ + private securityCatalogPackageHolders(type: SecurityCatalogType, name: string): string[] { + const holders = new Set(); + const suffix = `:${name}`; + for (const [key, item] of this.metadata.get(type) ?? []) { + if (key === name || !key.endsWith(suffix)) continue; + const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; + holders.add(typeof stamped === 'string' && stamped !== '' ? stamped : key.slice(0, -suffix.length)); + } + const claimant = this.securityCatalogClaims.get(type)?.get(name); + if (claimant !== undefined) holders.add(claimant); + return [...holders]; + } + // ========================================== // Object Registration (Ownership Model) // ========================================== diff --git a/packages/objectql/src/security-catalog-namespace.ts b/packages/objectql/src/security-catalog-namespace.ts index e8ef6267b9d..a50ca9c8eae 100644 --- a/packages/objectql/src/security-catalog-namespace.ts +++ b/packages/objectql/src/security-catalog-namespace.ts @@ -34,6 +34,26 @@ * `_packageId`; * - **a built-in** — {@link BUILT_IN_SECURITY_CATALOG_NAMES}. * + * ## The cold boot + * + * At a cold boot every package registers (the kernel's first phase) BEFORE the + * environment catalog hydrates from `sys_metadata` (`ObjectQLPlugin.start`), so + * neither door above can see an environment-held name then: the stored row + * arrives second, in the bare slot. Once hydration has run and before any other + * plugin starts, the engine plugin asks the registry for every package-held + * position and permission-set name the environment catalog also holds + * ({@link ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES}), and one such name refuses the + * boot with this rule's envelope, naming both holders. So a cold boot, a hot + * install and an artifact boot answer alike. (Maintainer ruling, letter A on + * #22307, record 6063176077; ADR-0048 addendum N.3.) + * + * The environment's item is read as what it is — a bare-slot item, whatever + * package envelope hydration grafted onto it. At a cold boot the stored row is + * hydrated after the package registered, so the protocol's artifact-protection + * merge stamps it with that package's `_packageId`, and a stamp alone would read + * it as the package's own definition. Only a registration with no package ever + * writes the bare slot. + * * ## What it deliberately does not judge * * - ⛔ An environment-catalog save over a package-held name. The ruling covers @@ -41,7 +61,8 @@ * 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. + * and is never refused here: the hydration write stays unjudged as a write, + * and the boot check above judges the PACKAGE's claim against it. * - ⛔ 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 @@ -104,6 +125,19 @@ export const BUILT_IN_SECURITY_CATALOG_NAMES: Readonly(PLATFORM_CAPABILITY_NAMES), }); +/** + * The catalog types the environment catalog can hold, which are the ones the + * cold-boot check reads (module doc, "The cold boot"): the metadata-type registry + * declares `position` and `permission` `allowRuntimeCreate: true`, so an + * environment can author either. A `capability` is code-only + * (`allowRuntimeCreate: false`): the runtime metadata API refuses to create one, + * so the environment catalog holds none. + */ +export const ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES: readonly SecurityCatalogType[] = Object.freeze([ + 'position', + 'permission', +]); + /** Who holds a security catalog name (module doc, "The holders"). */ export type SecurityCatalogHolder = | { readonly kind: 'package'; readonly packageId: string } diff --git a/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts index ae909758ccf..f3162a21cc4 100644 --- a/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts @@ -28,22 +28,33 @@ // legacy overlay: the action still discards that overlay and heals the record // to the shipped artifact. // -// ## Why a booted stack, booted twice +// ## Why a booted stack, booted twice — and the legacy overlay written after it // // The legacy overlay cannot be minted through a door any more (the lock refuses // both), so it is written straight into `sys_metadata` the way an older release -// left it. The second, cold boot on the same file is what makes it a real -// overlay: the boot's reconciliation projects it onto the record, so the record -// enforces the overlay's grants and the boot's drift pass reports the set as +// left it. It is written into the RUNNING second boot, not before it: since +// ADR-0048 addendum N.3 (ruling letter A on #22307) a cold boot whose +// environment catalog holds a package-held permission-set name is refused +// (`security-catalog-cold-boot-environment-holder.dogfood.test.ts` pins that), +// so a deployment carrying this row runs the action on the release before the +// upgrade, or not at all. The two passes the boot runs for it at `kernel:ready` +// are then run on it, by the functions the security plugin's boot calls: +// `reconcilePermissionSetProjection` projects it onto the record, so the record +// enforces the overlay's grants, and the drift pass reports the set as // `overlay_shadow` — the field shape the action exists for. The list read that -// stamps the runtime package's id is issued after that boot, and a precondition -// asserts the stamp before any refusal is read: without it a refusal would -// prove nothing. +// stamps the runtime package's id is issued after the cold boot, and a +// precondition asserts the stamp before any refusal is read: without it a +// refusal would prove nothing. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; -import { securityObjects, computePermissionSetDriftDiagnostics } from '@objectstack/plugin-security'; +import { + securityObjects, + computePermissionSetDriftDiagnostics, + persistPermissionSetDriftDiagnostics, + reconcilePermissionSetProjection, +} from '@objectstack/plugin-security'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -168,8 +179,23 @@ describe('[#21860] Discard Overlay refuses every set no code package ships, with const cloned = await stack.apiAs(token, action.method, action.target.slice(API_BASE.length), body); expect(cloned.status, JSON.stringify(await cloned.clone().json().catch(() => ({})))).toBe(201); + await stack.stop(); + + // The cold boot. + stack = undefined; + stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // The producer the card names: the list read a Studio page load issues. + const list = await stack.apiAs(token, 'GET', '/meta/permission'); + expect(list.status).toBe(200); + // The control's legacy overlay, as an older release left it: an active, - // environment-wide stored definition of a name the package ships. + // environment-wide stored definition of a name the package ships — + // written into the running deployment (see the header for why not + // before the cold boot), and made a real overlay by the two passes the + // security plugin's boot runs for it. const now = new Date().toISOString(); await ql.insert('sys_metadata', { type: 'permission', @@ -183,17 +209,9 @@ describe('[#21860] Discard Overlay refuses every set no code package ships, with updated_at: now, metadata: JSON.stringify({ name: SHIPPED, label: 'Showcase Contributor (legacy overlay)', objects: OVERLAY_OBJECTS }), }, SYS); - await stack.stop(); - - // The cold boot that makes the legacy row a real overlay. - stack = undefined; - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); - token = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - - // The producer the card names: the list read a Studio page load issues. - const list = await stack.apiAs(token, 'GET', '/meta/permission'); - expect(list.status).toBe(200); + const protocol = await stack.kernel.getServiceAsync('protocol'); + await reconcilePermissionSetProjection(protocol, { ql, metadata: await stack.kernel.getServiceAsync('metadata') }); + await persistPermissionSetDriftDiagnostics(ql, await computePermissionSetDriftDiagnostics(ql)); }, 300_000); afterAll(async () => { @@ -210,7 +228,7 @@ describe('[#21860] Discard Overlay refuses every set no code package ships, with .toEqual([{ _packageId: PKG, _provenance: 'org' }]); }); - it('precondition: the shipped set\'s record enforces the legacy overlay, and the boot reported it overlay_shadow', async () => { + it('precondition: the shipped set\'s record enforces the legacy overlay, and the drift pass reported it overlay_shadow', async () => { const [record] = await ql.find('sys_permission_set', { where: { name: SHIPPED }, limit: 1 }, SYS); expect(grantedObjects(record)).toEqual(Object.keys(OVERLAY_OBJECTS)); expect(shippedArtifactObjects.length).toBeGreaterThan(Object.keys(OVERLAY_OBJECTS).length); diff --git a/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts new file mode 100644 index 00000000000..1b516473953 --- /dev/null +++ b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// One name, one holder for positions and permission sets, on the cold boot +// (ADR-0048 addendum N.3; maintainer ruling letter A on #22307, record +// 6063176077): a package-held name the environment catalog already holds +// refuses the boot, as it refuses a hot install. Over the real composition, +// restarted on one database file. +// +// ## Why the cold boot needed its own check +// +// A boot registers every package in the kernel's first phase, through the +// package door, and hydrates the environment catalog from `sys_metadata` in its +// second (`ObjectQLPlugin.start`). The door therefore could not see an +// environment-held name at a cold boot: the stored row hydrated over the +// package's definition with a `[Registry] Collision` warning, and served in its +// place. The same package hot-installed was refused (`422`, holder +// `environment`). Measured on `origin/main` 28bff18d0c with the same steps, in a +// probe that was not committed: the cold boot came up, with two collision +// warnings, and the by-name read answered the environment's definitions. With +// the check ablated, this file's two refusal cases go red and its two controls +// stay green. +// +// The engine plugin now judges every package-held position and permission-set +// name against the environment's items right after hydration, and refuses the +// boot with the door's envelope, naming both holders. The refusal leaves +// `start()`, so the kernel wraps it; the envelope is the wrapper's `cause`. +// +// ## The cases +// +// - an environment-saved permission set and position, then a package declaring +// both: the hot install is refused, and so is the cold boot; +// - a row saved over a package-held name before the packaged locks: the save +// door refuses that write now, so the row is written at the driver the way +// an older release left it, and the restart is refused; +// - CONTROL: stored definitions under two built-in position names — the +// platform's own declaration sits beside them (ADR-0005), and the restart +// boots; +// - CONTROL: a package whose names the environment does not hold boots on a +// database whose environment holds others, and restarts. +// +// Each case boots its own database file. A refused boot leaves no kernel to +// stop, so the files live in this test file's own working directory, which the +// dogfood run removes at its end, rather than being removed here. + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { NAMESPACE_CONFLICT_CODE } from '@objectstack/objectql'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { context: { isSystem: true } } as const; +const BOOT_TIMEOUT = 300_000; + +const BASE_ID = 'com.dogfood.coldbootbase'; +const ADDON_ID = 'com.dogfood.coldbootaddon'; +const SET = 'coldboot_env_set'; +const POSITION = 'coldboot_env_position'; + +const manifestOf = (id: string, namespace: string) => ({ + id, namespace, version: '0.0.1', type: 'app' as const, name: id, engines: { protocol: '^17' }, +}); + +const Note = ObjectSchema.create({ + name: 'coldbootbase_note', + label: 'Cold Boot Note', + pluralLabel: 'Cold Boot Notes', + fields: { title: Field.text({ label: 'Title', maxLength: 80 }) }, +}); + +/** The deployment's app, declaring no catalog name. */ +const baseApp = defineStack({ manifest: manifestOf(BASE_ID, 'coldbootbase'), objects: [Note] } as any); + +/** A package declaring one permission set and one position. */ +const addon = (set: string, position: string) => + defineStack({ + manifest: manifestOf(ADDON_ID, 'coldbootaddon'), + permissions: [{ name: set, label: 'Shipped by the package', objects: {} }], + positions: [{ name: position, label: 'Shipped by the package' }], + } as any); + +/** The deployment with the package added to its configuration. */ +const withAddon = (set: string, position: string) => + composeStacks([baseApp, addon(set, position)] as any, { manifest: 'preserve' } as any); + +type Envelope = Error & { + code?: string; + status?: number; + conflicts?: Array<{ catalogType: string; name: string; incomingPackageId: string; existingHolder: unknown }>; +}; + +/** A boot's refusal, unwrapped from the kernel's `start()` wrapper — or `undefined` and the stack. */ +async function bootOrRefusal(config: unknown, databaseFile: string): Promise<{ refusal?: Envelope; stack?: VerifyStack }> { + try { + return { stack: await bootStack(config, { databaseFile }) }; + } catch (e) { + return { refusal: ((e as { cause?: unknown }).cause ?? e) as Envelope }; + } +} + +const environmentConflicts = (set: string, position: string) => [ + { catalogType: 'position', name: position, incomingPackageId: ADDON_ID, existingHolder: { kind: 'environment' } }, + { catalogType: 'permission', name: set, incomingPackageId: ADDON_ID, existingHolder: { kind: 'environment' } }, +]; + +/** A fresh database file in this test file's working directory (see the header). */ +const databaseFile = () => join(mkdtempSync(join(process.cwd(), 'catalog-cold-boot-')), 'deployment.db'); + +describe('ADR-0048 N.3: a package-held position or permission-set name the environment catalog holds refuses the cold boot, as it refuses a hot install', () => { + let stack: VerifyStack | undefined; + afterEach(async () => { + await stack?.stop(); + stack = undefined; + }); + + // The built-in control saves under two built-in position names, which the + // platform's package registers, so the save needs the documented hatch. The + // protocol reads `OS_METADATA_WRITABLE` ONCE per process and memoises it, so + // it is set for the whole file, before the first boot: set inside the one + // case, a save in an earlier case would already have memoised it closed. No + // other case depends on it: a new position name saves without it, and the + // packaged permission-set lock refuses its save with or without it. + let previousWritable: string | undefined; + beforeAll(() => { + previousWritable = process.env.OS_METADATA_WRITABLE; + process.env.OS_METADATA_WRITABLE = 'position'; + }); + afterAll(() => { + if (previousWritable === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = previousWritable; + }); + + it('environment-saved names, then a package declaring both: the hot install is refused, and so is the cold boot, naming both holders', async () => { + const db = databaseFile(); + stack = await bootStack(baseApp, { databaseFile: db }); + const token = await stack.signIn(); + const savedSet = await stack.apiAs(token, 'PUT', `/meta/permission/${SET}`, { name: SET, label: 'Saved in the environment', objects: {} }); + expect(savedSet.status, JSON.stringify(await savedSet.clone().json().catch(() => ({})))).toBe(200); + const savedPosition = await stack.apiAs(token, 'PUT', `/meta/position/${POSITION}`, { name: POSITION, label: 'Saved in the environment' }); + expect(savedPosition.status, JSON.stringify(await savedPosition.clone().json().catch(() => ({})))).toBe(200); + + // The hot install: the engine door every install path reaches. + const manifest = await stack.kernel.getServiceAsync<{ register(m: unknown): Promise | void }>('manifest'); + let hot: Envelope | undefined; + try { + await manifest.register({ + ...manifestOf(ADDON_ID, 'coldbootaddon'), + permissions: [{ name: SET, label: 'Shipped by the package', objects: {} }], + positions: [{ name: POSITION, label: 'Shipped by the package' }], + }); + } catch (e) { + hot = e as Envelope; + } + expect(hot?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(hot?.status).toBe(422); + expect(hot?.conflicts).toEqual(environmentConflicts(SET, POSITION)); + await stack.stop(); + stack = undefined; + + // The cold boot with the package in the configuration. + const { refusal, stack: booted } = await bootOrRefusal(withAddon(SET, POSITION), db); + stack = booted; + expect(refusal, 'the cold boot was refused').toBeDefined(); + expect(refusal!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal!.status).toBe(422); + expect(refusal!.conflicts).toEqual(environmentConflicts(SET, POSITION)); + }, BOOT_TIMEOUT); + + it('a row saved over a package-held name before the packaged locks refuses the restart, naming both holders', async () => { + const set = 'coldboot_legacy_set'; + const position = 'coldboot_legacy_position'; + const db = databaseFile(); + stack = await bootStack(withAddon(set, position), { databaseFile: db }); + const token = await stack.signIn(); + // The save door refuses the write now (the packaged locks)... + const locked = await stack.apiAs(token, 'PUT', `/meta/permission/${set}`, { name: set, label: 'Over the package', objects: {} }); + expect(locked.status).toBe(403); + // ...so the rows are written the way an older release left them: active, + // environment-wide, bound to no package. + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const now = new Date().toISOString(); + for (const [type, name, body] of [ + ['permission', set, { name: set, label: 'Saved before the lock', objects: {} }], + ['position', position, { name: position, label: 'Saved before the lock' }], + ] as const) { + await ql.insert('sys_metadata', { + type, name, organization_id: null, package_id: null, state: 'active', version: 1, checksum: null, + created_at: now, updated_at: now, metadata: JSON.stringify(body), + }, SYS); + } + await stack.stop(); + stack = undefined; + + const { refusal, stack: booted } = await bootOrRefusal(withAddon(set, position), db); + stack = booted; + expect(refusal, 'the restart was refused').toBeDefined(); + expect(refusal!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(refusal!.status).toBe(422); + expect(refusal!.conflicts).toEqual(environmentConflicts(set, position)); + }, BOOT_TIMEOUT); + + it('CONTROL — stored definitions under built-in position names: the restart boots, and the stored definition answers', async () => { + const db = databaseFile(); + stack = await bootStack(baseApp, { databaseFile: db }); + const saveToken = await stack.signIn(); + for (const name of ['org_admin', 'everyone']) { + const saved = await stack.apiAs(saveToken, 'PUT', `/meta/position/${name}`, { name, label: `Repurposed ${name}` }); + expect(saved.status, JSON.stringify(await saved.clone().json().catch(() => ({})))).toBe(200); + } + await stack.stop(); + stack = undefined; + + const { refusal, stack: booted } = await bootOrRefusal(baseApp, db); + stack = booted; + expect(refusal).toBeUndefined(); + const token = await stack!.signIn(); + const read = await stack!.apiAs(token, 'GET', '/meta/position/org_admin'); + expect(read.status).toBe(200); + const body: any = await read.json(); + expect(body?.item?.label ?? body?.data?.label ?? body?.label).toBe('Repurposed org_admin'); + }, BOOT_TIMEOUT); + + it('CONTROL — a package whose names the environment does not hold boots beside the environment\'s, and restarts', async () => { + const db = databaseFile(); + stack = await bootStack(baseApp, { databaseFile: db }); + const token = await stack.signIn(); + const saved = await stack.apiAs(token, 'PUT', `/meta/permission/${SET}`, { name: SET, label: 'Saved in the environment', objects: {} }); + expect(saved.status).toBe(200); + await stack.stop(); + stack = undefined; + + for (const boot of ['first boot with the package', 'same-package restart']) { + const { refusal, stack: booted } = await bootOrRefusal(withAddon('coldboot_other_set', 'coldboot_other_position'), db); + stack = booted; + expect(refusal, boot).toBeUndefined(); + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + expect(ql.registry.getItem('permission', 'coldboot_other_set')?._packageId, boot).toBe(ADDON_ID); + expect(ql.registry.getItem('permission', SET)?.label, boot).toBe('Saved in the environment'); + await stack!.stop(); + stack = undefined; + } + }, BOOT_TIMEOUT); +}); 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 9ae85bc8608..3abef0d8934 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 @@ -75,7 +75,7 @@ describe('a package\'s security catalog name another holder holds refuses the bo } }); - async function artifactStack(artifact: unknown) { + async function artifactStack(artifact: unknown, databaseUrl = ':memory:') { const dir = mkdtempSync(join(tmpdir(), 'os-catalog-one-holder-')); dirs.push(dir); const artifactPath = join(dir, 'objectstack.json'); @@ -83,7 +83,7 @@ describe('a package\'s security catalog name another holder holds refuses the bo return createStandaloneStack({ artifactPath, projectRoot: dir, - databaseUrl: ':memory:', + databaseUrl, skipSeedData: true, runPlatformMigrations: false, }); @@ -165,6 +165,40 @@ describe('a package\'s security catalog name another holder holds refuses the bo expectRefusal(refusal, SECURITY_PLUGIN_ID, { kind: 'package', packageId: 'com.test.dup' }); }, BOOT_TIMEOUT); + // The cold-boot half (ADR-0048 N.3; maintainer ruling letter A on #22307): + // on an artifact boot as on any other, every package registers before the + // environment catalog hydrates from `sys_metadata`, so the package door + // cannot see the environment's names; the engine plugin judges them right + // after hydration and refuses the boot. The refusal leaves `start()`, so the + // kernel wraps it, and the envelope is the wrapper's `cause`. + it('artifact boot over one database: a package declaring a position and a permission set the environment catalog already holds', async () => { + const dbDir = mkdtempSync(join(tmpdir(), 'os-catalog-cold-boot-')); + dirs.push(dbDir); + const databaseUrl = `file:${join(dbDir, 'catalog.db')}`; + const base = body('com.test.env-base', { position: 'base_position', permission: 'base_set', capability: 'base.export' }); + + const first = await boot((await artifactStack({ manifest: manifestOf('com.test.env-project'), packages: [{ manifest: base }] }, databaseUrl)).plugins); + expect(first.refusal).toBeUndefined(); + const protocol = first.kernel.getService('protocol'); + await protocol.saveMetaItem({ type: 'permission', name: 'env_held_set', item: { name: 'env_held_set', label: 'saved in the environment', objects: {} } }); + await protocol.saveMetaItem({ type: 'position', name: 'env_held_position', item: { name: 'env_held_position', label: 'saved in the environment' } }); + await first.kernel.shutdown(); + kernels.splice(kernels.indexOf(first.kernel), 1); + + const added = body('com.test.env-added', { position: 'env_held_position', permission: 'env_held_set', capability: 'env_added.export' }); + const { refusal } = await boot( + (await artifactStack({ manifest: manifestOf('com.test.env-project'), packages: [{ manifest: base }, { manifest: added }] }, databaseUrl)).plugins, + ); + const cause = (refusal as Error & { cause?: Refusal & { conflicts?: unknown[] } } | undefined)?.cause; + expect(cause, 'the boot was refused with the one-holder envelope').toBeDefined(); + expect(cause!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(cause!.status).toBe(422); + expect(cause!.conflicts).toEqual([ + { catalogType: 'position', name: 'env_held_position', incomingPackageId: 'com.test.env-added', existingHolder: { kind: 'environment' } }, + { catalogType: 'permission', name: 'env_held_set', incomingPackageId: 'com.test.env-added', existingHolder: { kind: 'environment' } }, + ]); + }, 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'), 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 index 6ab24bf953f..70a87048da7 100644 --- a/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json +++ b/scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json @@ -4,5 +4,5 @@ "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)." + "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); the hydration write stays unjudged as a write, and right after `sys_metadata` hydration the engine plugin judges every package-held position and permission-set name against the environment's items and refuses the boot with the same envelope (ADR-0048 addendum N.3, ruling letter A on #22307) — ⛔ do not read a bare-slot item's grafted `_packageId` as the package's own claim there, and do not move that check after another plugin's `start()`." }