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/.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..38be1989b49 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, @@ -914,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, @@ -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..b7052b57831 --- /dev/null +++ b/packages/objectql/src/registry-bare-name-one-holder-request-family.test.ts @@ -0,0 +1,417 @@ +// 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 { HookHandler, 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): Record => ({ ...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; + } +}; + +/** 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 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'); + }); +}); 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." }