Skip to content

Commit e030d43

Browse files
feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does (ADR-0048 N.3) (#22365)
Fixes #22307 Clause-②: no Executes the maintainer's ruling letter A on #22307 (ruling record 6063176077): the restart path refuses too. After `sys_metadata` hydration and before `kernel:ready`, the engine checks every package-held permission set and position name against the environment catalog, and a name the environment already holds fails the boot with the 422 `NAMESPACE_CONFLICT` envelope the package door uses, naming both holders. A cold boot, a hot install and an artifact boot now answer alike (Q4 = A, ruling record 6050490870). The ADR-0048 addendum N.3 amendment is Tier H and rides its own draft PR, from branch `claude/issue-22307-adr-0048-n3-amendment`. This PR carries no `docs/adr/**` file. ## What changed - **`packages/objectql/src/plugin.ts`.** `ObjectQLPlugin.start()` calls a new private `refuseEnvironmentHeldSecurityCatalogNames()` right after the hydration block (`restoreMetadataFromDb`, or the project-kernel skip line) and before Phase 3's schema sync. Any conflict throws `SecurityCatalogNameConflictError` with `door: 'cold-boot'`, which fails `start()` and with it the boot. It runs whether or not the kernel hydrated. - **`packages/objectql/src/registry.ts`.** - A private `SchemaRegistry.environmentHeldSecurityCatalogConflicts()` returns every package-held position and permission-set name that also has a bare-slot item. Built-in names are skipped. Results are sorted by type, then name. - A private `securityCatalogPackageHolders()` reads the package half of the holder reading: composite slots and install claims, never the bare slot. - A module-level `findEnvironmentHeldSecurityCatalogNames(registry)` is the plugin's handle on that reading. It is not re-exported from `index.ts` or `core.ts`, so the public surface does not grow. - `SecurityCatalogNameConflictError` takes an optional `{ door: 'cold-boot' }`, which changes only the message: which package declares each name, and a remedy stated for a restart. `code`, `status`, `httpStatus` and `conflicts[]` are unchanged. - **`packages/objectql/src/security-catalog-namespace.ts`.** `ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` (`position`, `permission`: the two types the metadata-type registry declares `allowRuntimeCreate: true`), and a module-doc section, "The cold boot". - **`.changeset/22307-cold-boot-catalog-refusal.md`** (new). `'@objectstack/objectql': major`, the BREAKING banner, the ADR-0087 marker `not-required (no-migration-prescription)`, the upgrade shape and the remedy. - **`.changeset/22135-security-catalog-one-holder.md`** (pending, not yet released). See Acceptance notes, "A pending release note this PR corrects". - **`scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`.** The invariant gains the cold-boot half. No new error code, no `packages/spec` change. ## Where each refusal sits (for the merge with #22331, which landed first) `main` was merged at e3ae92a, after #22331 landed. The merge was clean, and the order in `ObjectQLPlugin.start()` on this head is: 1. #22331's `installDeploymentPlatformGlobalObjects(ctx)`, the first statement of `start()`. 2. `restoreMetadataFromDb(ctx)`: `sys_metadata` hydration. 3. **This PR's `refuseEnvironmentHeldSecurityCatalogNames()`**: right after the hydration `if`/`else` and before Phase 3's `installRegisteredSchemas`. It runs before any plugin that depends on the engine starts, and before `kernel:ready`. 4. #22331's `assertDeploymentPlatformGlobalObjectsUnchanged(ctx)`, at the top of the `kernel:ready` hook. The two changes share no hunk. This PR's new method sits directly after `restoreMetadataFromDb`'s method body, and its import line comes after the `picklist-resolution` import block. ## Mechanism assumptions, measured - **M1, the admission today.** Reproduced through `bootStack` on one database file, on the untouched base 28bff18. Boot 1 saved a permission set and a position through `PUT /api/v1/meta/permission/NAME` and `PUT /api/v1/meta/position/NAME`. Both answered `200`; a new position name needs no `OS_METADATA_WRITABLE`. Boot 2, cold, added a package declaring both: it booted, with two `[Registry] Collision` warnings, and the by-name read answered the environment's definitions. Boot 3 hot-installed the same package: `422 NAMESPACE_CONFLICT`, both names held by `environment`. - **M2, where the check sits.** As above. Boot shapes: - standalone `os serve` / `os dev` / `bootStack`: `environmentId` unset, hydration runs, the check runs (measured, dogfood); - the artifact boot (`createStandaloneStack`): `environmentId: 'env_local'` with `hydrateMetadataFromDb: true`, hydration runs, the check runs (measured, runtime pin); - a project kernel with `environmentId` and no `hydrateMetadataFromDb`: hydration is skipped, and the check runs over whatever reached the bare slot, normally nothing (code reading); - a host with no `protocol` service, or one without `loadMetaFromDb`: nothing hydrates, and the check runs with nothing to judge (code reading). `loadMetadataFromService` at the top of `start()` syncs `object`, `view`, `app`, `flow` and `hook` only, so no other boot-time path writes these two types into the bare slot. - **M3, the holder reading. Partly falsified, route changed by the ruling's intent.** At a cold boot the hydrated environment row is NOT an unstamped bare-slot item. Hydration runs after the package registered, and the protocol's artifact-protection merge grafts the package's envelope onto the stored row. Measured on base: the bare slot `probe22307_set` carries `_packageId: com.probe.addon22307` and `_provenance: package`, so #22197's stamp-based reading answers "the package itself" and finds no second holder. The check therefore reads every bare-slot item as the environment's, whatever stamp it wears: only a registration with no package writes the bare slot. A package holds a name through a composite slot or a claim, never through the bare slot. The envelope class, holder kinds and claims are #22197's. - **M4, built-ins.** Built-in names are skipped. Through `bootStack`, with `OS_METADATA_WRITABLE=position`, environment saves under `org_admin` and `everyone` answered `200`, and the restart boots, with `GET /api/v1/meta/position/org_admin` answering the saved definition. S2b's pins are green: `builtin-positions.boot.test.ts` is in the plugin-security suite below. - **M5, the legacy shape.** The save door refuses it now (`PUT /api/v1/meta/permission/NAME` over a package-held set answers `403`, with or without `?package=`), so the rows were written at the driver. A row bound to no package refuses the restart, naming both holders (pinned). So does a row bound to the package itself (`package_id` = the package; objectql pin). A hot install refuses that bound row alike: measured, holder `environment`. A legacy row over one of the platform security plugin's own permission sets (`member_default`) refuses the restart, naming `com.objectstack.plugin-security`. On base, all three boot. - **M6, capabilities.** `PUT /api/v1/meta/capability/NAME` answers `403` ("code-only … allowRuntimeCreate=false"), so the environment catalog holds no capability. The check reads permission sets and positions only, and no capability path reaches it. ## Door table: base vs head "Base" is the untouched 28bff18, or a15b8af with the check ablated, as each row says. "Head" is 72dcb8e (3c160a2 changes comments only). Boots go through `@objectstack/verify`'s `bootStack` on one database file unless the row says otherwise. | Door | Base | Head | |---|---|---| | Cold boot: environment-saved permission set and position, then a package declaring both | boots; two `[Registry] Collision` warnings; the by-name read answers the environment's definitions (28bff18 and ablated) | refused: `Plugin com.objectstack.engine.objectql failed to start`, cause `422 NAMESPACE_CONFLICT`, two conflicts, incoming the package, holder `environment` | | Hot install (post-boot `manifest.register`) of that package | refused, `422`, holder `environment`, both names | unchanged | | Artifact boot (`createStandaloneStack`, `file:` database), a package added over environment-saved names | boots (ablated: runtime pin red) | refused, same envelope | | Built-in shadow: environment saves under `org_admin` and `everyone`, restart | boots (ablated) | boots; the stored definition answers | | Legacy row (written at the driver, bound to no package) over a package-held set and position, restart | boots, one collision warning (28bff18) | refused, holder `environment`, both names | | Legacy row bound to the package itself, restart | boots (ablated) | refused, holder `environment` | | Legacy row over the platform's `member_default`, restart | boots (ablated) | refused, incoming `com.objectstack.plugin-security` | | Same-package restart; a package whose names the environment does not hold | boots | boots | | Remedy: boot without the package, `DELETE /api/v1/meta/permission/NAME` and `/position/NAME`, boot with it | (n/a) | both `200`, no row left, the boot with the package comes up | | Environment save of a capability | `403` code-only | unchanged | ## In-repo census The examples ship no `sys_metadata` rows, so the environment catalog holds no names on a fresh boot. Measured on a15b8af: a fresh boot of each example on a database file, then a restart. | Example | Package-held items | Environment rows (`permission`/`position`) after the boot | Restart | |---|---|---|---| | `app-crm` | 10 permission sets, 9 positions | 0 | boots | | `app-showcase` | 17 permission sets, 16 positions | 0 | boots | | `app-multi-package` | 8 permission sets, 6 positions | 0 | boots | The counts include the platform's own items (`plugin-security`'s 8 permission sets and 6 built-in positions). Names held twice: 0. `app-todo` declares no catalog name (#22197's census) and is not a dogfood dependency, so it was not booted. Deployed environments: NOT MEASURED. ## Tests The head is 3c160a2. Against 72dcb8e it changes comment lines only, in the new dogfood file (5 added, 3 removed, 0 outside a `//` comment). The runs below are at 72dcb8e or earlier, as each line says. - `@objectstack/objectql`, whole suite at e3ae92a: 387 files / 7615 passed. At 72dcb8e, `protocol-boot-hydration-scoped.test.ts`: 16 passed (8 of them new). - `@objectstack/plugin-security`, whole suite at e3ae92a: 184 files / 3869 passed, 45 skipped. That includes S2b's `builtin-positions.boot.test.ts` and `bootstrap-declared-positions.test.ts`. - `@objectstack/runtime`, whole suite at e3ae92a: 340 files / 4777 passed, 19 skipped. `standalone-stack-security-catalog-one-holder.test.ts` has 6, 1 of them new. - Dogfood, the CI split, at e3ae92a: - 1/3: 76 files / 567 passed; - 2/3: 75 files passed and 1 failed (539 tests, 1 failed, 1 skipped); - 3/3: 75 files passed and 1 skipped (669 passed, 8 skipped). The one red was this PR's own built-in control: its `PUT /api/v1/meta/position/org_admin` answered `403` with the hatch set. The protocol memoises `OS_METADATA_WRITABLE` at its first read in a process, and the control set it only after the file's first case had already saved through the metadata door. It passed in isolation before the second merge and failed in the full shard after it; what made that difference is NOT MEASURED. At 72dcb8e the file opens the hatch before its first boot. The new file and the re-shaped Discard Overlay file then ran: 2 files / 11 passed. - Before the second merge, at bdfba35: dogfood 1/3 76 files passed; 2/3 75 passed and 1 failed (the Discard Overlay file, re-shaped since); 3/3 74 passed and 1 skipped. - `typecheck` at 72dcb8e: `objectql` (`tsc --noEmit` plus `check:test-typecheck`: 40 files, 234 errors, 65 pinned signatures, no new signature) and `dogfood`, exit 0. `runtime` at e3ae92a, exit 0; no runtime file changed after it. - `pnpm exec eslint --no-inline-config --format json` over the 7 touched TypeScript files at 72dcb8e: 7 files, 0 errors, 0 warnings. This narrowed run is a measurement, not a skipped one, on three grounds: - the population comes from `eslint.config.mjs` itself (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`), and all 7 files are in it; - the count, 7, is read from the JSON output; - the config enables no type-aware linting (no `parserOptions.project`, as stated at `eslint.config.mjs:328`), so this diff cannot move any untouched file's verdict. The whole-repo `pnpm lint` is CI's. ## Ablation The call was neutralised through `scripts/ablation-replace.mjs`, which wraps the run and restores on exit. In `plugin.ts`, `this.refuseEnvironmentHeldSecurityCatalogNames();` became the same call behind an always-false guard carrying the marker `ABLATION_22307_MARKER`, so the method stays referenced and the DTS build still runs. - **Landed on disk:** anchor 1 → 0, replacement 0 → 1, blob `399ddf47c127` → `2cf40c49e6e2`. `objectql` was rebuilt (exit 0), and `ablation-dist-preflight` found the marker in 2 built files. - **objectql pins (from `src`):** 5 failed / 11 passed of 16 in `protocol-boot-hydration-scoped.test.ts`. All 5 refusal pins went red: per type, the environment-held name and the row bound to the package, plus every conflict in one refusal. The controls stayed green: distinct names per type, and a built-in name the platform declares beside a stored definition. - **runtime pins (from `dist`):** 1 failed / 5 passed. The artifact-boot case went red; #22197's five stayed green. - **dogfood pins (from `dist`):** 2 failed / 2 passed. The cold-boot case and the legacy-row case went red; the built-in shadow and distinct-name controls stayed green. - **Base readings under ablation** (an uncommitted probe): the cold boot booted with two collision warnings; the row bound to the package booted cold and was refused hot; the `member_default` overlay booted; S2b booted. - **Restore:** blob back to `399ddf47c127` == HEAD, `git diff HEAD` empty, `git status --porcelain` empty. After a rebuild, `ablation-dist-preflight --absent` is green: the marker is absent from all 14 built files and the tree is clean. The ablation ran at a15b8af. The second `main` merge (e3ae92a) brought #22331's `plugin.ts` hunks, none of them on this check's lines, and the refusal pins were re-run green at 72dcb8e. ## Clause-② (measured on the built entry declarations at 72dcb8e) `packages/objectql/dist/{index,core}.d.ts` and the shared chunk declare no new exported name. `findEnvironmentHeldSecurityCatalogNames`, `ENVIRONMENT_HELD_SECURITY_CATALOG_TYPES` and `SecurityCatalogNameConflictError` are absent from the entries' export lists. The only new declaration text is three private member names (`SchemaRegistry.environmentHeldSecurityCatalogConflicts`, `SchemaRegistry.securityCatalogPackageHolders`, `ObjectQLPlugin.refuseEnvironmentHeldSecurityCatalogNames`) plus JSDoc. No widening was found, so `Clause-②: no` stands. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 81 commands at the head, 3c160a2. All 81 ran with exit codes recorded, and `--ran` reconciles 81/81 with 0 NOT-MEASURED (a derived zero). 80 exited 0. The same 81 were derived and run at 72dcb8e, with the same answers. One exited 1, by design: `check-empty-changeset --base origin/main`. It is the deliberate correction of #22135's pending note (see Acceptance notes), and the gate's own text says to confirm that class on the PR, not restore the note. On e3ae92a, `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) until eight packages outside this change were built: `studio`, `client-react`, `embedder-openai`, `knowledge-memory`, `knowledge-ragflow`, `organizations`, `service-cluster-redis` and `service-knowledge`. On 72dcb8e and 3c160a2 it exits 0. The changeset gates: `check-changeset-no-major --base` exits 0 (pre mode `next`), `check:adr-0087-registration` exits 0, and `check:changeset-gate-self-tests` exits 0. CI's own lanes are declared to CI and are NOT MEASURED here: the Test Core shards, Temporal Conformance, Dogfood Verify CLI, Build Core and the workspace type-check lanes. `origin/main` is 7 commits ahead of the head, among them #22352 (`plugin-security` grant readers) and #22353 (`metadata-protocol` seed loader); none touches a file of this PR. `git merge-tree` against it is clean, so `main` was not merged again. ## Acceptance notes - **A pending release note this PR corrects (`check-empty-changeset` stays red by design).** `.changeset/22135-security-catalog-one-holder.md` is #22135's pending note, not yet consumed by a release (`packages/objectql` is at `17.7.0`). Its "What is NOT refused" paragraph said a package added at cold boot over an environment-held name "is not refused at cold boot". On this PR's merge that sentence is false, and both notes would publish in the same release. That one sentence now says the door cannot see the name at cold boot, and that the engine checks it right after the environment catalog loads and refuses the boot. Nothing else in the note changed. The gate's own text names this shape a DELIBERATE CORRECTION, to be confirmed on the PR, not restored. If a release consumes the note before this PR lands, the edit no longer reaches a published CHANGELOG, and the correct move then is an erratum PR against that CHANGELOG entry. - **The 2026-08-24 legacy-overlay remedies lose their boot-time population for code-package-declared sets.** The overlay detection reading and the drift pass's `overlay_shadow` run in `plugin-security`'s `kernel:ready`. A boot carrying an environment overlay of a package-declared set is now refused before `kernel:ready`, so on a deployment that boots, those branches see no such overlay. The same holds for the Discard Overlay action's discard path for such a set. The ruling names this cost ("including rows saved before the packaged locks"). The upgrade route is in the changeset: rename, or remove the row. A deployment can also run Discard Overlay on the release it runs now, before upgrading. `permission-set-discard-overlay-eligibility.dogfood.test.ts` (#21860's pin) wrote its legacy overlay before a cold boot, which is now refused. It now writes the overlay into the running deployment and runs the two passes the boot ran for it, by the functions the security plugin's boot calls (`reconcilePermissionSetProjection`, then the drift pass), so its preconditions and its control still hold. - **The refusal leaves `start()`, so the kernel wraps it.** `bootstrap()` rejects with `Plugin com.objectstack.engine.objectql failed to start - rollback complete: …`, and the envelope is the wrapper's `cause`, as with any `start()`-time refusal (#22197's item-seam refusal from `plugin-security.start` included). The pins read `cause`. - **Org-scoped rows are not judged.** Boot hydration loads env-wide rows only (`organization_id IS NULL`), and org-scoped rows never reach the registry, so the check judges the env-wide catalog. That is the population hydration serves. - **A refused boot over a `sqlite-wasm` file can still flush after the refusal.** In a probe, removing the database directory right after the refused `bootStack` raised `ENOENT` from the driver's atomic write. The committed dogfood file keeps its database files in the test file's working directory, which the dogfood run removes at its end, and never boots a file again after it was refused. Noted, not filed: a boot that failed has no process left to serve. - **Files outside the engine lane:** - `packages/qa/dogfood/test/security-catalog-cold-boot-environment-holder.dogfood.test.ts` (new) and `packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts` (re-shaped, above): `domain:cli`. - `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts` (one case added, and the artifact-stack helper takes a `databaseUrl`): `domain:cli`. - `scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json`. - `.changeset/22135-security-catalog-one-holder.md` (above). ## Patch round 1 — the release note's remedy, completed Both contract reviews passed: 6070947709 on this PR, which also confirms the correction of #22135's pending note, and 6070955792 on the ADR PR. This round changes text only. The code, the pins and `.changeset/22135-security-catalog-one-holder.md` are unchanged. The head is cf1a9dd. - **`.changeset/22307-cold-boot-catalog-refusal.md`.** "The upgrade shape" names the legacy plural types. "The one-line fix" now has three parts: - **Before upgrading, for a permission set.** The `kernel:ready` overlay reading names the sets this release refuses. The audited Discard Overlay action, or `DELETE /api/v1/meta/permission/NAME`, removes each overlay without touching the database, including on the platform's own sets. - **After upgrading, for a package that can be left out.** Boot without it, then delete through the metadata API. - **After upgrading, for a name the platform security plugin declares.** The SQL delete of the active, environment-wide rows under the type or its legacy plural. The changeset also says that no `os` command deletes a `sys_metadata` row offline. - **`content/docs/permissions/permission-sets.mdx`.** One clause under "Declared ≠ enforced", on the Discard Overlay remedy: discard such an overlay before you upgrade, because a deployment that still holds one does not boot. **Measured, clause by clause:** - **The current release.** This branch with the check ablated through `scripts/ablation-replace.mjs` (blob `9b18363e90ef` → `b3701fcc3a70`, marker in `dist/`), a legacy `member_default` overlay written at the driver, then a restart: - The boot logged one `kernel:ready` warning, "[security] 1 package-declared permission set(s) are being shadowed by an environment overlay — … use the audited "Discard Overlay" action on it …", naming `member_default`. - The record read `drift_status: overlay_shadow`, and Discard Overlay answered `200` and left no active row. - On the same release, `DELETE /api/v1/meta/permission/viewer_readonly` over a legacy overlay of that platform set answered `200` ("Customization overlay deleted — permission/viewer_readonly reset to artifact default") and left no active row. So Discard Overlay is not the only database-free remedy before the upgrade; the changeset names both. - **The restore.** `ablation-replace` put the blob back (== HEAD, `git diff HEAD` empty). After the rebuild, `ablation-dist-preflight --absent` was green on `dist/` at once. It was green on the tree once this round's doc edit, the one dirty path at that moment, was committed (cf1a9dd). - **The head, check live:** - The database on which the current release ran Discard Overlay on `member_default` boots. - Rows of type `permissions` and `positions` (the legacy plurals) over package-held names refuse the restart, both named. - A `draft` row over a third package-held name is not loaded and not named. - `loadMetaFromDb` selects `state: 'active'` and `organization_id: null`, and folds the type through `PLURAL_TO_SINGULAR`, which maps `permissions` to `permission` and `positions` to `position` on `main`. It sets no `package_id` condition: a row bound to the package itself refuses too, measured in the first round. - **The SQL.** The changeset's `DELETE` statements, run through Python's `sqlite3` against the refused database files (one per type, and one for `member_default`), deleted 1 row each. Each restart then booted. - **The CLI.** `os meta delete` and `os data delete` build an API client and require a token (`createApiClient`, `requireAuth`), and no command under `packages/cli/src/commands` deletes a `sys_metadata` row. - **The action.** `discard_permission_set_overlay`, labelled "Discard Overlay", on `sys_permission_set`, in the list-item and record-header locations, visible while `drift_status` is `overlay_shadow`. It is documented on `content/docs/permissions/permission-sets.mdx` under "Declared ≠ enforced — diagnosing a frozen package set". Positions have no overlay reading (it reads the `permission` / `permissions` types) and no such action. - **NOT MEASURED:** the metadata API delete on a set a non-platform package ships, and a position overlay before upgrading. **Gates at cf1a9dd.** `dispatch-gates --commands` derived 107 commands; the doc page added the docs families. All 107 ran with exit codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0, including `check-changeset-no-major --base`, `check-adr-0087-registration --base`, `check:doc-authoring`, `check:docs-*`, `check-doc-frontmatter`, `@objectstack/spec`'s `check:docs` and `check:doc-formula-expressions`. One exited 1 by design: `check-empty-changeset --base origin/main`, the confirmed #22135 correction. `origin/main` is 12 commits ahead; `git merge-tree` against it is clean, so `main` was not merged. **One more file outside the engine lane:** `content/docs/permissions/permission-sets.mdx` (`domain:devx`). ## Patch round 2 — the metadata-API delete reaches singular-typed rows only The at-tier contract review on cf1a9dd (6071828819) failed two remedy sentences, and judged everything else right: the code, the #22135 correction (confirmed on that head), case 3's SQL, the CLI sentence, the docs clause and the semver. The two sentences are case 1's "So does `DELETE /api/v1/meta/permission/NAME`" and case 2's metadata-API delete. Both are false for a row stored under the legacy plural `permissions` / `positions`, a shape the changeset's own "upgrade shape" paragraph names. This round changes `.changeset/22307-cold-boot-catalog-refusal.md` only. No code, pin, docs page or `.changeset/22135-security-catalog-one-holder.md` change. The head is 39ef237. **Measured first; the review's reading holds.** - **The current release** (this branch with the check ablated through `scripts/ablation-replace.mjs`, blob `9b18363e90ef` → `b3701fcc3a70`, marker in `dist/`): - A legacy overlay of `viewer_readonly` stored under `permissions`: `DELETE /api/v1/meta/permission/viewer_readonly` answered `200` with `{"success":true,"reset":false,"message":"No customization overlay found for permission/viewer_readonly — already at artifact default."}`, and the `permissions` row stayed active. Discard Overlay on the same set answered `200` and left no active row. - `mcp_agent_restricted` with two active rows, one bound to no package and one bound to `com.objectstack.plugin-security`: the first `DELETE` answered `200` "Customization overlay deleted — … reset to artifact default" and removed one row, leaving the bound one. A second `DELETE` removed it. - **The head, check live, case 2.** A package's permission set and position stored under `permissions` / `positions`. Booted without the package, `DELETE /api/v1/meta/permission/pr2_set` answered `200` "No permission 'pr2_set' found — nothing to delete.", and `DELETE /api/v1/meta/position/pr2_pos` answered "No position 'pr2_pos' found — nothing to delete." Both rows stayed active, and the boot with the package added back was refused, both names held by `environment`. - **The restore.** Blob == HEAD and `git diff HEAD` empty. After the rebuild, `ablation-dist-preflight --absent` is green on `dist/` and on the tree. **The text fix, as the record names it:** - **Case 1:** "neither touches the database" now reads "neither needs direct database access". - **Case 3's heading** now reads "for a name the platform security plugin declares, or for any row the metadata API does not reach". - **One paragraph after the three cases**, before the CLI sentence: - the two `DELETE` routes reach a row stored under `permission` or `position` only, one row per call; - a plural-typed row is not reached: `200`, nothing found, nothing removed; - where a name has two active rows, 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. This also corrects round 1's summary above: the metadata-API delete is a database-free remedy before the upgrade only for a row stored under the singular type. - `content/docs/permissions/permission-sets.mdx`'s clause does not name the metadata-API delete, so the page is unchanged. **Gates at 39ef237.** `dispatch-gates --commands` derived 107 commands. All 107 ran with exit codes recorded, and `--ran` reconciles 107/107 with 0 NOT-MEASURED. 106 exited 0; one exited 1 by design: `check-empty-changeset --base origin/main`, the confirmed #22135 correction. `origin/main` is 22 commits ahead. `git merge-tree` against it is clean, so `main` was not merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ef9029 commit e030d43

11 files changed

Lines changed: 705 additions & 43 deletions

‎.changeset/22135-security-catalog-one-holder.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ The platform's own permission sets (`admin_full_access`, `member_default`, …)
2222

2323
**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.
2424

25-
**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.
25+
**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).
2626

