Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
27 changes: 27 additions & 0 deletions .changeset/22135-security-catalog-one-holder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/objectql': major
---

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

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 author does about a refused name is pick a different one, which no conversion can choose for them -->

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

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

**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:

- another installed package;
- the environment catalog: an item authored in this environment, in the registry's bare slot with no package;
- 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`).

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`.

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

**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.
10 changes: 6 additions & 4 deletions packages/core/src/security/security-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
/**
* ADR-0131 D2–D4 — the catalog read's own rules, over stand-in readers.
*
* What the real readers answer (the registry's by-name precedence for a name
* two packages ship, the sets a booted showcase holds) is pinned against the
* real readers elsewhere: `packages/objectql/src/security-catalog-shared-name.test.ts`
* and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`.
* What the real readers answer (a name a second package is refused, so every
* reader answers its one holder; the sets a booted showcase holds) is pinned
* against the real readers elsewhere: the `security catalog read — a name two
* packages ship` describe in `packages/objectql/src/protocol-boot-hydration-scoped.test.ts`
* (the refusal itself, door by door: `registry-security-catalog-namespace.test.ts`
* beside it) and `packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`.
* Here: the read order, the union, the disabled-package rule, and that a read
* which did not happen is never reported as "no such item".
*/
Expand Down
21 changes: 11 additions & 10 deletions packages/core/src/security/security-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,19 @@
* misses the platform's own permission sets, so a resolver reading it would
* fail the platform administrator anchor closed.
*
* ## A name two packages ship — today's answer, pinned until it is ruled
* ## A name two packages ship — ruled: there is only ever one holder
*
* The by-name read takes no package context, because an assignment carries
* only the name (ADR-0131 D4). So which body a name two installed packages both
* ship resolves to is decided by the registry's own by-name precedence: a
* stored override in the bare slot first (ADR-0005), else the FIRST-registered
* package's body. Measured: with no override every by-name read answers the
* first-registered package; once one package stores an override bound to
* itself, every by-name read answers that override. Whether the catalog should
* refuse a shared name instead is an open maintainer question; until it is
* ruled, the pins beside this module hold today's answer, and a change to it
* is a decision, not a refactor.
* only the name (ADR-0131 D4). So a name two installed packages both shipped
* would resolve by the registry's own precedence — measured before the ruling:
* the FIRST-registered package's body, or whichever package stored an override.
* The maintainer ruled that ambiguity out instead (Q4 = A on #15196): each
* catalog type holds one name per deployment, and the engine registry refuses
* a package registering a name an installed package, the environment catalog
* or a built-in already holds (`@objectstack/objectql`,
* `security-catalog-namespace.ts`). So this read never chooses between two
* packages' bodies; a stored override in the bare slot is the holder's own
* (ADR-0005), and it answers ahead of the holder's shipped body.
*
* ## What this read does NOT answer
*
Expand Down
22 changes: 18 additions & 4 deletions packages/objectql/src/engine-capability-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import { describe, it, expect } from 'vitest';
import { pluralToSingular } from '@objectstack/spec/shared';
import { ObjectQL } from './engine';
import { NAMESPACE_CONFLICT_CODE } from './registry';

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

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

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

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