From 3f834fb71e1f94bb9c74ee6b25de385a59faee82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:25:15 +0000 Subject: [PATCH 1/3] feat(objectql)!: an API, an object-less action and an engine function hold one name per package The request family of ruling C's phase 1 on the bare-name rule the data family introduced. Once the cold boot has sealed the registry: - an API or an object-less action whose name another installed package holds is refused at the package door and the item seam, as a picklist is; an action bound to an object (keyed objectName:name) neither holds a name nor is refused over one; - a hook-binder bind bringing a function another package holds is refused ahead of every registration and bind, the holders being the claims each package's bind records (the runtime's handler owner app: is read as the package); - the cold boot reads all three under the same switch, which stays 'warn' as ruled. The refusal's reason now names the consumers of the types it lists, and the boot warning no longer says the first-registered holder is served: an API, an object-less action's handler and a function are served last-registered. ADR-0048's 2026-11-06 addendum gains P1.7. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1EuHiCqvh8VEz81QD --- .changeset/30803-bare-name-request-family.md | 22 + .../0048-cross-package-metadata-collision.md | 103 +++-- packages/objectql/src/bare-name-one-holder.ts | 175 +++++++- packages/objectql/src/hook-binder.ts | 16 + packages/objectql/src/plugin.ts | 12 +- ...are-name-one-holder-request-family.test.ts | 416 ++++++++++++++++++ packages/objectql/src/registry.ts | 140 ++++-- ...-one-holder-request-family.dogfood.test.ts | 198 +++++++++ 8 files changed, 982 insertions(+), 100 deletions(-) create mode 100644 .changeset/30803-bare-name-request-family.md create mode 100644 packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts create mode 100644 packages/qa/dogfood/test/bare-name-one-holder-request-family.dogfood.test.ts diff --git a/.changeset/30803-bare-name-request-family.md b/.changeset/30803-bare-name-request-family.md new file mode 100644 index 00000000000..866659c7973 --- /dev/null +++ b/.changeset/30803-bare-name-request-family.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: an API, an object-less action and an engine function hold one name per package — once the cold boot has sealed the registry, a package declaring an API or object-less action, or binding a function, under a name another installed package holds is refused, naming both holders and the rename; a boot that already holds such a clash warns + +Clause-②: no (narrowing) — the engine's package door (`SchemaRegistry.installPackage`), item seam (`SchemaRegistry.registerItem`) and hook binder (`bindHooksToEngine`) refuse, after the cold boot, a package registration they accepted before: one whose API or object-less action, or one of whose functions, 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 API, object-less action or function 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.7). + + + +**BREAKING** accept-set narrowing at the package registration door, the second family of maintainer ruling C on #30798, phase 1 (the first was picklists, mappings and datasets): managed install (`install-local`, a post-start `manifest.register`, a hot reload) and package registration (`ObjectQL.registerApp` and every package-bound `registerItem`), for APIs and object-less actions; and the hook binder's function bind, for functions. ADR-0048 §3.4 narrowed (addendum 2026-11-06, P1.7). + +- **Why.** Measured on a booted showcase with two packages sharing one bare name per type, in both registration orders: the endpoint matcher kept one API per name, so the other package's declared path answered `404`; `/actions/global/NAME` ran the last-registered package's object-less action while `/meta/action/NAME` served the first-registered package's declaration; and the engine's function registry kept the last-registered function, which a flow `script` node's `function` and an action's `target` resolve with no package. Each answer flipped with registration order; only the action's handler bind warned, naming neither package. +- **Before.** Two installed packages could each ship an API or an object-less action under one name, or bind a function 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`, once every package of the boot has registered and bound its functions), a package registration declaring an API or object-less action under a name another installed package holds is refused ahead of every mutation, and a hook-binder bind bringing a function another package holds is refused ahead of every registration and bind it would make. 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. A function's holder is named as its package, never as the runtime's handler owner `app:`. A refused function bind is logged by `AppPlugin` at `error` and leaves the package's other registrations as they are. +- **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. The maintainer ruled this on 2026-11-06: the boot warns, and only a new holder is refused (`BARE_NAME_COLD_BOOT_MODE` stays `'warn'`). The warning's sentence about what is served now reads "changes nothing about which holder each caller is served" for every family, since an API, an object-less action's handler and a function are served last-registered. +- **Who moves.** An author whose package declares an API or object-less action, or binds a function, 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.** A client, flow or action that named the old name must name the new one; nothing rewrites stored references. +- **Unchanged.** An action bound to an object (`objectName`, or a collector's `object`): keyed `objectName:name` from declaration to handler, it neither holds a name nor is refused over one. A hook's `handler`, which resolves among its own package's functions only. The same package registering or binding its own name again. An environment item under a package's name (never judged, never a holder: the ADR-0005 overlay). A bind naming no owner, a function entry with no callable handler, and a direct `ObjectQL.registerFunction` call. `OS_METADATA_COLLISION=warn` does not downgrade the refusal. + +**Measured producers.** On objectstack `1aeeafd9`, the four examples carry 3 APIs, 1 object-less action and 3 functions (`app-showcase`: 3 APIs, 1 object-less action, 2 functions; `app-todo`: 1 function), and no name has more than one holder. HotCRM `6961ab1` (five packages, one artifact) declares no API, no object-less action and no function in its stack (its 40 actions are all bound to objects). Measured separately: no name coincides across the two sets. Deployed and marketplace packages, and functions a runtime module registers outside the stack definition, NOT MEASURED. diff --git a/docs/adr/0048-cross-package-metadata-collision.md b/docs/adr/0048-cross-package-metadata-collision.md index b8092eaf4dd..64bc6c74eb6 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 and datasets, 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 and engine functions, supersedes nothing). --- @@ -266,19 +266,23 @@ package — stays covered by the `naming/namespace-prefix` lint in `os lint`. > above. Every other type keeps §3.4 as written. See > [Addendum (2026-10-08)](#addendum-2026-10-08-the-security-catalog-holds-one-name-per-deployment--34-narrowed). -> **Narrowed (2026-11-06) — picklists, mappings and datasets 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` — so with two holders every -> caller is served the first-registered one, measured on a booted two-package -> deployment (#30803). Once the cold boot has sealed the registry, a package whose -> picklist, mapping or dataset bears a name another installed package holds is -> refused at managed install and at package registration (a built-in's name, at -> managed install), 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. -> The other types the measurement found resolving without a package context — -> `api`, the object-less `action`, the engine function registry, `email_template`, -> `webhook`, `connector`, `flow`, `agent` and `skill` — are to follow, each in its -> own change. Every other type keeps §3.4 as written. See +> **Narrowed (2026-11-06) — picklists, mappings, datasets, APIs, object-less +> actions and engine functions 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 — 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 or object-less action +> 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. The other types the measurement found resolving without a +> package context — `email_template`, `webhook`, `connector`, `flow`, `agent` and +> `skill` — are to follow, each family in its own change. Every other type, +> an action bound to an object among them, keeps §3.4 as written. 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) @@ -890,9 +894,9 @@ first phase. ## Addendum (2026-11-06): bare names resolved without a package hold one package — §3.4 narrowed > **Status of this addendum: Accepted** — it records phase 1 of a maintainer -> ruling. It **narrows §3.4 for three more metadata types and supersedes no other -> text**: §§1–6, the 2026-08-08 addendum and the 2026-10-08 addendum stand -> verbatim. +> ruling. It **narrows §3.4 for more metadata types, one family per change (P1.2, +> P1.7), 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 > 「同意 c」 (ruling record 6676670839, 2026-11-06). The ruled option's text, @@ -969,12 +973,14 @@ the engine plugin seals the registry, and every name two installed packages already hold is handled by one switch, `BARE_NAME_COLD_BOOT_MODE`: - **`'warn'`** — one warning line naming every such name, both holders and the - rename; the boot continues and keeps serving the first-registered holder; + rename; the boot continues and changes nothing about which holder each caller + is served; - **`'refuse'`** — the boot is refused with the P1.2 envelope, as N.3's amendment refuses the security catalog's. -The switch is `'warn'` until the maintainer rules on the cold boot of a -deployment that already holds a clash. In either mode the doors refuse a NEW +The maintainer ruled the cold boot of a deployment that already holds a clash on +2026-11-06 (#30803): it warns, and only a new holder is refused — the switch is +`'warn'`. In either mode the doors refuse a NEW holder after the seal. A package that already holds a name is never a new holder of it: a reload, a re-install or an upgrade of a package that came through the boot beside another holder keeps its name, while a third package is refused, @@ -999,19 +1005,58 @@ the doors never refuse. ### P1.5 To follow The same measurement found these types resolving without a package context too: -`api` (the endpoint store is keyed by name — the other package's path answers -404), the object-less `action` (the last handler runs while `/meta` serves the -first declaration), the engine function registry (`functions:`, last wins, read by -flow `script` nodes and action `target` refs), `email_template`, `webhook` (the -second package's webhook is never subscribed), `connector`, `flow`, `agent` and -`skill`. Each joins P1.2's list in its own change under #30803, with its own pins, -narrowing §3.4 the same way; a type the security seat claims lands there instead. -`tool` and `rag_pipeline` share a reader with `agent` and were not measured. +`email_template`, `webhook` (the second package's webhook is never subscribed), +`connector`, `flow`, `agent` and `skill`. Each family joins P1.2's list in its own +change under #30803 — `flow`, `webhook`, `connector` and `email_template`; then +`agent` and `skill` — with its 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. ### P1.6 Where it is implemented `@objectstack/objectql`: the rule, the holders and the switch in `bare-name-one-holder.ts`; the package door in `SchemaRegistry.installPackage` and -the item seam in `SchemaRegistry.registerItem`; the seal in +the item seam in `SchemaRegistry.registerItem`; the function door in +`bindHooksToEngine`, through `SchemaRegistry.claimBareNameFunctions`; the seal in `SchemaRegistry.sealBareNameHolders`, called by the engine plugin at `kernel:bootstrapped` through `sealBareNameHoldersAtColdBoot`. + +### P1.7 The request family: `api`, the object-less `action`, the engine function registry + +Measured with P1.1's two packages, in both orders (#30803, stage 1): + +- **`api`** — the endpoint matcher keeps one API per name, the last-registered: + that package's path answers `200` and the other package's declared path `404`. +- **the object-less `action`** — `/actions/global/NAME` runs the last-registered + package's handler while `/meta/action/NAME` serves the first-registered + package's declaration, so the button a client draws and the code that runs + come from two packages. The handler bind warns that it replaces one, naming + neither package. +- **the engine function registry** — one function per name, the last-registered, + resolved with no package by a flow `script` node's `function` and an action's + `target`. + +**What is refused.** An API and an object-less action join P1.2's list: the same +two doors, the same holders, the same envelope and rename. An action **bound to +an object** (`objectName`, or a collector's `object`) is not covered — it is keyed +`objectName:name` from declaration to handler — so it neither holds a name nor is +refused over one, whatever object-less action shares its name. + +A function is not a registry item: a package's functions are code its runtime +module brings, and the one door they pass is the hook binder's bind +(`bindHooksToEngine`, `functions`), which the runtime makes for a package under +the handler owner `app:` — read as that package. The bind records the +package's claim to each function name; once the cold boot has sealed the +registry, a bind bringing a function another package holds is refused ahead of +every registration and bind it would make, every conflicting name in one +refusal, naming both holders and the rename. A package binding its own functions +again (a hot reload) is no new holder, and uninstalling a package releases its +claims. Not judged: a bind naming no owner, an entry with no callable handler, +and a direct `ObjectQL.registerFunction` call — the imperative registration a +host's own code makes outside any package's bind. A hook's `handler` was never +ambiguous: the binder resolves it among its own package's functions only. + +**The cold boot** reads the bind claims with the registry's holders, under P1.3's +switch: two packages of one boot binding one function name are named in the same +warning line, as packages, never as their handler owners. diff --git a/packages/objectql/src/bare-name-one-holder.ts b/packages/objectql/src/bare-name-one-holder.ts index 38f1a2b2eb6..360122a5fca 100644 --- a/packages/objectql/src/bare-name-one-holder.ts +++ b/packages/objectql/src/bare-name-one-holder.ts @@ -7,7 +7,9 @@ * security catalog's sibling (`security-catalog-namespace.ts`), generalised to * the types a booted two-package measurement found resolving to ONE package's * item for every caller (maintainer ruling C on #30798, phase 1; ADR-0048 §3.4, - * narrowed 2026-11-06). + * 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). * * ## The rule * @@ -25,15 +27,35 @@ * package's mapping, and the other package's import answers * `MAPPING_TARGET_MISMATCH`; * - `dataset` — a dashboard's `datasetName` query runs the first-registered - * package's dataset. + * package's dataset; + * - `api` — the endpoint matcher keeps ONE api per name, the last-registered, + * so the other package's declared path answers `404`; + * - `action`, object-less only — `/actions/global/NAME` runs the + * last-registered package's handler while `/meta/action/NAME` serves the + * first-registered package's declaration, so the button a client draws and + * the code that runs come from two packages; + * - `function` — the engine's function registry keeps one function per name, + * the last-registered, and a flow `script` node's `function` and an + * action's `target` resolve it with no package. * - * Every one of them flips with registration order, and none says so. + * 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 + * neither package). + * + * ⛔ An action bound to an object (`objectName`, or a collector's `object`) is + * NOT covered: it is keyed `objectName:name` from declaration to handler, so + * two packages' same-named actions on their own objects never meet. Nor is a + * hook naming a function: the hook binder resolves it among its own package's + * functions only. * * ## The holders * * - **a package** — a name another installed package declares: its claim, * recorded at install, or an item registered under its own id (the - * registry's composite `PACKAGE:NAME` slot); + * registry's composite `PACKAGE:NAME` slot). A function is held by the + * package whose handlers the hook binder bound it for + * ({@link handlerOwnerPackageId}): the function registry is the engine's, + * not the registry's, so its one door is that bind; * - **a built-in** — {@link BUILT_IN_BARE_NAMES}; none today for the covered * types, kept in the vocabulary so a platform-held name is refused the way * the security catalog refuses one. @@ -54,8 +76,10 @@ * engine plugin seals the registry ({@link SchemaRegistry.sealBareNameHolders}) * and reads every name two packages hold. What it does with them is * {@link BARE_NAME_COLD_BOOT_MODE}: `'warn'` names both holders and the rename - * once per name and boots; `'refuse'` refuses the boot with the doors' - * envelope. After the seal both doors refuse a NEW holder at once. + * once per name and boots, changing nothing about which holder each caller is + * served; `'refuse'` refuses the boot with the doors' envelope. After the seal + * both doors, and the hook binder's function bind, refuse a NEW holder at + * once. * * A package that already holds a name is never a new holder of it: a reload, a * re-install or an upgrade of a package that came through the boot beside @@ -69,50 +93,109 @@ * - A package's own name again — one holder, not two. * - `picklistExtensions` — an extension adds values to another package's list * by design; its own refusal (a repeated value) is the picklist module's. + * - An action bound to an object, and a hook's `handler` (above). + * - A direct `ObjectQL.registerFunction` call: the imperative registration a + * host's own code makes, outside any package's bind. * - Every type not in {@link BARE_NAME_ONE_HOLDER_TYPES}: §3.4's coexistence * stands for them (package-scoped resolution, a key that carries the object, * the job key `appId:jobName`). */ import type { ObjectStackManifest } from '@objectstack/spec/kernel'; +import { isObjectLessActionKey, standaloneActionOwnerKey } from './action-governance.js'; -/** A metadata type the rule covers, as the registry keys it. */ -export type BareNameOneHolderType = 'picklist' | 'mapping' | 'dataset'; +/** + * A covered type the registry stores — its items reach the engine's package + * door and the registry's item seam, keyed as the registry keys them. + */ +export type BareNameRegistryType = 'picklist' | 'mapping' | 'dataset' | 'api' | 'action'; -/** The covered types, in the order a refusal lists them. */ -export const BARE_NAME_ONE_HOLDER_TYPES: readonly BareNameOneHolderType[] = Object.freeze([ +/** + * A covered type: a registry type, or `function` — the engine's function + * registry, which a package reaches through the hook binder's bind alone. + */ +export type BareNameOneHolderType = BareNameRegistryType | 'function'; + +/** The covered registry types, in the order a refusal lists them. */ +export const BARE_NAME_REGISTRY_TYPES: readonly BareNameRegistryType[] = Object.freeze([ 'picklist', 'mapping', 'dataset', + 'api', + 'action', ]); -/** Is `type` one of the covered types (as the registry keys it)? */ +/** Every covered type, in the order a refusal and a cold boot list them. */ +export const BARE_NAME_ONE_HOLDER_TYPES: readonly BareNameOneHolderType[] = Object.freeze([ + ...BARE_NAME_REGISTRY_TYPES, + 'function', +]); + +/** Is `type` one of the covered types? */ export function isBareNameOneHolderType(type: unknown): type is BareNameOneHolderType { return typeof type === 'string' && (BARE_NAME_ONE_HOLDER_TYPES as readonly string[]).includes(type); } +/** Is `type` a covered type the registry stores (as the registry keys it)? */ +export function isBareNameRegistryType(type: unknown): type is BareNameRegistryType { + return typeof type === 'string' && (BARE_NAME_REGISTRY_TYPES as readonly string[]).includes(type); +} + +/** + * Does `item` of the covered registry `type` hold its name under the rule? + * Every item does, except an action bound to an object: the rule covers the + * object-less action only (module doc), read with the engine's own owner-key + * ladder ({@link standaloneActionOwnerKey}). + */ +export function holdsBareName(type: BareNameRegistryType, item: unknown): boolean { + if (type !== 'action') return true; + return isObjectLessActionKey(standaloneActionOwnerKey(item)); +} + /** - * The stack collection each type is declared under — the keys the engine's - * collection loop reads (`registerMetadataCollections`). + * The stack collection each registry type is declared under — the keys the + * engine's collection loop reads (`registerMetadataCollections`). */ -const COLLECTION_KEY: Readonly> = Object.freeze({ +const COLLECTION_KEY: Readonly> = Object.freeze({ picklist: 'picklists', mapping: 'mappings', dataset: 'datasets', + api: 'apis', + action: 'actions', }); /** * The names the platform itself holds, whichever package registers first. - * None today: the platform ships no picklist, mapping or dataset of its own - * under a name a package could take. Applied at the package door only, as the - * security catalog applies its own. + * None today: the platform ships no item of a covered type under a name a + * package could take. Applied at the package door only, as the security + * catalog applies its own. */ export const BUILT_IN_BARE_NAMES: Readonly>> = Object.freeze({ picklist: new Set(), mapping: new Set(), dataset: new Set(), + api: new Set(), + action: new Set(), + function: new Set(), }); +/** + * The prefix the runtime binds a package's handlers under + * (`appArtifactHandlerOwner` in `@objectstack/runtime`: `app:`). + */ +const HANDLER_OWNER_PREFIX = 'app:'; + +/** + * The package a handler owner names: the runtime binds a package's hooks, + * functions and action bodies under `app:`, and the rule names the + * package. Any other owner is its own name. + */ +export function handlerOwnerPackageId(owner: string): string { + return owner.startsWith(HANDLER_OWNER_PREFIX) && owner.length > HANDLER_OWNER_PREFIX.length + ? owner.slice(HANDLER_OWNER_PREFIX.length) + : owner; +} + /** * What a cold boot does with a name two installed packages already hold * (module doc, "The cold boot"): `'warn'` — name both holders and the rename, @@ -135,7 +218,7 @@ export type BareNameHolder = /** One `(type, name)` a package declares. */ export interface DeclaredBareName { - readonly type: BareNameOneHolderType; + readonly type: BareNameRegistryType; readonly name: string; } @@ -157,17 +240,20 @@ function itemName(item: unknown): string | undefined { * the engine's registration seams read: the manifest's own collections and * each nested `plugins[]` entry's (a nested plugin contributes under its * parent package — `ObjectQL.registerPlugin`), one level deep, arrays only. + * An action bound to an object declares nothing here ({@link holdsBareName}); + * a function is never declared by a manifest — it is code, and its door is + * the hook binder's bind. */ export function declaredBareNames(manifest: ObjectStackManifest | unknown): DeclaredBareName[] { const out: DeclaredBareName[] = []; const read = (source: unknown) => { if (!source || typeof source !== 'object') return; - for (const type of BARE_NAME_ONE_HOLDER_TYPES) { + for (const type of BARE_NAME_REGISTRY_TYPES) { const items = (source as Record)[COLLECTION_KEY[type]]; if (!Array.isArray(items)) continue; for (const item of items) { const name = itemName(item); - if (name !== undefined) out.push({ type, name }); + if (name !== undefined && holdsBareName(type, item)) out.push({ type, name }); } } }; @@ -179,7 +265,54 @@ export function declaredBareNames(manifest: ObjectStackManifest | unknown): Decl /** How a refusal names a covered type. */ export function bareNameTypeLabel(type: BareNameOneHolderType): string { - return type; + switch (type) { + case 'api': return 'API'; + case 'action': return 'object-less action'; + default: return type; + } +} + +/** + * How each covered type's consumer names it with no package — the clause a + * refusal's reason carries for the types it lists. + */ +const CONSUMER_CLAUSE: Readonly> = Object.freeze({ + picklist: 'a field names its picklist', + mapping: 'an import its mapping', + dataset: 'a dashboard its dataset', + api: 'the endpoint matcher keeps one API per name', + action: '`/actions/global/NAME` runs an object-less action by its name', + function: 'a flow `script` node and an action `target` name a function', +}); + +/** The noun a reason names a covered type by. */ +const TYPE_NOUN: Readonly> = Object.freeze({ + picklist: 'a picklist', + mapping: 'a mapping', + dataset: 'a dataset', + api: 'an API', + action: 'an object-less action', + function: 'a function', +}); + +const joinList = (parts: readonly string[]): string => + parts.length <= 1 ? (parts[0] ?? '') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`; + +/** + * Why a second holder is refused, in one sentence a reader can act on — for + * the covered types `types` lists (every covered type when none), in the + * order {@link BARE_NAME_ONE_HOLDER_TYPES} lists them. Shared by the doors' + * refusal and the cold boot's warning. + */ +export function bareNameReason(types: readonly BareNameOneHolderType[] = BARE_NAME_ONE_HOLDER_TYPES): string { + const present = BARE_NAME_ONE_HOLDER_TYPES.filter((t) => types.includes(t)); + const listed = present.length > 0 ? present : BARE_NAME_ONE_HOLDER_TYPES; + const nouns = joinList(listed.map((t) => TYPE_NOUN[t])); + return ( + `${nouns.charAt(0).toUpperCase()}${nouns.slice(1)} ${listed.length === 1 ? 'holds' : 'each hold'} one name per ` + + `deployment: ${joinList(listed.map((t) => CONSUMER_CLAUSE[t]))}, with no package to tell two definitions ` + + 'apart, so with two holders every caller is served the same one, chosen by registration order.' + ); } /** How a refusal names a holder. */ diff --git a/packages/objectql/src/hook-binder.ts b/packages/objectql/src/hook-binder.ts index a275fdc8edc..60cc2e7081e 100644 --- a/packages/objectql/src/hook-binder.ts +++ b/packages/objectql/src/hook-binder.ts @@ -178,6 +178,10 @@ function hookHandlerNotInPackageRefusal( * Idempotent on `(packageId, hook.name, event, object)`: re-binding the * same set after a hot reload first calls `unregisterHooksByPackage` * (when `packageId` is provided). + * + * @throws {BareNameConflictError} once the cold boot has sealed the registry, + * when `opts.functions` brings a function another package holds + * (`bare-name-one-holder.ts`) — before anything is registered or bound. */ export function bindHooksToEngine( engine: ObjectQL, @@ -199,6 +203,18 @@ export function bindHooksToEngine( // such `script` node failed with "no function named 'x' is registered", // naming the one thing the author had actually done. if (opts.functions && typeof (engine as any).registerFunction === 'function') { + // [#30803] One name, one package holder for a function + // (`bare-name-one-holder.ts`): the engine's function registry keeps one + // function per name and a `script` node or an action `target` resolves it + // with no package, so once the cold boot has sealed the registry a bind + // bringing a function another package holds is refused here, ahead of + // every registration below, naming both holders. Before the seal the + // claims are only recorded, for the cold boot to read. + const claimable = Object.entries(opts.functions) + .filter(([, entry]) => normalizeFlowFunctionEntry(entry) !== undefined) + .map(([name]) => name); + const registry = (engine as { registry?: { claimBareNameFunctions?: (owner: string | undefined, names: readonly string[]) => void } }).registry; + if (typeof registry?.claimBareNameFunctions === 'function') registry.claimBareNameFunctions(opts.packageId, claimable); for (const [name, entry] of Object.entries(opts.functions)) { const fn = normalizeFlowFunctionEntry(entry); if (!fn) { diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index a5e31f6caf9..db9cb2b602c 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -65,8 +65,9 @@ import { // [ADR-0048 N.3] The security catalog's one-holder envelope, raised here by the // cold-boot check (see `refuseEnvironmentHeldSecurityCatalogNames`). import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js'; -// One name, one package holder for a picklist, mapping or dataset — the cold -// boot's seal and its one switch (`bare-name-one-holder.ts`, "The cold boot"). +// One name, one package holder for the bare-name types (a picklist, a mapping, a +// dataset, an API, an object-less action, a function) — 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'; // [ADR-0029 D3] The registry's own refusal of a second owner — read, never @@ -1215,9 +1216,10 @@ export class ObjectQLPlugin implements Plugin { ctx.hook('kernel:bootstrapped', async () => { // [#30803] Seal the bare-name holders (`bare-name-one-holder.ts`): every // package of this boot has registered by now — the AppPlugin bodies in - // `init()`, the install-local rehydrate at `kernel:ready` — so from here - // on the package door and the item seam refuse a NEW holder of a - // picklist, mapping or dataset name. A name the boot left held twice is + // `init()` and their functions in `start()`, the install-local rehydrate + // at `kernel:ready` — so from here on the package door, the item seam + // and the hook binder's function bind refuse a NEW holder of a covered + // name. A name the boot left held twice is // the switch's: warned once, naming both holders and the rename, or the // boot refused. Never sealed on a kernel that withholds the boot-phase // hooks: there the doors never refuse. diff --git a/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts new file mode 100644 index 00000000000..ea850947e57 --- /dev/null +++ b/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts @@ -0,0 +1,416 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one package holder — the request family: an API, an object-less + * action and an engine function (maintainer ruling C on #30798, phase 1, the + * family's own change under #30803; `bare-name-one-holder.ts` states the rule, + * the holders and the cold boot). The data family's pins are + * `registry-bare-name-one-holder.test.ts`; this file holds the same matrix for + * the next three, and what is particular to them: + * + * - `api` and the object-less `action` are registry types, judged at the + * engine's package door and the registry's item seam exactly as a picklist + * is; + * - an action BOUND to an object is not covered — it is keyed + * `objectName:name` from declaration to handler — so it neither holds a + * name nor is refused over one; + * - a function is not a registry item: the engine's function registry keeps + * one per name, and its one package door is the hook binder's bind + * (`bindHooksToEngine`, `opts.functions`), which the runtime makes under the + * owner `app:`. + * + * 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 { BUILT_IN_BARE_NAMES, bareNameReason, handlerOwnerPackageId } 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 RequestType = 'api' | 'action'; +const TYPES: RequestType[] = ['api', 'action']; +const COLLECTION: Record = { api: 'apis', action: 'actions' }; + +/** A body each type's schema accepts, marked with `tag`; an `action` here is object-less. */ +const body = (type: RequestType, name: string, tag: string): Record => + type === 'api' + ? { + name, summary: `${tag} api`, path: `/api/v1/apps/${tag}/${name}`, method: 'GET', + type: 'object_operation', objectParams: { object: `${tag}_item`, operation: 'find' }, + } + : { name, label: `${tag} action`, type: 'script', body: { language: 'js', source: `return '${tag}';`, capabilities: [] } }; + +/** The marker `body` wrote: an API's `summary` (its schema has no `label`), an action's `label`. */ +const marker = (item: unknown): string | undefined => + (item as { label?: string; summary?: string } | undefined)?.label ?? (item as { summary?: string } | undefined)?.summary; + +/** An action bound to `object`. */ +const boundAction = (name: string, tag: string, object: string) => ({ ...body('action', name, tag), objectName: object }); + +/** The package's own object, so a bound action has a target. */ +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) { + 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; + } +}; + +/** The functions a package's handlers bring, as the runtime binds them: under `app:`. */ +const fn = (tag: string) => async () => tag; +const bindFunctions = (ql: ObjectQL, packageId: string, names: string[], tag = packageId.split('.').pop()!) => + ql.bindHooks([], { packageId: `app:${packageId}`, functions: Object.fromEntries(names.map((n) => [n, fn(tag)])) }); + +/** A plugin registering `manifests` (and binding `functions`) in its `start()` — a package of the cold boot, before the seal. */ +const bootPackages = (manifests: unknown[], functions: Array<{ packageId: string; names: string[] }> = []) => ({ + name: 'test.bare-name.request-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 ql = ctx.getService('objectql'); + for (const f of functions) bindFunctions(ql, f.packageId, f.names); + }, +}); + +const kernels: ObjectKernel[] = []; +const boot = async (atBoot: unknown[] = [], functions: Array<{ packageId: string; names: string[] }> = []) => { + 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 || functions.length > 0) await kernel.use(bootPackages(atBoot, functions) 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.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) => `${tag} ${type}`; + + 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(type === 'api' ? `the API "${name}"` : `the object-less action "${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('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('action — only the object-less action holds its name', () => { + it('CONTROL — two packages\' same-named actions bound to their own objects coexist (keyed objectName:name)', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { actions: [boundAction('mark_done', 'alpha', 'alpha_item')] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', {}, { actions: [boundAction('mark_done', 'beta', 'beta_item')] }))).toBeUndefined(); + }); + + it('CONTROL — a bound action is no holder of an object-less action\'s name, and is not refused over one', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { actions: [boundAction('run_it', 'alpha', 'alpha_item')] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', { action: ['run_it'] }))).toBeUndefined(); + expect(await register(pkg('com.acme.gamma', {}, { actions: [boundAction('run_it', 'gamma', 'gamma_item')] }))).toBeUndefined(); + // …while a second OBJECT-LESS one is refused, naming the object-less holder only. + const err = await register(pkg('com.acme.delta', { action: ['run_it'] })); + expectRefusal(err, 'com.acme.delta', { kind: 'package', packageId: 'com.acme.beta' }, 'delta_run_it'); + expect(err!.conflicts).toHaveLength(1); + }); + + it('the item seam judges the object-less action and lets a bound one through', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { action: ['run_it'] }))).toBeUndefined(); + expect(await caught(() => registry.registerItem('action', boundAction('run_it', 'direct', 'direct_item'), 'name', 'com.acme.direct'))).toBeUndefined(); + expectRefusal( + await caught(() => registry.registerItem('action', body('action', 'run_it', 'direct'), 'name', 'com.acme.other')), + 'com.acme.other', { kind: 'package', packageId: 'com.acme.alpha' }, + ); + }); +}); + +describe('function — the engine function registry, through the hook binder\'s bind', () => { + it('the runtime\'s handler owner `app:` is read as the package', () => { + expect(handlerOwnerPackageId('app:com.acme.alpha')).toBe('com.acme.alpha'); + expect(handlerOwnerPackageId('metadata-service')).toBe('metadata-service'); + expect(handlerOwnerPackageId('app:')).toBe('app:'); + }); + + it('after the cold boot, a bind bringing a function another package holds is refused naming both and the rename, and registers nothing', async () => { + const { ql, register } = await boot(); + expect(await register(pkg('com.acme.alpha'))).toBeUndefined(); + expect(await register(pkg('com.acme.beta'))).toBeUndefined(); + bindFunctions(ql, 'com.acme.alpha', ['shared_fn']); + const served = ql.resolveFunction('shared_fn'); + + const err = await caught(() => bindFunctions(ql, 'com.acme.beta', ['shared_fn', 'beta_only_fn'])); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, 'beta_shared_fn'); + expect(err!.conflicts!.map((c) => `${c.type}/${c.name}`)).toEqual(['function/shared_fn']); + expect(err!.message).toContain('the function "shared_fn"'); + // Ahead of every registration: neither function of the refused bind is registered. + expect(ql.resolveFunction('shared_fn')).toBe(served); + expect(ql.resolveFunctionEntry('shared_fn')?.packageId).toBe('app:com.acme.alpha'); + expect(ql.resolveFunction('beta_only_fn')).toBeUndefined(); + }); + + it('ABLATION — before the seal the same two binds coexist, the last-registered served', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + expect(await caught(() => registry.claimBareNameFunctions('app:com.acme.alpha', ['shared_fn']))).toBeUndefined(); + expect(await caught(() => registry.claimBareNameFunctions('app:com.acme.beta', ['shared_fn']))).toBeUndefined(); + const conflicts = registry.sealBareNameHolders(); + expect(conflicts.map((c) => `${c.type}/${c.name}: ${c.incomingPackageId} vs ${(c.existingHolder as Holder).packageId}`)).toEqual([ + 'function/shared_fn: com.acme.beta vs com.acme.alpha', + ]); + expectRefusal( + await caught(() => registry.claimBareNameFunctions('app:com.acme.gamma', ['shared_fn'])), + 'com.acme.gamma', { kind: 'package', packageId: 'com.acme.alpha' }, + ); + }); + + it('CONTROL — a package binding its own functions again (a hot reload) is one holder, not two', async () => { + const { ql, register } = await boot(); + expect(await register(pkg('com.acme.alpha'))).toBeUndefined(); + bindFunctions(ql, 'com.acme.alpha', ['shared_fn']); + expect(await caught(() => bindFunctions(ql, 'com.acme.alpha', ['shared_fn']))).toBeUndefined(); + }); + + it('CONTROL — an owner-less bind is never judged, and holds nothing', async () => { + const { ql, register } = await boot(); + expect(await register(pkg('com.acme.alpha'))).toBeUndefined(); + bindFunctions(ql, 'com.acme.alpha', ['shared_fn']); + expect(await caught(() => ql.bindHooks([], { functions: { shared_fn: fn('anon'), anon_fn: fn('anon') } }))).toBeUndefined(); + expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['anon_fn']))).toBeUndefined(); + }); + + it('CONTROL — an entry with no callable handler claims nothing', async () => { + const { ql } = await boot(); + ql.bindHooks([], { packageId: 'app:com.acme.alpha', functions: { not_code: { effect: 'pure' } as never } }); + expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['not_code']))).toBeUndefined(); + }); + + it('uninstalling the holder releases the name', async () => { + const { ql, registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha'))).toBeUndefined(); + bindFunctions(ql, 'com.acme.alpha', ['shared_fn']); + expect(registry.uninstallPackage('com.acme.alpha')).toBe(true); + expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['shared_fn']))).toBeUndefined(); + }); + + describe('the cold boot of a deployment whose packages already bind one function name', () => { + const atBoot = () => [pkg('com.acme.alpha'), pkg('com.acme.beta')]; + const binds = () => [ + { packageId: 'com.acme.alpha', names: ['shared_fn'] }, + { packageId: 'com.acme.beta', names: ['shared_fn'] }, + ]; + + it('boots, and warns once naming both packages (not their handler owners) and the rename', async () => { + const { ql, bareNameWarnings } = await boot(atBoot(), binds()); + const warnings = bareNameWarnings(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('the function "shared_fn"'); + expect(warnings[0]).toContain('package "com.acme.beta"'); + expect(warnings[0]).toContain('package "com.acme.alpha"'); + expect(warnings[0]).not.toContain('app:com.acme'); + expect(warnings[0]).toContain('beta_shared_fn'); + // Unchanged by the rule: the last-registered function is served. + expect(ql.resolveFunctionEntry('shared_fn')?.packageId).toBe('app:com.acme.beta'); + }); + + it('both keep binding their own function after the boot; a third package is refused naming both', async () => { + const { ql } = await boot(atBoot(), binds()); + expect(await caught(() => bindFunctions(ql, 'com.acme.alpha', ['shared_fn']))).toBeUndefined(); + expect(await caught(() => bindFunctions(ql, 'com.acme.beta', ['shared_fn']))).toBeUndefined(); + const err = await caught(() => bindFunctions(ql, 'com.acme.gamma', ['shared_fn'])); + expect(err?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.conflicts!.map((c) => c.existingHolder)).toEqual([ + { kind: 'package', packageId: 'com.acme.alpha' }, + { kind: 'package', packageId: 'com.acme.beta' }, + ]); + }); + }); +}); + +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', { api: ['a1'], action: ['r1'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.beta', { api: ['a1'], action: ['r1'] })); + expect(err!.conflicts!.map((c) => `${c.type}/${c.name}`)).toEqual(['api/a1', 'action/r1']); + expect(err!.message).toContain(bareNameReason(['api', 'action'])); + expect(bareNameReason(['api', 'action'])).toMatch(/^An API and an object-less action each hold one name per deployment: /); + expect(bareNameReason(['function'])).toMatch(/^A function holds one name per deployment: /); + }); + + it('a built-in name is refused to a package at the package door, naming the platform', async () => { + const builtIns = BUILT_IN_BARE_NAMES.api as Set; + builtIns.add('platform_api'); + try { + const { register } = await boot(); + const err = await register(pkg('com.acme.alpha', { api: ['platform_api'] })); + expectRefusal(err, 'com.acme.alpha', { kind: 'built-in' }, 'alpha_platform_api'); + } finally { + builtIns.delete('platform_api'); + } + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 08310c37bf6..2a2356586a9 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -77,11 +77,14 @@ import { import { BUILT_IN_BARE_NAMES, bareNameHolderKey, + bareNameReason, bareNameTypeLabel, BARE_NAME_ONE_HOLDER_TYPES, declaredBareNames, describeBareNameHolder, - isBareNameOneHolderType, + handlerOwnerPackageId, + holdsBareName, + isBareNameRegistryType, suggestedBareNameRename, type BareNameColdBootMode, type BareNameHolder, @@ -2772,7 +2775,7 @@ export interface BareNameConflict { readonly type: BareNameOneHolderType; /** The name both holders claim. */ readonly name: string; - /** The package whose registration this refusal stopped (at a cold boot: the later of the two, by id). */ + /** The package whose registration this refusal stopped (at a cold boot: a later-registered holder). */ readonly incomingPackageId: string; /** Who already holds the name. */ readonly existingHolder: BareNameHolder; @@ -2789,32 +2792,29 @@ function bareNameConflictLine(c: BareNameConflict): string { ); } -/** - * Why a second holder is refused, in one sentence a reader can act on — shared - * by the doors' refusal and the cold boot's warning. - */ -const BARE_NAME_REASON = - 'Picklists, mappings and datasets each hold one name per deployment: a field names its picklist, an ' + - 'import its mapping and a dashboard its dataset by the bare name, with no package to tell two definitions ' + - 'apart, so with two holders every caller is served the same one, chosen by registration order.'; +/** Why a second holder is refused, for the types `conflicts` carry (`bareNameReason`). */ +const bareNameReasonFor = (conflicts: readonly BareNameConflict[]): string => + bareNameReason(conflicts.map((c) => c.type)); /** The cold-boot warning for the names two installed packages already hold (`bare-name-one-holder.ts`). */ export function bareNameColdBootWarning(conflicts: readonly BareNameConflict[]): string { return ( `Bare-name conflict at boot: ${conflicts.length === 1 ? 'a second holder' : `${conflicts.length} second holders`} ` + - `among the installed packages — ${conflicts.map(bareNameConflictLine).join('; ')}. ${BARE_NAME_REASON} ` + - `The boot continues and keeps serving the first-registered holder; a further package declaring the ` + - `name is refused at install. Rename the item in one package (its namespace prefixed, as above), or ` + - `uninstall one of the two packages. See ADR-0048.` + `among the installed packages — ${conflicts.map(bareNameConflictLine).join('; ')}. ${bareNameReasonFor(conflicts)} ` + + `The boot continues and changes nothing about which holder each caller is served; a further package ` + + `declaring the name is refused at install. Rename the item in one package (its namespace prefixed, as ` + + `above), or uninstall one of the two packages. See ADR-0048.` ); } /** - * Raised when a package registers a picklist, mapping or dataset whose name + * Raised when a package registers an item of a covered type (a picklist, + * mapping, dataset, API or object-less action) or binds a function whose name * another package (or the platform) already holds — at managed install, at - * package registration, or, under `BARE_NAME_COLD_BOOT_MODE = 'refuse'`, at - * the cold boot (`bare-name-one-holder.ts` states the rule, the holders, the - * cold boot, and what it deliberately does not judge). + * package registration, at the hook binder's function bind, or, under + * `BARE_NAME_COLD_BOOT_MODE = 'refuse'`, at the cold boot + * (`bare-name-one-holder.ts` states the rule, the holders, the cold boot, and + * what it deliberately does not judge). * * {@link SecurityCatalogNameConflictError}'s sibling, in its shape and for its * reason: the ADR-0112 envelope (`code` + `status: 422`) so a wire door answers @@ -2851,12 +2851,12 @@ export class BareNameConflictError extends Error { 'a built-in name is never available to a package.'; super( options.door === 'cold-boot' - ? `Bare-name conflict at boot: ${conflicts.map(bareNameConflictLine).join('; ')}. ${BARE_NAME_REASON} ` + + ? `Bare-name conflict at boot: ${conflicts.map(bareNameConflictLine).join('; ')}. ${bareNameReasonFor(conflicts)} ` + `The boot is refused. Rename the item in one of the packages, or uninstall one of them, then ` + `restart. See ADR-0048.` : `Bare-name conflict: package "${first.incomingPackageId}" cannot register ` + `${conflicts.length === 1 ? 'a name' : `${conflicts.length} names`} another holder already holds — ` + - `${conflicts.map(bareNameConflictLine).join('; ')}. ${BARE_NAME_REASON} ${remedy} See ADR-0048.`, + `${conflicts.map(bareNameConflictLine).join('; ')}. ${bareNameReasonFor(conflicts)} ${remedy} See ADR-0048.`, ); this.name = 'BareNameConflictError'; this.conflicts = conflicts; @@ -4608,20 +4608,26 @@ export class SchemaRegistry { } /** - * The packages holding `(type, name)`, in the order the item store serves - * them — the first is the holder a caller with no package is served — then - * any holder by install claim alone. A package holds a name through an item - * registered under it (its composite `:` slot) or its - * install claim. ⛔ Never the bare slot: that is the environment catalog - * (the ADR-0005 overlay), whatever `_packageId` its hydrated row wears. + * The packages holding `(type, name)`, in registration order — the item + * store's order (whose first is the holder a caller with no package is + * served by {@link getItem}), then any holder by claim alone. A package holds + * a name through an item registered under it (its composite + * `:` slot, when the item is one the rule covers — + * `holdsBareName`: an action bound to an object holds nothing) or its claim: + * recorded at install, or, for a function, at the hook binder's bind + * ({@link claimBareNameFunctions}). ⛔ Never the bare slot: that is the + * environment catalog (the ADR-0005 overlay), whatever `_packageId` its + * hydrated row wears. */ private bareNamePackageHolders(type: BareNameOneHolderType, name: string): string[] { const holders = new Set(); - const suffix = `:${name}`; - for (const [key, item] of this.metadata.get(type) ?? []) { - if (key === name || !key.endsWith(suffix)) continue; - const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; - holders.add(typeof stamped === 'string' && stamped !== '' ? stamped : key.slice(0, -suffix.length)); + if (isBareNameRegistryType(type)) { + const suffix = `:${name}`; + for (const [key, item] of this.metadata.get(type) ?? []) { + if (key === name || !key.endsWith(suffix) || !holdsBareName(type, item)) continue; + const stamped = (item as { _packageId?: unknown } | null | undefined)?._packageId; + holders.add(typeof stamped === 'string' && stamped !== '' ? stamped : key.slice(0, -suffix.length)); + } } for (const claimant of this.bareNameClaims.get(type)?.get(name) ?? []) holders.add(claimant); return [...holders]; @@ -4669,7 +4675,7 @@ export class SchemaRegistry { /** * The package door's half of the rule: once sealed, refuse a package whose - * declared picklists, mappings or datasets bear a name another holder holds, + * declared items of a covered registry type bear a name another holder holds, * ahead of every mutation {@link installPackage} makes — the security * catalog's disposition, for its reasons: every conflict collected into one * refusal, no co-owner exemption, no `collisionPolicy` downgrade, and a @@ -4727,15 +4733,57 @@ export class SchemaRegistry { } } + /** + * The function registry's door (`bare-name-one-holder.ts`): the hook binder + * asks this before it registers the functions a package's handlers bring + * (`bindHooksToEngine`), with the owner it binds them under — `app:`, + * read as the package id ({@link handlerOwnerPackageId}). The function + * registry is the engine's and keeps one function per name, the + * last-registered, so the holders are the claims recorded here. + * + * Before the seal it records the claims and refuses nothing — the cold boot + * reads them ({@link sealBareNameHolders}). Once sealed it refuses, ahead of + * recording anything, a bind that brings a function another package holds, + * every conflicting name in one refusal; a package binding its own functions + * again is no new holder. An owner-less bind is never judged. + * + * @throws {BareNameConflictError} once sealed, naming both holders of each + * conflicting function and the rename. + */ + claimBareNameFunctions(owner: string | undefined, names: readonly string[]): void { + if (!owner || names.length === 0) return; + const packageId = handlerOwnerPackageId(owner); + const unique = [...new Set(names.filter((n) => typeof n === 'string' && n !== ''))]; + if (this.bareNameHoldersSealed) { + const conflicts: BareNameConflict[] = []; + for (const name of unique) { + for (const existingHolder of this.bareNameHoldersOtherThan('function', name, packageId, { builtIns: true })) { + conflicts.push({ + type: 'function', name, incomingPackageId: packageId, existingHolder, + rename: this.bareNameRenameFor(packageId, name), + }); + } + } + if (conflicts.length > 0) throw new BareNameConflictError(conflicts); + } + let byName = this.bareNameClaims.get('function'); + if (!byName) this.bareNameClaims.set('function', byName = new Map()); + for (const name of unique) { + let claimants = byName.get(name); + if (!claimants) byName.set(name, claimants = new Set()); + claimants.add(packageId); + } + } + /** * The cold boot's seal (`bare-name-one-holder.ts`, "The cold boot"): from - * this call on, both doors refuse a new holder of a covered name. Answers - * every name the boot left held twice, as the conflicts - * {@link BareNameConflictError} reports — the holder a caller with no package - * is served as the existing one, each other package as an incoming one with - * its rename — and a package holding a built-in name. Sorted by type, then - * name, so two boots of one deployment report alike. Records and refuses - * nothing else: what the boot does with the answer is + * this call on, both doors and the hook binder's function bind refuse a new + * holder of a covered name. Answers every name the boot left held twice, as + * the conflicts {@link BareNameConflictError} reports — the first-registered + * holder as the existing one, each later one as an incoming one with its + * rename — and a package holding a built-in name. Sorted by type, then name, + * so two boots of one deployment report alike. Records and refuses nothing + * else: what the boot does with the answer is * {@link sealBareNameHoldersAtColdBoot}'s mode. */ sealBareNameHolders(): BareNameConflict[] { @@ -4743,9 +4791,11 @@ export class SchemaRegistry { const conflicts: BareNameConflict[] = []; for (const type of BARE_NAME_ONE_HOLDER_TYPES) { const candidates = new Set(); - for (const key of this.metadata.get(type)?.keys() ?? []) { - const sep = key.lastIndexOf(':'); - if (sep > 0) candidates.add(key.slice(sep + 1)); + if (isBareNameRegistryType(type)) { + for (const key of this.metadata.get(type)?.keys() ?? []) { + const sep = key.lastIndexOf(':'); + if (sep > 0) candidates.add(key.slice(sep + 1)); + } } for (const name of this.bareNameClaims.get(type)?.keys() ?? []) candidates.add(name); for (const name of [...candidates].sort()) { @@ -4760,10 +4810,10 @@ export class SchemaRegistry { } continue; } - const [served, ...others] = packages; + const [first, ...others] = packages; for (const packageId of others) { conflicts.push({ - type, name, incomingPackageId: packageId, existingHolder: { kind: 'package', packageId: served }, + type, name, incomingPackageId: packageId, existingHolder: { kind: 'package', packageId: first }, rename: this.bareNameRenameFor(packageId, name), }); } @@ -7545,7 +7595,7 @@ export class SchemaRegistry { // plugin registering through the registry directly). ⛔ A registration // with no `packageId` is the environment catalog — the ADR-0005 overlay — // and is never judged. - if (packageId && isBareNameOneHolderType(type)) { + if (packageId && isBareNameRegistryType(type) && holdsBareName(type, item)) { const holders = this.bareNameHoldersOtherThan(type, baseName, packageId, { builtIns: false }); if (holders.length > 0) { const rename = this.bareNameRenameFor(packageId, baseName); diff --git a/packages/qa/dogfood/test/bare-name-one-holder-request-family.dogfood.test.ts b/packages/qa/dogfood/test/bare-name-one-holder-request-family.dogfood.test.ts new file mode 100644 index 00000000000..5c61290c2c6 --- /dev/null +++ b/packages/qa/dogfood/test/bare-name-one-holder-request-family.dogfood.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#30803] One name, one package holder for an API, an object-less action and +// an engine function — the request family (maintainer ruling C on #30798, +// phase 1; ADR-0048 §3.4 narrowed; `packages/objectql/src/bare-name-one-holder.ts`), +// on a booted showcase. The data family's booted pin is +// `bare-name-one-holder.dogfood.test.ts`. +// +// The registry's own pins (`registry-bare-name-one-holder-request-family.test.ts`) +// drive the engine's package door, the item seam and the hook binder's bind +// 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 API, one +// object-less action and one function under the same names — boots, and +// logs ONE warning line naming both packages (never the runtime's +// `app:` handler owner) and the rename for every name; +// - `POST /marketplace/install-local` of a third package declaring the held +// API name is 422 PLUGIN_REGISTER_FAILED naming both holders and the +// rename, installs nothing, and its endpoint path stays unserved; +// - the same door refusing a package that declares the held object-less +// action and the showcase's own API name, naming both; +// - CONTROL: a package declaring fresh names, and an action BOUND to its own +// object under the held object-less action's name, installs. +// +// A function cannot come through install-local (it is code; that door carries +// no runtime module), so its post-boot door is pinned in-process only. + +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 { defineStack } from '@objectstack/spec'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { bootShowcase } from './showcase-boot.js'; + +const SHOWCASE_PKG = 'com.example.showcase'; +const SHOWCASE_API = 'showcase_task_feed'; +const API = 'qa_shared_api'; +const ACTION = 'qa_shared_run'; +const FN = 'qa_shared_fn'; + +const item = (p: string) => ({ + name: `${p}_item`, + label: `${p} item`, + sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, +}); +const api = (p: string, name = API) => ({ + name, path: `/api/v1/apps/${p}/feed`, method: 'GET', summary: `${p} feed`, + type: 'object_operation', objectParams: { object: `${p}_item`, operation: 'find' }, authRequired: true, +}); +const action = (p: string, name = ACTION, extra: Record = {}) => ({ + name, label: `${p} run`, type: 'script', body: { language: 'js', source: `return { from: '${p}' };`, capabilities: [] }, ...extra, +}); + +/** One of the two packages the cold boot registers, each shipping all three 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)], + apis: [api(p)], + actions: [action(p)], + functions: { [FN]: async () => 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 API, object-less action and function 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 }; + }; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-bare-name-request-')); + 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 both packages and the rename for every name', 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 API "${API}"`); + expect(line).toContain(`the object-less action "${ACTION}"`); + expect(line).toContain(`the function "${FN}"`); + for (const name of [API, ACTION, FN]) 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"'); + expect(line, 'the package, never the handler owner').not.toContain('app:com.qa'); + expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta'])); + }); + + it('install-local of a third package declaring a held API name is 422 PLUGIN_REGISTER_FAILED naming both holders and the rename; nothing is installed or served', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('gamma', { apis: [api('gamma')] })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`the API "${API}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain('com.qa.bare.beta'); + expect(message).toContain(`"gamma_${API}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + expect((await stack.apiAs(admin, 'GET', '/apps/gamma/feed')).status, 'the refused endpoint is not served').toBe(404); + }); + + it('install-local of a package declaring the held object-less action and the showcase\'s API name is refused, naming both', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('delta', { + apis: [api('delta', SHOWCASE_API)], + actions: [action('delta')], + })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`the API "${SHOWCASE_API}"`); + expect(message).toContain(SHOWCASE_PKG); + expect(message).toContain(`the object-less action "${ACTION}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain(`"delta_${ACTION}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + }); + + it('CONTROL — install-local of a package declaring fresh names, and an action bound to its own object under the held action name, installs', async () => { + const { status, body } = await installLocal(inline('epsilon', { + apis: [api('epsilon', 'epsilon_feed')], + actions: [action('epsilon', 'epsilon_run'), action('epsilon', ACTION, { objectName: 'epsilon_item' })], + })); + expect(status, JSON.stringify(body)).toBeLessThan(300); + expect(await packageIds()).toContain('com.qa.bare.epsilon'); + }); +}); From 0990e5dd1bb2bbc5448fcf284cbe57b0394e850e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:55:53 +0000 Subject: [PATCH 2/3] test(objectql): type the request-family pins' function fixtures The bind fixtures handed `bindHooks` functions returning a string, which its `Record` option does not accept, and the bound action fixture lost its index signature in the spread; the test layer's typecheck (tsconfig.test.json) refused the file. Each fixture is now a distinct `HookHandler` closure (the cells compare identity only), and the bound action is typed as a record. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1EuHiCqvh8VEz81QD --- .../registry-bare-name-one-holder-request-family.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts index ea850947e57..b7052b57831 100644 --- a/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts +++ b/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts @@ -35,7 +35,7 @@ import { sealBareNameHoldersAtColdBoot, } from './registry.js'; import { BUILT_IN_BARE_NAMES, bareNameReason, handlerOwnerPackageId } from './bare-name-one-holder.js'; -import type { ObjectQL } from './engine.js'; +import type { HookHandler, ObjectQL } from './engine.js'; type ManifestService = { register(m: unknown): void | Promise }; type Holder = { kind: string; packageId?: string }; @@ -66,7 +66,7 @@ const marker = (item: unknown): string | undefined => (item as { label?: string; summary?: string } | undefined)?.label ?? (item as { summary?: string } | undefined)?.summary; /** An action bound to `object`. */ -const boundAction = (name: string, tag: string, object: string) => ({ ...body('action', name, tag), objectName: object }); +const boundAction = (name: string, tag: string, object: string): Record => ({ ...body('action', name, tag), objectName: object }); /** The package's own object, so a bound action has a target. */ const itemObject = (ns: string) => ({ @@ -98,8 +98,9 @@ const caught = async (fn: () => unknown): Promise => { } }; +/** A function a package's handlers bring: a distinct closure per call, so `resolveFunction`'s identity tells two holders apart. */ +const fn = (tag: string): HookHandler => Object.assign(async () => {}, { tag }); /** The functions a package's handlers bring, as the runtime binds them: under `app:`. */ -const fn = (tag: string) => async () => tag; const bindFunctions = (ql: ObjectQL, packageId: string, names: string[], tag = packageId.split('.').pop()!) => ql.bindHooks([], { packageId: `app:${packageId}`, functions: Object.fromEntries(names.map((n) => [n, fn(tag)])) }); From 247853de2279045496cd6fc1b351e5a1500ecf15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:23:01 +0000 Subject: [PATCH 3/3] docs(objectql): the bare-name anchor names the request family and the binder's door; PR 1's changeset records the cold-boot ruling The ADR-0048 anchor on bare-name-one-holder.ts named only the data family (picklist, mapping, dataset; served first-registered) and two doors. It now names the request family too (API, object-less action, engine function; served last-registered) and the hook binder's bind (bindHooksToEngine through SchemaRegistry.claimBareNameFunctions) as the third door. Same ADR citations. PR 1's still-pending changeset called the cold-boot question the maintainer's open call; it was ruled on 2026-11-06 (the boot warns, only a new holder is refused), so the note now says that. ADR-0048 P1.1's heading names the data family it covers, beside P1.7's request family. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxXA6Z1QEsocQJhugE --- .changeset/30803-bare-name-one-package-holder.md | 2 +- docs/adr/0048-cross-package-metadata-collision.md | 2 +- .../packages__objectql__src__bare-name-one-holder.ts.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/30803-bare-name-one-package-holder.md b/.changeset/30803-bare-name-one-package-holder.md index 3007466ab0a..35b1e4a5157 100644 --- a/.changeset/30803-bare-name-one-package-holder.md +++ b/.changeset/30803-bare-name-one-package-holder.md @@ -15,7 +15,7 @@ Skills-docs: none — `skills/**` does not teach that two packages may share a p - **Why.** A field's `picklist: NAME`, an import's `mappingName` and a dashboard's `datasetName` are resolved with no package. Measured on a booted showcase with two packages sharing one bare name per type, in both registration orders: the picklist served both packages' objects the first-registered package's options (the other package's own values refused `VALIDATION_FAILED`), the other package's import answered `400 MAPPING_TARGET_MISMATCH`, and a dataset query ran the first-registered package's dataset. Each answer flipped with registration order, and nothing warned. - **Before.** Two installed packages could each ship a picklist, mapping or dataset under one name, at boot or by a later install; every caller was served whichever registered first. - **Now.** After the cold boot (the engine plugin seals the registry at `kernel:bootstrapped`, once every package of the boot has registered), a package registration declaring such a name another installed package holds is refused ahead of every mutation, so it leaves no record behind. The envelope is ADR-0112's: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE`, exported) and `status: 422`; `install-local`'s inline import answers `422 PLUGIN_REGISTER_FAILED` with this message. The message lists every conflicting name, names both holders, and carries the rename that clears it: the incoming package's namespace prefixed to the name (`beta_status`). The thrown error carries `conflicts[]` with `{ type, name, incomingPackageId, existingHolder, rename }`. A third package declaring a name two packages already hold is refused naming both. -- **The cold boot.** A deployment whose installed packages already share such a name still boots: one warning line names every such name, both holders and the rename, and the first-registered holder keeps being served. Whether that boot should be refused instead is the maintainer's open call; the switch (`BARE_NAME_COLD_BOOT_MODE`, `'warn'`) sits in `bare-name-one-holder.ts`. +- **The cold boot.** A deployment whose installed packages already share such a name still boots: one warning line names every such name, both holders and the rename, and the first-registered holder keeps being served. The maintainer ruled this on 2026-11-06: the boot warns, and only a new holder is refused; the switch (`BARE_NAME_COLD_BOOT_MODE`, `'warn'`) sits in `bare-name-one-holder.ts`. - **Who moves.** An author whose package declares a picklist, mapping or dataset 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.** A field, import or dashboard that named the old name must name the new one; nothing rewrites stored references. - **Unchanged.** The same package registering its own name again (a reload, a re-install, an upgrade, including a package that came through the boot beside another holder). An environment item under a package's name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged, and the environment catalog is never a holder — it is the ADR-0005 overlay. A picklist extension of another package's list. Every other metadata type, which keeps ADR-0048 §3.4's coexistence (`page/home` in two packages, object-bound actions keyed `objectName:name`, jobs keyed `appId:jobName`). `OS_METADATA_COLLISION=warn` does not downgrade the refusal. A kernel that withholds the boot-phase hooks never seals, so its doors never refuse. diff --git a/docs/adr/0048-cross-package-metadata-collision.md b/docs/adr/0048-cross-package-metadata-collision.md index 64bc6c74eb6..38be1989b49 100644 --- a/docs/adr/0048-cross-package-metadata-collision.md +++ b/docs/adr/0048-cross-package-metadata-collision.md @@ -918,7 +918,7 @@ first phase. > are phase 2 (#30804, #30805); `defineStack`, the save door and the ADR-0129 > amendment are phase 3 (#30806). -### P1.1 Why §3.4's premise does not hold for these three types +### P1.1 Why §3.4's premise does not hold for the data family: `picklist`, `mapping`, `dataset` §3.4 rests on every caller carrying its package id. These three consumers carry none when they resolve the name. Measured on a booted showcase with two packages, 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 f35654d8d7a..d7bd544a8a2 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; a field's picklist, an import's mappingName and a dashboard's datasetName do not, so with two holders every caller is served the first-registered one. The maintainer's ruling C on #30798 (phase 1, ADR-0048 addendum 2026-11-06) narrows §3.4 for these types: one name, one PACKAGE holder. After the cold boot seals the registry (kernel:bootstrapped), a package declaring a name another installed package or a built-in holds is refused at the package door and the item seam, naming both holders and the namespace-prefixed rename. ⛔ Never judge the environment catalog — an environment item under a package's name is the ADR-0005 overlay — 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. Two 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 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. ⛔ Never judge the environment catalog — an environment item under a package's name is the ADR-0005 overlay — 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." }