Skip to content

Commit fe98cc6

Browse files
feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both (#22197)
Fixes #22135 Clause-②: no Executes the maintainer's ruling Q4 = A on #15196 (ruling record 6050490870): positions, permission sets and capabilities each hold one name per deployment. A package registering a name that an installed package, the environment catalog or a built-in already holds is refused, and the error names both holders. ADR-0048 §3.4's coexistence stands for every other metadata type. The ADR-0048 §3.4 narrowing note was Tier H and rode its own PR, #22198, now on `main`. This PR carries no `docs/adr/**` file. ## What changed - **`packages/objectql/src/security-catalog-namespace.ts` (new).** The rule in one place: the three types, the built-in names, the holder vocabulary (`package` / `environment` / `built-in`), and the reader of a manifest's declared names. It reads the same sources the engine's registration seams read: the manifest's own `positions` / `permissions` / `capabilities` and each nested `plugins[]` entry's, arrays only. A manifest-stage `permissions` grant block is never read as permission sets. - **`SchemaRegistry.installPackage` — the package door.** It refuses ahead of every mutation, beside the namespace gate, so a refused package leaves no record, no namespace ownership and no claim. Every conflict is listed in one refusal. The package's claims are recorded after a successful install and released by `uninstallPackage`. The claims are what the door reads for names no registered item records, and `installPackage` itself registers no items. Through `ObjectQL.registerApp` (every boot and hot-install door), a package's permission sets and capabilities are also registered items under the package, and so are its positions since #22262 landed on `main`. There the claims agree with the items. With the claims ablated, the `registerApp` doors still refuse and the direct `installPackage` door does not (Patch round 3), so the claims stay. - **`SchemaRegistry.registerItem` — the item seam.** A package-bound registration of a catalog type over a name another holder holds is refused before anything is stamped or stored. Built-ins are not asked here: the platform registers its own built-in positions at this seam, under its own package id (the S2 stage, now on `main`), and that registration is the built-in holder's own. For a built-in name the environment holder is not asked either; see Patch round 2. A registration with no package is the bare slot, which is what every `sys_metadata` hydration and metadata write-through writes. It is never judged: an environment save over a package-held name is outside the ruling. - **The envelope** reuses the namespace gate's shape and registered code: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE`, already exported), `status: 422`, `httpStatus: 422`. The condition is the same one, a name in a deployment-wide namespace already taken, and so is the remedy: rename, or uninstall the other holder. The class (`SecurityCatalogNameConflictError`) stays unexported, as the registry's other refusal classes are. It is not the namespace gate's class, whose message names a `manifest.namespace` and offers the `OS_METADATA_COLLISION=warn` downgrade. Neither is true here, and `collisionPolicy: 'warn'` does not downgrade this refusal (pinned). - **No new error code; no `packages/spec` change.** ### Where the doors are, measured The card names three doors. What this PR measured is that all three are reached through ONE: `ObjectQL.registerApp` → `SchemaRegistry.installPackage`, which every package registration hits in the kernel's Phase 1, before any `start()`. - `AppPlugin`'s security registrar (`registerInMemory`, the `'app-plugin'` registrar) and the artifact door (`MetadataPlugin._registerArtifactBodyCollections`, the `'artifact-door'` registrar) both run in Phase 2. - Neither runs for a package the engine has not installed: `AppPlugin.init` registers every package of its bundle through the `manifest` service first, a multi-package artifact package by package. - So the producer-side fix is the package door, and `packages/metadata/src/plugin.ts` and `packages/runtime/src/app-plugin.ts` are unchanged. The runtime pins boot both registrars' real compositions and see the boot refused before either runs. ## Door table: base vs head "Base" is the same tree with both gates ablated, at 1604e09 (rows 1, 2 and 6 were also measured on the untouched base 7ef50a4, with the same answers). "Head" is 8ad6385. Boots go through `@objectstack/verify`'s `bootStack`; the artifact rows go through `createStandaloneStack`. | Door | Base | Head | |---|---|---| | Boot, door-less (`new AppPlugin(stack)`): two stacks sharing a position, a permission set and a capability name | boots. The by-name read answers the position from the LAST stack (metadata-service slot) and the set and the capability from the FIRST (registry order) | boot refused: `422 NAMESPACE_CONFLICT`, 3 conflicts, second stack vs first stack | | Boot: an app declaring `everyone` / `manage_users` / `admin_full_access` | boots. The by-name read of `admin_full_access` answers the APP's set | refused. Holder `built-in` for the first two. For `admin_full_access` the app registers before `plugin-security` in `bootStack`, so the platform's registration is the one stopped, naming the app | | Artifact boot: two packages of one artifact sharing names; a package declaring `everyone` | boots (runtime pins red under ablation) | refused in Phase 1, before the artifact door registers anything | | Hot install: post-boot `manifest.register` over a held name | accepted, package record written | refused, no record | | Hot install: `POST /api/v1/marketplace/install-local`, inline manifest | `200`, installed | `422 PLUGIN_REGISTER_FAILED`, the route's own code, with this refusal's message in `error.message`; no record | | `POST /api/v1/packages` | `400`: the strict body refuses `positions`, the retired `capabilities` and a flat `permissions` list | unchanged. No catalog collection can arrive here | | Environment catalog holds a permission set, then a package declaring it is hot-installed | accepted | refused, holder `environment` | | Same-package hot reload | accepted | accepted | | Environment save over a package-held permission set (`PUT /api/v1/meta/permission/NAME`, with or without `?package=`) — not covered by the ruling | `403 NOT_OVERRIDABLE` (the packaged permission-set lock) | unchanged | | Environment save of a position over a package-held position name (`PUT /api/v1/meta/position/NAME`) — not covered by the ruling | `200`, and the saved position then answers the by-name read ahead of the package's | unchanged by this PR. Since #22262 landed on `main`: `403 NOT_OVERRIDABLE` (see Acceptance notes) | Named but not measured: - **The artifact door's HMR reload** (`MetadataPlugin._reloadAndAnnounce`). It re-registers into the metadata service without `registerApp`, so a dev-loop edit giving a package a held name is served until restart. The restart's boot refuses it. - **`install-local`'s cloud-sourced install.** Its existing code tolerates a register failure: it warns, persists the ledger entry, and answers success. The next boot's rehydrate logs the refusal at `error` and skips the package. That is code reading only (it needs a control plane). ## In-repo collision census (M2) **Instrument.** A tsx census over `examples/app-crm`, `examples/app-showcase`, `examples/app-todo` and `examples/app-multi-package`: each config's top level, its `packages[]` bodies and its nested `plugins[]`. Against those it reads the built-ins: `BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`, `PLATFORM_CAPABILITY_NAMES`, and `plugin-security`'s `securityDefaultPermissionSets`. **Result at 1604e09:** 50 declarations — crm 3 positions / 2 sets; showcase 10 / 9 / 2 capabilities; todo 0; multi-package 0; built-ins 6 positions / 10 capabilities / 8 sets. Names with more than one holder: **0**. Same-holder repeats: 0. **The guard, measured with the gate in place at 8ad6385:** `crm`, `showcase` and `multi-package` boot through `bootStack`, and `security-catalog-showcase.dogfood.test.ts` (3 postures) and `multi-package-artifact.dogfood.test.ts` are green. `app-todo` declares no catalog name. Deployed and marketplace packages are NOT MEASURED. ## The P1.2 pin, flipped S1's shared-name pin is the `security catalog read — a name two packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts`. It added no `P1.2` label, which is why a `git grep` misses it. It now pins the ruled answer at the same seams: - a second package registering the name is refused (envelope + both holders), and every reader answers the one holder; - an override the holder stored for itself answers for every caller; - a position two packages declare is refused at the package door, so one stack's declaration reaches the metadata service. `core`'s `security-catalog.test.ts` pointed at a non-existent `security-catalog-shared-name.test.ts`. It now names that describe and the new door pins. `security-catalog.ts`'s module doc said the shared-name answer was "pinned until it is ruled", and is rewritten to the ruled answer. `engine-capability-provenance.test.ts` pinned two packages' same-named capabilities coexisting, the exact behaviour the ruling removes. It flips to the refusal. ## Tests (at 8ad6385) - `@objectstack/objectql`: `registry-security-catalog-namespace.test.ts` (new, 28 cases), `protocol-boot-hydration-scoped.test.ts`, `engine-capability-provenance.test.ts`, `registry-collision-order.test.ts` and `registry-artifact-co-ownership.test.ts`: 5 files, 64 passed. Full objectql suite before the merges: 382 files, 7553 tests. The one red was the coexistence pin flipped above; it is green after the flip. - `@objectstack/runtime`: `standalone-stack-security-catalog-one-holder.test.ts` (new, 4) and `standalone-stack-security-registrar.test.ts`: 2 files, 6 passed. - `@objectstack/core` `security-catalog.test.ts`: 14 passed. `@objectstack/plugin-security` `builtin-positions.boot.test.ts` + `builtin-positions.test.ts` (S2's): 18 passed. - dogfood: `security-catalog-showcase`, `multi-package-artifact`, plus a local door probe that is not committed: 3 files, 44 passed. - Downstream sweep before the merges, against the rebuilt `objectql` dist: runtime 337 files / 5465, plugin-security 172 / 3663, rest 266 / 5120, verify 18 / 133, cloud-connection 41 / 505. All green. - `typecheck` for objectql, core and runtime (with `check:test-typecheck`): exit 0. No new test-typecheck debt. ## Ablation Both gates were ablated together through `scripts/ablation-replace.mjs`, which wraps the run and restores on exit: - the package-door call became a `globalThis` marker write; - the item-seam condition gained an always-false marker conjunct. Both mutations landed on disk: anchor 1 → 0, blob `b96099a12688` → `12c018d406ab`. `objectql` was rebuilt and `ablation-dist-preflight` found both markers in `dist/`. The DTS step failed on the now-unused private method, and the JS bundle the suites read was emitted. - **objectql pins (read from `src`):** 22 failed / 21 passed of 43. Every refusal pin went red, including both flipped P1.2 cases and the flipped capability pin. The controls stayed green: same-package reload, uninstall releases the name, the environment-registration carve-out, non-catalog coexistence, the grant-block reader, and the platform's own built-in registration. - **runtime boot pins (read from `dist`):** 3 failed / 1 passed. The control stayed green. **Restore:** the blob is back to `b96099a12688` and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` is green for both markers (dist and whole tree). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived 78 commands on 8ad6385; all 78 were run, and `--ran` reconciles 78/78 with exit codes recorded. All 78 exited 0. On the pre-merge tree 053cc2e two needed a prerequisite first: `check-engine-split-ratio` refused the shallow clone (deepened with `git fetch --shallow-since=2026-07-03`), and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET until eight unrelated packages were built. On 8ad6385 both ran green with the rest. CI's own lanes (Test Core shards, Temporal Conformance, the Dogfood shards, Build Core, the workspace type-check) are declared to CI and are NOT MEASURED here. After the run, `origin/main` moved 6 commits, none of which touches a file in this PR. ## Acceptance notes - **Environment save of a position over a package-held position name.** Before #22262 it was accepted (`200`), and the saved position then won the by-name read; permission sets were protected on the same door by the packaged permission-set lock (`403`). Since #22262 landed on `main`, a package's positions are registered items under the package, and the same save answers `403 NOT_OVERRIDABLE` ("'position' is not allowOrgOverride in the registry"), measured on f16fcd0 with `PUT /api/v1/meta/position/shared_pos?package=w`. Outside this ruling either way; this PR changes nothing there. - **Cold boot vs the environment-catalog holder.** A package registers through `ObjectQL.registerApp` in Phase 1, and `sys_metadata` hydrates in Phase 2 (`ObjectQLPlugin.start`). A package added to a deployment whose environment catalog already holds one of its names is therefore NOT refused at cold boot: the env row hydrates over it, with the registry's existing collision warning. It is refused on a hot install. From the registry's seat, that arrival is indistinguishable from an environment save over a package-held name, which the ruling leaves out. The `CONTROL` case in `registry-security-catalog-namespace.test.ts` pins that the bare slot is not judged. A plugin's own `start()` is different: every plugin that depends on the engine starts after that hydration, so a package-bound registration it makes at the item seam DOES meet the environment holder, and is refused (holder `environment`). The exception is a built-in name, which the platform declares there itself (Patch round 2). A `git grep` for literal catalog-type `registerItem` calls in production source finds one such registration: `plugin-security`'s built-in positions. Carrier: #22307 (ruled A: the cold boot refuses too; it lands separately). - **Order and the platform's permission sets.** `plugin-security` declares the platform's sets on its own manifest (configurable through `defaultPermissionSets`), so they are package-held. When an app registers before it, as `bootStack` composes, the platform's registration is the one refused, naming the app. The boot fails either way, and both holders are named. - **`install-local` inline import** answers its own `PLUGIN_REGISTER_FAILED` for any register refusal (this one and the namespace gate's alike), so `error.code` does not carry `NAMESPACE_CONFLICT` there. The refusal's text is in `error.message`. Not changed here. - **The ledger row comment for `NAMESPACE_CONFLICT`** in `packages/spec/src/api/error-code-ledger.zod.ts` describes the manifest-namespace condition only. The spelling, owner key and face are unchanged, and the provenance gate is green. A one-line comment noting the second condition is a spec-lane follow-up, not made here. - **Files outside the engine lane:** `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts` (new test; `runtime` is `domain:cli`'s package); `scripts/adr-anchors/packages__objectql__src__security-catalog-namespace.ts.json` (new ADR anchor); and, from patch round 1, five `domain:cli` dogfood files: `packages/qa/dogfood/test/showcase-security.ts`, `showcase-d7-default-profile.dogfood.test.ts`, `authored-row-write-scope.dogfood.test.ts`, `bulk-widener-probe.dogfood.test.ts` and `owd-public-read-write-write-floor.dogfood.test.ts` (each: the `SecurityPlugin` construction, with its comment and imports). ## Patch round 1 — the dogfood fixtures declared one permission set twice The Dogfood Regression Gate (all 3 shards) was red on 8ad6385. In every failing boot, `plugin-security` registered a permission set that the app package already held. The fixtures handed an app-declared set to `SecurityPlugin`'s `defaultPermissionSets`, which plugin-security declares on its own manifest, while the app declared the same set too: - `showcase_member_default`, through `showcaseAppDefaultSecurity()` and the D7 test; - `wscope_*`, `probe_widener` and `owdw_*`, in three fixtures. Measured: - `os serve` / `objectstack dev` never composes this. It hands the plugin only the default's NAME (`appSecurityPluginOptions`), and the app registers the set. A real `objectstack dev --fresh` boot of `examples/app-showcase` came up with the gate in place: health 200. - The two copies were the same definition: 101 of 101 leaves equal. Fixed at the producer: each fixture declares the set once, as the app's, and wires the default by name, as the CLI does. A runtime pin holds the refused composition. All three dogfood shards are green locally on 089b1c8 (74 + 74 + 74 files) and in CI. ## Patch round 2 — the platform's built-in positions met an environment row at boot The merge queue removed this PR (record 6056019838). `plugin-security`'s `registerBuiltinPositions` was refused at the item seam: position `org_admin`, incoming `com.objectstack.plugin-security`, holder `environment`. That refusal failed `SecurityPlugin.start`, and with it the boot. S2b's pins went red: `builtin-positions.boot.test.ts`, "a stored definition under a built-in name" (3 postures), and `bootstrap-declared-positions.test.ts`, "a stored definition shadowing a built-in name is neither seeded nor restamped". Measured on 19c86b7 (this branch with `main` merged, before the fix): - **Boot order.** `SecurityPlugin` depends on the engine. So `ObjectQLPlugin.start` hydrates `sys_metadata` into the bare slot BEFORE `SecurityPlugin.start` declares the built-in positions. Through a real door: with `OS_METADATA_WRITABLE=position`, `PUT /api/v1/meta/position/org_admin` answered `200`, and the cold restart failed ("Plugin com.objectstack.security failed to start", with this refusal). Without that setting the save answers `403 NOT_OVERRIDABLE`. - **Who registers.** `registerBuiltinPositions` registers exactly the six static built-in names (`BUILTIN_IDENTITY_NAMES` + `AUDIENCE_ANCHOR_POSITIONS`), under the platform's own package id. That is the built-in holder declaring its own names, not a second holder. - **What S2b needs.** The stored definition keeps answering first from the bare slot (ADR-0005), and the platform's declaration sits beside it. Fixed at the producer, the item seam in `SchemaRegistry.registerItem`: for a built-in name, it no longer asks the environment holder. An environment item under a built-in name exists only because an environment save went over the platform's name, which is outside the ruling. Unchanged: - a second PACKAGE registering a built-in name at the item seam is refused, in either order; - the package door refuses a package declaring a built-in name (holder `built-in`); - for any other name, an environment item still refuses a package-bound registration at the item seam (holder `environment`). No same-definition exception, no `collisionPolicy` change, and S2b's pins are untouched. `registry-security-catalog-namespace.test.ts` gained three cases, one per behaviour above (the third is a `CONTROL`). **Reverse verification.** The new condition was mutated through `scripts/ablation-replace.mjs` to ask the environment holder again (blob `c60d9bad21bc` → `eeca074e4f80`). `objectql` was rebuilt, and `ablation-dist-preflight` found the marker in `dist/`. The queue's signature came back: S2b's 3 boot postures and the bootstrap-declared-positions case went red with `SecurityCatalogNameConflictError` (`org_admin` held by the environment catalog), and so did the new admit case. Restored: blob == HEAD, and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` is green. All suites, the three dogfood shards and the 82 derived gates were green at ffa6d51, and so was CI. ## Patch round 3 — #22262 landed on `main` first #22262 (squash 0b997ea) adds `positions` to the engine's `METADATA_ARRAY_KEYS`, so `ObjectQL.registerApp` now registers a package's positions under the package. This branch merged `main` at fbcbcf1. The merge touched none of this PR's files, and `registry.ts`'s logic is unchanged. **Comments only.** Four comments this PR added said a package's positions never reach the engine registry's item store. Each now reads true on `main`: the `securityCatalogClaims` doc and the `installPackage` comment in `registry.ts`, the declared-names reader's note in `security-catalog-namespace.ts`, and the header of `standalone-stack-security-catalog-one-holder.test.ts`. No behaviour changed, so no reverse leg was re-run. **Measured with #22262 in the tree** (f16fcd0, this branch with `main` merged; through `bootStack`; local probes, not committed): - A package's own positions arrive both as claims and as registry items under the same package, and stay one holder. The showcase boots with 10 positions under `com.example.showcase` and 6 under `com.objectstack.plugin-security`, and 10 claims. Re-registering the showcase is not refused. A second package declaring `contributor` is refused, holder `com.example.showcase`. - The built-in case is unchanged. With environment saves under `org_admin` and `everyone` (`OS_METADATA_WRITABLE=position`), the cold restart boots, and both names resolve to the environment's saved definitions. - `PUT /api/v1/meta/position/shared_pos?package=w` over a package-held position answers `403 NOT_OVERRIDABLE`. - The door probes behind the table above answer as before: crm, showcase and multi-package boot; the two-stack boot, the built-in names, the hot install and `install-local` are refused, each naming both holders; the same-package reload is accepted. **The claims, ablated.** This was measured on a throwaway local merge of #22262's head 7ed88a6, never pushed. All seven files #22262 landed are byte-identical to that head's. The claim recording was replaced by a no-op (`scripts/ablation-replace.mjs`), `objectql` was rebuilt, and the marker was proven in `dist/`. The runtime boot pins stayed green (5 of 5): every `registerApp` door refuses through the registered items alone. Two objectql pins went red: the P1.2 position case and the `collisionPolicy: 'warn'` pin. Both reach the package door through a direct `installPackage` call, which registers no items. So the claims stay. Restored: blob == HEAD; after a rebuild, `ablation-dist-preflight --absent` is green. **Tests at f16fcd0**, all under `os-verify-lock`: - `@objectstack/objectql`, whole suite: 383 files / 7572 passed. - `@objectstack/plugin-security`, whole suite: 179 files / 3775 passed, 45 skipped. - `@objectstack/runtime`, whole suite: 340 files / 5505 passed, 19 skipped. - Dogfood, the CI split: shard 1/3, 74 files / 557 passed; 2/3, 74 files / 535 passed, 1 skipped; 3/3, 73 passed + 1 skipped files / 661 passed, 8 skipped. - `typecheck` for `objectql` and `runtime` (`tsc --noEmit` + `check:test-typecheck`): exit 0. - Gates: `dispatch-gates --commands` derived 82 on f16fcd0. All 82 ran and exited 0, and `--ran` reconciles 82/82, 0 NOT MEASURED. CI on f16fcd0: 32 checks success, including Dogfood Regression Gate 1/3 to 3/3 and Test Core 1/6 to 6/6. Three were skipped (Build Docs, Console Pin Gate, Packed-tarball smoke). ## Patch round 4 — the changeset level, one comment, the cold-boot carrier The contract review on f16fcd0 (record 6061710772) failed two texts and one missing carrier. The code stands; this round changes text only. - **The changeset level.** `.changeset/22135-security-catalog-one-holder.md` graded `@objectstack/objectql` `minor`, on the premise that Changesets pre mode was not yet on `main`. It is: `.changeset/pre.json` (mode `pre`, tag `next`) landed with #22084 (a87d8be), an ancestor of this branch's merge base. The changeset now grades `major`, and its BREAKING sentence says the change ships as `major` on the v18 pre-release line. The ADR-0087 marker and `Clause-②: no` stay. `pnpm changeset status` resolves `@objectstack/objectql` to `18.0.0-next.0`. - **The cold-boot carrier.** One sentence in the changeset states the boundary: at cold boot, packages register before the environment catalog loads from `sys_metadata`, so a package newly added over a permission-set or position name the environment catalog already holds is not refused at cold boot, and the registry's existing collision warning fires. A hot install of the same package is refused. #22307 has since been ruled A (the cold boot refuses too); see Patch round 5. Measured on 9e4ed5d with a local probe (not committed): the cold boot with the package added came up with no refusal and two `[Registry] Collision` warnings, one for the permission set and one for the position. The hot install answered `422 NAMESPACE_CONFLICT`, both names held by `environment`, and left no package record. A capability cannot be saved in the environment (`403`, a code-only type), so the sentence names the two types an environment can hold. - **One comment.** The runtime pin's comment called the position one "no registry slot holds". That stopped being true when #22262 put a package's positions into the registry under the package. The comment now says the refusal reports the position, the permission set and the capability the first package holds. A sweep of this PR's added lines finds no other sentence saying positions do not reach the registry. - **The `start()`-time half** of the round-2 question is settled as A (keep): the ruling names the environment catalog as a holder and does not distinguish phase. No change. **Checks at 9e4ed5d:** - The changeset gates: `check-changeset-no-major` `--self-test` and `--base`, exit 0 (pre mode, tag `next`, so the no-major guard stands aside; given this PR's body, the level axis reads `Clause-②: no`); `check-empty-changeset` `--self-test` and `--base`, exit 0; `check-changeset-fixed`, exit 0; `check:adr-0087-registration`, exit 0 (1 declared-breaking changeset, carrying its ADR-0087 disposition); `check:changeset-gate-self-tests`, exit 0. - `pnpm --filter @objectstack/runtime exec vitest run src/standalone-stack-security-catalog-one-holder.test.ts`: 1 file / 5 passed. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - Gates: `dispatch-gates --commands` for the two touched paths derived 61 commands. All 61 ran and exited 0, and `--ran` reconciles 61/61, 0 NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c is clean, so `main` was not merged. ## Patch round 5 — #22307 was ruled The maintainer ruled #22307 A while round 4 ran: a cold boot is to refuse too. That work lands in its own PR, not in this one. The changeset's cold-boot sentence keeps its measured clauses and now ends "a cold-boot refusal is ruled and tracked on #22307, which lands separately", which is true on this PR's merge and stays true after #22307 lands. Nothing else changed. **Checks at 99fba80:** `check-changeset-no-major --base`, exit 0 (pre mode, tag `next`; given this PR's body, the level axis reads `Clause-②: no`); `check-empty-changeset --base`, exit 0; `check:adr-0087-registration`, exit 0. `dispatch-gates --commands` for the one touched path derived 20 commands; all 20 exited 0, and `--ran` reconciles 20/20, 0 NOT MEASURED. `git merge-tree` against `origin/main` 4e4111c 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 34dba5a commit fe98cc6

15 files changed

Lines changed: 1080 additions & 103 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/objectql': major
3+
---
4+
5+
feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders
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 author does about a refused name is pick a different one, which no conversion can choose for them -->
10+
11+
**BREAKING** — an accept-set narrowing at the package registration door, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it.
12+
13+
**Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged.
14+
15+
**What is refused, and where.** `SchemaRegistry.installPackage` refuses a package whose declared `positions`, `permissions` (permission sets) or `capabilities` — top level, or on a nested `plugins[]` entry — name something another holder already holds. It refuses ahead of every mutation, so a refused package leaves no record behind. The holders are:
16+
17+
- another installed package;
18+
- the environment catalog: an item authored in this environment, in the registry's bare slot with no package;
19+
- a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`).
20+
21+
The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. The platform's own declaration of a built-in name there (`@objectstack/plugin-security` declaring the six built-in positions) is the built-in holder's, so it is never refused. An environment item already stored under that name keeps answering first. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`.
22+
23+
**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.
24+
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.
26+
27+
**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.

‎packages/core/src/security/security-catalog.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
1111
/**
1212
* ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers.
1313
*
14-
* What the real readers answer (the registry's by-name precedence for a name
15-
* two packages ship, the sets a booted showcase holds) is pinned against the
16-
* real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts`
17-
* and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`.
14+
* What the real readers answer (a name a second package is refused, so every
15+
* reader answers its one holder; the sets a booted showcase holds) is pinned
16+
* against the real readers elsewhere: the `security catalog read — a name two
17+
* packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts`
18+
* (the refusal itself, door by door: `registry-security-catalog-namespace.test.ts`
19+
* beside it) and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`.
1820
* Here: the read order, the union, the disabled-package rule, and that a read
1921
* which did not happen is never reported as "no such item".
2022
*/

‎packages/core/src/security/security-catalog.ts‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,19 @@
5050
* misses the platform's own permission sets, so a resolver reading it would
5151
* fail the platform administrator anchor closed.
5252
*
53-
* ## A name two packages ship — today's answer, pinned until it is ruled
53+
* ## A name two packages ship — ruled: there is only ever one holder
5454
*
5555
* The by-name read takes no package context, because an assignment carries
56-
* only the name (ADR-0131 D4). So which body a name two installed packages both
57-
* ship resolves to is decided by the registry's own by-name precedence: a
58-
* stored override in the bare slot first (ADR-0005), else the FIRST-registered
59-
* package's body. Measured: with no override every by-name read answers the
60-
* first-registered package; once one package stores an override bound to
61-
* itself, every by-name read answers that override. Whether the catalog should
62-
* refuse a shared name instead is an open maintainer question; until it is
63-
* ruled, the pins beside this module hold today's answer, and a change to it
64-
* is a decision, not a refactor.
56+
* only the name (ADR-0131 D4). So a name two installed packages both shipped
57+
* would resolve by the registry's own precedence — measured before the ruling:
58+
* the FIRST-registered package's body, or whichever package stored an override.
59+
* The maintainer ruled that ambiguity out instead (Q4 = A on #15196): each
60+
* catalog type holds one name per deployment, and the engine registry refuses
61+
* a package registering a name an installed package, the environment catalog
62+
* or a built-in already holds (`@objectstack/objectql`,
63+
* `security-catalog-namespace.ts`). So this read never chooses between two
64+
* packages' bodies; a stored override in the bare slot is the holder's own
65+
* (ADR-0005), and it answers ahead of the holder's shipped body.
6566
*
6667
* ## What this read does NOT answer
6768
*

‎packages/objectql/src/engine-capability-provenance.test.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import { describe, it, expect } from 'vitest';
4747
import { pluralToSingular } from '@objectstack/spec/shared';
4848
import { ObjectQL } from './engine';
49+
import { NAMESPACE_CONFLICT_CODE } from './registry';
4950

5051
/**
5152
* The exact read `bootstrapDeclaredCapabilities` performs on the engine, kept
@@ -112,14 +113,27 @@ describe('registerApp — declared capabilities carry registry provenance (#5870
112113
expect(cap?._provenance).toBe(permission?._provenance);
113114
});
114115

115-
it('keeps two packages\' same-named capabilities attributed to their own owner', () => {
116+
// This case used to pin two packages' same-named capabilities COEXISTING,
117+
// each attributed to its own owner (ADR-0048 §3.4's coexistence). The
118+
// maintainer's ruling Q4 = A on #15196 takes the security catalog out of
119+
// §3.4: one capability name, one holder per deployment — the second package
120+
// is refused at registration, and the first keeps its attribution. The
121+
// refusal is pinned door by door in `registry-security-catalog-namespace.test.ts`.
122+
it('refuses a second package\'s same-named capability; the first keeps its attribution', () => {
116123
const engine = new ObjectQL();
117124
engine.registerApp({ id: 'com.acme.crm', capabilities: [{ name: 'export_data', label: 'CRM Export' }] });
118-
engine.registerApp({ id: 'com.acme.hr', capabilities: [{ name: 'export_data', label: 'HR Export' }] });
125+
let refusal: (Error & { code?: string; status?: number; existingHolder?: unknown }) | undefined;
126+
try {
127+
engine.registerApp({ id: 'com.acme.hr', capabilities: [{ name: 'export_data', label: 'HR Export' }] });
128+
} catch (e) {
129+
refusal = e as typeof refusal;
130+
}
131+
expect(refusal?.code).toBe(NAMESPACE_CONFLICT_CODE);
132+
expect(refusal?.status).toBe(422);
133+
expect(refusal?.existingHolder).toEqual({ kind: 'package', packageId: 'com.acme.crm' });
119134

120135
expect(engine.registry.getItem<any>('capability', 'export_data', 'com.acme.crm')?.label).toBe('CRM Export');
121-
expect(engine.registry.getItem<any>('capability', 'export_data', 'com.acme.hr')?.label).toBe('HR Export');
122-
expect(readDeclaredShape(engine, 'capability')).toHaveLength(2);
136+
expect(readDeclaredShape(engine, 'capability').map((c) => c._packageId)).toEqual(['com.acme.crm']);
123137
});
124138

125139
it('stamps capabilities declared by a NESTED plugin too (the second seam)', () => {

0 commit comments

Comments
 (0)