2727
**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.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/objectql': major
3+
---
4+
5+
feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does
6+
7+
Clause-②: no
8+
9+
<!-- 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 -->
10+
11+
**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.
12+
13+
**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).
14+
15+
**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.
16+
17+
**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.
18+
19+
**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).
20+
21+
**The one-line fix: rename the item in the package, or rename or delete the environment's item, then restart.**
22+
23+
- **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.
24+
- **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.
25+
- **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.
26+
27+
`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.
28+
29+
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.
30+
31+
**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.

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,9 @@ either can be live on one row, and they need different remedies:
368368
current artifact immediately. It refuses on any set that no installed code
369369
package ships (a set created in this environment, a clone, or a set saved
370370
into a writable runtime package), so it can never destroy a genuinely
371-
environment-authored set.
371+
environment-authored set. **Discard it before you upgrade:** from the
372+
release that adds ADR-0048's cold-boot check, a deployment that still holds
373+
such an overlay does not boot, so the action can no longer reach it.
372374
- **Provenance skip.** The record's `managed_by` column predates package
373375
provenance tracking (a legacy insert without `managed_by: 'package'` —
374376
typically a database first initialized on an older release line), so boot

‎packages/objectql/src/plugin.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import {
3535
collectManifestPicklistReferences,
3636
describeUnresolvedPicklistReferences,
3737
} from './picklist-resolution.js';
38+
// [ADR-0048 N.3] The security catalog's one-holder envelope, raised here by the
39+
// cold-boot check (see `refuseEnvironmentHeldSecurityCatalogNames`).
40+
import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js';
3841

3942
export type { Plugin, PluginContext };
4043

@@ -935,6 +938,13 @@ export class ObjectQLPlugin implements Plugin {
935938
ctx.logger.info('Project kernel — skipping sys_metadata hydration (metadata sourced from artifact)');
936939
}
937940

941+
// [ADR-0048 N.3, ruling letter A on #22307] The environment catalog is in
942+
// the registry now, and every package registered before it: a package-held
943+
// position or permission-set name the environment already holds refuses the
944+
// boot here, before any other plugin starts. See
945+
// {@link refuseEnvironmentHeldSecurityCatalogNames}.
946+
this.refuseEnvironmentHeldSecurityCatalogNames();
947+
938948
// Phase 3: Sync any new schemas that were just hydrated from the DB
939949
// (e.g. CRM objects seeded via template — they must have tables before use).
940950
await this.installRegisteredSchemas(ctx);
@@ -2106,6 +2116,43 @@ export class ObjectQLPlugin implements Plugin {
21062116
}
21072117
}
21082118

2119+
/**
2120+
* [ADR-0048 N.3 — maintainer ruling letter A on #22307, record 6063176077]
2121+
* The cold boot refuses a package-held position or permission-set name the
2122+
* environment catalog already holds, as a hot install does.
2123+
*
2124+
* At a cold boot every package registers in the kernel's first phase, through
2125+
* the package door, BEFORE `sys_metadata` hydrates into the registry's bare
2126+
* slot ({@link restoreMetadataFromDb}, just above in `start()`), so the door
2127+
* could not see the environment's names. The hydration write itself stays
2128+
* unjudged; this judges each package's claim against what it wrote, with the
2129+
* door's envelope (`SecurityCatalogNameConflictError`: `422`
2130+
* `NAMESPACE_CONFLICT`, every conflict listed, both holders named).
2131+
*
2132+
* Placed right after hydration and before Phase 3's schema sync, which is
2133+
* before `kernel:ready` and before every plugin that depends on the engine
2134+
* starts. A registration made after this point meets the environment's items
2135+
* at the registry's own package door or item seam, so between them every
2136+
* package registration of the boot is judged. Called whether or not this
2137+
* kernel hydrated: without hydration the bare slot holds only what a
2138+
* package-less registration put there, judged the same way, and usually
2139+
* nothing.
2140+
*
2141+
* The reading is the registry's, kept off the public surface
2142+
* ({@link findEnvironmentHeldSecurityCatalogNames}: which items are the
2143+
* environment's, and the built-in carve-out).
2144+
*
2145+
* @throws {SecurityCatalogNameConflictError} with `door: 'cold-boot'`, which
2146+
* fails `start()` and with it the boot.
2147+
*/
2148+
private refuseEnvironmentHeldSecurityCatalogNames(): void {
2149+
const registry = this.ql?.registry;
2150+
if (!registry) return;
2151+
const conflicts = findEnvironmentHeldSecurityCatalogNames(registry);
2152+
if (conflicts.length === 0) return;
2153+
throw new SecurityCatalogNameConflictError(conflicts, { door: 'cold-boot' });
2154+
}
2155+
21092156
/**
21102157
* Bridge all SchemaRegistry objects to the metadata service.
21112158
*

0 commit comments

Comments
 (0)