From 664f51c59fa05a7af186bf03ec9fdea9af8dfd78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 05:28:36 +0000 Subject: [PATCH 1/3] feat(objectql)!: a flow, a webhook, a connector and an email template hold one name per package (bare-name phase 1, 3/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow family of maintainer ruling C on #30798, phase 1: `flow`, `webhook`, `connector` and `email_template` join the bare-name registry types. Measured on a booted showcase with two packages sharing one name per type, in both orders: the automation engine arms one flow per name (lexicographically-first package, with a warning) while `/meta/flow/NAME` serves the first-registered declaration; the webhook dispatcher's declared reader keeps the first-registered webhook and never subscribes the other; `/meta/connector/NAME` serves the first-registered descriptor (two provider-bound instances already refuse the boot, ADR-0097); a send's `template` resolves the first-registered email template. A third package installed after the boot under any of those names installed (200) and was shadowed. - `bare-name-one-holder.ts`: the four types, in family order, with their collection keys, labels, consumer clauses and nouns; the holder reading excludes a type's OVERRIDABLE BASELINE (the email service's built-in templates, which an app's same-named template overrides by contract, ADR-0131 D6): that package neither holds a template name nor is refused one. - `registry.ts`: the holder reading and the cold-boot seal strip a bundled type's registry-key member (`pkg:name@locale`), so an email template's NAME holds its bundle; the item seam skips the baseline. - New `registry-bare-name-one-holder-flow-family.test.ts` (52 cells) and the booted dogfood pin `bare-name-one-holder-flow-family.dogfood.test.ts`. - ADR-0048: header, §3.4 note, P1.5, P1.6 and the new P1.8; the anchor's invariant names the third family and the baseline; one changeset. Claude-Session: https://claude.ai/code/session_017xRBcTJTE7kSbcMoJQ4Txf Co-authored-by: Claude --- .changeset/30803-bare-name-flow-family.md | 23 + .../0048-cross-package-metadata-collision.md | 119 +++-- packages/objectql/src/bare-name-one-holder.ts | 127 +++++- packages/objectql/src/plugin.ts | 5 +- ...y-bare-name-one-holder-flow-family.test.ts | 429 ++++++++++++++++++ packages/objectql/src/registry.ts | 56 ++- ...ame-one-holder-flow-family.dogfood.test.ts | 239 ++++++++++ ...bjectql__src__bare-name-one-holder.ts.json | 2 +- 8 files changed, 935 insertions(+), 65 deletions(-) create mode 100644 .changeset/30803-bare-name-flow-family.md create mode 100644 packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts create mode 100644 packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts diff --git a/.changeset/30803-bare-name-flow-family.md b/.changeset/30803-bare-name-flow-family.md new file mode 100644 index 00000000000..3fb88461b7e --- /dev/null +++ b/.changeset/30803-bare-name-flow-family.md @@ -0,0 +1,23 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: a flow, a webhook, a connector and an email template hold one name per package — once the cold boot has sealed the registry, a package declaring one under a name another installed package holds is refused at install and registration, naming both holders and the rename; a boot that already holds such a clash warns + +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 flow, webhook, connector or email template 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 flow, webhook, connector or email template 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.8). + + + +**BREAKING** accept-set narrowing at the package registration door, the third family of maintainer ruling C on #30798, phase 1 (the first was picklists, mappings and datasets; the second APIs, object-less actions and functions): managed install (`install-local`, a post-start `manifest.register`, a hot reload) and package registration (`ObjectQL.registerApp` and every package-bound `registerItem`), for flows, webhooks, connectors and email templates. ADR-0048 §3.4 narrowed (addendum 2026-11-06, P1.8). + +- **Why.** Measured on a booted showcase with two packages sharing one bare name per type, in both registration orders: the automation engine armed ONE flow per name — the lexicographically-first package's, whichever registered first, with a warning — while `/meta/flow/NAME` served the first-registered package's declaration; the webhook dispatcher's declared reader kept the first-registered package's webhook and never subscribed the other; `/meta/connector/NAME` (the connector picker's read) served the first-registered package's descriptor, and two provider-bound instances under one name already refused the boot (ADR-0097); a send's `template: NAME` resolved the first-registered package's email template. Each answer flipped with registration order; only the flow precedence warned, and it armed one of them all the same. A third package installed after the boot under any of those names installed (`200`) and was shadowed. +- **Before.** Two installed packages could each ship a flow, webhook, connector or email template 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), a package registration declaring a flow, webhook, connector or email template under a name another installed package holds is refused ahead of every mutation, so it leaves no record behind. The envelope is the first family's: `code: 'NAMESPACE_CONFLICT'`, `status: 422`, `install-local` answering `422 PLUGIN_REGISTER_FAILED`, every conflicting name in one refusal, both holders named, the rename (the incoming package's namespace prefixed) carried, `conflicts[]` structured. An email template's name holds its whole `(name, locale)` bundle: two members of one package's bundle are one holder, and another package's member of that name — another locale included — is refused on the name. +- **The overridable baseline.** The platform's built-in auth templates are declared under the email service's own package (`com.objectstack.service.email`), and an app's same-named template overrides them by contract (ADR-0131 D6). That package is an overridable baseline for `email_template` (`OVERRIDABLE_BASELINE_PACKAGES`, exported): it neither holds a template name nor is refused one, so an app overriding `auth.password_reset` still installs; the first OTHER package to take a built-in name holds it, and a second is refused naming the first. +- **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 flow precedence warning keeps naming both packages and arming one. 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'`). +- **Who moves.** An author whose package declares a flow, webhook, connector or email template 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 flow, trigger, picker binding, activation row or send that named the old name must name the new one; nothing rewrites stored references. +- **Unchanged.** The same package registering its own name again. An environment item under a package's name (never judged, never a holder: the ADR-0005 overlay), a paired `.custom` package's declared override of a base flow among them (ADR-0146 D9: a stored row). The email service's built-in templates (above). The automation service's own duplicate refusal of two provider-bound connector instances in one boot (ADR-0097). A job keyed `appId:jobName`, an action bound to an object, every type the rule does not list (ADR-0048 §3.4 as written). `OS_METADATA_COLLISION=warn` does not downgrade the refusal. + +**Measured producers.** On objectstack `24bc2593`, the importable examples carry 33 flows (`app-showcase` 32, `app-crm` 1), 1 webhook, 4 connectors and 1 email template (all `app-showcase`; `app-multi-package` none), and no name has more than one holder; `app-todo`'s three flows are `todo_`-prefixed. The platform's own packages declare one email template bundle each of the four built-in auth templates under the email service's package — the baseline — and no flow, webhook or connector. HotCRM is not checked out in this container and is NOT MEASURED here; its five packages share one namespace family, so a same-name clash among them would be 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 754400e2a96..ac7d06e0dbe 100644 --- a/docs/adr/0048-cross-package-metadata-collision.md +++ b/docs/adr/0048-cross-package-metadata-collision.md @@ -5,7 +5,7 @@ **Builds on**: [ADR-0003](./0003-package-as-first-class-citizen.md) (package as first-class citizen), [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay precedence), [ADR-0008](./0008-metadata-repository-and-change-log.md) (metadata repository, `MetaRef` identity), [ADR-0010](./0010-metadata-protection-model.md) (package provenance / `_packageId` stamping) **Consumers**: `@objectstack/objectql` (`SchemaRegistry.registerItem`, `ObjectQL.registerApp`, install path), `@objectstack/console` / `objectui` (routing + metadata resolution), package authors, CLI/CI install path, the app marketplace registry. **Surfaced by**: ADR-0046 review (doc naming) → generalised to all bare-named metadata → re-examined through the app-marketplace install lens. -**Addenda**: [2026-08-08 — publish-time / marketplace namespace exclusivity](#addendum-2026-08-08-publish-time--marketplace-namespace-exclusivity) (**Accepted** 2026-08-08 — maintainer ruling on #1825, [acceptance comment 5226411687](https://github.com/objectstack-ai/objectstack/issues/1825#issuecomment-5226411687), landed by PR #6671; additive to §3.2, supersedes nothing); [2026-11-06 — bare names resolved without a package hold one package](#addendum-2026-11-06-bare-names-resolved-without-a-package-hold-one-package--34-narrowed) (**Accepted**, ruling C on #30798, phase 1; narrows §3.4 for picklists, mappings, datasets, APIs, object-less actions and engine functions, supersedes nothing). +**Addenda**: [2026-08-08 — publish-time / marketplace namespace exclusivity](#addendum-2026-08-08-publish-time--marketplace-namespace-exclusivity) (**Accepted** 2026-08-08 — maintainer ruling on #1825, [acceptance comment 5226411687](https://github.com/objectstack-ai/objectstack/issues/1825#issuecomment-5226411687), landed by PR #6671; additive to §3.2, supersedes nothing); [2026-11-06 — bare names resolved without a package hold one package](#addendum-2026-11-06-bare-names-resolved-without-a-package-hold-one-package--34-narrowed) (**Accepted**, ruling C on #30798, phase 1; narrows §3.4 for picklists, mappings, datasets, APIs, object-less actions, engine functions, flows, webhooks, connectors and email templates, supersedes nothing). --- @@ -267,22 +267,28 @@ package — stays covered by the `naming/namespace-prefix` lint in `os lint`. > [Addendum (2026-10-08)](#addendum-2026-10-08-the-security-catalog-holds-one-name-per-deployment--34-narrowed). > **Narrowed (2026-11-06) — picklists, mappings, datasets, APIs, object-less -> actions 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 +> actions, engine functions, flows, webhooks, connectors and email templates are +> out of §3.4.** Their consumers resolve the name with no package — a field's +> `picklist`, an import's `mappingName`, a dashboard's `datasetName`, the +> endpoint matcher's one API per name, `/actions/global/NAME`, a flow `script` +> node's function, the automation engine's one armed flow per name, the +> dispatcher's one declared webhook per name, the connector picker's one +> connector per name, a send's `template` — so with two holders every caller is +> served one of them, chosen by registration order, measured on a booted +> two-package deployment (#30803). Once the cold boot has sealed the registry, a +> package whose picklist, mapping, dataset, API, object-less action, flow, +> webhook, connector or email template bears a name another installed package +> holds is refused at managed install and at package registration (a built-in's +> name, at managed install), and a package whose handlers bring a function +> another package holds is refused at the hook binder's bind, each naming both +> holders and the rename. ⛔ The environment catalog is never a holder: an +> environment item under a package's name is the ADR-0005 overlay. ⛔ Nor is the +> email service's built-in template bundle: an app's same-named template +> overrides it by contract (ADR-0131 D6), so that package holds no template name +> and is refused none. The remaining types the measurement found resolving +> without a package context — `agent` and `skill` — are to follow in the series' +> last change. Every other type, an action bound to an object among them, keeps +> §3.4 as written. See > [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) @@ -911,8 +917,8 @@ first phase. > **Status of this addendum: Accepted** — it records phase 1 of a maintainer > ruling. It **narrows §3.4 for more metadata types, one family per change (P1.2, -> P1.7), and supersedes no other text**: §§1–6, the 2026-08-08 addendum and the -> 2026-10-08 addendum stand verbatim. +> P1.7, P1.8), 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, @@ -1020,18 +1026,17 @@ the doors never refuse. ### P1.5 To follow -The same measurement found these types resolving without a package context too: -`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. +The same measurement found `agent` and `skill` resolving without a package +context too. They join P1.2's list in the series' last change under #30803, with +their own pins, narrowing §3.4 the same way (the maintainer kept all of them in +#30803 on 2026-11-06). `tool` and `rag_pipeline` share a reader with `agent` and +were not measured. The request family that was listed here is P1.7; the flow +family — `flow`, `webhook`, `connector` and `email_template` — is P1.8. ### P1.6 Where it is implemented -`@objectstack/objectql`: the rule, the holders and the switch in +`@objectstack/objectql`: the rule, the holders, the overridable baseline +(`OVERRIDABLE_BASELINE_PACKAGES`, P1.8) and the switch in `bare-name-one-holder.ts`; the package door in `SchemaRegistry.installPackage` and the item seam in `SchemaRegistry.registerItem`; the function door in `bindHooksToEngine`, through `SchemaRegistry.claimBareNameFunctions`; the seal in @@ -1076,3 +1081,61 @@ 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. + +### P1.8 The flow family: `flow`, `webhook`, `connector`, `email_template` + +Measured with P1.1's two packages, in both orders (#30803, PR 3): + +- **`flow`** — the automation engine keys its flow map by the bare name and + arms ONE flow per name: the lexicographically-first package's + (`resolveFlowPrecedence`), whichever registered first, with a warning naming + both — the one type of the nine that warns; `/meta/flow/NAME` serves the + FIRST-registered package's declaration, so in one order the body the designer + opens and the body that runs come from two packages. A third package installed + after the boot under that name installs (`200`) and is shadowed. +- **`webhook`** — the dispatcher's declared-webhook reader keeps the + FIRST-registered package's declaration per name and never subscribes the + other, silently; `/meta/webhook/NAME` serves the same one. +- **`connector`** — `/meta/connector/NAME` (the by-name door the designer's + connector picker reads) serves the FIRST-registered package's descriptor, + silently. Two provider-bound instances under one name at boot are already + refused by the automation service (ADR-0097's duplicate, fatal at boot); after + the boot the second is logged at `error` and skipped, its install answering + `200`. +- **`email_template`** — a send's `template: NAME` resolves the + FIRST-registered package's bundle, silently; `/meta/email_template/NAME` serves + the same one. + +**What is refused.** All four join P1.2's list: the same two doors, the same +holders, the same envelope and rename. A provider-bound connector instance and a +catalog descriptor hold the name alike — it is the connector's identity in the +engine's registry and the picker both. An email template's NAME holds its +bundle: the registry keys the type by `(name, locale)` (`pkg:name@locale`), the +holder reading strips the member, two members of one package's bundle are one +holder, and another package's member of that name — another locale included — is +one conflict, on the name. + +**The overridable baseline.** The platform's built-in auth templates are +code-declared under the email service's own package +(`EmailServicePlugin.init`, `@objectstack/plugin-email`), and the template loader +serves any OTHER package's `(name, locale)` member over them by contract +(ADR-0131 D6/D10: an app declaring `auth.password_reset` overrides the +platform's copy). So that package is an overridable baseline for +`email_template` (`OVERRIDABLE_BASELINE_PACKAGES`): it neither holds a template +name nor is refused one, at the package door, the item seam and the cold boot +alike. The first other package to take a built-in name holds it; a second is +refused naming the first, never the baseline. No other type has a baseline today. + +**Not judged.** A paired `.custom` package's declared override of a base +package's flow (ADR-0146 D9 item 1) is a stored row bound to its pair, hydrated +into the bare slot — the environment catalog, never a package holder, so the +override path stays open. The automation service's own duplicate refusal of two +provider-bound instances in ONE boot (ADR-0097) runs before the seal and stands; +the doors here judge the install that follows a boot. The kernel `metadata` +service's `(type, name)` overwrite is not a consumer of these four on a +composed runtime (the webhook reader falls back to it only with no registry), +and is not judged. + +**The cold boot** reads the four with the registry's other holders, under +P1.3's switch: the flow precedence warning keeps naming both packages and arming +one, as before; the bare-name warning names every clash once with its rename. diff --git a/packages/objectql/src/bare-name-one-holder.ts b/packages/objectql/src/bare-name-one-holder.ts index 360122a5fca..dc48074114e 100644 --- a/packages/objectql/src/bare-name-one-holder.ts +++ b/packages/objectql/src/bare-name-one-holder.ts @@ -9,7 +9,8 @@ * item for every caller (maintainer ruling C on #30798, phase 1; ADR-0048 §3.4, * 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). + * object-less `action`, the engine's function registry), then the flow family + * (`flow`, `webhook`, `connector`, `email_template`). * * ## The rule * @@ -36,11 +37,27 @@ * 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. + * action's `target` resolve it with no package; + * - `flow` — the automation engine keys its flow map by the bare name and + * arms ONE flow per name, the lexicographically-first package's, whichever + * registered first (it warns, naming both); `/meta/flow/NAME` serves the + * FIRST-registered package's declaration, so the body the designer opens + * and the body that runs can come from two packages; + * - `webhook` — the dispatcher's declared-webhook reader keeps the + * FIRST-registered package's declaration per name and never subscribes + * the other, and `/meta/webhook/NAME` serves the same one; + * - `connector` — `/meta/connector/NAME` (the by-name door the designer's + * connector picker reads) serves the FIRST-registered package's + * declaration; a provider-bound instance is the engine's one connector + * per name, and a second one at boot already refuses the boot (ADR-0097); + * - `email_template` — a send's `template: NAME` resolves the + * FIRST-registered package's bundle, and `/meta/email_template/NAME` + * serves the same one. * * 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). + * neither package; the flow precedence warns, naming both, and arms one of + * them all the same). * * ⛔ 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 @@ -66,6 +83,15 @@ * bare slot (every `sys_metadata` hydration and write-through) is never read * here, and a registration with no package is never judged. * + * ⛔ **Never an overridable baseline** ({@link OVERRIDABLE_BASELINE_PACKAGES}). + * The platform's built-in email templates are code-declared under the email + * service's own package, and the template loader serves any other package's + * item of the same `(name, locale)` over them by contract (ADR-0131 D6/D10: + * an app declaring `auth.password_reset` overrides the platform's copy). That + * package neither holds a template name nor is refused one: the first OTHER + * package to take the name holds it, and a second is refused naming the + * first. No other type has a baseline today. + * * ## The cold boot * * Every package registers again at every boot, through the same two doors. A @@ -96,6 +122,13 @@ * - 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. + * - The email service's built-in templates (above): the overridable baseline. + * - A paired `.custom` package's declared override of a base package's flow + * (ADR-0146 D9): a stored row bound to its pair, hydrated into the bare + * slot — the environment catalog, never a package holder. + * - A provider-bound connector instance two packages of ONE boot declare: + * the automation service's own duplicate refusal (ADR-0097) stops that boot + * before the seal; the doors here judge the install that follows a boot. * - 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`). @@ -108,7 +141,10 @@ import { isObjectLessActionKey, standaloneActionOwnerKey } from './action-govern * 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'; +export type BareNameRegistryType = + | 'picklist' | 'mapping' | 'dataset' + | 'api' | 'action' + | 'flow' | 'webhook' | 'connector' | 'email_template'; /** * A covered type: a registry type, or `function` — the engine's function @@ -116,21 +152,31 @@ export type BareNameRegistryType = 'picklist' | 'mapping' | 'dataset' | 'api' | */ 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([ +/** + * Every covered type, in the order a refusal and a cold boot list them: one + * family after another, each in the order its change named them. + */ +export const BARE_NAME_ONE_HOLDER_TYPES: readonly BareNameOneHolderType[] = Object.freeze([ + // the data family 'picklist', 'mapping', 'dataset', + // the request family 'api', 'action', -]); - -/** 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', + // the flow family + 'flow', + 'webhook', + 'connector', + 'email_template', ]); +/** The covered registry types, in the order a refusal lists them. */ +export const BARE_NAME_REGISTRY_TYPES: readonly BareNameRegistryType[] = Object.freeze( + BARE_NAME_ONE_HOLDER_TYPES.filter((type): type is BareNameRegistryType => type !== '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); @@ -162,6 +208,10 @@ const COLLECTION_KEY: Readonly> = Object.fr dataset: 'datasets', api: 'apis', action: 'actions', + flow: 'flows', + webhook: 'webhooks', + connector: 'connectors', + email_template: 'emailTemplates', }); /** @@ -177,8 +227,46 @@ export const BUILT_IN_BARE_NAMES: Readonly(), action: new Set(), function: new Set(), + flow: new Set(), + webhook: new Set(), + connector: new Set(), + email_template: new Set(), }); +/** + * Per covered type, the packages whose items are an OVERRIDABLE BASELINE + * (module doc, "The holders"): the platform's copies, which that type's one + * consumer serves any other package's same-named item over, by contract. Such + * a package neither holds a name of that type nor is refused one — at the + * package door, the item seam and the cold boot alike. + * + * `email_template`: the email service declares the built-in auth templates + * under its own package id (`EmailServicePlugin.init`, `@objectstack/plugin-email`), + * and its template loader picks any other package's `(name, locale)` member + * before the built-in's (`BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID` there — the + * same spelling, pinned together by the flow family's booted dogfood pin). + */ +export const OVERRIDABLE_BASELINE_PACKAGES: Readonly>> = Object.freeze({ + picklist: new Set(), + mapping: new Set(), + dataset: new Set(), + api: new Set(), + action: new Set(), + function: new Set(), + flow: new Set(), + webhook: new Set(), + connector: new Set(), + email_template: new Set(['com.objectstack.service.email']), +}); + +/** + * Is `packageId`'s item of `type` an overridable baseline + * ({@link OVERRIDABLE_BASELINE_PACKAGES}) — never a holder, never judged? + */ +export function isOverridableBaseline(type: BareNameOneHolderType, packageId: string | undefined): boolean { + return packageId !== undefined && OVERRIDABLE_BASELINE_PACKAGES[type].has(packageId); +} + /** * The prefix the runtime binds a package's handlers under * (`appArtifactHandlerOwner` in `@objectstack/runtime`: `app:`). @@ -242,13 +330,19 @@ function itemName(item: unknown): string | undefined { * 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. + * the hook binder's bind. A package that is a type's overridable baseline + * ({@link isOverridableBaseline}, by the manifest's own id) declares nothing + * of that type. A bundled type (`email_template`) declares its NAME: two + * members of one bundle are one declaration, read twice. */ export function declaredBareNames(manifest: ObjectStackManifest | unknown): DeclaredBareName[] { const out: DeclaredBareName[] = []; + const selfId = (manifest as { id?: unknown } | null | undefined)?.id; + const packageId = typeof selfId === 'string' ? selfId : undefined; const read = (source: unknown) => { if (!source || typeof source !== 'object') return; for (const type of BARE_NAME_REGISTRY_TYPES) { + if (isOverridableBaseline(type, packageId)) continue; const items = (source as Record)[COLLECTION_KEY[type]]; if (!Array.isArray(items)) continue; for (const item of items) { @@ -268,6 +362,7 @@ export function bareNameTypeLabel(type: BareNameOneHolderType): string { switch (type) { case 'api': return 'API'; case 'action': return 'object-less action'; + case 'email_template': return 'email template'; default: return type; } } @@ -283,6 +378,10 @@ const CONSUMER_CLAUSE: Readonly> = Object. 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', + flow: 'the automation engine arms one flow per name', + webhook: 'the webhook dispatcher subscribes one declared webhook per name', + connector: 'a flow `connector_action` node and the connector picker name a connector', + email_template: 'a send names its email template', }); /** The noun a reason names a covered type by. */ @@ -293,6 +392,10 @@ const TYPE_NOUN: Readonly> = Object.freeze api: 'an API', action: 'an object-less action', function: 'a function', + flow: 'a flow', + webhook: 'a webhook', + connector: 'a connector', + email_template: 'an email template', }); const joinList = (parts: readonly string[]): string => diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 96f57405ecd..4a9474307a4 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -66,8 +66,9 @@ import { // cold-boot check (see `refuseEnvironmentHeldSecurityCatalogNames`). import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js'; // One name, one package holder for the bare-name types (a picklist, a mapping, a -// dataset, an API, an object-less action, a function) — the cold boot's seal and -// its one switch (`bare-name-one-holder.ts`, "The cold boot"). +// dataset, an API, an object-less action, a function, a flow, a webhook, a +// connector, an email template) — the cold boot's seal and its one switch +// (`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'; import { planPackageUpgrade } from './package-upgrade-environment.js'; diff --git a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts new file mode 100644 index 00000000000..b93008971f8 --- /dev/null +++ b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts @@ -0,0 +1,429 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * One name, one package holder — the flow family: a flow, a webhook, a + * connector and an email template (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`, the request family's + * `registry-bare-name-one-holder-request-family.test.ts`; this file holds the + * same matrix for the next four, and what is particular to them: + * + * - all four are registry types, judged at the engine's package door and the + * registry's item seam exactly as a picklist is; + * - `email_template` is a BUNDLED type: the registry keys it + * `pkg:name@locale`, so the holder reading strips the member, the NAME is + * what is held, and two members of one package's bundle are one holder; + * - the email service's built-in templates are an overridable baseline + * (`OVERRIDABLE_BASELINE_PACKAGES`): that package neither holds a template + * name nor is refused one, so an app's override of `auth.welcome` keeps + * its contract (ADR-0131 D6/D10) while a second app's is refused. + * + * Every refusal is asserted by its ADR-0112 envelope (`code` + `status`), the + * two holders it names and the rename it carries. + * + * Ablations: the same registrations BEFORE the seal coexist, so every refusal + * here is the seal's. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from './plugin.js'; +import { + SchemaRegistry, + NAMESPACE_CONFLICT_CODE, + sealBareNameHoldersAtColdBoot, +} from './registry.js'; +import { + BARE_NAME_ONE_HOLDER_TYPES, + BARE_NAME_REGISTRY_TYPES, + BUILT_IN_BARE_NAMES, + OVERRIDABLE_BASELINE_PACKAGES, + bareNameReason, + bareNameTypeLabel, + declaredBareNames, + isOverridableBaseline, +} 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 FlowType = 'flow' | 'webhook' | 'connector' | 'email_template'; +const TYPES: FlowType[] = ['flow', 'webhook', 'connector', 'email_template']; +const COLLECTION: Record = { flow: 'flows', webhook: 'webhooks', connector: 'connectors', email_template: 'emailTemplates' }; + +/** The email service's package, as `EmailServicePlugin.init` registers it (the overridable baseline). */ +const EMAIL_SERVICE_PKG = 'com.objectstack.service.email'; + +/** A body each type's schema accepts, marked with `tag` in its `label`. */ +const body = (type: FlowType, name: string, tag: string, extra: Record = {}): Record => { + switch (type) { + case 'flow': + return { name, label: `${tag} flow`, type: 'autolaunched', status: 'active', nodes: [{ id: 'start', type: 'start', label: 'Start' }], edges: [], ...extra }; + case 'webhook': + return { name, label: `${tag} webhook`, url: `https://hooks.example/${tag}/${name}`, method: 'POST', isActive: false, ...extra }; + case 'connector': + return { name, label: `${tag} connector`, type: 'api', description: `${tag} connector`, enabled: false, ...extra }; + case 'email_template': + return { name, label: `${tag} template`, locale: 'en-US', subject: `${tag} subject`, bodyHtml: `

${tag}

`, ...extra }; + } +}; + +/** The marker `body` wrote: the item's `label`. */ +const marker = (item: unknown): string | undefined => (item as { label?: string } | undefined)?.label; + +/** The package's own object, so a webhook or flow has something to bind to. */ +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; +}; + +/** The email service's own package payload, as its plugin registers it: a system plugin in the `sys` namespace. */ +const emailServicePkg = (templates: Record[]) => ({ + id: EMAIL_SERVICE_PKG, name: 'Email Service', version: '1.0.0', type: 'plugin', scope: 'system', namespace: 'sys', + emailTemplates: templates, +}); + +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.flow-family.boot-packages', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + init: async () => {}, + start: async (ctx: { getService(name: string): T }) => { + const manifest = ctx.getService('manifest'); + for (const m of manifests) await manifest.register(m); + }, +}); + +const kernels: ObjectKernel[] = []; +const boot = async (atBoot: unknown[] = []) => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const warn = vi.spyOn((kernel as unknown as { logger: { warn: (...a: unknown[]) => void } }).logger, 'warn'); + await kernel.use(new ObjectQLPlugin()); + if (atBoot.length > 0) await kernel.use(bootPackages(atBoot) as never); + kernels.push(kernel); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + ql.registry.logLevel = 'silent'; + const manifest = kernel.getService('manifest'); + const bareNameWarnings = () => + warn.mock.calls.map((c) => String(c[0])).filter((m) => m.startsWith('Bare-name conflict at boot')); + return { ql, registry: ql.registry, register: (m: unknown) => caught(() => manifest.register(m)), bareNameWarnings }; +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +/** The envelope, the two holders and the rename of one refusal. */ +function expectRefusal(err: Refusal | undefined, incoming: string, holder: Holder, rename?: string) { + expect(err, 'the registration was refused').toBeDefined(); + expect(err!.name).toBe('BareNameConflictError'); + expect(err!.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err!.status).toBe(422); + expect(err!.incomingPackageId).toBe(incoming); + expect(err!.existingHolder).toEqual(holder); + expect(err!.message).toContain(incoming); + if (holder.packageId) expect(err!.message).toContain(holder.packageId); + if (rename) { + expect(err!.conflicts![0].rename).toBe(rename); + expect(err!.message).toContain(rename); + } +} + +describe('the covered types, in family order', () => { + it('the four join the list after the request family; the registry types are every covered type but the function', () => { + expect(BARE_NAME_ONE_HOLDER_TYPES).toEqual([ + 'picklist', 'mapping', 'dataset', + 'api', 'action', 'function', + 'flow', 'webhook', 'connector', 'email_template', + ]); + expect(BARE_NAME_REGISTRY_TYPES).toEqual(BARE_NAME_ONE_HOLDER_TYPES.filter((t) => t !== 'function')); + expect(bareNameTypeLabel('email_template')).toBe('email template'); + for (const type of ['flow', 'webhook', 'connector'] as const) expect(bareNameTypeLabel(type)).toBe(type); + }); + + it('a manifest declares each of the four by its collection, nested plugins included, a bundle once', () => { + const declared = declaredBareNames({ + id: 'com.acme.alpha', + flows: [body('flow', 'f1', 'a')], webhooks: [body('webhook', 'w1', 'a')], connectors: [body('connector', 'c1', 'a')], + emailTemplates: [body('email_template', 'e1', 'a'), body('email_template', 'e1', 'a', { locale: 'zh-CN' })], + plugins: [{ name: 'inner', flows: [body('flow', 'f2', 'a')] }], + }); + expect(declared.map((d) => `${d.type}/${d.name}`)).toEqual([ + 'flow/f1', 'webhook/w1', 'connector/c1', 'email_template/e1', 'email_template/e1', 'flow/f2', + ]); + }); +}); + +describe.each(TYPES)('%s — after the cold boot, a second package declaring a name another package holds', (type) => { + const name = `shared_${type}`; + const label = (tag: string) => marker(body(type, name, tag)); + + it('is refused at the package door, naming both packages and the rename, and leaves no record behind', async () => { + const { registry, register } = await boot(); + expect(registry.bareNameHoldersAreSealed).toBe(true); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + + const err = await register(pkg('com.acme.beta', { [type]: [name] })); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, `beta_${name}`); + expect(err!.conflicts).toEqual([ + { type, name, incomingPackageId: 'com.acme.beta', existingHolder: { kind: 'package', packageId: 'com.acme.alpha' }, rename: `beta_${name}` }, + ]); + expect(err!.message).toContain(`the ${bareNameTypeLabel(type)} "${name}"`); + expect(registry.getPackage('com.acme.beta')).toBeUndefined(); + expect(marker(registry.getItem(type, name))).toBe(label('alpha')); + }); + + it('ABLATION — before the seal the same two registrations coexist (the doors judge nothing during a boot)', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + registry.installPackage(pkg('com.acme.alpha', { [type]: [name] }) as never, 'code'); + expect(await caught(() => registry.installPackage(pkg('com.acme.beta', { [type]: [name] }) as never, 'code'))).toBeUndefined(); + registry.registerItem(type, body(type, name, 'alpha'), 'name', 'com.acme.alpha'); + expect(await caught(() => registry.registerItem(type, body(type, name, 'beta'), 'name', 'com.acme.beta'))).toBeUndefined(); + registry.sealBareNameHolders(); + expect(await caught(() => registry.installPackage(pkg('com.acme.gamma', { [type]: [name] }) as never, 'code'))).toBeDefined(); + expect(await caught(() => registry.registerItem(type, body(type, name, 'gamma'), 'name', 'com.acme.gamma'))).toBeDefined(); + }); + + it('the item seam refuses a package-bound registration over another package\'s name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + const err = await caught(() => registry.registerItem(type, body(type, name, 'direct'), 'name', 'com.acme.direct')); + expectRefusal(err, 'com.acme.direct', { kind: 'package', packageId: 'com.acme.alpha' }); + expect(marker(registry.getItem(type, name))).toBe(label('alpha')); + }); + + it('a nested plugin declares under its parent package, and is held to the same rule', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { plugins: [{ name: 'inner', [COLLECTION[type]]: [body(type, name, 'inner')] }] }))).toBeUndefined(); + const err = await register(pkg('com.acme.beta', { [type]: [name] })); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }); + }); + + it('CONTROL — the same package registering its own name again is one holder, not two', async () => { + const { register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + }); + + it('uninstalling the holder releases the name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(registry.uninstallPackage('com.acme.alpha')).toBe(true); + expect(await register(pkg('com.acme.beta', { [type]: [name] }))).toBeUndefined(); + }); + + it('CONTROL — an environment registration (no package: the ADR-0005 overlay) is never judged, and never holds a name', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(await caught(() => registry.registerItem(type, body(type, name, 'env'), 'name'))).toBeUndefined(); + registry.registerItem(type, body(type, `env_${type}`, 'env'), 'name'); + expect(await register(pkg('com.acme.beta', { [type]: [`env_${type}`] }))).toBeUndefined(); + }); + + describe('the cold boot of a deployment that already holds the clash', () => { + const atBoot = () => [pkg('com.acme.alpha', { [type]: [name] }), pkg('com.acme.beta', { [type]: [name] })]; + + it('boots, and warns once naming both holders and the rename (switch "warn")', async () => { + const { registry, bareNameWarnings } = await boot(atBoot()); + expect(registry.bareNameHoldersAreSealed).toBe(true); + const warnings = bareNameWarnings(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`the ${bareNameTypeLabel(type)} "${name}"`); + expect(warnings[0]).toContain('com.acme.alpha'); + expect(warnings[0]).toContain('com.acme.beta'); + expect(warnings[0]).toContain(`beta_${name}`); + expect(warnings[0]).toContain('changes nothing about which holder each caller is served'); + }); + + it('both holders keep their name across a reload; a third package is refused naming both', async () => { + const { register } = await boot(atBoot()); + expect(await register(pkg('com.acme.alpha', { [type]: [name] }))).toBeUndefined(); + expect(await register(pkg('com.acme.beta', { [type]: [name] }))).toBeUndefined(); + const err = await register(pkg('com.acme.gamma', { [type]: [name] })); + expectRefusal(err, 'com.acme.gamma', { kind: 'package', packageId: 'com.acme.alpha' }, `gamma_${name}`); + expect(err!.conflicts!.map((c) => c.existingHolder)).toEqual([ + { kind: 'package', packageId: 'com.acme.alpha' }, + { kind: 'package', packageId: 'com.acme.beta' }, + ]); + }); + + it('under the switch "refuse", the cold boot is refused with the doors\' envelope', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + for (const m of atBoot()) registry.installPackage(m as never, 'code'); + const err = await caught(() => sealBareNameHoldersAtColdBoot(registry, 'refuse')); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, `beta_${name}`); + expect(err!.message).toContain('The boot is refused'); + }); + }); +}); + +describe('connector — a descriptor and a provider-bound instance hold the name alike', () => { + it('a second package\'s provider-bound instance under a held descriptor name is refused, and the reverse', async () => { + const { register } = await boot(); + const instance = (tag: string) => body('connector', 'shared_conn', tag, { provider: 'rest', providerConfig: { baseUrl: `https://${tag}.example` }, auth: { type: 'none' }, enabled: true }); + expect(await register(pkg('com.acme.alpha', { connector: ['shared_conn'] }))).toBeUndefined(); + expectRefusal( + await register(pkg('com.acme.beta', {}, { connectors: [instance('beta')] })), + 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, 'beta_shared_conn', + ); + expect(await register(pkg('com.acme.gamma', {}, { connectors: [{ ...instance('gamma'), name: 'live_conn' }] }))).toBeUndefined(); + expectRefusal( + await register(pkg('com.acme.delta', { connector: ['live_conn'] })), + 'com.acme.delta', { kind: 'package', packageId: 'com.acme.gamma' }, 'delta_live_conn', + ); + }); +}); + +describe('email_template — the name holds the bundle, and the built-in templates are an overridable baseline', () => { + const member = (tag: string, name: string, locale: string) => body('email_template', name, tag, { locale }); + + it('two members of one package\'s bundle are one holder; a second package\'s member of that name is refused once', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'shared_tpl', 'en-US'), member('alpha', 'shared_tpl', 'zh-CN')] }))).toBeUndefined(); + expect(registry.getItem('email_template', 'shared_tpl', undefined, 'zh-CN')).toBeDefined(); + // One holder, so the package's own second registration passes… + expect(await register(pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'shared_tpl', 'en-US'), member('alpha', 'shared_tpl', 'zh-CN')] }))).toBeUndefined(); + // …and another package's member — another locale — is one conflict, on the name. + const err = await register(pkg('com.acme.beta', {}, { emailTemplates: [member('beta', 'shared_tpl', 'fr-FR')] })); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, 'beta_shared_tpl'); + expect(err!.conflicts).toHaveLength(1); + expect(err!.conflicts![0].name).toBe('shared_tpl'); + }); + + it('the item seam holds the name through a member key (`pkg:name@locale`), every member of a bundle alike', async () => { + const { registry, register } = await boot(); + expect(await register(pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'shared_tpl', 'zh-CN')] }))).toBeUndefined(); + expectRefusal( + await caught(() => registry.registerItem('email_template', member('direct', 'shared_tpl', 'en-US'), 'name', 'com.acme.direct')), + 'com.acme.direct', { kind: 'package', packageId: 'com.acme.alpha' }, + ); + expectRefusal( + await caught(() => registry.registerItem('email_template', member('direct', 'shared_tpl', 'ja-JP'), 'name', 'com.acme.direct')), + 'com.acme.direct', { kind: 'package', packageId: 'com.acme.alpha' }, + ); + }); + + it('the vocabulary names the email service\'s package as the one baseline, for email templates only', () => { + expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template]).toEqual([EMAIL_SERVICE_PKG]); + for (const type of BARE_NAME_ONE_HOLDER_TYPES) { + if (type !== 'email_template') expect(OVERRIDABLE_BASELINE_PACKAGES[type].size, type).toBe(0); + } + expect(isOverridableBaseline('email_template', EMAIL_SERVICE_PKG)).toBe(true); + expect(isOverridableBaseline('flow', EMAIL_SERVICE_PKG)).toBe(false); + expect(isOverridableBaseline('email_template', 'com.acme.alpha')).toBe(false); + expect(isOverridableBaseline('email_template', undefined)).toBe(false); + expect(declaredBareNames(emailServicePkg([member('sys', 'auth.welcome', 'en-US')]))).toEqual([]); + }); + + it('CONTROL — an app overriding a built-in template after the boot installs: the baseline holds nothing', async () => { + const { registry, register } = await boot([emailServicePkg([member('sys', 'auth.welcome', 'en-US'), member('sys', 'auth.welcome', 'zh-CN')])]); + expect(await register(pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'auth.welcome', 'en-US')] }))).toBeUndefined(); + expect(registry.getArtifactItem<{ label: string }>('email_template', 'auth.welcome', 'com.acme.alpha')?.label).toBe('alpha template'); + // …and a SECOND app's override is refused, naming the first app only — never the baseline. + const err = await register(pkg('com.acme.beta', {}, { emailTemplates: [member('beta', 'auth.welcome', 'en-US')] })); + expectRefusal(err, 'com.acme.beta', { kind: 'package', packageId: 'com.acme.alpha' }, 'beta_auth.welcome'); + expect(err!.conflicts).toHaveLength(1); + expect(err!.message).not.toContain(EMAIL_SERVICE_PKG); + }); + + it('CONTROL — the baseline package is refused nothing: re-registering its templates over an app\'s override passes, at both doors', async () => { + const { registry, register } = await boot([ + emailServicePkg([member('sys', 'auth.welcome', 'en-US')]), + pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'auth.welcome', 'en-US')] }), + ]); + expect(await register(emailServicePkg([member('sys', 'auth.welcome', 'en-US')]))).toBeUndefined(); + expect(await caught(() => registry.registerItem('email_template', member('sys', 'auth.welcome', 'ja-JP'), 'name', EMAIL_SERVICE_PKG))).toBeUndefined(); + }); + + it('CONTROL — the cold boot of the baseline beside an app\'s override is no clash', async () => { + const { bareNameWarnings, registry } = await boot([ + emailServicePkg([member('sys', 'auth.welcome', 'en-US')]), + pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'auth.welcome', 'en-US')] }), + ]); + expect(registry.bareNameHoldersAreSealed).toBe(true); + expect(bareNameWarnings()).toEqual([]); + const conflicts = registry.sealBareNameHolders(); + expect(conflicts).toEqual([]); + }); + + it('the cold boot of two apps overriding one built-in name warns, naming the two apps and never the baseline', async () => { + const { bareNameWarnings } = await boot([ + emailServicePkg([member('sys', 'auth.welcome', 'en-US')]), + pkg('com.acme.alpha', {}, { emailTemplates: [member('alpha', 'auth.welcome', 'en-US')] }), + pkg('com.acme.beta', {}, { emailTemplates: [member('beta', 'auth.welcome', 'zh-CN')] }), + ]); + const warnings = bareNameWarnings(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('the email template "auth.welcome"'); + expect(warnings[0]).toContain('com.acme.alpha'); + expect(warnings[0]).toContain('com.acme.beta'); + expect(warnings[0]).not.toContain(EMAIL_SERVICE_PKG); + }); +}); + +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', { flow: ['f1'], webhook: ['w1'], connector: ['c1'], email_template: ['e1'] }))).toBeUndefined(); + const err = await register(pkg('com.acme.beta', { flow: ['f1'], webhook: ['w1'], connector: ['c1'], email_template: ['e1'] })); + expect(err!.conflicts!.map((c) => `${c.type}/${c.name}`)).toEqual(['flow/f1', 'webhook/w1', 'connector/c1', 'email_template/e1']); + expect(err!.message).toContain(bareNameReason(['flow', 'webhook', 'connector', 'email_template'])); + expect(bareNameReason(['flow', 'webhook', 'connector', 'email_template'])).toMatch( + /^A flow, a webhook, a connector and an email template each hold one name per deployment: the automation engine arms one flow per name, the webhook dispatcher subscribes one declared webhook per name, /, + ); + expect(bareNameReason(['email_template'])).toMatch(/^An email template holds one name per deployment: a send names its email template, /); + }); + + it('a built-in name is refused to a package at the package door, naming the platform', async () => { + const builtIns = BUILT_IN_BARE_NAMES.flow as Set; + builtIns.add('platform_flow'); + try { + const { register } = await boot(); + const err = await register(pkg('com.acme.alpha', { flow: ['platform_flow'] })); + expectRefusal(err, 'com.acme.alpha', { kind: 'built-in' }, 'alpha_platform_flow'); + } finally { + builtIns.delete('platform_flow'); + } + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 2bed68a7104..369fca9fc7f 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -90,6 +90,7 @@ import { handlerOwnerPackageId, holdsBareName, isBareNameRegistryType, + isOverridableBaseline, suggestedBareNameRename, type BareNameColdBootMode, type BareNameHolder, @@ -4630,20 +4631,25 @@ export class SchemaRegistry { * 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. + * hydrated row wears. ⛔ Never a type's overridable baseline + * ({@link isOverridableBaseline}): the platform's copies, which the + * consumer serves another package's item over by contract. A bundled type's + * key carries its member (`pkg:name@locale`, `bundleBaseKey`): the NAME is + * what is held, so every member of one package's bundle is that one holder. */ private bareNamePackageHolders(type: BareNameOneHolderType, name: string): string[] { const holders = new Set(); 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 base = bundleBaseKey(key); + if (base === name || !base.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)); + holders.add(typeof stamped === 'string' && stamped !== '' ? stamped : base.slice(0, -suffix.length)); } } for (const claimant of this.bareNameClaims.get(type)?.get(name) ?? []) holders.add(claimant); - return [...holders]; + return [...holders].filter((packageId) => !isOverridableBaseline(type, packageId)); } /** @@ -4806,8 +4812,10 @@ export class SchemaRegistry { const candidates = new Set(); 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)); + // A bundled type's key ends `@`: the NAME is the candidate. + const base = bundleBaseKey(key); + const sep = base.lastIndexOf(':'); + if (sep > 0) candidates.add(base.slice(sep + 1)); } } for (const name of this.bareNameClaims.get(type)?.keys() ?? []) candidates.add(name); @@ -7611,17 +7619,20 @@ 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 && isBareNameRegistryType(type) && holdsBareName(type, item)) { + // One name, one package holder for the bare-name registry types (a + // picklist, a mapping, a dataset, an API, an object-less action, a flow, a + // webhook, a connector, an email template) — the item seam + // (`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; nor is a type's overridable baseline (the email service's + // built-in templates), which holds nothing and is refused nothing. + if (packageId && isBareNameRegistryType(type) && holdsBareName(type, item) && !isOverridableBaseline(type, packageId)) { const holders = this.bareNameHoldersOtherThan(type, baseName, packageId, { builtIns: false }); if (holders.length > 0) { const rename = this.bareNameRenameFor(packageId, baseName); @@ -8659,9 +8670,10 @@ 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; + // One name, one package holder for the bare-name types — a picklist, a + // mapping, a dataset, an API, an object-less action, a flow, a webhook, a + // connector, an email template: 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: @@ -8755,7 +8767,7 @@ 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`). + // …and its bare names of the covered types (see `bareNameClaims`). if (selfId !== undefined) this.recordBareNameClaims(selfId, manifest); this.log(`[Registry] Installed package: ${manifest.id} (${manifest.name})`); return pkg; @@ -8924,7 +8936,7 @@ 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. + // …and its bare names of the covered types. 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. diff --git a/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts b/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts new file mode 100644 index 00000000000..35588b5f3a9 --- /dev/null +++ b/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#30803] One name, one package holder for a flow, a webhook, a connector and +// an email template — the flow family (maintainer ruling C on #30798, phase 1; +// ADR-0048 §3.4 narrowed, P1.8; `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 request family's +// `bare-name-one-holder-request-family.dogfood.test.ts`. +// +// The registry's own pins (`registry-bare-name-one-holder-flow-family.test.ts`) +// drive the engine's package door and the item seam in-process. This file +// drives the rule at the doors a deployment reaches: +// +// - the cold boot of a deployment that already holds the clash — two +// `AppPlugin` packages, `alpha` and `beta`, shipping one flow, one webhook, +// one connector and one email template under the same names — boots, and +// logs ONE bare-name warning line naming all four, both packages and the +// rename for every name; the automation engine's own flow precedence +// keeps arming one of the two flows, as before; +// - `POST /marketplace/install-local` of a third package declaring the held +// flow name is 422 PLUGIN_REGISTER_FAILED naming both holders and the +// rename, installs nothing, and the engine keeps serving the armed flow; +// - the same door refusing a package that declares the held webhook name and +// the showcase's own flow and email template names, naming the showcase; +// - CONTROL: a package declaring fresh names installs — and so does one +// overriding the platform's built-in `auth.password_reset` template, whose +// holder package is the overridable baseline: the send then renders the +// app's template, and the vocabulary's spelling of that package is the +// email plugin's own. + +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 { OVERRIDABLE_BASELINE_PACKAGES } from '@objectstack/objectql'; +import { BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID } from '@objectstack/plugin-email'; +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_FLOW = 'showcase_task_completed'; +const SHOWCASE_TEMPLATE = 'showcase_task_done_email'; +const FLOW = 'qa_shared_flow'; +const HOOK = 'qa_shared_hook'; +const CONN = 'qa_shared_conn'; +const TPL = 'qa_shared_tpl'; +const BUILT_IN_TPL = 'auth.password_reset'; + +const item = (p: string) => ({ + name: `${p}_item`, label: `${p} item`, sharingModel: 'public_read_write', + fields: { name: { type: 'text', label: 'Name' } }, +}); +const flow = (p: string, name = FLOW) => ({ + name, label: `${p} flow`, type: 'autolaunched', status: 'active', + nodes: [{ id: 'start', type: 'start', label: `${p} start` }], edges: [], +}); +const webhook = (p: string, name = HOOK) => ({ + name, label: `${p} hook`, object: `${p}_item`, triggers: ['create'], url: `https://hooks.example/${p}`, method: 'POST', isActive: false, +}); +const connector = (p: string, name = CONN) => ({ + name, label: `${p} connector`, type: 'api', description: `${p} connector body`, enabled: false, +}); +const template = (p: string, name = TPL) => ({ + name, label: `${p} template`, locale: 'en-US', subject: `${p} subject`, bodyHtml: `

${p}

`, bodyText: p, active: true, +}); + +/** One of the two packages the cold boot registers, each shipping all four 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' }, + requires: ['webhooks', 'realtime'], + objects: [item(p)], + flows: [flow(p)], + webhooks: [webhook(p)], + connectors: [connector(p)], + emailTemplates: [template(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 flow, webhook, connector and email template name, on a booted showcase', () => { + let stack: VerifyStack; + let admin: string; + let storageDir: string; + let bootLines: string[] = []; + + const packageIds = async () => { + const r = await stack.apiAs(admin, 'GET', '/packages'); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + const rows = (body?.data?.packages ?? body?.packages ?? body?.data ?? body ?? []) as any[]; + return rows.map((p: any) => p?.manifest?.id ?? p?.id ?? p?.packageId).filter(Boolean).sort(); + }; + + const installLocal = async (payload: unknown) => { + const res = await stack.apiAs(admin, 'POST', '/marketplace/install-local', payload); + return { status: res.status, body: (await res.json()) as any }; + }; + + /** The flow the automation engine serves for `name` — its bare-name flow map. */ + const armedFlowLabel = async (name: string): Promise => { + const automation = (stack.kernel as any).getService('automation'); + return (await automation.getFlow(name))?.label; + }; + + /** The declared webhook the dispatcher's reader serves for `name`. */ + const declaredWebhookPackage = async (name: string): Promise => { + const declared = (stack.kernel as any).getService('declaredWebhooks'); + return (await declared.declaredWebhookNamed(name))?.packageId; + }; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-bare-name-flow-')); + 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 all four names, 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 flow "${FLOW}"`); + expect(line).toContain(`the webhook "${HOOK}"`); + expect(line).toContain(`the connector "${CONN}"`); + expect(line).toContain(`the email template "${TPL}"`); + for (const name of [FLOW, HOOK, CONN, TPL]) 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"'); + // The platform's built-in templates beside the showcase's are no clash: the baseline holds nothing. + expect(line).not.toContain(BUILT_IN_TPL); + expect(line).not.toContain(BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID); + expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta'])); + // Unchanged by the rule: the automation engine's precedence still arms one flow, naming both. + expect(bootLines.some((l) => l.includes(`Flow name collision: '${FLOW}'`) && l.includes('com.qa.bare.alpha') && l.includes('com.qa.bare.beta'))).toBe(true); + expect(await armedFlowLabel(FLOW)).toBe('alpha flow'); + expect(await declaredWebhookPackage(HOOK)).toBe('com.qa.bare.alpha'); + }); + + it('install-local of a third package declaring a held flow name is 422 PLUGIN_REGISTER_FAILED naming both holders and the rename; nothing is installed, the armed flow is untouched', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('gamma', { flows: [flow('gamma')] })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`the flow "${FLOW}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain('com.qa.bare.beta'); + expect(message).toContain(`"gamma_${FLOW}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + expect(await armedFlowLabel(FLOW)).toBe('alpha flow'); + }); + + it('install-local of a package declaring the held webhook name and the showcase\'s own flow and email template names is refused, naming the showcase', async () => { + const before = await packageIds(); + const { status, body } = await installLocal(inline('delta', { + flows: [flow('delta', SHOWCASE_FLOW)], + webhooks: [webhook('delta')], + emailTemplates: [template('delta', SHOWCASE_TEMPLATE)], + })); + expect([status, codeOf(body)], JSON.stringify(body)).toEqual([422, 'PLUGIN_REGISTER_FAILED']); + const message = String(body?.error?.message ?? ''); + expect(message).toContain(`the flow "${SHOWCASE_FLOW}"`); + expect(message).toContain(`the email template "${SHOWCASE_TEMPLATE}"`); + expect(message).toContain(SHOWCASE_PKG); + expect(message).toContain(`the webhook "${HOOK}"`); + expect(message).toContain('com.qa.bare.alpha'); + expect(message).toContain(`"delta_${HOOK}"`); + expect(await packageIds(), 'the package list is unchanged').toEqual(before); + expect(await declaredWebhookPackage(HOOK)).toBe('com.qa.bare.alpha'); + }); + + it('CONTROL — install-local of a package declaring fresh names, and overriding the built-in password-reset template, installs; the send renders the app\'s template', async () => { + expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template], 'the vocabulary spells the email plugin\'s own package id').toEqual([BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID]); + const { status, body } = await installLocal(inline('epsilon', { + flows: [flow('epsilon', 'epsilon_flow')], + webhooks: [webhook('epsilon', 'epsilon_hook')], + connectors: [connector('epsilon', 'epsilon_conn')], + emailTemplates: [template('epsilon', 'epsilon_tpl'), template('epsilon', BUILT_IN_TPL)], + })); + expect(status, JSON.stringify(body)).toBeLessThan(300); + expect(await packageIds()).toContain('com.qa.bare.epsilon'); + const email = (stack.kernel as any).getService('email'); + const rendered = await email.renderTemplate({ template: BUILT_IN_TPL, data: {} }); + expect(rendered?.subject).toBe('epsilon subject'); + }); +}); 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 d7bd544a8a2..d898b307bf9 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. 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." + "invariant": "ADR-0048 §3.4 retired the per-item cross-package throw because a caller carries its package id. Three families of consumers carry none, so with two holders every caller is served one package's item, picked by registration order: the data family — a field's picklist, an import's mappingName and a dashboard's datasetName — serves the first-registered one; the request family — the endpoint matcher's API, an object-less action's handler and the engine function registry's function, read by a flow script node or an action's target — serves the last-registered one; the flow family — the automation engine's one armed flow per name, the dispatcher's one declared webhook per name, the connector picker's one connector per name and a send's email template bundle — serves the first-registered one (the flow: the lexicographically-first package's, with a warning). The maintainer's ruling C on #30798 (phase 1, ADR-0048 addendum 2026-11-06, each family in its own change under #30803) narrows §3.4 for these types: one name, one PACKAGE holder. After the cold boot seals the registry (kernel:bootstrapped), a package bringing a name another installed package or a built-in holds is refused at three doors, naming both holders and the namespace-prefixed rename: the package door (SchemaRegistry.installPackage) and the item seam (SchemaRegistry.registerItem) for the registry types, and the hook binder's bind (bindHooksToEngine in hook-binder.ts, through SchemaRegistry.claimBareNameFunctions) for a function, ahead of every registration it would make. An email template's NAME holds its (name, locale) bundle, so the holder reading strips the registry key's member. ⛔ Never judge the environment catalog — an environment item under a package's name is the ADR-0005 overlay — never judge a type's overridable baseline (OVERRIDABLE_BASELINE_PACKAGES: the email service's built-in templates, which an app's same-named template overrides by contract, so that package neither holds a template name nor is refused one), never let OS_METADATA_COLLISION=warn downgrade the refusal, never judge during the boot (a deployment already holding a clash must still boot: the cold-boot switch decides warn or refuse), and never treat a package that already holds the name as a new holder." } From e01df46cce1db092238882a4625ec556e3f0baf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 05:35:29 +0000 Subject: [PATCH 2/3] test(objectql): pin the flow family's baseline spelling on the booted showcase and the bundled holder reading - `OVERRIDABLE_BASELINE_PACKAGES` is exported from `@objectstack/objectql`, so the dogfood pin can hold its spelling of the email service's package equal to the package the email plugin registers the built-in templates under on this boot (read back from the registry: the two spellings are pinned together across the package boundary without either package importing the other). - Two unit cells read an email template's holder from its member key alone: a package registering a bundle member through the registry directly (no install claim) holds the name, and the cold boot reads such a clash from the member keys as one conflict on the name. Claude-Session: https://claude.ai/code/session_017xRBcTJTE7kSbcMoJQ4Txf Co-authored-by: Claude --- packages/objectql/src/index.ts | 9 ++++++ ...y-bare-name-one-holder-flow-family.test.ts | 28 +++++++++++++++++++ ...ame-one-holder-flow-family.dogfood.test.ts | 28 +++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index caa3dbaf823..5bead33425c 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -96,6 +96,15 @@ export { // stay private to the engine. export { findPackageHeldSecurityCatalogNames } from './registry.js'; +// [#30803] The bare-name rule's overridable baseline (ADR-0048 P1.8): per +// covered type, the packages whose items any other package's same-named item +// is served over by contract — today the email service's built-in templates. +// Exported so the booted dogfood pin can hold this vocabulary's spelling of +// that package equal to the email plugin's own `BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID` +// across the package boundary; the rule's other vocabulary stays private to +// the engine. +export { OVERRIDABLE_BASELINE_PACKAGES } from './bare-name-one-holder.js'; + // [#15204 W2] Every `(type, name)` a manifest declares, read from the sources // the engine's registration seams read. Exported for the install-local door // (`@objectstack/cloud-connection`), which refuses a catalog-carrying package diff --git a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts index b93008971f8..8132eb13747 100644 --- a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts +++ b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts @@ -344,6 +344,34 @@ describe('email_template — the name holds the bundle, and the built-in templat ); }); + it('a holder is read from its member key alone (no claim): a package registering a bundle member through the registry directly holds the name', async () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + registry.sealBareNameHolders(); + // After the seal, alpha's first registration finds no holder and stores `com.acme.alpha:shared_tpl@zh-CN`. + expect(await caught(() => registry.registerItem('email_template', member('alpha', 'shared_tpl', 'zh-CN'), 'name', 'com.acme.alpha'))).toBeUndefined(); + expect(registry.getPackage('com.acme.alpha'), 'no package record, so no claim: the slot is the only record').toBeUndefined(); + expectRefusal( + await caught(() => registry.registerItem('email_template', member('direct', 'shared_tpl', 'en-US'), 'name', 'com.acme.direct')), + 'com.acme.direct', { kind: 'package', packageId: 'com.acme.alpha' }, + ); + // CONTROL — the same package's second member is its own bundle, not a second holder. + expect(await caught(() => registry.registerItem('email_template', member('alpha', 'shared_tpl', 'en-US'), 'name', 'com.acme.alpha'))).toBeUndefined(); + }); + + it('the cold boot reads a bundled clash from the member keys: two packages\' members of one name, registered directly, are one conflict on the name', () => { + const registry = new SchemaRegistry(); + registry.logLevel = 'silent'; + registry.registerItem('email_template', member('alpha', 'shared_tpl', 'en-US'), 'name', 'com.acme.alpha'); + registry.registerItem('email_template', member('alpha', 'shared_tpl', 'zh-CN'), 'name', 'com.acme.alpha'); + registry.registerItem('email_template', member('beta', 'shared_tpl', 'fr-FR'), 'name', 'com.acme.beta'); + registry.registerItem('flow', body('flow', 'own_flow', 'alpha'), 'name', 'com.acme.alpha'); + const conflicts = registry.sealBareNameHolders(); + expect(conflicts.map((c) => `${c.type}/${c.name}: ${c.incomingPackageId} vs ${(c.existingHolder as Holder).packageId}`)).toEqual([ + 'email_template/shared_tpl: com.acme.beta vs com.acme.alpha', + ]); + }); + it('the vocabulary names the email service\'s package as the one baseline, for email templates only', () => { expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template]).toEqual([EMAIL_SERVICE_PKG]); for (const type of BARE_NAME_ONE_HOLDER_TYPES) { diff --git a/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts b/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts index 35588b5f3a9..3eb2ca1b94a 100644 --- a/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts +++ b/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts @@ -25,8 +25,10 @@ // - CONTROL: a package declaring fresh names installs — and so does one // overriding the platform's built-in `auth.password_reset` template, whose // holder package is the overridable baseline: the send then renders the -// app's template, and the vocabulary's spelling of that package is the -// email plugin's own. +// app's template, and the vocabulary spells that package exactly as the +// email plugin registers the built-in templates on this boot (read back +// from the registry, so the two spellings are pinned together across the +// package boundary without either package importing the other). import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -36,7 +38,6 @@ import { type VerifyStack } from '@objectstack/verify'; import { AppPlugin } from '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import { OVERRIDABLE_BASELINE_PACKAGES } from '@objectstack/objectql'; -import { BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID } from '@objectstack/plugin-email'; import { defineStack } from '@objectstack/spec'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { bootShowcase } from './showcase-boot.js'; @@ -149,6 +150,14 @@ describe('one package holder per flow, webhook, connector and email template nam return (await declared.declaredWebhookNamed(name))?.packageId; }; + /** The package the email plugin registered the built-in `auth.password_reset` bundle under, read from the registry. */ + const builtInTemplatePackage = (): string | undefined => { + const ql = (stack.kernel as any).getService('objectql'); + const row = (ql.registry.listItems('email_template') as Array<{ name?: string; _packageId?: string }>) + .find((i) => i?.name === BUILT_IN_TPL && i._packageId !== 'com.qa.bare.epsilon'); + return row?._packageId; + }; + beforeAll(async () => { storageDir = mkdtempSync(join(tmpdir(), 'dogfood-bare-name-flow-')); const booted = await captureOutput(() => bootShowcase({ @@ -180,9 +189,13 @@ describe('one package holder per flow, webhook, connector and email template nam for (const name of [FLOW, HOOK, CONN, TPL]) 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"'); - // The platform's built-in templates beside the showcase's are no clash: the baseline holds nothing. + // The platform's built-in templates are the overridable baseline, and the vocabulary spells + // their package exactly as the email plugin registered them on this boot. + const baseline = builtInTemplatePackage(); + expect(baseline, 'the built-in templates are registered under a package').toBeDefined(); + expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template]).toEqual([baseline]); expect(line).not.toContain(BUILT_IN_TPL); - expect(line).not.toContain(BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID); + expect(line).not.toContain(baseline!); expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta'])); // Unchanged by the rule: the automation engine's precedence still arms one flow, naming both. expect(bootLines.some((l) => l.includes(`Flow name collision: '${FLOW}'`) && l.includes('com.qa.bare.alpha') && l.includes('com.qa.bare.beta'))).toBe(true); @@ -223,7 +236,8 @@ describe('one package holder per flow, webhook, connector and email template nam }); it('CONTROL — install-local of a package declaring fresh names, and overriding the built-in password-reset template, installs; the send renders the app\'s template', async () => { - expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template], 'the vocabulary spells the email plugin\'s own package id').toEqual([BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID]); + const baseline = builtInTemplatePackage(); + expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template], 'the vocabulary spells the package the email plugin registers the built-ins under').toEqual([baseline]); const { status, body } = await installLocal(inline('epsilon', { flows: [flow('epsilon', 'epsilon_flow')], webhooks: [webhook('epsilon', 'epsilon_hook')], @@ -235,5 +249,7 @@ describe('one package holder per flow, webhook, connector and email template nam const email = (stack.kernel as any).getService('email'); const rendered = await email.renderTemplate({ template: BUILT_IN_TPL, data: {} }); expect(rendered?.subject).toBe('epsilon subject'); + // The built-in bundle is still registered beside the override: overridden, not displaced. + expect(builtInTemplatePackage()).toBe(baseline); }); }); From c05d912a85ea0369cadcae4f7992921fd0e3afc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 22:15:19 +0000 Subject: [PATCH 3/3] fix(objectql): keep the overridable baseline off the published index; read it by the door's package id The booted flow-family pin now holds the baseline's spelling by behaviour: it reads the package the email plugin registered the built-in templates under from the registry, and an app overriding a built-in template must install, which it cannot if the engine's vocabulary spells that package any other way. OVERRIDABLE_BASELINE_PACKAGES leaves the objectql index, so the package's published surface does not widen, and the changeset no longer says it is exported. declaredBareNames reads the baseline by artifactPackageId (id, else name), the identity the package door judges, so the door and the seam agree on a manifest with no id. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5pr7Ua2kPRCMZNupZg --- .changeset/30803-bare-name-flow-family.md | 2 +- packages/objectql/src/bare-name-one-holder.ts | 9 ++++---- packages/objectql/src/index.ts | 9 -------- ...y-bare-name-one-holder-flow-family.test.ts | 5 +++++ ...ame-one-holder-flow-family.dogfood.test.ts | 21 ++++++++++--------- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/.changeset/30803-bare-name-flow-family.md b/.changeset/30803-bare-name-flow-family.md index 3fb88461b7e..9d0bb1d73c4 100644 --- a/.changeset/30803-bare-name-flow-family.md +++ b/.changeset/30803-bare-name-flow-family.md @@ -15,7 +15,7 @@ Skills-docs: none — `skills/**` does not teach that two packages may share a f - **Why.** Measured on a booted showcase with two packages sharing one bare name per type, in both registration orders: the automation engine armed ONE flow per name — the lexicographically-first package's, whichever registered first, with a warning — while `/meta/flow/NAME` served the first-registered package's declaration; the webhook dispatcher's declared reader kept the first-registered package's webhook and never subscribed the other; `/meta/connector/NAME` (the connector picker's read) served the first-registered package's descriptor, and two provider-bound instances under one name already refused the boot (ADR-0097); a send's `template: NAME` resolved the first-registered package's email template. Each answer flipped with registration order; only the flow precedence warned, and it armed one of them all the same. A third package installed after the boot under any of those names installed (`200`) and was shadowed. - **Before.** Two installed packages could each ship a flow, webhook, connector or email template 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), a package registration declaring a flow, webhook, connector or email template under a name another installed package holds is refused ahead of every mutation, so it leaves no record behind. The envelope is the first family's: `code: 'NAMESPACE_CONFLICT'`, `status: 422`, `install-local` answering `422 PLUGIN_REGISTER_FAILED`, every conflicting name in one refusal, both holders named, the rename (the incoming package's namespace prefixed) carried, `conflicts[]` structured. An email template's name holds its whole `(name, locale)` bundle: two members of one package's bundle are one holder, and another package's member of that name — another locale included — is refused on the name. -- **The overridable baseline.** The platform's built-in auth templates are declared under the email service's own package (`com.objectstack.service.email`), and an app's same-named template overrides them by contract (ADR-0131 D6). That package is an overridable baseline for `email_template` (`OVERRIDABLE_BASELINE_PACKAGES`, exported): it neither holds a template name nor is refused one, so an app overriding `auth.password_reset` still installs; the first OTHER package to take a built-in name holds it, and a second is refused naming the first. +- **The overridable baseline.** The platform's built-in auth templates are declared under the email service's own package (`com.objectstack.service.email`), and an app's same-named template overrides them by contract (ADR-0131 D6). That package is an overridable baseline for `email_template` (`OVERRIDABLE_BASELINE_PACKAGES`, private to the engine): it neither holds a template name nor is refused one, so an app overriding `auth.password_reset` still installs; the first OTHER package to take a built-in name holds it, and a second is refused naming the first. - **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 flow precedence warning keeps naming both packages and arming one. 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'`). - **Who moves.** An author whose package declares a flow, webhook, connector or email template 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 flow, trigger, picker binding, activation row or send that named the old name must name the new one; nothing rewrites stored references. - **Unchanged.** The same package registering its own name again. An environment item under a package's name (never judged, never a holder: the ADR-0005 overlay), a paired `.custom` package's declared override of a base flow among them (ADR-0146 D9: a stored row). The email service's built-in templates (above). The automation service's own duplicate refusal of two provider-bound connector instances in one boot (ADR-0097). A job keyed `appId:jobName`, an action bound to an object, every type the rule does not list (ADR-0048 §3.4 as written). `OS_METADATA_COLLISION=warn` does not downgrade the refusal. diff --git a/packages/objectql/src/bare-name-one-holder.ts b/packages/objectql/src/bare-name-one-holder.ts index dc48074114e..4abd9e88137 100644 --- a/packages/objectql/src/bare-name-one-holder.ts +++ b/packages/objectql/src/bare-name-one-holder.ts @@ -135,6 +135,7 @@ */ import type { ObjectStackManifest } from '@objectstack/spec/kernel'; +import { artifactPackageId } from '@objectstack/core'; import { isObjectLessActionKey, standaloneActionOwnerKey } from './action-governance.js'; /** @@ -244,7 +245,8 @@ export const BUILT_IN_BARE_NAMES: Readonly>> = Object.freeze({ picklist: new Set(), @@ -331,14 +333,13 @@ function itemName(item: unknown): string | undefined { * 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. A package that is a type's overridable baseline - * ({@link isOverridableBaseline}, by the manifest's own id) declares nothing + * ({@link isOverridableBaseline}, by its package id as the door reads it) declares nothing * of that type. A bundled type (`email_template`) declares its NAME: two * members of one bundle are one declaration, read twice. */ export function declaredBareNames(manifest: ObjectStackManifest | unknown): DeclaredBareName[] { const out: DeclaredBareName[] = []; - const selfId = (manifest as { id?: unknown } | null | undefined)?.id; - const packageId = typeof selfId === 'string' ? selfId : undefined; + const packageId = artifactPackageId(manifest); const read = (source: unknown) => { if (!source || typeof source !== 'object') return; for (const type of BARE_NAME_REGISTRY_TYPES) { diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 5bead33425c..caa3dbaf823 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -96,15 +96,6 @@ export { // stay private to the engine. export { findPackageHeldSecurityCatalogNames } from './registry.js'; -// [#30803] The bare-name rule's overridable baseline (ADR-0048 P1.8): per -// covered type, the packages whose items any other package's same-named item -// is served over by contract — today the email service's built-in templates. -// Exported so the booted dogfood pin can hold this vocabulary's spelling of -// that package equal to the email plugin's own `BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID` -// across the package boundary; the rule's other vocabulary stays private to -// the engine. -export { OVERRIDABLE_BASELINE_PACKAGES } from './bare-name-one-holder.js'; - // [#15204 W2] Every `(type, name)` a manifest declares, read from the sources // the engine's registration seams read. Exported for the install-local door // (`@objectstack/cloud-connection`), which refuses a catalog-carrying package diff --git a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts index 8132eb13747..a3afc431152 100644 --- a/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts +++ b/packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts @@ -382,6 +382,11 @@ describe('email_template — the name holds the bundle, and the built-in templat expect(isOverridableBaseline('email_template', 'com.acme.alpha')).toBe(false); expect(isOverridableBaseline('email_template', undefined)).toBe(false); expect(declaredBareNames(emailServicePkg([member('sys', 'auth.welcome', 'en-US')]))).toEqual([]); + // The baseline is read by the package id the door judges (`artifactPackageId`: `id`, else `name`). + expect(declaredBareNames({ name: EMAIL_SERVICE_PKG, emailTemplates: [member('sys', 'auth.welcome', 'en-US')] })).toEqual([]); + expect(declaredBareNames({ name: 'com.acme.alpha', emailTemplates: [member('alpha', 'auth.welcome', 'en-US')] })).toEqual([ + { type: 'email_template', name: 'auth.welcome' }, + ]); }); it('CONTROL — an app overriding a built-in template after the boot installs: the baseline holds nothing', async () => { diff --git a/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts b/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts index 3eb2ca1b94a..6328cb5582f 100644 --- a/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts +++ b/packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts @@ -25,10 +25,12 @@ // - CONTROL: a package declaring fresh names installs — and so does one // overriding the platform's built-in `auth.password_reset` template, whose // holder package is the overridable baseline: the send then renders the -// app's template, and the vocabulary spells that package exactly as the -// email plugin registers the built-in templates on this boot (read back -// from the registry, so the two spellings are pinned together across the -// package boundary without either package importing the other). +// app's template. That install is the spelling pin: the package the email +// plugin registers the built-in templates under on this boot is read back +// from the registry, and had the engine's baseline vocabulary spelled it +// any other way, that package would hold the name and the install would be +// refused naming it. The vocabulary stays private to the engine; neither +// package imports the other. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -37,7 +39,6 @@ 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 { OVERRIDABLE_BASELINE_PACKAGES } from '@objectstack/objectql'; import { defineStack } from '@objectstack/spec'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { bootShowcase } from './showcase-boot.js'; @@ -189,11 +190,9 @@ describe('one package holder per flow, webhook, connector and email template nam for (const name of [FLOW, HOOK, CONN, TPL]) 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"'); - // The platform's built-in templates are the overridable baseline, and the vocabulary spells - // their package exactly as the email plugin registered them on this boot. + // The platform's built-in templates are the overridable baseline: never named by the warning. const baseline = builtInTemplatePackage(); expect(baseline, 'the built-in templates are registered under a package').toBeDefined(); - expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template]).toEqual([baseline]); expect(line).not.toContain(BUILT_IN_TPL); expect(line).not.toContain(baseline!); expect(await packageIds(), 'both packages booted').toEqual(expect.arrayContaining(['com.qa.bare.alpha', 'com.qa.bare.beta'])); @@ -237,14 +236,16 @@ describe('one package holder per flow, webhook, connector and email template nam it('CONTROL — install-local of a package declaring fresh names, and overriding the built-in password-reset template, installs; the send renders the app\'s template', async () => { const baseline = builtInTemplatePackage(); - expect([...OVERRIDABLE_BASELINE_PACKAGES.email_template], 'the vocabulary spells the package the email plugin registers the built-ins under').toEqual([baseline]); + expect(baseline, 'the built-in templates are registered under a package').toBeDefined(); const { status, body } = await installLocal(inline('epsilon', { flows: [flow('epsilon', 'epsilon_flow')], webhooks: [webhook('epsilon', 'epsilon_hook')], connectors: [connector('epsilon', 'epsilon_conn')], emailTemplates: [template('epsilon', 'epsilon_tpl'), template('epsilon', BUILT_IN_TPL)], })); - expect(status, JSON.stringify(body)).toBeLessThan(300); + // The spelling pin: a refusal here would name the package the built-ins are registered under + // as a holder of the built-in name — the engine's baseline vocabulary spelling it otherwise. + expect(status, `the package ${baseline} registered the built-ins under is the baseline, not a holder: ${JSON.stringify(body)}`).toBeLessThan(300); expect(await packageIds()).toContain('com.qa.bare.epsilon'); const email = (stack.kernel as any).getService('email'); const rendered = await email.renderTemplate({ template: BUILT_IN_TPL, data: {} });