diff --git a/.changeset/registry-i18n-bundle-key.md b/.changeset/registry-i18n-bundle-key.md new file mode 100644 index 0000000000..19c6d222bc --- /dev/null +++ b/.changeset/registry-i18n-bundle-key.md @@ -0,0 +1,41 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): keep every locale of a declared i18n bundle in the registry (#7730) + +`EmailTemplateDefinitionSchema` declares that "multiple rows with the same `name` +but different `locale` form an i18n bundle" and that a template "is resolved by +`(name, locale)`". `SchemaRegistry.registerItem` keyed every item by its name +alone, so the second locale of a name collided with the first, went through the +`[Registry] Overwriting email_template: …` path, and replaced it. A stack +authoring an en-US and a zh-CN copy of one template materialized ONE row into +`sys_email_template`: declared, not enforced. The translated mail simply never +went out, with no error anywhere. + +**The key now carries the identity the spec declares.** A metadata type may +declare a discriminator (`ITEM_KEY_DISCRIMINATORS` in `registry.ts`); an item of +such a type is stored under `:@`, so the bundle's +members coexist. `email_template` / `locale` is the only entry today, and the +key computation is otherwise byte-identical — every other metadata type keeps +name-only identity and last-write-wins, which a pin test asserts. An item that +declares no locale is keyed as the canonical member, so `{ name }` and +`{ name, locale: 'en-US' }` remain one template and re-registration stays +idempotent. + +**Reads make the round trip whole.** Storing both rows is only half a fix if a +lookup then returns an arbitrary one, so a bare-name read of a bundled type +resolves through the same precedence tiers as before — ADR-0005 overlay, then +ADR-0048 prefer-local, then first composite — and picks the canonical (`en-US`) +member inside the winning tier, which is the locale `sendTemplate` already falls +back to. `getArtifactItem` keeps serving the packaged member over an overlay, +and withdrawal by name (`unregisterItem`, `removeOverlayEntry`, +`removeRuntimeShadow`) takes the whole bundle rather than one member, matching +the consumer side where `deactivateDeclaredEmailTemplate` sweeps +`sys_email_template` by name across locales because a delete event carries no +locale. + +For an app this shows up as declared email templates finally materializing per +locale: authoring `auth.welcome` in en-US and zh-CN now produces two +`sys_email_template` rows, and `IEmailService.sendTemplate` can pick the +recipient's language instead of whichever locale happened to be declared last. diff --git a/packages/objectql/src/registry-i18n-bundle-key.test.ts b/packages/objectql/src/registry-i18n-bundle-key.test.ts new file mode 100644 index 0000000000..edb1514fe0 --- /dev/null +++ b/packages/objectql/src/registry-i18n-bundle-key.test.ts @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7730] i18n bundles survive registration — `(name, locale)` is the key. + * + * `EmailTemplateDefinitionSchema` declares that "multiple rows with the same + * `name` but different `locale` form an i18n bundle" and that a template "is + * resolved by `(name, locale)`" (packages/spec/src/system/email-template.zod.ts). + * `registerItem` keyed every item by name alone, so the zh-CN row overwrote the + * en-US one through the `[Registry] Overwriting …` path and only the last + * locale ever reached `sys_email_template`. Declared, not enforced. + * + * The rows are only half the round trip: this file also pins the READ side, so + * a bare-name lookup of a bundle answers the same layer it always did (ADR-0005 + * overlay, then ADR-0048 prefer-local, then first composite) and picks the + * canonical locale within it, rather than whichever member the Map iterates + * first. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; +import { SchemaRegistry, ITEM_KEY_DISCRIMINATORS } from './registry'; + +/** A minimal spec-valid template; `locale` is supplied per case. */ +function tpl(name: string, locale: string | undefined, extra: Record = {}) { + return { + name, + label: `Label ${locale ?? '(default)'}`, + subject: `Subject ${locale ?? '(default)'}`, + bodyHtml: `

${locale ?? '(default)'}

`, + ...(locale === undefined ? {} : { locale }), + ...extra, + }; +} + +describe('SchemaRegistry — i18n bundle keys (#7730)', () => { + let registry: SchemaRegistry; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + }); + + describe('the declared bundle materializes', () => { + it('keeps both locales of one name — the reported symptom', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm'); + + const listed = registry.listItems('email_template'); + expect(listed).toHaveLength(2); + expect(listed.map((t) => t.locale).sort()).toEqual(['en-US', 'zh-CN']); + }); + + it('is what the `sys_email_template` materializer reads back', () => { + // `bootstrapDeclaredEmailTemplates` (plugin-email) reads + // `registry.listItems('email_template')` and upserts each row on + // `(name, locale)`. Four authored templates over three names — the QA + // stack's shape — must arrive as four rows, not three. + registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.reset', 'en-US'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('crm.digest', 'en-US'), 'name', 'com.acme.crm'); + + const pairs = registry + .listItems('email_template') + .map((t) => `${t.name}@${t.locale}`) + .sort(); + expect(pairs).toEqual([ + 'auth.reset@en-US', + 'auth.welcome@en-US', + 'auth.welcome@zh-CN', + 'crm.digest@en-US', + ]); + }); + + it('treats an omitted `locale` as the canonical member, not a fourth key', () => { + // The schema defaults `locale` to en-US, so `{ name }` and + // `{ name, locale: 'en-US' }` are the SAME template — a re-register, not + // a bundle member. Keying the absent value separately would double-seed. + registry.registerItem('email_template', tpl('auth.welcome', undefined), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'second write' }), 'name', 'com.acme.crm'); + + const listed = registry.listItems('email_template'); + expect(listed).toHaveLength(1); + expect(listed[0].subject).toBe('second write'); + }); + + it('still overwrites a genuine same-(name, locale) re-registration', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'first' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'second' }), 'name', 'com.acme.crm'); + + const listed = registry.listItems('email_template'); + expect(listed).toHaveLength(1); + expect(listed[0].subject).toBe('second'); + }); + + it('keeps two packages shipping the same (name, locale) apart', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'crm' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'hr' }), 'name', 'com.acme.hr'); + + expect(registry.listItems('email_template')).toHaveLength(2); + expect(registry.getItem('email_template', 'auth.welcome', 'com.acme.hr')?.subject).toBe('hr'); + }); + }); + + describe('the canonical member is the spec default, not a copy of it', () => { + it('pins the registry canonical locale to `EmailTemplateDefinitionSchema`', () => { + // The discriminator table carries the canonical locale as a literal. + // This is the assertion that stops it drifting from the schema default + // it mirrors — change one without the other and this goes red. + const parsed = EmailTemplateDefinitionSchema.parse(tpl('auth.welcome', undefined)); + expect(ITEM_KEY_DISCRIMINATORS.email_template.canonical).toBe(parsed.locale); + expect(ITEM_KEY_DISCRIMINATORS.email_template.field).toBe('locale'); + }); + }); + + describe('bare-name reads answer the same layer they always did', () => { + it('returns the canonical member regardless of registration order', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm'); + + expect(registry.getItem('email_template', 'auth.welcome')?.locale).toBe('en-US'); + expect(registry.getItem('email_template', 'auth.welcome', 'com.acme.crm')?.locale).toBe('en-US'); + }); + + it('still resolves a bundle that has no canonical member', () => { + // A stack may localize a template into zh-CN only. Before the key + // carried a locale this resolved because the single row sat on the bare + // name; a bundle-blind read would now answer `undefined` — a regression + // the fix must not introduce. + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm'); + + expect(registry.getItem('email_template', 'auth.welcome')?.locale).toBe('zh-CN'); + }); + + it('keeps ADR-0005 overlay precedence over the packaged bundle', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'overlay' }), 'name'); + + expect(registry.getItem('email_template', 'auth.welcome')?.subject).toBe('overlay'); + expect(registry.getItem('email_template', 'auth.welcome', 'com.acme.crm')?.subject).toBe('overlay'); + }); + + it('keeps ADR-0048 prefer-local precedence across packages', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'crm' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'hr' }), 'name', 'com.acme.hr'); + + expect(registry.getItem('email_template', 'auth.welcome', 'com.acme.hr')?.subject).toBe('hr'); + expect(registry.getItem('email_template', 'auth.welcome', 'com.acme.crm')?.subject).toBe('crm'); + }); + + it('serves the packaged artifact to `getArtifactItem`, never the overlay', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay' }), 'name'); + + const artifact = registry.getArtifactItem('email_template', 'auth.welcome'); + expect(artifact?.subject).toBe('packaged'); + expect(artifact?._packageId).toBe('com.acme.crm'); + }); + }); + + describe('withdrawal takes the whole bundle', () => { + it('unregisters every locale of a name', () => { + // Delete events carry `(type, name)` and no locale — the same reason + // `deactivateDeclaredEmailTemplate` sweeps `sys_email_template` by name + // across locales. Leaving a member behind would make it re-seed. + registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.reset', 'en-US'), 'name', 'com.acme.crm'); + + registry.unregisterItem('email_template', 'auth.welcome'); + + expect(registry.listItems('email_template').map((t) => t.name)).toEqual(['auth.reset']); + expect(registry.getItem('email_template', 'auth.welcome')).toBeUndefined(); + }); + + it('does not take a second package\'s same-named bundle with it', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'crm' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'crm' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'hr' }), 'name', 'com.acme.hr'); + + registry.unregisterItem('email_template', 'auth.welcome'); + + const left = registry.listItems('email_template'); + expect(left).toHaveLength(1); + expect(left[0].subject).toBe('hr'); + }); + + it('removes the overlay members and leaves the packaged bundle serving', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'packaged' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN', { subject: 'overlay' }), 'name'); + + expect(registry.removeOverlayEntry('email_template', 'auth.welcome')).toBe(true); + expect(registry.getItem('email_template', 'auth.welcome')?.subject).toBe('packaged'); + // Idempotent: nothing left to remove. + expect(registry.removeOverlayEntry('email_template', 'auth.welcome')).toBe(false); + }); + + it('heals the runtime shadow so the packaged bundle becomes visible again', () => { + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'packaged' }), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US', { subject: 'overlay' }), 'name'); + + expect(registry.removeRuntimeShadow('email_template', 'auth.welcome')).toBe(true); + expect(registry.getItem('email_template', 'auth.welcome')?.subject).toBe('packaged'); + // Conservative as before: with no artifact underneath it declines. + registry.registerItem('email_template', tpl('crm.digest', 'en-US'), 'name'); + expect(registry.removeRuntimeShadow('email_template', 'crm.digest')).toBe(false); + expect(registry.getItem('email_template', 'crm.digest')).toBeDefined(); + }); + }); + + describe('scope — only declared-discriminated types are re-keyed', () => { + it('leaves an undiscriminated type keyed by name alone, even when it carries a `locale`', () => { + // The key computation is generic to every registered metadata type. A + // type whose identity the spec does NOT declare as a pair must keep + // last-write-wins on the name, or this fix would quietly change the + // identity of every other metadata kind. + registry.registerItem('page', { name: 'home', locale: 'en-US', title: 'first' }, 'name', 'com.acme.crm'); + registry.registerItem('page', { name: 'home', locale: 'zh-CN', title: 'second' }, 'name', 'com.acme.crm'); + + const pages = registry.listItems('page'); + expect(pages).toHaveLength(1); + expect(pages[0].title).toBe('second'); + expect(registry.getItem('page', 'home')?.title).toBe('second'); + }); + + it('declares exactly one discriminated type today', () => { + // A guard on the blast radius: adding a type here re-keys every item of + // that type, so it is a deliberate contract change, not a tweak. + expect(Object.keys(ITEM_KEY_DISCRIMINATORS)).toEqual(['email_template']); + }); + }); + + describe('the #7557 disabled-package gate still sees every member', () => { + it('hides the whole bundle when its owning package is disabled', () => { + // `listItems` filters by each item's `_packageId` (PR #7700). Re-keying + // must not let a bundle member slip past that filter. + registry.installPackage({ id: 'com.acme.crm', name: 'CRM', version: '1.0.0' } as any); + registry.registerItem('email_template', tpl('auth.welcome', 'en-US'), 'name', 'com.acme.crm'); + registry.registerItem('email_template', tpl('auth.welcome', 'zh-CN'), 'name', 'com.acme.crm'); + expect(registry.listItems('email_template')).toHaveLength(2); + + registry.disablePackage('com.acme.crm'); + expect(registry.listItems('email_template')).toHaveLength(0); + + registry.enablePackage('com.acme.crm'); + expect(registry.listItems('email_template')).toHaveLength(2); + }); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index f8c7f2ac3d..4cfe84e254 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -892,6 +892,116 @@ function isCodeArtifactBody(item: unknown): boolean { return !isTenantAuthored(it); } +// ============================================================================ +// i18n bundles — metadata types whose identity is (name, ) +// ============================================================================ + +/** + * [#7730] Metadata types whose IDENTITY is a pair, not a name. + * + * `registerItem` keys every item by `name` (composite `:` when + * a package ships it). For most types that IS the identity. `email_template` + * declares otherwise: `EmailTemplateDefinitionSchema` states that "multiple + * rows with the same `name` but different `locale` form an i18n bundle; the + * service picks the best match for the recipient's locale, falling back to + * `en-US`" (`packages/spec/src/system/email-template.zod.ts`), and its header + * says a template "is resolved by `(name, locale)`". A name-only key cannot + * hold that: the second locale collided with the first and overwrote it + * through the `[Registry] Overwriting …` path, so a stack authoring en-US and + * zh-CN copies materialized ONE row into `sys_email_template` — declared, not + * enforced. + * + * The discriminator is declared PER TYPE rather than duck-typed off a `locale` + * property, because the key computation is generic to every registered + * metadata type: reading whatever `item.locale` happened to be set would + * silently re-key any other type that grows a locale-ish field, which is a much + * larger contract change than the one this table makes. `email_template` is the + * only type on `main` whose schema declares a top-level `locale` that is part + * of its identity (`grep ' locale:' packages/spec/src/**\/*.zod.ts` — the + * other hits are SCIM users, execution context, discovery and translation + * payloads, none of which is a registered metadata type). + * + * `canonical` is the bundle member a bare-name read resolves to. It mirrors the + * schema's own `locale` default and `sendTemplate`'s documented fallback, and + * `registry-i18n-bundle-key.test.ts` pins the two together so this copy cannot + * drift from the spec. + */ +export const ITEM_KEY_DISCRIMINATORS: Readonly> = { + email_template: { field: 'locale', canonical: 'en-US' }, +}; + +/** + * Separator between an item's name and its bundle discriminator inside a + * storage key: `pkg:auth.welcome@zh-CN`. + * + * `@` is unambiguous here in both directions. A discriminated type's `name` can + * never contain it (`EmailTemplateDefinitionSchema` pins dotted snake_case), + * and a scoped package id that does (`@acme/crm:auth.welcome@zh-CN`) is still + * parsed correctly because {@link bundleBaseKey} only looks for the separator + * AFTER the last `:`. + */ +const BUNDLE_KEY_SEPARATOR = '@'; + +/** The discriminator value an item declares, trimmed; `''` when it declares none. */ +function discriminatorValue(item: unknown, field: string): string { + const holder = item as Record | null | undefined; + const raw = holder?.[field] ?? holder?.content?.[field]; + return typeof raw === 'string' ? raw.trim() : ''; +} + +/** `pkg:auth.welcome` + `zh-CN` → `pkg:auth.welcome@zh-CN`. */ +function withDiscriminator(baseKey: string, value: string): string { + return `${baseKey}${BUNDLE_KEY_SEPARATOR}${value}`; +} + +/** + * Strip a storage key's bundle suffix: `pkg:auth.welcome@zh-CN` → + * `pkg:auth.welcome`. Keys of undiscriminated types come back unchanged, and so + * does a scoped package id's own `@` (the search starts after the last `:`). + */ +function bundleBaseKey(key: string): string { + const at = key.indexOf(BUNDLE_KEY_SEPARATOR, key.lastIndexOf(':') + 1); + return at === -1 ? key : key.slice(0, at); +} + +/** One member of an i18n bundle, as stored. */ +type BundleEntry = { key: string; item: any }; + +/** + * The members of `(type, name)`'s bundle, grouped by the SAME precedence tiers + * {@link SchemaRegistry.getItem} applies to an undiscriminated type — so + * bundling changes WHICH ROWS EXIST, never which layer wins: + * + * - `bare` — bare-key group (`name@`): the ADR-0005 runtime/DB overlay + * - `local` — the `currentPackageId` composite group (ADR-0048 prefer-local) + * - `other` — every other composite group, in Map insertion order + * + * Within a tier the `canonical` member is first, then insertion order — so a + * bare-name read of a bundle is decided by the spec's declared fallback locale + * rather than by which locale the author happened to declare first. + */ +function collectBundle( + collection: Map, + name: string, + disc: { field: string; canonical: string }, + currentPackageId?: string, +): { bare: BundleEntry[]; local: BundleEntry[]; other: BundleEntry[] } { + const bare: BundleEntry[] = []; + const local: BundleEntry[] = []; + const other: BundleEntry[] = []; + const localBase = currentPackageId ? `${currentPackageId}:${name}` : undefined; + for (const [key, item] of collection) { + const base = bundleBaseKey(key); + if (base === name) bare.push({ key, item }); + else if (base.endsWith(`:${name}`)) (localBase && base === localBase ? local : other).push({ key, item }); + } + const canonicalFirst = (entries: BundleEntry[]) => { + const idx = entries.findIndex((e) => discriminatorValue(e.item, disc.field) === disc.canonical); + return idx <= 0 ? entries : [entries[idx], ...entries.filter((_, i) => i !== idx)]; + }; + return { bare: canonicalFirst(bare), local: canonicalFirst(local), other: canonicalFirst(other) }; +} + export class SchemaRegistry { // ========================================== // Logging control @@ -1822,8 +1932,21 @@ export class SchemaRegistry { ); } + // [#7730] The key carries the item's IDENTITY. For most types that is the + // name alone; a discriminated type (see ITEM_KEY_DISCRIMINATORS) is keyed + // by the pair its spec declares, so the members of an i18n bundle coexist + // instead of the second one overwriting the first. An item that declares no + // discriminator is keyed as the CANONICAL member — the same row the schema + // default produces — so `{ name }` and `{ name, locale: 'en-US' }` remain + // one item and a re-register stays idempotent. + const disc = ITEM_KEY_DISCRIMINATORS[type]; + const withDisc = (base: string) => + disc ? withDiscriminator(base, discriminatorValue(item, disc.field) || disc.canonical) : base; + + // The bare (overlay) slot for this item — see the artifact-vs-DB warning below. + const bareKey = withDisc(baseName); // Use composite key (packageId:name) when packageId is provided - const storageKey = packageId ? `${packageId}:${baseName}` : baseName; + const storageKey = packageId ? withDisc(`${packageId}:${baseName}`) : bareKey; if (collection.has(storageKey)) { this.log(`[Registry] Overwriting ${type}: ${storageKey}`); @@ -1862,8 +1985,12 @@ export class SchemaRegistry { // ADR-0005 behavior, but the silent shadowing can surprise package // authors and operators. Log a single warning so the situation is // discoverable in startup logs. - if (packageId && collection.has(baseName)) { - const dbOnly = collection.get(baseName) as any; + // [#7730] `bareKey` rather than `baseName`: for a discriminated type the + // overlay slot this warning asks about is the bundle member with the SAME + // discriminator (`auth.welcome@zh-CN`), not the name on its own — which is + // a key no discriminated item is ever stored under. + if (packageId && collection.has(bareKey)) { + const dbOnly = collection.get(bareKey) as any; if (dbOnly && !dbOnly._packageId) { console.warn( `[Registry] Collision: ${type}/${baseName} ships from package ` + @@ -1907,6 +2034,32 @@ export class SchemaRegistry { console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`); return; } + + // [#7730] A discriminated type's name addresses a BUNDLE, so a withdrawal + // by name takes the whole bundle — the same shape as the consumer side, + // where `deactivateDeclaredEmailTemplate` sweeps `sys_email_template` by + // name across locales because a delete event carries no locale. Removing + // one member would leave the rest registered and re-seedable, i.e. an + // undeletable template. The GROUP is chosen exactly as before (the bare + // group if it exists, else the first composite group in insertion order), + // so this does not start deleting a second package's same-named item. + const disc = ITEM_KEY_DISCRIMINATORS[type]; + if (disc) { + const { bare, local, other } = collectBundle(collection, name, disc); + const group = bare.length > 0 ? bare : [...local, ...other]; + if (group.length === 0) { + console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`); + return; + } + const groupBase = bundleBaseKey(group[0].key); + for (const { key } of group) { + if (bundleBaseKey(key) !== groupBase) continue; + collection.delete(key); + this.log(`[Registry] Unregistered ${type}: ${key}`); + } + return; + } + if (collection.has(name)) { collection.delete(name); this.log(`[Registry] Unregistered ${type}: ${name}`); @@ -1948,6 +2101,20 @@ export class SchemaRegistry { const collection = this.metadata.get(type); if (!collection) return undefined; + + // [#7730] A discriminated type's name addresses a BUNDLE, so every tier + // below is a group rather than a single key. The tiers themselves are + // unchanged (overlay → prefer-local → first composite); within a tier the + // canonical member wins, which is the locale `sendTemplate` falls back to. + // A caller that needs a specific member reads the bundle from + // `listItems(type)` and picks on the discriminator — this API takes a name + // and can only answer about the name. + const disc = ITEM_KEY_DISCRIMINATORS[type]; + if (disc) { + const { bare, local, other } = collectBundle(collection, name, disc, currentPackageId); + return ([...bare, ...local, ...other][0]?.item as T) ?? undefined; + } + // A bare-key entry (a runtime/DB overlay rehydrated by restoreMetadataFromDb) // intentionally shadows the packaged composite item — ADR-0005 overlay // precedence (a customization wins over its package default). This is @@ -2009,6 +2176,24 @@ export class SchemaRegistry { } const collection = this.metadata.get(type); if (!collection) return undefined; + + // [#7730] Bundle-aware form of the three tiers below, for a type whose + // identity is (name, discriminator). Same order — prefer-local composite, + // then any composite, then the bare-key fallback with its package check — + // over bundle GROUPS instead of single keys, with the canonical member + // first inside each group. + const disc = ITEM_KEY_DISCRIMINATORS[type]; + if (disc) { + const { bare, local, other } = collectBundle(collection, name, disc, currentPackageId); + for (const { item } of [...local, ...other]) { + if (isCodeArtifactBody(item)) return item as T; + } + for (const { item } of bare) { + if (isCodeArtifactBody(item) && (!currentPackageId || item._packageId === currentPackageId)) return item as T; + } + return undefined; + } + // ADR-0048 prefer-local: when the caller resolves within a package, the // artifact owned by that package wins over a first-match composite scan, // so two installed packages shipping the same name don't resolve by Map @@ -2059,7 +2244,28 @@ export class SchemaRegistry { */ removeRuntimeShadow(type: string, name: string): boolean { const collection = this.metadata.get(type); - if (!collection || !collection.has(name)) return false; + if (!collection) return false; + + // [#7730] For a discriminated type the shadow sits at `name@` + // — nothing is ever stored under the plain name — so the plain-key test + // would silently answer "no shadow" for every email template. The overlay + // layer is keyed by name alone (`sys_metadata` is unique on type+name+org), + // so the shadow group is swept as one, exactly as the delete event + // addresses it. + const disc = ITEM_KEY_DISCRIMINATORS[type]; + if (disc) { + const { bare, local, other } = collectBundle(collection, name, disc); + if (bare.length === 0) return false; + const artifact = [...local, ...other].find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); + if (!artifact) return false; + for (const { key } of bare) { + collection.delete(key); + this.log(`[Registry] Removed runtime shadow ${type}: ${key} (artifact ${artifact.item._packageId} restored)`); + } + return true; + } + + if (!collection.has(name)) return false; for (const [key, item] of collection) { if (key !== name && key.endsWith(`:${name}`)) { const it = item as any; @@ -2112,7 +2318,27 @@ export class SchemaRegistry { */ removeOverlayEntry(type: string, name: string): boolean { const collection = this.metadata.get(type); - if (!collection || !collection.has(name)) return false; + if (!collection) return false; + + // [#7730] Same relocation as {@link removeRuntimeShadow}: a discriminated + // type's overlay slot is `name@`, so the plain-key read + // finds nothing and the deleted overlay would keep being served for the + // life of the process — the very residue #5079 removed. The refusal is + // still per entry: a bundle member that IS a packaged artifact stays. + const disc = ITEM_KEY_DISCRIMINATORS[type]; + if (disc) { + const { bare } = collectBundle(collection, name, disc); + let removed = false; + for (const { key, item } of bare) { + if (isCodeArtifactBody(item)) continue; + collection.delete(key); + this.log(`[Registry] Removed overlay entry ${type}: ${key} (no layer serves it any more)`); + removed = true; + } + return removed; + } + + if (!collection.has(name)) return false; const plain = collection.get(name) as any; if (plain && plain._packageId && plain._packageId !== 'sys_metadata' && !isTenantAuthored(plain)) { return false;