From 5329ee0ccda9bc00d4482458fd5926fe152e1eaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:54:10 +0000 Subject: [PATCH 01/11] feat(objectql)!: the cold boot refuses a package-held position or permission-set name the environment catalog already holds After sys_metadata hydration and before any plugin that depends on the engine starts, ObjectQLPlugin.start asks the registry for every package-held position and permission-set name the environment catalog also holds, and one such name refuses the boot with the package door's envelope (422 NAMESPACE_CONFLICT, every conflict listed, both holders named). The hydration write itself stays unjudged. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/objectql/src/plugin.ts | 47 ++++++ packages/objectql/src/registry.ts | 145 ++++++++++++++++-- .../src/security-catalog-namespace.ts | 36 ++++- 3 files changed, 213 insertions(+), 15 deletions(-) diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 9a41af3ad87..399ddf47c12 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 }; @@ -821,6 +824,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); @@ -1992,6 +2002,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/registry.ts b/packages/objectql/src/registry.ts index fc1eac1a80d..4eba60ec06d 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, @@ -1581,6 +1582,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 @@ -1597,6 +1623,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; @@ -1614,7 +1646,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) => @@ -1622,19 +1654,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; @@ -1645,6 +1679,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. * @@ -2365,6 +2414,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 } From 2e90831ce31dccf4be371bcc0f913df4e1a9cfb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:05:27 +0000 Subject: [PATCH 02/11] test(objectql,runtime): pin the cold-boot refusal of an environment-held catalog name objectql: a real kernel boot (ObjectQLPlugin, the package door in Phase 1, the real protocol hydrating stored rows) refuses a package-held position or permission-set name the environment catalog holds, lists every conflict, treats a row bound to the package itself as the environment's, and boots the controls (distinct names, a built-in name the platform declares beside a stored definition). runtime: the artifact boot over one database refuses the same shape. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../protocol-boot-hydration-scoped.test.ts | 138 +++++++++++++++++- ...-stack-security-catalog-one-holder.test.ts | 38 ++++- 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 8d155789d66..61d864e4c23 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 } 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,135 @@ 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: 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 })); + await kernel.use({ + name: 'test.stored-rows-and-package', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: any) => { + ql = ctx.getService('objectql'); + ql.registry.logLevel = 'silent'; + ctx.registerService('protocol', new ObjectStackProtocolImplementation(makeEngine(ql.registry, rows))); + await ctx.getService('manifest').register(pkg); + beforeHydration?.(ql); + }, + } as any); + 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/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'), From 2f103738b744ace61a5fecd2f0387f899c5a42db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:07:16 +0000 Subject: [PATCH 03/11] test(dogfood): pin the cold-boot refusal through bootStack restarts on one database Environment-saved names then a package declaring both: the hot install and the cold boot are refused alike, naming both holders. A row saved over a package-held name before the packaged locks refuses the restart. Controls: stored definitions under built-in position names boot, and a package whose names the environment does not hold boots and restarts. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...ld-boot-environment-holder.dogfood.test.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts 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..b0d41b2c975 --- /dev/null +++ b/packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts @@ -0,0 +1,232 @@ +// 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 through this file's own +// steps before the check: the cold boot below came up, with two collision +// warnings, and the by-name read answered the environment's definitions. +// +// 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, 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; + }); + + 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(); + const previous = process.env.OS_METADATA_WRITABLE; + process.env.OS_METADATA_WRITABLE = 'position'; + try { + stack = await bootStack(baseApp, { databaseFile: db }); + const token = await stack.signIn(); + for (const name of ['org_admin', 'everyone']) { + const saved = await stack.apiAs(token, '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; + } finally { + if (previous === undefined) delete process.env.OS_METADATA_WRITABLE; + else process.env.OS_METADATA_WRITABLE = previous; + } + + 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); +}); From bdfba35e20c1955284f63f5efe3eb40eca330637 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:14:22 +0000 Subject: [PATCH 04/11] chore(changeset): the cold-boot refusal's upgrade shape and remedy; the one-holder entry's cold-boot sentence The new changeset grades @objectstack/objectql major on the v18 pre-release line and names the upgrade shape (an environment-wide stored position or permission set under a name a configured package declares, pre-lock rows included) and the remedy. The unreleased one-holder changeset said a cold boot was not refused; on this change's merge it is, so that sentence now says where the cold boot is judged. The ADR anchor gains the cold-boot half. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../22135-security-catalog-one-holder.md | 2 +- .changeset/22307-cold-boot-catalog-refusal.md | 23 +++++++++++++++++++ ...l__src__security-catalog-namespace.ts.json | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .changeset/22307-cold-boot-catalog-refusal.md 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..75e75ee0f3d --- /dev/null +++ b/.changeset/22307-cold-boot-catalog-refusal.md @@ -0,0 +1,23 @@ +--- +'@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` 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.** To reach the environment's item through the metadata API, boot once without the package in the configuration, rename or delete the item (`DELETE /api/v1/meta/permission/`, `DELETE /api/v1/meta/position/`), and add the package back. For a name the platform security plugin declares, delete the environment-wide `sys_metadata` row of that type and name in the database. 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/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()`." } From 004ebc3904746188cebd6abfe1cae403d0bbc5c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:31:05 +0000 Subject: [PATCH 05/11] test(dogfood): write the Discard Overlay control's legacy overlay into the running deployment A cold boot whose environment catalog holds a package-held permission-set name is now refused (ADR-0048 N.3), so the legacy overlay of the shipped set can no longer ride the restart into the deployment. It is written after the cold boot, and the two passes the security plugin's boot runs for it (projection reconciliation and the drift pass) are run on it, so the control still meets the field shape the action exists for. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...iscard-overlay-eligibility.dogfood.test.ts | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) 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); From 452e2a54b849dcb9e6d1227e99ea51d58004d103 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:31:32 +0000 Subject: [PATCH 06/11] test(objectql): spell the cold-boot pin's stored row body as the row's string column The overlay row's `metadata` column is a string; the cold-boot pins passed the body object, a type error the test layer's exact ratchet refuses. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/objectql/src/protocol-boot-hydration-scoped.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index 61d864e4c23..a6bb3054afc 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -356,7 +356,7 @@ describe('cold boot — a package-held catalog name the environment catalog hold 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: catalogBody(type, name, 'saved in the environment') }); + 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, From 9be6548cafd0b7dcc3fd8817dd6e2be22cc8f6b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:27:09 +0000 Subject: [PATCH 07/11] test(objectql): type the cold-boot pins' inline plugin by its contract The inline plugin erased its context and the manifest lookup to `any`, which the service-lookup erasure rule refuses; it now takes `PluginContext` and names each slot's contract. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/protocol-boot-hydration-scoped.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts index a6bb3054afc..1129f4cb55a 100644 --- a/packages/objectql/src/protocol-boot-hydration-scoped.test.ts +++ b/packages/objectql/src/protocol-boot-hydration-scoped.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { MetadataManager } from '@objectstack/metadata'; -import { createSecurityCatalogReader, ObjectKernel } 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'; @@ -389,18 +389,19 @@ describe('cold boot — a package-held catalog name the environment catalog hold kernels.push(kernel); let ql!: ObjectQL; await kernel.use(new ObjectQLPlugin({ registerProtocol: false })); - await kernel.use({ + const storedRowsAndPackage: Plugin = { name: 'test.stored-rows-and-package', version: '1.0.0', dependencies: ['com.objectstack.engine.objectql'], - init: async (ctx: any) => { - ql = ctx.getService('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('manifest').register(pkg); + await ctx.getService<{ register(m: unknown): Promise | void }>('manifest').register(pkg); beforeHydration?.(ql); }, - } as any); + }; + await kernel.use(storedRowsAndPackage); try { await kernel.bootstrap(); return { ql }; From 72dcb8e513ddcd8190ad5a4715a7e6f3be644b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:46:07 +0000 Subject: [PATCH 08/11] test(dogfood): open the position hatch for the whole cold-boot file, before its first boot The protocol memoises OS_METADATA_WRITABLE at its first read in a process, and since the package door answers a save before the body checks, the first case's saves read it. Set inside the built-in control only, the hatch was already memoised closed there and the save answered 403 in the shard run. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...ld-boot-environment-holder.dogfood.test.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) 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 index b0d41b2c975..28d63b80400 100644 --- 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 @@ -40,7 +40,7 @@ // 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, afterEach } from 'vitest'; +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'; @@ -112,6 +112,23 @@ describe('ADR-0048 N.3: a package-held position or permission-set name the envir 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 }); @@ -183,21 +200,14 @@ describe('ADR-0048 N.3: a package-held position or permission-set name the envir it('CONTROL — stored definitions under built-in position names: the restart boots, and the stored definition answers', async () => { const db = databaseFile(); - const previous = process.env.OS_METADATA_WRITABLE; - process.env.OS_METADATA_WRITABLE = 'position'; - try { - stack = await bootStack(baseApp, { databaseFile: db }); - const token = await stack.signIn(); - for (const name of ['org_admin', 'everyone']) { - const saved = await stack.apiAs(token, '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; - } finally { - if (previous === undefined) delete process.env.OS_METADATA_WRITABLE; - else process.env.OS_METADATA_WRITABLE = previous; + 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; From 3c160a23e3b5e7ba42505ba4bde65f2911ba5169 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:23:15 +0000 Subject: [PATCH 09/11] test(dogfood): say how the cold-boot file's base reading was taken The base reading came from a probe with the same steps, not from this file; the file's own base reading is the ablation's. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...y-catalog-cold-boot-environment-holder.dogfood.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index 28d63b80400..1b516473953 100644 --- 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 @@ -14,9 +14,11 @@ // 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 through this file's own -// steps before the check: the cold boot below came up, with two collision -// warnings, and the by-name read answered the environment's definitions. +// `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 From cf1a9dd6db406f5addff9cf884a715d2eb4380c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:46:18 +0000 Subject: [PATCH 10/11] docs(changeset,permissions): the cold-boot refusal's remedy, before and after the upgrade The changeset now names what an operator can do before upgrading (the kernel:ready overlay reading lists the permission sets this release refuses; Discard Overlay or the metadata API delete removes each overlay without touching the database) and after it (boot without a package that can be left out and delete through the metadata API; for a name the platform security plugin declares, the SQL delete of the active, environment-wide rows under the type or its legacy plural). No CLI command deletes a sys_metadata row offline. The Permission Sets page tells the reader to discard such an overlay before upgrading. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22307-cold-boot-catalog-refusal.md | 10 ++++++++-- content/docs/permissions/permission-sets.mdx | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/22307-cold-boot-catalog-refusal.md b/.changeset/22307-cold-boot-catalog-refusal.md index 75e75ee0f3d..259264aaeb9 100644 --- a/.changeset/22307-cold-boot-catalog-refusal.md +++ b/.changeset/22307-cold-boot-catalog-refusal.md @@ -16,8 +16,14 @@ Clause-②: no **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` 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 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.** To reach the environment's item through the metadata API, boot once without the package in the configuration, rename or delete the item (`DELETE /api/v1/meta/permission/`, `DELETE /api/v1/meta/position/`), and add the package back. For a name the platform security plugin declares, delete the environment-wide `sys_metadata` row of that type and name in the database. Nothing renames or removes either item automatically. +**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 touches the database. 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.** 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. + +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 From 39ef237d40b56c769ef4b917fa2227f207450594 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:39:24 +0000 Subject: [PATCH 11/11] docs(changeset): the metadata-API delete reaches singular-typed rows only, one per call DELETE /api/v1/meta/permission|position/NAME matches the stored type exactly, so a row stored under the legacy plural permissions / positions is not reached (200, nothing found) and stays to refuse the next boot; a name with two active rows needs two calls. The changeset says so, routes a plural-typed row to Discard Overlay before upgrading or to the SQL after it for any name, and says the remedies need no direct database access rather than that they do not touch it. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22307-cold-boot-catalog-refusal.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/22307-cold-boot-catalog-refusal.md b/.changeset/22307-cold-boot-catalog-refusal.md index 259264aaeb9..627ed100d2d 100644 --- a/.changeset/22307-cold-boot-catalog-refusal.md +++ b/.changeset/22307-cold-boot-catalog-refusal.md @@ -20,9 +20,11 @@ Clause-②: no **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 touches the database. Positions have no such reading and no such action. +- **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.** 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. +- **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.