Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/22135-security-catalog-one-holder.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/objectql': major
---
Expand All @@ -22,6 +22,6 @@

**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.
31 changes: 31 additions & 0 deletions .changeset/22307-cold-boot-catalog-refusal.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) the refusal removes no key, export or field and changes the shape of no stored body; what an operator does about a refused name is rename or remove one of the two items, which no conversion can choose for them -->

**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/<id>/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/<name>`, 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/<name>`, `DELETE /api/v1/meta/position/<name>`), 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 = '<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/<name>` and `DELETE /api/v1/meta/position/<name>` 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.
4 changes: 3 additions & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading