diff --git a/.changeset/30803-bare-name-one-package-holder.md b/.changeset/30803-bare-name-one-package-holder.md new file mode 100644 index 00000000000..3007466ab0a --- /dev/null +++ b/.changeset/30803-bare-name-one-package-holder.md @@ -0,0 +1,22 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: a picklist, mapping or dataset name holds one package — once the cold boot has sealed the registry, a package declaring a name another installed package already holds is refused at install and registration, 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`) and item seam (`SchemaRegistry.registerItem`) refuse, after the cold boot, a package registration they accepted before: one whose picklist, mapping or dataset 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 a picklist, mapping or dataset 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. + + + +**BREAKING** accept-set narrowing at the package registration door: managed install (`install-local`, a post-start `manifest.register`, a hot reload) and package registration (`ObjectQL.registerApp` and every package-bound `registerItem`), for picklists, mappings and datasets. Maintainer ruling C on #30798, phase 1; ADR-0048 §3.4 narrowed (addendum 2026-11-06). + +- **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`. +- **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. + +**Measured producers.** On objectstack `c17719dd`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) carry 7 picklist, mapping and dataset declarations, and no name has more than one holder; the platform's own packages declare none. HotCRM `6961ab1` (five packages, one artifact) carries 5 picklists, 3 mappings and 2 datasets, and no name has more than one holder. The two sets were measured separately, and two names coincide across them: the datasets `task_metrics` (HotCRM `src/sales/datasets/` and `app-todo`) and `opportunity_metrics` (HotCRM and `app-crm`). No shipped composition installs HotCRM beside an example, so nothing shipped is refused; an environment that installs HotCRM beside `app-todo` or `app-crm` is a Who-moves case above. Deployed and marketplace packages NOT MEASURED. diff --git a/docs/adr/0048-cross-package-metadata-collision.md b/docs/adr/0048-cross-package-metadata-collision.md index a679e8609cf..b8092eaf4dd 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). +**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). --- @@ -266,6 +266,21 @@ 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 +> [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) Renaming a colliding namespace on install would require rewriting **every** @@ -869,3 +884,134 @@ for a package-bound registration that reaches the registry directly. Every packa registration reaches the package door before any in-memory registrar runs, so the boot of a conflicting composition — an artifact boot included — is refused in its 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 this addendum records** — #30798, answered by the maintainer +> 「同意 c」 (ruling record 6676670839, 2026-11-06). The ruled option's text, +> verbatim: +> +> > **C. Every name carries the namespace** — Extend ADR-0129's literal prefix to +> > every package-authored top-level metadata name (`crm_send_email`), except +> > names already derived from a prefixed object (fields, object extensions, +> > container views) or already namespaced (translations `_`); +> > enforced at `defineStack`/`os validate` and the save door; same-name clashes +> > become impossible by construction +> +> and its phasing, verbatim: "**Now:** extend the existing registration refusal +> (A's mechanism) to the silent types. It closes silent mis-resolution for +> packages already in the field. After phase 3 it remains only as the backstop +> for the cases C cannot reach: packages that declare no namespace, the shared +> `base`/`system`/`sys` namespaces, and third-party packages inside the +> deprecation window." Phase 1 is #30803; the lint, the templates and the skills +> 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 + +§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, +`alpha` and `beta`, each shipping one item per type under the same bare name, in +both registration orders (#30803, stage 1): + +- **`picklist`** — a field's `picklist: NAME` resolves the first-registered + package's list for both packages' objects: the second package's object is + served the other package's options, and its own values are refused at write + (`VALIDATION_FAILED`). +- **`mapping`** — an import's `mappingName` resolves the first-registered + package's mapping; the other package's import answers `400 + MAPPING_TARGET_MISMATCH`. +- **`dataset`** — a dashboard's `datasetName` query runs the first-registered + package's dataset. + +Each answer flips with registration order, and none warns. + +### P1.2 What is refused + +Once the cold boot has sealed the registry (P1.3), a package registration is +refused when a picklist, mapping or dataset it declares bears a name another +holder already holds — at managed install (the package door, +`SchemaRegistry.installPackage`, ahead of every mutation) and at package +registration (the item seam, `SchemaRegistry.registerItem`, for a package-bound +registration that reaches the registry directly). The holders: + +- **an installed package** — including a disabled one, which is still installed + and still holds its names; a nested plugin declares under its parent package; +- **a built-in** — none today for these types; the vocabulary keeps the holder so + a platform-held name is refused the way the security catalog refuses one, and, + as there, only the package door asks it. + +⛔ **Never the environment catalog** — the one difference from N.2. For these +types an item authored in this environment under a package's name is the ADR-0005 +overlay of that package's item, not a second holder; a registration with no +package is never judged, and the registry's bare slot is never read as a holder. + +The error is N.2's envelope (`422`, the §3.2 namespace gate's code), lists every +conflicting name in one refusal, names both holders, and carries the rename that +clears it: the incoming package's namespace prefixed to the name (`beta_status`), +the one naming rule ruling C ends at. A package with no namespace, or one in a +shared `base`/`system`/`sys` namespace, is refused without a rename. + +### P1.3 The cold boot + +Every package registers again at every boot, through the same two doors, so a +deployment that already holds a clash would stop booting if the doors refused +then. During a boot the doors therefore judge nothing. Once every package of the +boot has registered — `kernel:bootstrapped`, after the `kernel:ready` rehydrates — +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; +- **`'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 +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, +naming both. A kernel that withholds the boot-phase hooks never seals, and there +the doors never refuse. + +### P1.4 What stays as §3.4 has it + +- **Same-package re-registration** is one holder, not two. +- **Every type not listed** keeps §3.4's coexistence: two packages' `page/home` + still coexist, an object-bound action stays keyed `objectName:name`, a job stays + keyed `appId:jobName`, and the unmanaged import's own all-type refusal is + unchanged. +- **A picklist extension** (`picklistExtensions`) adds values to another + package's list by design; it holds no name. +- **A paired `.custom` package's declared override** (ADR-0146 D2) is a stored + row bound to its pair, never a package holder, so the refusal does not close + the override path. +- **No downgrade.** `OS_METADATA_COLLISION=warn` softens the §3.2 namespace gate + only. + +### 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. + +### 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 +`SchemaRegistry.sealBareNameHolders`, called by the engine plugin at +`kernel:bootstrapped` through `sealBareNameHoldersAtColdBoot`. diff --git a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts index 3b6048d68ee..a2088c73eb6 100644 --- a/packages/cli/test/package-install-local-jobs-pull.integration.test.ts +++ b/packages/cli/test/package-install-local-jobs-pull.integration.test.ts @@ -94,7 +94,14 @@ const LEGACY_APP_ID = 'com.example.pulllegacy'; const LEGACY_BOUND = 'pull_legacy_bound'; const LEGACY_UNBOUND = 'pull_legacy_unbound'; -const MAPPING_NAME = 'orders_pull'; +/** + * Each package's own mapping, named under its namespace: a mapping name is held + * by ONE package per deployment (ADR-0048 §3.4 narrowed, 2026-11-06), so these + * packages, installed into one kernel, cannot share one. `mappingTypo` is the + * one-letter typo of a package's own mapping name — a mapping it never declares. + */ +const mappingNameOf = (ns: string) => `${ns}_orders_pull`; +const mappingTypo = (ns: string) => `${ns}_orders_pul`; function orderObject(name: string) { return { @@ -105,10 +112,10 @@ function orderObject(name: string) { }; } -/** A mapping that pulls into `target`; `withSource: false` drops its `connectorSource` (an import-only mapping). */ -function mapping(target: string, withSource = true) { +/** The mapping `name`, pulling into `target`; `withSource: false` drops its `connectorSource` (an import-only mapping). */ +function mapping(name: string, target: string, withSource = true) { return { - name: MAPPING_NAME, + name, targetObject: target, fieldMapping: [{ source: 'id', target: 'external_id' }], mode: 'upsert', @@ -127,15 +134,15 @@ function pullArtifact(id: string, ns: string, opts: { job: unknown; withSource?: return { manifest: { id, namespace: ns, version: '0.1.0', type: 'app', name: ns }, objects: [orderObject(`${ns}_order`)], - mappings: [mapping(`${ns}_order`, opts.withSource ?? true)], + mappings: [mapping(mappingNameOf(ns), `${ns}_order`, opts.withSource ?? true)], jobs: [opts.job], }; } -const GOOD_ARTIFACT = pullArtifact(GOOD_APP_ID, 'pull_jobs', { job: pullJob(GOOD_JOB, MAPPING_NAME) }); -const MISSING_ARTIFACT = pullArtifact(MISSING_APP_ID, 'pull_missing', { job: pullJob(MISSING_JOB, 'orders_pul') }); -const NOSOURCE_ARTIFACT = pullArtifact(NOSOURCE_APP_ID, 'pull_nosource', { job: pullJob(NOSOURCE_JOB, MAPPING_NAME), withSource: false }); -const OFF_ARTIFACT = pullArtifact(OFF_APP_ID, 'pull_off', { job: pullJob(OFF_JOB, 'orders_pul', { enabled: false }) }); +const GOOD_ARTIFACT = pullArtifact(GOOD_APP_ID, 'pull_jobs', { job: pullJob(GOOD_JOB, mappingNameOf('pull_jobs')) }); +const MISSING_ARTIFACT = pullArtifact(MISSING_APP_ID, 'pull_missing', { job: pullJob(MISSING_JOB, mappingTypo('pull_missing')) }); +const NOSOURCE_ARTIFACT = pullArtifact(NOSOURCE_APP_ID, 'pull_nosource', { job: pullJob(NOSOURCE_JOB, mappingNameOf('pull_nosource')), withSource: false }); +const OFF_ARTIFACT = pullArtifact(OFF_APP_ID, 'pull_off', { job: pullJob(OFF_JOB, mappingTypo('pull_off'), { enabled: false }) }); /** What the install route persists — the compiled bundle, flattened — in an earlier build's layout. */ const LEGACY_ENTRY = { @@ -150,8 +157,8 @@ const LEGACY_ENTRY = { type: 'app', name: 'Pull Legacy', objects: [orderObject('pull_legacy_order')], - mappings: [mapping('pull_legacy_order')], - jobs: [pullJob(LEGACY_BOUND, MAPPING_NAME), pullJob(LEGACY_UNBOUND, 'orders_pul')], + mappings: [mapping(mappingNameOf('pull_legacy'), 'pull_legacy_order')], + jobs: [pullJob(LEGACY_BOUND, mappingNameOf('pull_legacy')), pullJob(LEGACY_UNBOUND, mappingTypo('pull_legacy'))], }, installedAt: '2026-01-01T00:00:00.000Z', installedBy: 'admin', @@ -415,7 +422,7 @@ describe('install-local refuses an enabled job whose pull does not bind', () => // The CLI names the code the runtime answered with. expect(run.output).toMatch(/Install failed \(422 VALIDATION_ERROR\)/); expect(run.output).toContain(MISSING_JOB); - expect(run.output).toContain("pull.mapping: this artifact declares no mapping 'orders_pul'"); + expect(run.output).toContain(`pull.mapping: this artifact declares no mapping '${mappingTypo('pull_missing')}'`); expect(run.output).toContain('os validate'); }); @@ -424,7 +431,7 @@ describe('install-local refuses an enabled job whose pull does not bind', () => expect(run.exit, `${run.output}${transcript('install')}`).toBe(1); expect(run.output).toMatch(/Install failed \(422 VALIDATION_ERROR\)/); expect(run.output).toContain(NOSOURCE_JOB); - expect(run.output).toContain(`mapping '${MAPPING_NAME}' declares no connectorSource`); + expect(run.output).toContain(`mapping '${mappingNameOf('pull_nosource')}' declares no connectorSource`); }); it('a refused package leaves nothing behind: not in the ledger, and its job not scheduled', () => { @@ -476,6 +483,6 @@ describe('rehydrate of an entry an earlier build installed, holding a pull job t const out = readings.output.restart ?? ''; const line = out.split('\n').find((l) => l.includes(LEGACY_UNBOUND) && l.includes('NOT scheduled')); expect(line, `no warn names the withheld pull job${transcript('restart')}`).toBeDefined(); - expect(line).toContain("pull.mapping: this artifact declares no mapping 'orders_pul'"); + expect(line).toContain(`pull.mapping: this artifact declares no mapping '${mappingTypo('pull_legacy')}'`); }); }); diff --git a/packages/objectql/src/bare-name-one-holder.ts b/packages/objectql/src/bare-name-one-holder.ts new file mode 100644 index 00000000000..38f1a2b2eb6 --- /dev/null +++ b/packages/objectql/src/bare-name-one-holder.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one PACKAGE holder — the bare-name rule for the metadata types + * whose consumers resolve a name without package context, and the vocabulary + * {@link SchemaRegistry}'s refusal of a second holder is written in. The + * 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). + * + * ## The rule + * + * A package whose item of a covered type bears a name another installed + * package (or the platform, as a built-in) already holds is refused at managed + * install and at package registration, and the refusal names both holders and + * the rename. Why these types: their consumers carry no package when they + * resolve the name, so with two holders every caller is served the same one — + * measured on a booted showcase with two packages sharing one bare name: + * + * - `picklist` — a field's `picklist: NAME` resolves the FIRST-registered + * package's list for every object, so the second package's object is served + * the other package's options and refuses its own values; + * - `mapping` — an import's `mappingName` resolves the first-registered + * 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. + * + * Every one of them flips with registration order, and none says so. + * + * ## 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); + * - **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. + * + * ⛔ **Never the environment catalog.** An item authored in this environment + * under a package's name is the ADR-0005 overlay — the sanctioned + * customization of that package's item — not a second holder. The registry's + * bare slot (every `sys_metadata` hydration and write-through) is never read + * here, and a registration with no package is never judged. + * + * ## The cold boot + * + * Every package registers again at every boot, through the same two doors. A + * deployment that already holds a clash — two installed packages that both + * ship `industry` — would stop booting if the doors refused then. So during a + * boot the doors judge nothing; once every package has registered (the + * kernel's `kernel:bootstrapped`, after the `kernel:ready` rehydrates), the + * 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. + * + * 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 naming both. + * + * ## What it deliberately does not judge + * + * - ⛔ The environment catalog (above). + * - ⛔ A downgrade. `OS_METADATA_COLLISION=warn` softens the ADR-0048 Phase 1 + * namespace gate only. + * - 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. + * - 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'; + +/** A metadata type the rule covers, as the registry keys it. */ +export type BareNameOneHolderType = 'picklist' | 'mapping' | 'dataset'; + +/** The covered types, in the order a refusal lists them. */ +export const BARE_NAME_ONE_HOLDER_TYPES: readonly BareNameOneHolderType[] = Object.freeze([ + 'picklist', + 'mapping', + 'dataset', +]); + +/** Is `type` one of the covered types (as the registry keys it)? */ +export function isBareNameOneHolderType(type: unknown): type is BareNameOneHolderType { + return typeof type === 'string' && (BARE_NAME_ONE_HOLDER_TYPES as readonly string[]).includes(type); +} + +/** + * The stack collection each type is declared under — the keys the engine's + * collection loop reads (`registerMetadataCollections`). + */ +const COLLECTION_KEY: Readonly> = Object.freeze({ + picklist: 'picklists', + mapping: 'mappings', + dataset: 'datasets', +}); + +/** + * 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. + */ +export const BUILT_IN_BARE_NAMES: Readonly>> = Object.freeze({ + picklist: new Set(), + mapping: new Set(), + dataset: new Set(), +}); + +/** + * 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, + * once per name, and boot; `'refuse'` — refuse the boot with the doors' + * envelope, as the security catalog's cold boot does. + */ +export type BareNameColdBootMode = 'warn' | 'refuse'; + +/** + * The switch. `'warn'` until the maintainer rules on the cold boot of a + * deployment that already holds a clash (#30803); the doors refuse a NEW + * holder after the boot in either mode. + */ +export const BARE_NAME_COLD_BOOT_MODE: BareNameColdBootMode = 'warn'; + +/** Who holds a covered name (module doc, "The holders"). */ +export type BareNameHolder = + | { readonly kind: 'package'; readonly packageId: string } + | { readonly kind: 'built-in' }; + +/** One `(type, name)` a package declares. */ +export interface DeclaredBareName { + readonly type: BareNameOneHolderType; + readonly name: string; +} + +/** + * The name an item is registered under — the engine seam's own derivation for + * a non-view collection (`resolveMetadataItemName`: `name`, else `id`). An + * item with neither is skipped there, so it claims nothing here. + */ +function itemName(item: unknown): string | undefined { + if (!item || typeof item !== 'object') return undefined; + const { name, id } = item as { name?: unknown; id?: unknown }; + if (typeof name === 'string' && name !== '') return name; + if (typeof id === 'string' && id !== '') return id; + return undefined; +} + +/** + * Every covered `(type, name)` a manifest declares, read from the SAME sources + * 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. + */ +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) { + 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 }); + } + } + }; + read(manifest); + const plugins = (manifest as { plugins?: unknown } | null | undefined)?.plugins; + if (Array.isArray(plugins)) for (const plugin of plugins) read(plugin); + return out; +} + +/** How a refusal names a covered type. */ +export function bareNameTypeLabel(type: BareNameOneHolderType): string { + return type; +} + +/** How a refusal names a holder. */ +export function describeBareNameHolder(holder: BareNameHolder): string { + return holder.kind === 'package' + ? `package "${holder.packageId}"` + : 'the platform, as a built-in name no package can declare'; +} + +/** A stable identity for de-duplicating holders. */ +export function bareNameHolderKey(holder: BareNameHolder): string { + return holder.kind === 'package' ? `package:${holder.packageId}` : holder.kind; +} + +/** + * The rename a refusal carries: the package's namespace prefixed to the name + * (`crm_industry`), the one naming rule ruling C ends at — or `undefined` when + * the package declares no namespace, or the name already carries it. + */ +export function suggestedBareNameRename(namespace: string | undefined, name: string): string | undefined { + if (!namespace) return undefined; + const prefix = `${namespace}_`; + return name.startsWith(prefix) ? undefined : `${prefix}${name}`; +} diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 6c84b72b332..a5e31f6caf9 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -65,6 +65,10 @@ 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"). +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 // re-derived, when a residual object's name is already owned. import { ObjectOwnershipConflictError, collectManifestBoundActions, collectManifestObjectNames, describeBoundActionCollision, describeUnboundActionTargets, FIND_BOUND_ACTION_COLLISIONS, type BoundActionCollision } from './registry.js'; @@ -1209,6 +1213,16 @@ export class ObjectQLPlugin implements Plugin { // would tell a brand-new deployment its gates are open moments after they // were closed. 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 + // 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. + const registry = this.ql?.registry; + if (registry) sealBareNameHoldersAtColdBoot(registry, BARE_NAME_COLD_BOOT_MODE, ctx.logger); // [ADR-0139 D5.2 / D5.3] A `controlled_by_parent` detail over a master // the engine composes no read over fails the boot, naming both and the // remedy. Judged HERE, not at `kernel:ready`: the object-scoped read diff --git a/packages/objectql/src/registry-bare-name-one-holder.test.ts b/packages/objectql/src/registry-bare-name-one-holder.test.ts new file mode 100644 index 00000000000..b931016463d --- /dev/null +++ b/packages/objectql/src/registry-bare-name-one-holder.test.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one package holder — the bare-name rule for a picklist, a mapping + * and a dataset, at the engine's package door, at the registry's item seam and + * at the cold boot (maintainer ruling C on #30798, phase 1; + * `bare-name-one-holder.ts` states the rule, the holders and the cold boot). + * + * Why these three: a booted two-package measurement (#30803) found each one's + * consumer resolving the bare 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. + * + * The door under test is the real one: the `manifest` service ObjectQLPlugin + * registers, which every package registration reaches through + * `ObjectQL.registerApp` → `SchemaRegistry.installPackage`. The cold boot is + * the real one too: a plugin registering packages in its `start()`, and the + * engine plugin sealing at `kernel:bootstrapped`. + * + * Every refusal is asserted by its ADR-0112 envelope (`code` + `status`), by + * the two holders it names and by the rename it carries; the prose is not + * pinned beyond naming them. + * + * Ablations: the same registrations BEFORE the seal coexist — the doors judge + * nothing until the cold boot has sealed — so every refusal below is the + * seal's. Controls keep the refusal as narrow as the ruling: a package's own + * name again, an environment registration over a package's name (the ADR-0005 + * overlay), a type the rule does not cover, a picklist extension. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { + SchemaRegistry, + NAMESPACE_CONFLICT_CODE, + sealBareNameHoldersAtColdBoot, + bareNameColdBootWarning, +} from './registry.js'; +import { BARE_NAME_COLD_BOOT_MODE, BUILT_IN_BARE_NAMES } 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 BareType = 'picklist' | 'mapping' | 'dataset'; +const TYPES: BareType[] = ['picklist', 'mapping', 'dataset']; +const COLLECTION: Record = { picklist: 'picklists', mapping: 'mappings', dataset: 'datasets' }; + +/** A body each type's schema accepts, marked with `tag`. */ +const body = (type: BareType, name: string, tag: string): Record => { + switch (type) { + case 'picklist': + return { name, label: `${tag} list`, options: [{ label: `${tag} one`, value: `${tag}_one` }] }; + case 'mapping': + return { name, label: `${tag} mapping`, sourceFormat: 'csv', targetObject: `${tag}_item`, fieldMapping: [{ source: 'Name', target: 'name' }] }; + case 'dataset': + return { + name, label: `${tag} dataset`, object: `${tag}_item`, + dimensions: [{ name: 'note', label: 'Note', field: 'note', type: 'string' }], + measures: [{ name: 'row_count', label: 'Count', aggregate: 'count' }], + }; + } +}; + +/** + * 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', ...extra }; + for (const type of TYPES) { + const names = decl[type]; + if (names) out[COLLECTION[type]] = names.map((n) => body(type, n, ns)); + } + return out; +}; + +const caught = async (fn: () => unknown): Promise => { + try { + await fn(); + return undefined; + } catch (e) { + return e as Refusal; + } +}; + +/** A plugin registering `manifests` in its `start()` — a package of the cold boot, before the seal. */ +const bootPackages = (manifests: unknown[]) => ({ + name: 'test.bare-name.boot-packages', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async () => {}, + start: async (ctx: { getService(name: string): T }) => { + const manifest = ctx.getService('manifest'); + for (const m of manifests) await manifest.register(m); + }, +}); + +const kernels: ObjectKernel[] = []; +const boot = async (atBoot: unknown[] = []) => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const warn = vi.spyOn((kernel as unknown as { logger: { warn: (...a: unknown[]) => void } }).logger, 'warn'); + await kernel.use(new ObjectQLPlugin()); + if (atBoot.length > 0) await kernel.use(bootPackages(atBoot) as never); + kernels.push(kernel); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const manifest = kernel.getService('manifest'); + const bareNameWarnings = () => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.startsWith('Bare-name conflict at boot')); + return { 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); + } +} + +it('the switch is "warn" until the maintainer rules on the cold boot (#30803)', () => { + expect(BARE_NAME_COLD_BOOT_MODE).toBe('warn'); +}); + +describe.each(TYPES)('%s — after the cold boot, a second package declaring a name another package holds', (type) => { + const name = `shared_${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}` }, + ]); + // Ahead of every mutation: no package record, no claim, no item. + expect(registry.getPackage('com.acme.beta')).toBeUndefined(); + expect(registry.getItem<{ label: string }>(type, name)?.label).toBe(`alpha ${type === 'picklist' ? 'list' : type}`); + }); + + 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'; + expect(registry.bareNameHoldersAreSealed).toBe(false); + 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(); + // …and once sealed, a third is refused at both doors. + 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(registry.getItem(type, name, 'com.acme.direct')).toMatchObject({ label: expect.stringContaining('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(); + // The bare slot, with no package — what `sys_metadata` hydration and the + // metadata write-through register, here over a package's name… + expect(await caught(() => registry.registerItem(type, body(type, name, 'env'), 'name'))).toBeUndefined(); + // …and an environment-only name is no holder a package is refused against. + 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}`); + // Still served: the first-registered holder, as before the rule. + expect(registry.getItem<{ label: string }>(type, name)?.label).toContain('alpha'); + }); + + 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('the cold boot\'s reading', () => { + it('names every clash once, the served holder as the existing one, sorted by type then name', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.beta', { dataset: ['d1'], picklist: ['p2', 'p1'] }) as never, 'code'); + registry.installPackage(pkg('com.acme.alpha', { dataset: ['d1'], picklist: ['p1', 'p2'], mapping: ['m1'] }) as never, 'code'); + const warn = vi.fn(); + const conflicts = sealBareNameHoldersAtColdBoot(registry, 'warn', { warn }); + expect(conflicts.map((c) => `${c.type}/${c.name}: ${c.incomingPackageId} vs ${(c.existingHolder as Holder).packageId}`)).toEqual([ + 'picklist/p1: com.acme.alpha vs com.acme.beta', + 'picklist/p2: com.acme.alpha vs com.acme.beta', + 'dataset/d1: com.acme.alpha vs com.acme.beta', + ]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toBe(bareNameColdBootWarning(conflicts)); + expect(registry.bareNameHoldersAreSealed).toBe(true); + }); + + it('a boot with no clash seals and says nothing', async () => { + const { registry, bareNameWarnings } = await boot([pkg('com.acme.alpha', { picklist: ['p1'] }), pkg('com.acme.beta', { picklist: ['p2'] })]); + expect(registry.bareNameHoldersAreSealed).toBe(true); + expect(bareNameWarnings()).toEqual([]); + }); + + it('an environment item under a package\'s name is no clash at the cold boot', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.alpha', { picklist: ['p1'] }) as never, 'code'); + registry.registerItem('picklist', body('picklist', 'p1', 'env'), 'name'); + expect(sealBareNameHoldersAtColdBoot(registry, 'refuse')).toEqual([]); + }); +}); + +describe('the refusal reports, and only refuses, what the ruling covers', () => { + it('every conflicting name is listed in one refusal, and a refused package claims none of its names', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', { picklist: ['p1'], mapping: ['m1'], dataset: ['d1'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.beta', { picklist: ['p1', 'free_list'], mapping: ['m1'], dataset: ['d1'] })); + expect(err?.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.conflicts!.map((c) => `${c.type}/${c.name}`)).toEqual(['picklist/p1', 'mapping/m1', 'dataset/d1']); + expect(await register(pkg('com.acme.gamma', { picklist: ['free_list'] }))).toBeUndefined(); + }); + + it('a package with no namespace is refused without a rename', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', { picklist: ['p1'] }))).toBeUndefined(); + const { namespace: _ns, ...bare } = pkg('com.acme.beta', { picklist: ['p1'] }); + const err = await register(bare); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }); + expect(err!.conflicts![0].rename).toBeUndefined(); + }); + + it('a built-in name is refused to a package at the package door, naming the platform', async () => { + const builtIns = BUILT_IN_BARE_NAMES.picklist as Set; + builtIns.add('platform_list'); + try { + const { register } = await boot(); + const err = await register(pkg('com.acme.alpha', { picklist: ['platform_list'] })); + expectRefusal(err, 'com.acme.alpha', { kind: 'built-in' }, 'alpha_platform_list'); + } finally { + builtIns.delete('platform_list'); + } + }); + + it('CONTROL — a type the rule does not cover, shared by two packages, still coexists (ADR-0048 §3.4)', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { pages: [{ name: 'home', label: 'alpha home', type: 'app' }] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', {}, { pages: [{ name: 'home', label: 'beta home', type: 'app' }] }))).toBeUndefined(); + expect(registry.getItem<{ label: string }>('page', 'home', 'com.acme.beta')?.label).toBe('beta home'); + }); + + it('CONTROL — a picklist extension of another package\'s list is not a second holder', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { picklist: ['p1'] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', {}, { + picklistExtensions: [{ extend: 'p1', options: [{ label: 'beta extra', value: 'beta_extra' }] }], + }))).toBeUndefined(); + expect(registry.getPackage('com.acme.beta')).toBeDefined(); + }); + + it('collisionPolicy "warn" does not downgrade it', async () => { + const registry = new SchemaRegistry({ collisionPolicy: 'warn' }); + registry.logLevel = 'silent'; + registry.sealBareNameHolders(); + registry.installPackage(pkg('com.acme.alpha', { mapping: ['m1'] }) as never, 'code'); + const err = await caught(() => registry.installPackage(pkg('com.acme.beta', { mapping: ['m1'] }) as never, 'code')); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, 'beta_m1'); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b8b80a6856c..08310c37bf6 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -72,6 +72,21 @@ import { securityCatalogTypeLabel, type SecurityCatalogHolder, } from './security-catalog-namespace.js'; +// One name, one package holder for the bare-name types whose consumers resolve +// without package context — the rule, the holders, the cold boot (module doc). +import { + BUILT_IN_BARE_NAMES, + bareNameHolderKey, + bareNameTypeLabel, + BARE_NAME_ONE_HOLDER_TYPES, + declaredBareNames, + describeBareNameHolder, + isBareNameOneHolderType, + suggestedBareNameRename, + type BareNameColdBootMode, + type BareNameHolder, + type BareNameOneHolderType, +} from './bare-name-one-holder.js'; // One default record page per object per deployment — the holders and the one // place a manifest's default record pages are read from (module doc). import { @@ -2751,6 +2766,137 @@ export class SecurityCatalogNameConflictError extends Error { } } +/** One second holder {@link BareNameConflictError} refused (or a cold boot reported). */ +export interface BareNameConflict { + /** The covered type, as the registry keys it. */ + 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). */ + readonly incomingPackageId: string; + /** Who already holds the name. */ + readonly existingHolder: BareNameHolder; + /** The rename that clears it — the incoming package's namespace prefixed — when it has one. */ + readonly rename?: string; +} + +/** One conflict's line, the same at every door. */ +function bareNameConflictLine(c: BareNameConflict): string { + return ( + `package "${c.incomingPackageId}" declares the ${bareNameTypeLabel(c.type)} "${c.name}", ` + + `already held by ${describeBareNameHolder(c.existingHolder)}` + + (c.rename ? ` — rename it to "${c.rename}" in "${c.incomingPackageId}"` : '') + ); +} + +/** + * 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.'; + +/** 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.` + ); +} + +/** + * Raised when a package registers a picklist, mapping or dataset 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). + * + * {@link SecurityCatalogNameConflictError}'s sibling, in its shape and for its + * reason: the ADR-0112 envelope (`code` + `status: 422`) so a wire door answers + * `422`, never the `500` fallback, and the code the namespace gate registers — + * the condition is the same one, "a name in a deployment-wide namespace is + * already taken", and so is the remedy: rename, or uninstall the other holder. + * + * Every conflict the registration carries is listed — `conflicts` — and each + * names both holders and, when the incoming package declares a namespace, the + * rename that clears it. The top-level fields repeat the first. + */ +export class BareNameConflictError extends Error { + readonly code = NAMESPACE_CONFLICT_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. */ + readonly httpStatus = 422; + /** Every second holder this registration would have created, in declaration order. */ + readonly conflicts: readonly BareNameConflict[]; + /** The first conflict's type. */ + readonly itemType: BareNameOneHolderType; + /** The first conflict's name. */ + readonly itemName: string; + /** The package whose registration this refusal stopped. */ + readonly incomingPackageId: string; + /** The first conflict's existing holder. */ + readonly existingHolder: BareNameHolder; + + constructor(conflicts: readonly BareNameConflict[], options: { door?: 'cold-boot' } = {}) { + const [first] = conflicts; + const remedy = first.existingHolder.kind === 'package' + ? `Rename the item in "${first.incomingPackageId}"${first.rename ? ` (to "${first.rename}")` : ''}, ` + + `rename it in "${first.existingHolder.packageId}", or uninstall one of the two packages.` + : `Rename the item in "${first.incomingPackageId}"${first.rename ? ` (to "${first.rename}")` : ''} — ` + + '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} ` + + `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.`, + ); + this.name = 'BareNameConflictError'; + this.conflicts = conflicts; + this.itemType = first.type; + this.itemName = first.name; + this.incomingPackageId = first.incomingPackageId; + this.existingHolder = first.existingHolder; + } +} + +/** + * The cold boot's half of the bare-name rule (`bare-name-one-holder.ts`, "The + * cold boot"): seal the registry — from here on both doors refuse a new holder + * at once — and act on every name two installed packages already hold, by + * `mode`: `'warn'` logs {@link bareNameColdBootWarning} once and returns the + * conflicts; `'refuse'` throws {@link BareNameConflictError} with + * `door: 'cold-boot'`. The engine plugin calls it at `kernel:bootstrapped`, + * once every package of the boot has registered. + * + * @throws {BareNameConflictError} under `'refuse'`, when any name is held twice. + */ +export function sealBareNameHoldersAtColdBoot( + registry: SchemaRegistry, + mode: BareNameColdBootMode, + logger?: { warn(message: string, meta?: unknown): void }, +): BareNameConflict[] { + const conflicts = registry.sealBareNameHolders(); + if (conflicts.length === 0) return conflicts; + if (mode === 'refuse') throw new BareNameConflictError(conflicts, { door: 'cold-boot' }); + logger?.warn(bareNameColdBootWarning(conflicts), { + conflicts: conflicts.map((c) => ({ + type: c.type, + name: c.name, + holders: [c.existingHolder.kind === 'package' ? c.existingHolder.packageId : c.existingHolder.kind, c.incomingPackageId], + ...(c.rename ? { rename: c.rename } : {}), + })), + }); + return conflicts; +} + /** One second default record page {@link DefaultRecordPageConflictError} refused. */ export interface DefaultRecordPageConflict { /** The object both pages are the default record page of. */ @@ -3909,6 +4055,23 @@ export class SchemaRegistry { }); } + /** Record who holds one bare name (`bare-name-one-holder.ts`) before its first write. */ + private noteBareNameClaim(type: BareNameOneHolderType, name: string): void { + this.restorePoint?.note(`bareNameClaim\u0000${type}\u0000${name}`, () => { + const saved = this.bareNameClaims.get(type)?.get(name); + const copy = saved ? new Set(saved) : undefined; + return () => { + let byName = this.bareNameClaims.get(type); + if (copy !== undefined) { + if (!byName) this.bareNameClaims.set(type, byName = new Map()); + byName.set(name, copy); + } else { + byName?.delete(name); + } + }; + }); + } + /** * Record the default record page claims before `packageId`'s first rewrite * of them ({@link recordDefaultRecordPageClaims} drops all of the package's @@ -4416,6 +4579,199 @@ export class SchemaRegistry { return [...holders]; } + // ========================================== + // Bare names — one name, one package holder (`bare-name-one-holder.ts`) + // ========================================== + + /** + * Covered type → name → the installed packages that declared it, in install + * order — recorded by {@link installPackage}, forgotten by + * {@link uninstallPackage}, for the reason {@link securityCatalogClaims} is: + * the package door registers no item, so the claim is how it remembers a + * declared name. A SET, unlike the security catalog's single claimant: a + * cold boot registers every package before anything is judged, so two + * packages that both declare a name both hold it until one is renamed or + * uninstalled. + */ + private bareNameClaims = new Map>>(); + + /** + * Set once, by {@link sealBareNameHolders} at the end of the cold boot. Until + * then the doors judge nothing (module doc, "The cold boot"); from then on + * both refuse a new holder. + */ + private bareNameHoldersSealed = false; + + /** Has the cold boot sealed the bare-name holders (the doors now refuse)? */ + get bareNameHoldersAreSealed(): boolean { + return this.bareNameHoldersSealed; + } + + /** + * 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. + */ + 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)); + } + for (const claimant of this.bareNameClaims.get(type)?.get(name) ?? []) holders.add(claimant); + return [...holders]; + } + + /** + * The rename a refusal carries for `name` in `packageId` — its namespace + * prefixed ({@link suggestedBareNameRename}) — read from the manifest the + * door holds, else from the installed record. None for a shareable platform + * namespace (`base` / `system` / `sys`): it names no one package. + */ + private bareNameRenameFor(packageId: string, name: string, namespace?: string): string | undefined { + const ns = namespace ?? this.getPackage(packageId)?.manifest?.namespace; + if (!ns || isShareableNamespace(ns)) return undefined; + return suggestedBareNameRename(ns, name); + } + + /** + * Every holder `incomingPackageId` would join by holding `(type, name)` — + * the ONE predicate both doors ask ({@link refuseBareNameConflicts} at the + * package door, the item seam in {@link registerItem}). Empty before the + * seal, and empty when `incomingPackageId` already holds the name: a reload, + * a re-install or an upgrade of a package that came through the boot beside + * another holder is not a new holder (module doc, "The cold boot"). A + * built-in name is read only when `builtIns` (the package door), as the + * security catalog reads its own. + */ + private bareNameHoldersOtherThan( + type: BareNameOneHolderType, + name: string, + incomingPackageId: string, + opts: { builtIns: boolean }, + ): BareNameHolder[] { + if (!this.bareNameHoldersSealed) return []; + const packages = this.bareNamePackageHolders(type, name); + if (packages.includes(incomingPackageId)) return []; + const found = new Map(); + if (opts.builtIns && BUILT_IN_BARE_NAMES[type].has(name)) found.set(bareNameHolderKey({ kind: 'built-in' }), { kind: 'built-in' }); + for (const packageId of packages) { + const holder: BareNameHolder = { kind: 'package', packageId }; + found.set(bareNameHolderKey(holder), holder); + } + return [...found.values()]; + } + + /** + * 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, + * 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 + * package with no identity not judged. + * + * @throws {BareNameConflictError} ADR-0112 envelope, naming both holders of + * each conflicting name and the rename. + */ + private refuseBareNameConflicts(manifest: ObjectStackManifest, selfId: string | undefined): void { + if (selfId === undefined || !this.bareNameHoldersSealed) return; + const conflicts: BareNameConflict[] = []; + const seen = new Set(); + for (const { type, name } of declaredBareNames(manifest)) { + // One package declaring one name twice is one holder. + const key = `${type}|${name}`; + if (seen.has(key)) continue; + seen.add(key); + for (const existingHolder of this.bareNameHoldersOtherThan(type, name, selfId, { builtIns: true })) { + conflicts.push({ + type, + name, + incomingPackageId: selfId, + existingHolder, + rename: this.bareNameRenameFor(selfId, name, manifest.namespace), + }); + } + } + if (conflicts.length > 0) throw new BareNameConflictError(conflicts); + } + + /** + * Record `packageId`'s bare-name claims — additive, like + * {@link recordSecurityCatalogClaims} and for its reasons; released by + * {@link uninstallPackage}. Recorded before the seal too: the cold boot reads + * them. + */ + private recordBareNameClaims(packageId: string, manifest: ObjectStackManifest): void { + for (const { type, name } of declaredBareNames(manifest)) { + let byName = this.bareNameClaims.get(type); + if (!byName) this.bareNameClaims.set(type, byName = new Map()); + this.noteBareNameClaim(type, name); + let claimants = byName.get(name); + if (!claimants) byName.set(name, claimants = new Set()); + claimants.add(packageId); + } + } + + /** Drop every bare-name claim `packageId` holds. */ + private forgetBareNameClaims(packageId: string): void { + for (const byName of this.bareNameClaims.values()) { + for (const [name, claimants] of byName) { + claimants.delete(packageId); + if (claimants.size === 0) byName.delete(name); + } + } + } + + /** + * 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 + * {@link sealBareNameHoldersAtColdBoot}'s mode. + */ + sealBareNameHolders(): BareNameConflict[] { + this.bareNameHoldersSealed = true; + 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)); + } + for (const name of this.bareNameClaims.get(type)?.keys() ?? []) candidates.add(name); + for (const name of [...candidates].sort()) { + if (name === '') continue; + const packages = this.bareNamePackageHolders(type, name); + if (BUILT_IN_BARE_NAMES[type].has(name)) { + for (const packageId of packages) { + conflicts.push({ + type, name, incomingPackageId: packageId, existingHolder: { kind: 'built-in' }, + rename: this.bareNameRenameFor(packageId, name), + }); + } + continue; + } + const [served, ...others] = packages; + for (const packageId of others) { + conflicts.push({ + type, name, incomingPackageId: packageId, existingHolder: { kind: 'package', packageId: served }, + rename: this.bareNameRenameFor(packageId, name), + }); + } + } + } + return conflicts; + } + // ========================================== // Object Registration (Ownership Model) // ========================================== @@ -7179,6 +7535,26 @@ export class SchemaRegistry { } } + // One name, one package holder for a picklist, a mapping or a dataset — + // the item seam (`bare-name-one-holder.ts`; ADR-0048 §3.4 narrowed). Once + // the cold boot has sealed the holders, a package registering one under a + // name another package holds — and that it does not hold itself — is + // refused BEFORE anything below stamps or stores it. The package door has + // already judged what a manifest declares; this is the seam every OTHER + // package-bound registration reaches (a nested plugin's collections, a + // 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)) { + const holders = this.bareNameHoldersOtherThan(type, baseName, packageId, { builtIns: false }); + if (holders.length > 0) { + const rename = this.bareNameRenameFor(packageId, baseName); + throw new BareNameConflictError( + holders.map((existingHolder) => ({ type, name: baseName, incomingPackageId: packageId, existingHolder, rename })), + ); + } + } + // One default record page per object per deployment — the item seam // (maintainer ruling A on objectstack#23015; `default-record-page.ts`): a // package registering a page as the default record page of an object @@ -7305,7 +7681,11 @@ export class SchemaRegistry { // ⚠️ Except for the three security catalog types, refused at the top of this // method: an assignment names a position or a permission set with no // package context, so there a second holder is an ambiguity no caller can - // resolve (maintainer ruling Q4 = A on #15196). + // resolve (maintainer ruling Q4 = A on #15196). ⚠️ And except for the + // bare-name types, refused (once the cold boot has sealed) at the top of + // this method too: a field's picklist, an import's mapping and a dashboard's + // dataset are resolved by name with no package (ruling C on #30798; + // `bare-name-one-holder.ts`). // // Same-package re-registration still overwrites (idempotent reload), and a // runtime/DB overlay over a packaged item is the sanctioned ADR-0005 path, @@ -8203,6 +8583,16 @@ export class SchemaRegistry { // exactly as two strangers do. this.refuseDefaultRecordPageConflicts(manifest, selfId); + // One name, one package holder for a picklist, a mapping or a dataset — + // the names their consumers resolve with no package (ADR-0048 §3.4 + // narrowed; maintainer ruling C on #30798, phase 1; + // `bare-name-one-holder.ts`). Also ahead of every mutation, with no + // co-owner exemption and no `collisionPolicy` downgrade, for the security + // catalog's reasons. Judged only once the cold boot has sealed the holders: + // during a boot every package registers again, and a deployment that + // already holds a clash is the cold boot's to report. + this.refuseBareNameConflicts(manifest, selfId); + const now = new Date().toISOString(); // [#18877] 「缺省 = 保持,有旗 = 设置」 — an install that was not asked to move @@ -8289,6 +8679,8 @@ export class SchemaRegistry { if (selfId !== undefined) this.recordSecurityCatalogClaims(selfId, manifest); // …and the default record pages it declares (see `defaultRecordPageClaims`). if (selfId !== undefined) this.recordDefaultRecordPageClaims(selfId, manifest); + // …and its picklist, mapping and dataset names (see `bareNameClaims`). + if (selfId !== undefined) this.recordBareNameClaims(selfId, manifest); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); return pkg; } @@ -8456,6 +8848,8 @@ export class SchemaRegistry { // …and the default record pages it declared, so the next package may // declare a default for those objects. this.forgetDefaultRecordPageClaims(id); + // …and its picklist, mapping and dataset names. + this.forgetBareNameClaims(id); // [#30581] …and the navigation entries it put into other packages' apps: // with no row left, the fold's disabled check cannot withhold them. this.setPackageNavContributions(id, []); diff --git a/packages/objectql/src/security-catalog-namespace.ts b/packages/objectql/src/security-catalog-namespace.ts index 446185286c0..545ed9771a7 100644 --- a/packages/objectql/src/security-catalog-namespace.ts +++ b/packages/objectql/src/security-catalog-namespace.ts @@ -22,7 +22,10 @@ * the capability to the FIRST-registered package and the position to the * LAST-registered one. For every other metadata type ADR-0048 §3.4's * coexistence stands: a caller there carries its own package, and package-scoped - * resolution disambiguates. This module is that narrowing, and only it. + * resolution disambiguates. This module is that narrowing, and only it — except + * the types whose consumers resolve a name without package context, held to one + * PACKAGE holder by its sibling `bare-name-one-holder.ts` (ruling C on #30798; + * there the environment catalog is never a holder). * * ## The holders * diff --git a/packages/qa/dogfood/test/bare-name-one-holder.dogfood.test.ts b/packages/qa/dogfood/test/bare-name-one-holder.dogfood.test.ts new file mode 100644 index 00000000000..282040408f4 --- /dev/null +++ b/packages/qa/dogfood/test/bare-name-one-holder.dogfood.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#30803] One name, one package holder for a picklist, a mapping and a +// dataset (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 registry's own pins (`registry-bare-name-one-holder.test.ts`) drive the +// engine's package door and item seam in-process. This file drives the rule at +// the doors a deployment reaches: +// +// - the cold boot of a deployment that already holds a clash — two +// `AppPlugin` packages, `alpha` and `beta`, shipping one picklist, mapping +// and dataset under the same bare names — boots, and logs ONE warning line +// naming both holders and the rename for every name (the switch's default, +// `'warn'`, until the maintainer rules); +// - `POST /marketplace/install-local` of a third package declaring one of +// those names after the boot is 422 PLUGIN_REGISTER_FAILED, naming both +// holders and the rename, and installs nothing; +// - the same door refusing a package that redeclares the showcase's own +// mapping and dataset names, naming the showcase; +// - CONTROL: a package declaring fresh names installs. + +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_MAPPING = 'showcase_inquiry_feed'; +const SHOWCASE_DATASET = 'showcase_task_metrics'; +const PICKLIST = 'qa_shared_status'; +const MAPPING = 'qa_shared_mapping'; +const DATASET = 'qa_shared_dataset'; + +const picklist = (p: string, name = PICKLIST) => ({ + name, label: `${p} status`, options: [{ label: `${p} one`, value: `${p}_one` }], +}); +const mapping = (p: string, name = MAPPING, targetObject = `${p}_item`) => ({ + name, label: `${p} mapping`, sourceFormat: 'csv', targetObject, fieldMapping: [{ source: 'Name', target: 'name' }], +}); +const dataset = (p: string, name = DATASET, object = `${p}_item`) => ({ + name, label: `${p} dataset`, object, + dimensions: [{ name: 'note', label: `${p} note`, field: 'note', type: 'string' }], + measures: [{ name: 'row_count', label: `${p} count`, aggregate: 'count' }], +}); + +/** 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: [ + { + name: `${p}_item`, + label: `${p} item`, + sharingModel: 'public_read_write', + fields: { + name: { type: 'text', label: 'Name' }, + note: { type: 'text', label: 'Note' }, + status: { type: 'select', label: 'Status', picklist: PICKLIST }, + }, + }, + ], + picklists: [picklist(p)], + mappings: [mapping(p)], + datasets: [dataset(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}` }, + ...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 picklist, mapping and dataset 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-')); + 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 holders 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'); + for (const name of [PICKLIST, MAPPING, DATASET]) { + expect(line, `names ${name}`).toContain(`"${name}"`); + expect(line, `and its rename`).toContain(`"beta_${name}"`); + } + expect(line).toContain('com.qa.bare.alpha'); + expect(line).toContain('com.qa.bare.beta'); + 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 picklist name is 422 PLUGIN_REGISTER_FAILED naming both holders and the rename; nothing is installed', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('gamma', { picklists: [picklist('gamma')] })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`"${PICKLIST}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain('com.qa.bare.beta'); + expect(message).toContain(`"gamma_${PICKLIST}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + }); + + it('install-local of a package redeclaring the showcase\'s mapping and dataset names is refused, naming the showcase', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('delta', { + mappings: [mapping('delta', SHOWCASE_MAPPING, 'showcase_task')], + datasets: [dataset('delta', SHOWCASE_DATASET, 'showcase_task')], + })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`"${SHOWCASE_MAPPING}"`); + expect(message).toContain(`"${SHOWCASE_DATASET}"`); + expect(message).toContain(SHOWCASE_PKG); + expect(message).toContain(`"delta_${SHOWCASE_MAPPING}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + }); + + it('CONTROL — install-local of a package declaring fresh names installs', async () => { + const { status, body } = await installLocal(inline('epsilon', { + picklists: [picklist('epsilon', 'epsilon_status')], + mappings: [mapping('epsilon', 'epsilon_mapping', 'showcase_task')], + datasets: [dataset('epsilon', 'epsilon_dataset', 'showcase_task')], + })); + 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 new file mode 100644 index 00000000000..f35654d8d7a --- /dev/null +++ b/scripts/adr-anchors/packages__objectql__src__bare-name-one-holder.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/objectql/src/bare-name-one-holder.ts", + "adrs": [ + "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." +}