diff --git a/.changeset/30803-bare-name-ai-family.md b/.changeset/30803-bare-name-ai-family.md new file mode 100644 index 00000000000..9dcaf9ed8a0 --- /dev/null +++ b/.changeset/30803-bare-name-ai-family.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: an agent and a skill hold one name per package — once the cold boot has sealed the registry, a package declaring one under a name another installed package holds is refused at install and registration, naming both holders and the rename; a boot that already holds such a clash warns. This closes phase 1 of the bare-name series. + +Clause-②: no (narrowing) — the engine's package door (`SchemaRegistry.installPackage`) and item seam (`SchemaRegistry.registerItem`) refuse, after the cold boot, a package registration they accepted before: one whose agent or skill bears a name another installed package holds. Nothing is widened. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` does not teach that two packages may share an agent or skill name, and the naming rule itself is phase 2's (#30805); the prescription ships in the refusal and warning text and in ADR-0048's 2026-11-06 addendum (P1.9). + + + +**BREAKING** accept-set narrowing at the package registration door, the fourth and last family of maintainer ruling C on #30798, phase 1 (picklists, mappings and datasets; then APIs, object-less actions and functions; then flows, webhooks, connectors and email templates): managed install (`install-local`, a post-start `manifest.register`, a hot reload) and package registration (`ObjectQL.registerApp` and every package-bound `registerItem`), for agents and skills. ADR-0048 §3.4 narrowed (addendum 2026-11-06, P1.9). + +- **Why.** Measured on a booted showcase with two packages sharing one agent name and one skill name, in both registration orders: `/meta/agent/NAME` served the first-registered package's agent, and an MCP client's `prompts/get` of the skill resolved the first-registered package's skill (`listSkillPrompts` keeps the first row per name), as did `/meta/skill/NAME`. Each answer flipped with registration order, and neither warned. A third package installed after the boot under either name installed (`200`) and was shadowed. +- **Before.** Two installed packages could each ship an agent or a skill under one name, at boot or by a later install; every caller was served one of them, chosen by registration order. +- **Now.** After the cold boot (the engine plugin seals the registry at `kernel:bootstrapped`), a package registration declaring an agent or a skill under a name another installed package holds is refused ahead of every mutation, so it leaves no record behind. The envelope is the first family's: `code: 'NAMESPACE_CONFLICT'`, `status: 422`, `install-local` answering `422 PLUGIN_REGISTER_FAILED`, every conflicting name in one refusal, both holders named, the rename (the incoming package's namespace prefixed) carried, `conflicts[]` structured. +- **The cold boot.** A deployment whose installed packages already share such a name still boots: the one warning line names every such name, both holders and the rename, and changes nothing about which holder each caller is served (maintainer ruling 2026-11-06: the boot warns, only a new holder is refused; `BARE_NAME_COLD_BOOT_MODE` stays `'warn'`). +- **Who moves.** An author whose package declares an agent or a skill under a name another package installed beside it already ships, at the next install or upgrade into a running deployment. **The one-line fix: rename the item in one of the two packages — the refusal names the namespace-prefixed name — or uninstall one of them.** An MCP client, an app or a skill that named the old name must name the new one; nothing rewrites stored references. +- **Unchanged.** The same package registering its own name again. An environment item under a package's name (never judged, never a holder: the ADR-0005 overlay). One package's agent and another package's skill under one name (two types). A `tool` and a `rag_pipeline`, which the ruling does not list, and every other type the rule does not list (ADR-0048 §3.4 as written). `OS_METADATA_COLLISION=warn` does not downgrade the refusal. + +**Measured producers.** On objectstack `30e0bd16`, no example app and no platform package declares an agent or a skill (`app-showcase`'s coverage file defers both, ADR-0063), so no shipped name has two holders. HotCRM is not checked out in this container and is NOT MEASURED here. Deployed and marketplace packages NOT MEASURED. diff --git a/docs/adr/0048-cross-package-metadata-collision.md b/docs/adr/0048-cross-package-metadata-collision.md index 7805ecdf42a..7a4d82b4c36 100644 --- a/docs/adr/0048-cross-package-metadata-collision.md +++ b/docs/adr/0048-cross-package-metadata-collision.md @@ -5,7 +5,7 @@ **Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package as first-class citizen), [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay precedence), [ADR-0008](./0008-metadata-repository-and-change-log.md) (metadata repository, `MetaRef` identity), [ADR-0010](./0010-metadata-protection-model.md) (package provenance / `_packageId` stamping) **Consumers**: `@objectstack/objectql` (`SchemaRegistry.registerItem`, `ObjectQL.registerApp`, install path), `@objectstack/console` / `objectui` (routing + metadata resolution), package authors, CLI/CI install path, the app marketplace registry. **Surfaced by**: ADR-0046 review (doc naming) → generalised to all bare-named metadata → re-examined through the app-marketplace install lens. -**Addenda**: [2026-08-08 — publish-time / marketplace namespace exclusivity](#addendum-2026-08-08-publish-time--marketplace-namespace-exclusivity) (**Accepted** 2026-08-08 — maintainer ruling on #1825, [acceptance comment 5226411687](https://github.com/objectstack-ai/objectstack/issues/1825#issuecomment-5226411687), landed by PR #6671; additive to §3.2, supersedes nothing); [2026-11-06 — bare names resolved without a package hold one package](#addendum-2026-11-06-bare-names-resolved-without-a-package-hold-one-package--34-narrowed) (**Accepted**, ruling C on #30798, phase 1; narrows §3.4 for picklists, mappings, datasets, APIs, object-less actions, engine functions, flows, webhooks, connectors and email templates, supersedes nothing). +**Addenda**: [2026-08-08 — publish-time / marketplace namespace exclusivity](#addendum-2026-08-08-publish-time--marketplace-namespace-exclusivity) (**Accepted** 2026-08-08 — maintainer ruling on #1825, [acceptance comment 5226411687](https://github.com/objectstack-ai/objectstack/issues/1825#issuecomment-5226411687), landed by PR #6671; additive to §3.2, supersedes nothing); [2026-11-06 — bare names resolved without a package hold one package](#addendum-2026-11-06-bare-names-resolved-without-a-package-hold-one-package--34-narrowed) (**Accepted**, ruling C on #30798, phase 1; narrows §3.4 for picklists, mappings, datasets, APIs, object-less actions, engine functions, flows, webhooks, connectors, email templates, agents and skills — phase 1 complete — supersedes nothing). --- @@ -267,28 +267,27 @@ package — stays covered by the `naming/namespace-prefix` lint in `os lint`. > [Addendum (2026-10-08)](#addendum-2026-10-08-the-security-catalog-holds-one-name-per-deployment--34-narrowed). > **Narrowed (2026-11-06) — picklists, mappings, datasets, APIs, object-less -> actions, engine functions, flows, webhooks, connectors and email templates are -> out of §3.4.** Their consumers resolve the name with no package — a field's +> actions, engine functions, flows, webhooks, connectors, email templates, agents +> and skills are out of §3.4.** Their consumers resolve the name with no package — a field's > `picklist`, an import's `mappingName`, a dashboard's `datasetName`, the > endpoint matcher's one API per name, `/actions/global/NAME`, a flow `script` > node's function, the automation engine's one armed flow per name, the > dispatcher's one declared webhook per name, the connector picker's one -> connector per name, a send's `template` — so with two holders every caller is +> connector per name, a send's `template`, `/meta/agent/NAME`, an MCP client's +> skill prompt — so with two holders every caller is > served one of them, chosen by registration order, measured on a booted > two-package deployment (#30803). Once the cold boot has sealed the registry, a > package whose picklist, mapping, dataset, API, object-less action, flow, -> webhook, connector or email template bears a name another installed package -> holds is refused at managed install and at package registration (a built-in's +> webhook, connector, email template, agent or skill bears a name another +> installed package holds is refused at managed install and at package registration (a built-in's > name, at managed install), and a package whose handlers bring a function > another package holds is refused at the hook binder's bind, each naming both > holders and the rename. ⛔ The environment catalog is never a holder: an > environment item under a package's name is the ADR-0005 overlay. ⛔ Nor is the > email service's built-in template bundle: an app's same-named template > overrides it by contract (ADR-0131 D6), so that package holds no template name -> and is refused none. The remaining types the measurement found resolving -> without a package context — `agent` and `skill` — are to follow in the series' -> last change. Every other type, an action bound to an object among them, keeps -> §3.4 as written. See +> and is refused none. Every other type keeps §3.4 as written, among them a +> `tool` and an action bound to an object. See > [Addendum (2026-11-06)](#addendum-2026-11-06-bare-names-resolved-without-a-package-hold-one-package--34-narrowed). ### 3.5 Namespace rename-on-install is deferred (non-goal) @@ -956,7 +955,7 @@ first phase. > **Status of this addendum: Accepted** — it records phase 1 of a maintainer > ruling. It **narrows §3.4 for more metadata types, one family per change (P1.2, -> P1.7, P1.8), and supersedes no other text**: §§1–6, the 2026-08-08 addendum and +> P1.7, P1.8, P1.9 — the last), and supersedes no other text**: §§1–6, the 2026-08-08 addendum and > the 2026-10-08 addendum stand verbatim. > > **Ruling this addendum records** — #30798, answered by the maintainer @@ -1063,14 +1062,14 @@ the doors never refuse. - **No downgrade.** `OS_METADATA_COLLISION=warn` softens the §3.2 namespace gate only. -### P1.5 To follow +### P1.5 The series, complete -The same measurement found `agent` and `skill` resolving without a package -context too. They join P1.2's list in the series' last change under #30803, with -their own pins, narrowing §3.4 the same way (the maintainer kept all of them in -#30803 on 2026-11-06). `tool` and `rag_pipeline` share a reader with `agent` and -were not measured. The request family that was listed here is P1.7; the flow -family — `flow`, `webhook`, `connector` and `email_template` — is P1.8. +Phase 1 joined P1.2's list one family per change under #30803, each measured +first, as the maintainer ordered on 2026-11-06 (all of them kept in #30803): the +data family (P1.1), the request family (P1.7), the flow family (P1.8) and, last, +the AI family — `agent` and `skill` (P1.9). P1.9's change closes phase 1. `tool` +and `rag_pipeline` were never in the ruling's list; P1.9 records what was +measured of them. ### P1.6 Where it is implemented @@ -1178,3 +1177,39 @@ and is not judged. **The cold boot** reads the four with the registry's other holders, under P1.3's switch: the flow precedence warning keeps naming both packages and arming one, as before; the bare-name warning names every clash once with its rename. + +### P1.9 The AI family: `agent`, `skill` — the series' last + +Measured with P1.1's two packages, in both orders, plus a third package +installed after the boot (#30803, PR 4): + +- **`agent`** — `/meta/agent/NAME` serves the FIRST-registered package's + agent, silently. It is the one by-name read of an agent the open framework + serves: the kernel `metadata` service, which the MCP `agent_prompt` reads, + answers no package agent at all on the composed runtime (ADR-0063 §2: agents + are platform-owned), and the agent runtime that resolves an agent by name for + chat is the cloud's, not measured here. +- **`skill`** — an MCP client's `prompts/get` of a skill resolves the + FIRST-registered package's skill (`listSkillPrompts` keeps the first row per + name and drops the other), silently; `/meta/skill/NAME` serves the same one. + ADR-0063 §2 names the skill the third-party extension primitive, so this is + the family's live collision. + +A third package installed after the boot under either name installs (`200`) and +is shadowed. Neither type warns. + +**What is refused.** Both join P1.2's list: the same two doors, the same +holders, the same envelope and rename. Neither is bundled, and neither has an +overridable baseline or a built-in name. An agent and a skill are two types: one +package's agent and another's skill under one name never meet. + +**Not in the list.** A `tool` is not covered — the ruling's types do not name it. +Measured beside them: `/meta/tool/NAME` serves the first-registered package's +tool as well; the readers that resolve a tool by name are the cloud's. A +`rag_pipeline` cannot be declared through `defineStack` (the stack schema +refuses `ragPipelines`) and registered nothing in the measurement. Both are left +as §3.4 has them. + +**The cold boot** reads the two with the registry's other holders, under P1.3's +switch (`'warn'`). This is the series' closing change: phase 1's list is +complete, and phase 2 (#30804, #30805) and phase 3 (#30806) follow. diff --git a/packages/objectql/src/bare-name-one-holder.ts b/packages/objectql/src/bare-name-one-holder.ts index 4abd9e88137..a88c66768e9 100644 --- a/packages/objectql/src/bare-name-one-holder.ts +++ b/packages/objectql/src/bare-name-one-holder.ts @@ -10,7 +10,8 @@ * narrowed 2026-11-06). Each family of covered types joins in its own change * under #30803: the data family first, then the request family (`api`, the * object-less `action`, the engine's function registry), then the flow family - * (`flow`, `webhook`, `connector`, `email_template`). + * (`flow`, `webhook`, `connector`, `email_template`), and last the AI family + * (`agent`, `skill`), which closes the series. * * ## The rule * @@ -52,7 +53,14 @@ * per name, and a second one at boot already refuses the boot (ADR-0097); * - `email_template` — a send's `template: NAME` resolves the * FIRST-registered package's bundle, and `/meta/email_template/NAME` - * serves the same one. + * serves the same one; + * - `agent` — `/meta/agent/NAME` serves the FIRST-registered package's + * agent, the one by-name read of an agent the open framework serves (the + * agent runtime that resolves an agent by name is the cloud's); + * - `skill` — an MCP client's `prompts/get` of a skill keeps the + * FIRST-registered package's skill (`listSkillPrompts` keeps the first + * row per name and drops the other), and `/meta/skill/NAME` serves the + * same one. * * Every one of them flips with registration order, and none says so (the * object-less action's handler bind warns that it replaces one, and names @@ -145,7 +153,8 @@ import { isObjectLessActionKey, standaloneActionOwnerKey } from './action-govern export type BareNameRegistryType = | 'picklist' | 'mapping' | 'dataset' | 'api' | 'action' - | 'flow' | 'webhook' | 'connector' | 'email_template'; + | 'flow' | 'webhook' | 'connector' | 'email_template' + | 'agent' | 'skill'; /** * A covered type: a registry type, or `function` — the engine's function @@ -171,6 +180,9 @@ export const BARE_NAME_ONE_HOLDER_TYPES: readonly BareNameOneHolderType[] = Obje 'webhook', 'connector', 'email_template', + // the AI family + 'agent', + 'skill', ]); /** The covered registry types, in the order a refusal lists them. */ @@ -213,6 +225,8 @@ const COLLECTION_KEY: Readonly> = Object.fr webhook: 'webhooks', connector: 'connectors', email_template: 'emailTemplates', + agent: 'agents', + skill: 'skills', }); /** @@ -232,6 +246,8 @@ export const BUILT_IN_BARE_NAMES: Readonly(), connector: new Set(), email_template: new Set(), + agent: new Set(), + skill: new Set(), }); /** @@ -259,6 +275,8 @@ export const OVERRIDABLE_BASELINE_PACKAGES: Readonly(), connector: new Set(), email_template: new Set(['com.objectstack.service.email']), + agent: new Set(), + skill: new Set(), }); /** @@ -383,6 +401,8 @@ const CONSUMER_CLAUSE: Readonly> = Object. webhook: 'the webhook dispatcher subscribes one declared webhook per name', connector: 'a flow `connector_action` node and the connector picker name a connector', email_template: 'a send names its email template', + agent: '`/meta/agent/NAME` serves one agent per name', + skill: 'an MCP client fetches a skill\'s prompt by its name', }); /** The noun a reason names a covered type by. */ @@ -397,6 +417,8 @@ const TYPE_NOUN: Readonly> = Object.freeze webhook: 'a webhook', connector: 'a connector', email_template: 'an email template', + agent: 'an agent', + skill: 'a skill', }); const joinList = (parts: readonly string[]): string => diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 4a9474307a4..7648d281c25 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -67,7 +67,7 @@ import { import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js'; // One name, one package holder for the bare-name types (a picklist, a mapping, a // dataset, an API, an object-less action, a function, a flow, a webhook, a -// connector, an email template) — the cold boot's seal and its one switch +// connector, an email template, an agent, a skill) — the cold boot's seal and its one switch // (`bare-name-one-holder.ts`, "The cold boot"). import { sealBareNameHoldersAtColdBoot } from './registry.js'; import { BARE_NAME_COLD_BOOT_MODE } from './bare-name-one-holder.js'; diff --git a/packages/objectql/src/registry-bare-name-one-holder-ai-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-ai-family.test.ts new file mode 100644 index 00000000000..d60cee2e71b --- /dev/null +++ b/packages/objectql/src/registry-bare-name-one-holder-ai-family.test.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one package holder — the AI family: an agent and a skill + * (maintainer ruling C on #30798, phase 1, the family's own change under + * #30803 and the series' last; `bare-name-one-holder.ts` states the rule, the + * holders and the cold boot). The data, request and flow families' pins are + * `registry-bare-name-one-holder.test.ts`, + * `registry-bare-name-one-holder-request-family.test.ts` and + * `registry-bare-name-one-holder-flow-family.test.ts`; this file holds the same + * matrix for the last two, and what is particular to them: + * + * - both are registry types, judged at the engine's package door and the + * registry's item seam exactly as a picklist is; neither is bundled; + * - a `tool` shares a stack beside them and is NOT covered (ruling + * 6688041914's nine types do not name it): two packages' same-named tools + * still install after the seal (CONTROL); + * - an agent and a skill are two types: one package's agent and another's + * skill under one name never meet (CONTROL). + * + * Every refusal is asserted by its ADR-0112 envelope (`code` + `status`), the + * two holders it names and the rename it carries. + * + * Ablations: the same registrations BEFORE the seal coexist, so every refusal + * here is the seal's. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { + SchemaRegistry, + NAMESPACE_CONFLICT_CODE, + sealBareNameHoldersAtColdBoot, +} from './registry.js'; +import { + BARE_NAME_ONE_HOLDER_TYPES, + BARE_NAME_REGISTRY_TYPES, + BUILT_IN_BARE_NAMES, + OVERRIDABLE_BASELINE_PACKAGES, + bareNameReason, + bareNameTypeLabel, + declaredBareNames, +} from './bare-name-one-holder.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; +type Holder = { kind: string; packageId?: string }; +type Conflict = { type: string; name: string; incomingPackageId: string; existingHolder: Holder; rename?: string }; +type Refusal = Error & { + code?: string; + status?: number; + incomingPackageId?: string; + existingHolder?: Holder; + conflicts?: Conflict[]; +}; + +type AiType = 'agent' | 'skill'; +const TYPES: AiType[] = ['agent', 'skill']; +const COLLECTION: Record = { agent: 'agents', skill: 'skills', tool: 'tools' }; + +/** A body each type's schema accepts, marked with `tag` in its `label`. */ +const body = (type: AiType | 'tool', name: string, tag: string, extra: Record = {}): Record => { + switch (type) { + case 'agent': + return { name, label: `${tag} agent`, role: `${tag} role`, instructions: `${tag} agent instructions`, ...extra }; + case 'skill': + return { name, label: `${tag} skill`, description: `${tag} skill`, instructions: `${tag} skill instructions`, tools: [], ...extra }; + case 'tool': + return { name, label: `${tag} tool`, description: `${tag} tool`, parameters: { type: 'object', properties: {} }, ...extra }; + } +}; + +/** The marker `body` wrote: the item's `label`. */ +const marker = (item: unknown): string | undefined => (item as { label?: string } | undefined)?.label; + +/** The package's own object. */ +const itemObject = (ns: string) => ({ + name: `${ns}_item`, label: `${ns} item`, + fields: { name: { type: 'text', label: 'Name' } }, +}); + +/** + * A flat package payload — the shape `AppPlugin.init` hands the manifest + * service — whose namespace is its id's last segment, so the rename a refusal + * carries is `_`. + */ +const pkg = (id: string, decl: Partial> = {}, extra: Record = {}) => { + const ns = id.split('.').pop()!; + const out: Record = { id, name: ns, namespace: ns, version: '1.0.0', type: 'app', objects: [itemObject(ns)], ...extra }; + for (const type of [...TYPES, 'tool'] as const) { + const names = decl[type]; + if (names) out[COLLECTION[type]] = [...((extra[COLLECTION[type]] as unknown[]) ?? []), ...names.map((n) => body(type, n, ns))]; + } + return out; +}; + +const caught = async (fn: () => unknown): Promise => { + try { + await fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +/** A plugin registering `manifests` in its `start()` — a package of the cold boot, before the seal. */ +const bootPackages = (manifests: unknown[]) => ({ + name: 'test.bare-name.ai-family.boot-packages', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async () => {}, + start: async (ctx: { getService(name: string): T }) => { + const manifest = ctx.getService('manifest'); + for (const m of manifests) await manifest.register(m); + }, +}); + +const kernels: ObjectKernel[] = []; +const boot = async (atBoot: unknown[] = []) => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const warn = vi.spyOn((kernel as unknown as { logger: { warn: (...a: unknown[]) => void } }).logger, 'warn'); + await kernel.use(new ObjectQLPlugin()); + if (atBoot.length > 0) await kernel.use(bootPackages(atBoot) as never); + kernels.push(kernel); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const manifest = kernel.getService('manifest'); + const bareNameWarnings = () => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.startsWith('Bare-name conflict at boot')); + return { ql, registry: ql.registry, register: (m: unknown) => caught(() => manifest.register(m)), bareNameWarnings }; +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +/** The envelope, the two holders and the rename of one refusal. */ +function expectRefusal(err: Refusal | undefined, incoming: string, holder: Holder, rename?: string) { + expect(err, 'the registration was refused').toBeDefined(); + expect(err!.name).toBe('BareNameConflictError'); + expect(err!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.status).toBe(422); + expect(err!.incomingPackageId).toBe(incoming); + expect(err!.existingHolder).toEqual(holder); + expect(err!.message).toContain(incoming); + if (holder.packageId) expect(err!.message).toContain(holder.packageId); + if (rename) { + expect(err!.conflicts![0].rename).toBe(rename); + expect(err!.message).toContain(rename); + } +} + +describe('the covered types, in family order', () => { + it('the two join the list last, after the flow family; the registry types are every covered type but the function', () => { + expect(BARE_NAME_ONE_HOLDER_TYPES).toEqual([ + 'picklist', 'mapping', 'dataset', + 'api', 'action', 'function', + 'flow', 'webhook', 'connector', 'email_template', + 'agent', 'skill', + ]); + expect(BARE_NAME_REGISTRY_TYPES).toEqual(BARE_NAME_ONE_HOLDER_TYPES.filter((t) => t !== 'function')); + for (const type of TYPES) { + expect(bareNameTypeLabel(type)).toBe(type); + expect(BUILT_IN_BARE_NAMES[type].size, type).toBe(0); + expect(OVERRIDABLE_BASELINE_PACKAGES[type].size, type).toBe(0); + } + }); + + it('a manifest declares each of the two by its collection, nested plugins included; a tool declares nothing', () => { + const declared = declaredBareNames({ + id: 'com.acme.alpha', + agents: [body('agent', 'a1', 'a')], skills: [body('skill', 's1', 'a')], tools: [body('tool', 't1', 'a')], + plugins: [{ name: 'inner', skills: [body('skill', 's2', 'a')] }], + }); + expect(declared.map((d) => `${d.type}/${d.name}`)).toEqual(['agent/a1', 'skill/s1', 'skill/s2']); + }); +}); + +describe.each(TYPES)('%s — after the cold boot, a second package declaring a name another package holds', (type) => { + const name = `shared_${type}`; + const label = (tag: string) => marker(body(type, name, tag)); + + it('is refused at the package door, naming both packages and the rename, and leaves no record behind', async () => { + const { registry, register } = await boot(); + expect(registry.bareNameHoldersAreSealed).toBe(true); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + + const err = await register(pkg('com.acme.beta', { [type]: [name] })); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, `beta_${name}`); + expect(err!.conflicts).toEqual([ + { type, name, incomingPackageId: 'com.acme.beta', existingHolder: { kind: 'package', packageId: 'com.acme.alpha' }, rename: `beta_${name}` }, + ]); + expect(err!.message).toContain(`the ${bareNameTypeLabel(type)} "${name}"`); + expect(registry.getPackage('com.acme.beta')).toBeUndefined(); + expect(marker(registry.getItem(type, name))).toBe(label('alpha')); + }); + + it('ABLATION — before the seal the same two registrations coexist (the doors judge nothing during a boot)', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.alpha', { [type]: [name] }) as never, 'code'); + expect(await caught(() => registry.installPackage(pkg('com.acme.beta', { [type]: [name] }) as never, 'code'))).toBeUndefined(); + registry.registerItem(type, body(type, name, 'alpha'), 'name', 'com.acme.alpha'); + expect(await caught(() => registry.registerItem(type, body(type, name, 'beta'), 'name', 'com.acme.beta'))).toBeUndefined(); + registry.sealBareNameHolders(); + expect(await caught(() => registry.installPackage(pkg('com.acme.gamma', { [type]: [name] }) as never, 'code'))).toBeDefined(); + expect(await caught(() => registry.registerItem(type, body(type, name, 'gamma'), 'name', 'com.acme.gamma'))).toBeDefined(); + }); + + it('the item seam refuses a package-bound registration over another package\'s name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + const err = await caught(() => registry.registerItem(type, body(type, name, 'direct'), 'name', 'com.acme.direct')); + expectRefusal(err, 'com.acme.direct', { kind: 'package', packageId: 'com.acme.alpha' }); + expect(marker(registry.getItem(type, name))).toBe(label('alpha')); + }); + + it('a nested plugin declares under its parent package, and is held to the same rule', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { plugins: [{ name: 'inner', [COLLECTION[type]]: [body(type, name, 'inner')] }] }))).toBeUndefined(); + const err = await register(pkg('com.acme.beta', { [type]: [name] })); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }); + }); + + it('CONTROL — the same package registering its own name again is one holder, not two', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + }); + + it('uninstalling the holder releases the name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(registry.uninstallPackage('com.acme.alpha')).toBe(true); + expect(await register(pkg('com.acme.beta', { [type]: [name] }))).toBeUndefined(); + }); + + it('CONTROL — an environment registration (no package: the ADR-0005 overlay) is never judged, and never holds a name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(await caught(() => registry.registerItem(type, body(type, name, 'env'), 'name'))).toBeUndefined(); + registry.registerItem(type, body(type, `env_${type}`, 'env'), 'name'); + expect(await register(pkg('com.acme.beta', { [type]: [`env_${type}`] }))).toBeUndefined(); + }); + + describe('the cold boot of a deployment that already holds the clash', () => { + const atBoot = () => [pkg('com.acme.alpha', { [type]: [name] }), pkg('com.acme.beta', { [type]: [name] })]; + + it('boots, and warns once naming both holders and the rename (switch "warn")', async () => { + const { registry, bareNameWarnings } = await boot(atBoot()); + expect(registry.bareNameHoldersAreSealed).toBe(true); + const warnings = bareNameWarnings(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`the ${bareNameTypeLabel(type)} "${name}"`); + expect(warnings[0]).toContain('com.acme.alpha'); + expect(warnings[0]).toContain('com.acme.beta'); + expect(warnings[0]).toContain(`beta_${name}`); + expect(warnings[0]).toContain('changes nothing about which holder each caller is served'); + }); + + it('both holders keep their name across a reload; a third package is refused naming both', async () => { + const { register } = await boot(atBoot()); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', { [type]: [name] }))).toBeUndefined(); + const err = await register(pkg('com.acme.gamma', { [type]: [name] })); + expectRefusal(err, 'com.acme.gamma', { kind: 'package', packageId: 'com.acme.alpha' }, `gamma_${name}`); + expect(err!.conflicts!.map((c) => c.existingHolder)).toEqual([ + { kind: 'package', packageId: 'com.acme.alpha' }, + { kind: 'package', packageId: 'com.acme.beta' }, + ]); + }); + + it('under the switch "refuse", the cold boot is refused with the doors\' envelope', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + for (const m of atBoot()) registry.installPackage(m as never, 'code'); + const err = await caught(() => sealBareNameHoldersAtColdBoot(registry, 'refuse')); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, `beta_${name}`); + expect(err!.message).toContain('The boot is refused'); + }); + }); +}); + +describe('what the AI family leaves alone', () => { + it('CONTROL — two packages\' tools under one name both install after the seal: a tool is not covered', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { tool: ['shared_tool'] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', { tool: ['shared_tool'] }))).toBeUndefined(); + expect(registry.getPackage('com.acme.beta')).toBeDefined(); + }); + + it('CONTROL — one package\'s agent and another\'s skill under one name are two types, never one conflict', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', { agent: ['shared_ai'] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', { skill: ['shared_ai'] }))).toBeUndefined(); + }); +}); + +describe('the refusal\'s reason names the consumers of the types it lists', () => { + it('one sentence per refusal, naming only the types it carries', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', { agent: ['a1'], skill: ['s1'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.beta', { agent: ['a1'], skill: ['s1'] })); + expect(err!.conflicts!.map((c) => `${c.type}/${c.name}`)).toEqual(['agent/a1', 'skill/s1']); + expect(err!.message).toContain(bareNameReason(['agent', 'skill'])); + expect(bareNameReason(['agent', 'skill'])).toMatch( + /^An agent and a skill each hold one name per deployment: `\/meta\/agent\/NAME` serves one agent per name and an MCP client fetches a skill's prompt by its name, /, + ); + expect(bareNameReason(['skill'])).toMatch(/^A skill holds one name per deployment: an MCP client fetches a skill's prompt by its name, /); + }); + + it('a built-in name is refused to a package at the package door, naming the platform', async () => { + const builtIns = BUILT_IN_BARE_NAMES.skill as Set; + builtIns.add('platform_skill'); + try { + const { register } = await boot(); + const err = await register(pkg('com.acme.alpha', { skill: ['platform_skill'] })); + expectRefusal(err, 'com.acme.alpha', { kind: 'built-in' }, 'alpha_platform_skill'); + } finally { + builtIns.delete('platform_skill'); + } + }); +}); diff --git a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts index a3afc431152..16b544fd5a2 100644 --- a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts +++ b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts @@ -170,7 +170,8 @@ function expectRefusal(err: Refusal | undefined, incoming: string, holder: Holde describe('the covered types, in family order', () => { it('the four join the list after the request family; the registry types are every covered type but the function', () => { - expect(BARE_NAME_ONE_HOLDER_TYPES).toEqual([ + // The families after this one follow it (the AI family's file pins the whole list). + expect(BARE_NAME_ONE_HOLDER_TYPES.slice(0, 10)).toEqual([ 'picklist', 'mapping', 'dataset', 'api', 'action', 'function', 'flow', 'webhook', 'connector', 'email_template', diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index e74130b9ed2..011d55f1aea 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -7629,7 +7629,7 @@ export class SchemaRegistry { // One name, one package holder for the bare-name registry types (a // picklist, a mapping, a dataset, an API, an object-less action, a flow, a - // webhook, a connector, an email template) — the item seam + // webhook, a connector, an email template, an agent, a skill) — the item seam // (`bare-name-one-holder.ts`; ADR-0048 §3.4 narrowed). Once the cold boot // has sealed the holders, a package registering one under a name another // package holds — and that it does not hold itself — is refused BEFORE diff --git a/packages/qa/dogfood/test/bare-name-one-holder-ai-family.dogfood.test.ts b/packages/qa/dogfood/test/bare-name-one-holder-ai-family.dogfood.test.ts new file mode 100644 index 00000000000..32d878a80eb --- /dev/null +++ b/packages/qa/dogfood/test/bare-name-one-holder-ai-family.dogfood.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#30803] One name, one package holder for an agent and a skill — the AI +// family, the series' last (maintainer ruling C on #30798, phase 1; ADR-0048 +// §3.4 narrowed, P1.9; `packages/objectql/src/bare-name-one-holder.ts`), on a +// booted showcase. The data, request and flow families' booted pins are +// `bare-name-one-holder.dogfood.test.ts`, +// `bare-name-one-holder-request-family.dogfood.test.ts` and +// `bare-name-one-holder-flow-family.dogfood.test.ts`. +// +// The registry's own pins (`registry-bare-name-one-holder-ai-family.test.ts`) +// drive the engine's package door and the item seam in-process. This file +// drives the rule at the doors a deployment reaches: +// +// - the cold boot of a deployment that already holds the clash — two +// `AppPlugin` packages, `alpha` and `beta`, shipping one agent and one +// skill under the same names — boots, and logs ONE bare-name warning line +// naming both, both packages and the rename for each; the readers keep +// serving the first-registered package's item, as before: `/meta/agent/NAME` +// and the skill an MCP client's `prompts/get` resolves; +// - `POST /marketplace/install-local` of a third package declaring the held +// agent name, and of a fourth declaring the held skill name, is 422 +// PLUGIN_REGISTER_FAILED naming both holders and the rename, and installs +// nothing; +// - CONTROL: a package declaring fresh agent and skill names installs — and +// so does its tool under the name both boot packages' tools share: a tool +// is not covered. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { AppPlugin } from '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { listSkillPrompts } from '@objectstack/mcp'; +import { defineStack } from '@objectstack/spec'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { bootShowcase } from './showcase-boot.js'; + +const AGENT = 'qa_shared_agent'; +const SKILL = 'qa_shared_skill'; +const TOOL = 'qa_shared_tool'; + +const item = (p: string) => ({ + name: `${p}_item`, label: `${p} item`, sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, +}); +const agent = (p: string, name = AGENT) => ({ name, label: `${p} agent`, role: `${p} role`, instructions: `${p} agent instructions` }); +const skill = (p: string, name = SKILL) => ({ name, label: `${p} skill`, description: `${p} skill`, instructions: `${p} skill instructions`, tools: [] }); +const tool = (p: string, name = TOOL) => ({ name, label: `${p} tool`, description: `${p} tool`, parameters: { type: 'object', properties: {} } }); + +/** One of the two packages the cold boot registers, each shipping the shared agent, skill and tool names. */ +function bootPackage(p: 'alpha' | 'beta') { + return defineStack({ + manifest: { id: `com.qa.bare.${p}`, namespace: p, name: `qa_bare_${p}`, version: '1.0.0', type: 'app' }, + objects: [item(p)], + agents: [agent(p)], + skills: [skill(p)], + tools: [tool(p)], + } as never); +} + +/** An inline install-local manifest. */ +const inline = (p: string, collections: Record) => ({ + manifest: { + id: `com.qa.bare.${p}`, namespace: p, name: `qa_bare_${p}`, version: '1.0.0', type: 'app', scope: 'project', + engines: { protocol: `^${PROTOCOL_MAJOR}` }, + objects: [item(p)], + ...collections, + }, +}); + +/** Every line the process writes while `run` is in flight (security-catalog-one-holder-http's instrument). */ +async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { + const chunks: string[] = []; + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + const warn = console.warn; + (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { chunks.push(String(chunk)); return stdout(chunk as any, ...rest); }; + (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { chunks.push(String(chunk)); return stderr(chunk as any, ...rest); }; + console.warn = (...args: unknown[]) => { chunks.push(`WARN ${args.map(String).join(' ')}\n`); warn(...args); }; + try { + const value = await run(); + return { value, lines: chunks.join('').split('\n') }; + } finally { + (process.stdout as any).write = stdout; + (process.stderr as any).write = stderr; + console.warn = warn; + } +} + +/** The level of one kernel-logger line (` …`), or `undefined`. */ +function levelOf(line: string): string | undefined { + return /^(?:\S+Z\s+)?(DEBUG|INFO|WARN|ERROR|FATAL)\b/.exec(line.trim())?.[1]; +} + +const codeOf = (body: any) => + (body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null; + +describe('one package holder per agent and skill name, on a booted showcase', () => { + let stack: VerifyStack; + let admin: string; + let storageDir: string; + let bootLines: string[] = []; + + const packageIds = async () => { + const r = await stack.apiAs(admin, 'GET', '/packages'); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + const rows = (body?.data?.packages ?? body?.packages ?? body?.data ?? body ?? []) as any[]; + return rows.map((p: any) => p?.manifest?.id ?? p?.id ?? p?.packageId).filter(Boolean).sort(); + }; + + const installLocal = async (payload: unknown) => { + const res = await stack.apiAs(admin, 'POST', '/marketplace/install-local', payload); + return { status: res.status, body: (await res.json()) as any }; + }; + + /** The package whose item `GET /meta/TYPE/NAME` serves. */ + const metaPackage = async (type: string, name: string): Promise => { + const r = await stack.apiAs(admin, 'GET', `/meta/${type}/${name}`); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + const d = body?.data ?? body; + return d?._packageId ?? d?.item?._packageId; + }; + + /** The skill an MCP client's `prompts/get` of `name` resolves: the MCP prompt projection over the merged `skill` list. */ + const skillPromptTitle = async (name: string): Promise => { + const r = await stack.apiAs(admin, 'GET', '/meta/skill'); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + const rows = (body?.data?.items ?? body?.data ?? body?.items ?? []) as unknown[]; + const prompts = await listSkillPrompts({ listSkills: async () => rows }); + return prompts.find((p) => p.name === name)?.title; + }; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-bare-name-ai-')); + const booted = await captureOutput(() => bootShowcase({ + extraPlugins: [ + new AppPlugin(bootPackage('alpha') as never), + new AppPlugin(bootPackage('beta') as never), + new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }), + ], + })); + stack = booted.value; + bootLines = booted.lines; + admin = await stack.signIn(); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('the cold boot of a deployment holding the clash boots, and warns once naming the agent and the skill, both packages and the rename for each', async () => { + const warnings = bootLines.filter((l) => l.includes('Bare-name conflict at boot')); + expect(warnings, 'one warning line').toHaveLength(1); + const line = warnings[0]!; + expect(levelOf(line)).toBe('WARN'); + expect(line).toContain(`the agent "${AGENT}"`); + expect(line).toContain(`the skill "${SKILL}"`); + for (const name of [AGENT, SKILL]) expect(line, `the rename of ${name}`).toContain(`"beta_${name}"`); + expect(line).toContain('package "com.qa.bare.alpha"'); + expect(line).toContain('package "com.qa.bare.beta"'); + // A tool is not covered: never named. + expect(line).not.toContain(TOOL); + expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta'])); + // Unchanged by the rule: the readers keep serving the first-registered package's item. + expect(await metaPackage('agent', AGENT)).toBe('com.qa.bare.alpha'); + expect(await metaPackage('skill', SKILL)).toBe('com.qa.bare.alpha'); + expect(await skillPromptTitle(SKILL)).toBe('alpha skill'); + }); + + it('install-local of a third package declaring a held agent name is 422 PLUGIN_REGISTER_FAILED naming both holders and the rename; nothing is installed', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('gamma', { agents: [agent('gamma')] })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`the agent "${AGENT}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain('com.qa.bare.beta'); + expect(message).toContain(`"gamma_${AGENT}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + expect(await metaPackage('agent', AGENT)).toBe('com.qa.bare.alpha'); + }); + + it('install-local of a package declaring a held skill name is refused the same way; the skill prompt is untouched', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('delta', { skills: [skill('delta')] })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`the skill "${SKILL}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain('com.qa.bare.beta'); + expect(message).toContain(`"delta_${SKILL}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + expect(await skillPromptTitle(SKILL)).toBe('alpha skill'); + }); + + it('CONTROL — install-local of a package declaring fresh agent and skill names, and the shared tool name, installs', async () => { + const { status, body } = await installLocal(inline('epsilon', { + agents: [agent('epsilon', 'epsilon_agent')], + skills: [skill('epsilon', 'epsilon_skill')], + tools: [tool('epsilon')], + })); + expect(status, JSON.stringify(body)).toBeLessThan(300); + expect(await packageIds()).toContain('com.qa.bare.epsilon'); + expect(await metaPackage('agent', 'epsilon_agent')).toBe('com.qa.bare.epsilon'); + expect(await skillPromptTitle('epsilon_skill')).toBe('epsilon skill'); + }); +}); diff --git a/scripts/adr-anchors/packages__objectql__src__bare-name-one-holder.ts.json b/scripts/adr-anchors/packages__objectql__src__bare-name-one-holder.ts.json index d898b307bf9..c6f7d473209 100644 --- a/scripts/adr-anchors/packages__objectql__src__bare-name-one-holder.ts.json +++ b/scripts/adr-anchors/packages__objectql__src__bare-name-one-holder.ts.json @@ -4,5 +4,5 @@ "ADR-0048", "ADR-0005" ], - "invariant": "ADR-0048 §3.4 retired the per-item cross-package throw because a caller carries its package id. Three families of consumers carry none, so with two holders every caller is served one package's item, picked by registration order: the data family — a field's picklist, an import's mappingName and a dashboard's datasetName — serves the first-registered one; the request family — the endpoint matcher's API, an object-less action's handler and the engine function registry's function, read by a flow script node or an action's target — serves the last-registered one; the flow family — the automation engine's one armed flow per name, the dispatcher's one declared webhook per name, the connector picker's one connector per name and a send's email template bundle — serves the first-registered one (the flow: the lexicographically-first package's, with a warning). The maintainer's ruling C on #30798 (phase 1, ADR-0048 addendum 2026-11-06, each family in its own change under #30803) narrows §3.4 for these types: one name, one PACKAGE holder. After the cold boot seals the registry (kernel:bootstrapped), a package bringing a name another installed package or a built-in holds is refused at three doors, naming both holders and the namespace-prefixed rename: the package door (SchemaRegistry.installPackage) and the item seam (SchemaRegistry.registerItem) for the registry types, and the hook binder's bind (bindHooksToEngine in hook-binder.ts, through SchemaRegistry.claimBareNameFunctions) for a function, ahead of every registration it would make. An email template's NAME holds its (name, locale) bundle, so the holder reading strips the registry key's member. ⛔ Never judge the environment catalog — an environment item under a package's name is the ADR-0005 overlay — never judge a type's overridable baseline (OVERRIDABLE_BASELINE_PACKAGES: the email service's built-in templates, which an app's same-named template overrides by contract, so that package neither holds a template name nor is refused one), never let OS_METADATA_COLLISION=warn downgrade the refusal, never judge during the boot (a deployment already holding a clash must still boot: the cold-boot switch decides warn or refuse), and never treat a package that already holds the name as a new holder." + "invariant": "ADR-0048 §3.4 retired the per-item cross-package throw because a caller carries its package id. Three families of consumers carry none, so with two holders every caller is served one package's item, picked by registration order: the data family — a field's picklist, an import's mappingName and a dashboard's datasetName — serves the first-registered one; the request family — the endpoint matcher's API, an object-less action's handler and the engine function registry's function, read by a flow script node or an action's target — serves the last-registered one; the flow family — the automation engine's one armed flow per name, the dispatcher's one declared webhook per name, the connector picker's one connector per name and a send's email template bundle — serves the first-registered one (the flow: the lexicographically-first package's, with a warning); the AI family — the agent /meta/agent/NAME serves and the skill an MCP client's prompts/get resolves — serves the first-registered one. The maintainer's ruling C on #30798 (phase 1, ADR-0048 addendum 2026-11-06, each family in its own change under #30803, the AI family last) narrows §3.4 for these types: one name, one PACKAGE holder. After the cold boot seals the registry (kernel:bootstrapped), a package bringing a name another installed package or a built-in holds is refused at three doors, naming both holders and the namespace-prefixed rename: the package door (SchemaRegistry.installPackage) and the item seam (SchemaRegistry.registerItem) for the registry types, and the hook binder's bind (bindHooksToEngine in hook-binder.ts, through SchemaRegistry.claimBareNameFunctions) for a function, ahead of every registration it would make. An email template's NAME holds its (name, locale) bundle, so the holder reading strips the registry key's member. ⛔ Never judge the environment catalog — an environment item under a package's name is the ADR-0005 overlay — never judge a type's overridable baseline (OVERRIDABLE_BASELINE_PACKAGES: the email service's built-in templates, which an app's same-named template overrides by contract, so that package neither holds a template name nor is refused one), never let OS_METADATA_COLLISION=warn downgrade the refusal, never judge during the boot (a deployment already holding a clash must still boot: the cold-boot switch decides warn or refuse), and never treat a package that already holds the name as a new holder." }