From ddd5af597c93f3e28f7ee67794d38df49374b58d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:42:39 +0000 Subject: [PATCH 1/3] fix(metadata-protocol): the artifact envelope is stamped only on the bundle member the package ships An environment overlay of an email_template member the package does not ship (a zh-CN beside a shipped en-US) was hydrated wearing the bundle's artifact envelope, so isCodeArtifactBody read it as packaged code. mergeArtifactProtection now grafts an artifact only onto a body of its own bundle member (itemDiscriminator), and its callers look the artifact up at the body's own member. removeRuntimeShadow's named-member test returns to member level. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...ol.lookup-artifact-item-call-sites.test.ts | 12 +- .../protocol.register-item-call-sites.test.ts | 2 +- packages/metadata-protocol/src/protocol.ts | 63 +++++- ...nment-overlay-email-locale-restart.test.ts | 179 +++++++++++++++++- .../src/registry-i18n-bundle-key.test.ts | 12 +- packages/objectql/src/registry.ts | 25 ++- 6 files changed, 263 insertions(+), 30 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index e4b4e4411d9..06647a5fc0f 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -70,10 +70,10 @@ type Disposition = 'definition' | 'served-package envelope' | 'no'; const SITES: ReadonlyArray = [ { member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition', why: 'the definition itself' }, - { member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item)', noPackage: true, disposition: 'served-package envelope', - why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule)' }, - { member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope', - why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' }, + { member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope', + why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member' }, + { member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope', + why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares; [#23074] at each item\'s own bundle member' }, { member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no', why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' }, { member: 'getMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', @@ -94,8 +94,8 @@ const SITES: ReadonlyArray; if (typeof a !== 'object') return item; + if (bundleMemberOf(type, a) !== bundleMemberOf(type, item)) return item; const out: Record = { ...(item as Record) }; if (itemLock === undefined && resolveItemLock({ artifact: [a], overlay: item }).layer === 'artifact') { if (a._lock !== undefined) out._lock = a._lock; @@ -1801,6 +1825,18 @@ function mergeArtifactProtection( return out; } +/** + * [#23074] The bundle member a body IS, for a type whose identity is a pair + * ({@link ITEM_KEY_DISCRIMINATORS}): `itemDiscriminator` under the type's + * canonical spelling, so the canonical member for a body that declares none. + * `undefined` for every other type, which has no members. The member + * {@link mergeArtifactProtection} matches an artifact against, and the one + * each of its callers looks the artifact up at. + */ +function bundleMemberOf(type: string, body: unknown): string | undefined { + return itemDiscriminator(canonicalMetaType(type), body); +} + /** * [#22024, ADR-0048, ADR-0010 §3.3] The package whose artifact envelope * ({@link mergeArtifactProtection}: `_packageId`, `_packageVersion`, @@ -9650,7 +9686,9 @@ export class ObjectStackProtocolImplementation implements // takes priority; else the item's own `_packageId`.) [#22024] // The by-name read takes the same rule ({@link envelopePackageId}). const itemPackageId = envelopePackageId(packageId, it); - const a = this.lookupArtifactItem(request.type, itemName, itemPackageId); + // [#23074] …and to the item's own bundle member, whose artifact + // alone may lend it an envelope ({@link mergeArtifactProtection}). + const a = this.lookupArtifactItem(request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)); let itemLock: ItemLock | undefined; if (typeof itemName === 'string' && (it as any)?._draft !== true) { const address: ItemAddress = { @@ -9671,7 +9709,7 @@ export class ObjectStackProtocolImplementation implements // is the other exit a client reads field metadata from, and // an overlay row wins over the (already-governed) registry // entry in the merge above, so it carries the same lie. - governed.push(this.governServedObject(request.type, mergeArtifactProtection(it, a, itemLock))); + governed.push(this.governServedObject(request.type, mergeArtifactProtection(request.type, it, a, itemLock))); } return { type: request.type, @@ -10436,8 +10474,11 @@ export class ObjectStackProtocolImplementation implements // serves. Before, a read naming no package took the first-registered // package's envelope, so a name two packages ship answered one // package's stored copy under the other package's `_packageId`. + // [#23074] …and at the served item's own bundle member, whose artifact + // alone may lend it an envelope ({@link mergeArtifactProtection}): a + // member the package does not ship is served as the environment's own. const artifactItem = this.lookupArtifactItem( - request.type, request.name, envelopePackageId(request.packageId, item), + request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item), ); // [#21738] The lock is the one item-lock resolution's, over the layers // this read resolved from its address: [#21803] every installed @@ -10458,7 +10499,7 @@ export class ObjectStackProtocolImplementation implements request.type, this.governServedObject( request.type, - mergeArtifactProtection(item, artifactItem, itemLock), + mergeArtifactProtection(request.type, item, artifactItem, itemLock), ), ); // ADR-0047 — list views additionally get reference-integrity @@ -18698,7 +18739,15 @@ export class ObjectStackProtocolImplementation implements this.hydrateExpandedViewItems(type, data, options, registry); return true; } - const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined); + // [#23074] The artifact of the row's OWN bundle member: an overlay of + // a member the package does not ship has none, so it registers as the + // tenant row it is ({@link mergeArtifactProtection}). It used to take + // the bundle's artifact canonical-first, and a `zh-CN` overlay beside + // a shipped `en-US` was registered wearing that envelope, which every + // registry reader then took for the package's own `zh-CN` artifact. + const artifact = this.lookupArtifactItem( + type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data), + ); // [#16702] Say what this row IS before the artifact envelope is grafted // on top of it. Every body reaching this hydrator came out of a // `sys_metadata` write and is therefore tenant-authored by definition @@ -18720,7 +18769,7 @@ export class ObjectStackProtocolImplementation implements // and its receipt rendered both as the package. Registered as the // tenant row it is, it is reported as one and never armed. const envelope = canonicalType === 'flow' ? undefined : artifact; - registry.registerItem(type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), 'name' as any); + registry.registerItem(type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), 'name' as any); this.hydrateExpandedViewItems(type, data, options, registry); return true; } @@ -18872,7 +18921,7 @@ export class ObjectStackProtocolImplementation implements // where the item's own artifact exists, its `_provenance` still // wins (ADR-0010 §3.3), as it does on the container. const authored = options.tenantAuthored ? stateTenantAuthorship(item) : item; - out.push(mergeArtifactProtection(authored, ownArtifact) as Record); + out.push(mergeArtifactProtection(type, authored, ownArtifact) as Record); } return out; } diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 901b89ccc79..3e47a7dc857 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -47,6 +47,7 @@ import { SysMetadataObject, } from '@objectstack/metadata-core'; import { ObjectQL } from './engine.js'; +import { isCodeArtifactBody } from './registry.js'; const PKG = 'com.acme.mail'; const NAME = 'acme.welcome'; @@ -161,6 +162,8 @@ async function boot( stores: Map>>, /** [#22877] `'control-plane'` boots a kernel with no environment, whose registry a save writes through to. */ kernel: 'environment' | 'control-plane' = 'environment', + /** [#23074] Runs once the package is registered, before the boot hydration. */ + beforeHydration?: (engine: ObjectQL) => void, ) { const engine = new ObjectQL(); engine.registerDriver(makeMemoryDriver(stores), true); @@ -169,6 +172,7 @@ async function boot( engine.registry.registerObject(o as any, 'test-package'); } engine.registerApp(mailManifest()); + beforeHydration?.(engine); const protocol = new ObjectStackProtocolImplementation( engine, () => new Map(), kernel === 'environment' ? 'env_prod' : undefined, ); @@ -386,9 +390,11 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2) it('DELETE by member of a member NO artifact ships: its overlay leaves the registry, and the canonical overlay stays', async () => { // The package ships `acme.digest` in `en-US` only (the showcase's - // shape). The hydrated `zh-CN` overlay wears the bundle's artifact - // envelope, so the heal must lift it as that member's shadow; leaving - // it kept serving the reset wording until the next boot. + // shape). Leaving the hydrated `zh-CN` overlay registered kept serving + // the reset wording until the next boot. [#23074] It is registered + // as the tenant row it is (no artifact envelope), so the heal's + // overlay-entry retirement takes it; the readers are pinned in the + // last block of this file. const stores = new Map>>(); const first = await boot(stores); await first.protocol.saveMetaItem({ type: T, name: DIGEST, item: { ...template('en-US', 'ADMIN digest en'), name: DIGEST } }); @@ -566,3 +572,170 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228 expect(registrySubject(peer.engine, 'en-US')).toBe('ADMIN en'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// [#23074] The artifact envelope is stamped only on the member the package ships +// ───────────────────────────────────────────────────────────────────────────── +// +// The package ships `acme.digest` in `en-US` only and `acme.welcome` in both +// locales. An environment overlay of `acme.digest` in `zh-CN` customizes a +// member no artifact ships, so it is the environment's own item. Before, the +// boot hydration grafted the bundle's `en-US` artifact envelope onto it +// (`_packageId`, `_packageVersion`, `_provenance: 'package'`): the registry's +// artifact lookup then answered the overlay itself as the package's `zh-CN` +// artifact, both read doors served it under the package's provenance, and the +// overlay-entry retirement refused it as code. CONTROL in every case: the +// shipped member's overlay (`acme.welcome` in `zh-CN`) keeps its artifact's +// envelope, lookup, read and heal. Last, a type with no members keeps its +// envelope, even where the overlay's `locale` differs from the artifact's. + +const PROTECTION_KEYS = ['_packageId', '_packageVersion', '_provenance'] as const; +const protectionOf = (body: unknown) => + Object.fromEntries(PROTECTION_KEYS.map((key) => [key, (body as Record | undefined)?.[key]])); +/** The envelope the package's artifacts carry, which an overlay of a shipped member wears. */ +const PACKAGED = { _packageId: PKG, _packageVersion: '1.0.0', _provenance: 'package' }; +/** What the hydration says of every stored row before any envelope is grafted (ADR-0010 `'org'`). */ +const TENANT = { _packageId: undefined, _packageVersion: undefined, _provenance: 'org' }; + +/** Overlays of `name` in `en-US` and `zh-CN`, then a restart: the second boot's registry holds both hydrated. */ +async function bootWithOverlays( + stores: Map>>, + name: string, + beforeHydration?: (engine: ObjectQL) => void, +) { + const first = await boot(stores); + for (const locale of ['en-US', 'zh-CN']) { + expect(await first.protocol.saveMetaItem({ type: T, name, item: { ...template(locale, `ADMIN ${name} ${locale}`), name } })) + .toMatchObject({ success: true }); + } + return boot(stores, 'environment', beforeHydration); +} + +describe('[#23074] the artifact envelope is stamped only on the member the package ships', () => { + it('the boot hydration: an unshipped member\'s overlay registers as the tenant row it is; CONTROL the shipped member\'s wears its artifact\'s envelope', async () => { + const { engine } = await bootWithOverlays(new Map(), DIGEST); + const zh = engine.registry.getItem(T, DIGEST, undefined, 'zh-CN'); + expect(zh?.subject).toBe(`ADMIN ${DIGEST} zh-CN`); + expect(protectionOf(zh)).toEqual(TENANT); + expect(isCodeArtifactBody(zh)).toBe(false); + // The package ships no `zh-CN` member, and the overlay is not one. + expect(engine.registry.getArtifactItem(T, DIGEST, undefined, 'zh-CN')).toBeUndefined(); + // CONTROL — the bundle's canonical member, which the package ships. + const en = engine.registry.getItem(T, DIGEST, undefined, 'en-US'); + expect(en?.subject).toBe(`ADMIN ${DIGEST} en-US`); + expect(protectionOf(en)).toEqual(PACKAGED); + expect(isCodeArtifactBody(en)).toBe(true); + + // CONTROL — a non-canonical member the package ships. + const shipped = (await bootWithOverlays(new Map(), NAME)).engine; + const shippedZh = shipped.registry.getItem(T, NAME, undefined, 'zh-CN'); + expect(shippedZh?.subject).toBe(`ADMIN ${NAME} zh-CN`); + expect(protectionOf(shippedZh)).toEqual(PACKAGED); + expect(isCodeArtifactBody(shippedZh)).toBe(true); + expect(shipped.registry.getArtifactItem(T, NAME, undefined, 'zh-CN')?.subject).toBe('PKG zh'); + }); + + it('the by-name read (?member=): an unshipped member is served as the environment\'s item; CONTROL the shipped member under its package', async () => { + const { protocol } = await bootWithOverlays(new Map(), DIGEST); + const zh: any = await protocol.getMetaItem({ type: T, name: DIGEST, member: 'zh-CN' }); + expect(zh.item.subject).toBe(`ADMIN ${DIGEST} zh-CN`); + expect(protectionOf(zh.item)).toEqual({ _packageId: undefined, _packageVersion: undefined, _provenance: undefined }); + expect(isCodeArtifactBody(zh.item)).toBe(false); + expect([zh.provenance, zh.packageId, zh.packageVersion]).toEqual([undefined, undefined, undefined]); + expect(zh).toMatchObject({ lock: 'none', editable: true, deletable: true }); + + const shipped = (await bootWithOverlays(new Map(), NAME)).protocol; + const shippedZh: any = await shipped.getMetaItem({ type: T, name: NAME, member: 'zh-CN' }); + expect(shippedZh.item.subject).toBe(`ADMIN ${NAME} zh-CN`); + expect(protectionOf(shippedZh.item)).toEqual(PACKAGED); + expect(shippedZh).toMatchObject({ + provenance: 'package', packageId: PKG, packageVersion: '1.0.0', lock: 'none', editable: true, deletable: true, + }); + }); + + it('the list, served and for execution: an unshipped member is listed as the environment\'s item; CONTROL the shipped members under their package', async () => { + const unshipped = (await bootWithOverlays(new Map(), DIGEST)).protocol; + const shipped = (await bootWithOverlays(new Map(), NAME)).protocol; + for (const audience of ['served', 'execution'] as const) { + const list = async (protocol: ObjectStackProtocolImplementation) => (((audience === 'served' + ? await protocol.getMetaItems({ type: T }) + : await protocol.getMetaItemsForExecution({ type: T })) as any).items ?? []) as any[]; + const member = (items: any[], name: string, locale: string) => + items.find((it) => it?.name === name && it?.locale === locale); + const items = await list(unshipped); + const zh = member(items, DIGEST, 'zh-CN'); + expect(zh?.subject, audience).toBe(`ADMIN ${DIGEST} zh-CN`); + expect(protectionOf(zh), audience).toEqual({ _packageId: undefined, _packageVersion: undefined, _provenance: undefined }); + expect(protectionOf(member(items, DIGEST, 'en-US')), audience).toEqual(PACKAGED); + expect(protectionOf(member(await list(shipped), NAME, 'zh-CN')), audience).toEqual(PACKAGED); + } + }); + + it('the reset (?member=): an unshipped member is retired by the overlay-entry tier; CONTROL the shipped member is un-shadowed onto its artifact', async () => { + // The registry's two heal verbs over the hydrated entries, as the + // delete's heal (`restoreArtifactRegistryView`) calls them. + const unshipped = (await bootWithOverlays(new Map(), DIGEST)).engine; + expect(unshipped.registry.removeRuntimeShadow(T, DIGEST, 'zh-CN')).toBe(false); + expect(unshipped.registry.removeOverlayEntry(T, DIGEST, 'zh-CN')).toBe(true); + expect(unshipped.registry.getItem(T, DIGEST, undefined, 'zh-CN')).toBeUndefined(); + expect(unshipped.registry.getItem(T, DIGEST, undefined, 'en-US')?.subject).toBe(`ADMIN ${DIGEST} en-US`); + + const shipped = (await bootWithOverlays(new Map(), NAME)).engine; + expect(shipped.registry.removeOverlayEntry(T, NAME, 'zh-CN')).toBe(false); + expect(shipped.registry.removeRuntimeShadow(T, NAME, 'zh-CN')).toBe(true); + expect(shipped.registry.getItem(T, NAME, undefined, 'zh-CN')?.subject).toBe('PKG zh'); + expect(shipped.registry.getItem(T, NAME, undefined, 'en-US')?.subject).toBe(`ADMIN ${NAME} en-US`); + + // Through the door, both members, at once and after a restart. + for (const [name, after] of [[DIGEST, undefined], [NAME, 'PKG zh']] as const) { + const stores = new Map>>(); + const { engine, protocol } = await bootWithOverlays(stores, name); + expect(await protocol.deleteMetaItem({ type: T, name, member: 'zh-CN' }), name).toMatchObject({ success: true }); + expect(engine.registry.getItem(T, name, undefined, 'zh-CN')?.subject, name).toBe(after); + expect(engine.registry.getItem(T, name, undefined, 'en-US')?.subject, name).toBe(`ADMIN ${name} en-US`); + expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject, name).toBe(after); + const restarted = await boot(stores); + expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject, name).toBe(after); + } + }); + + it('a registry that answers another member\'s artifact: the graft keeps it off the body', async () => { + // `lookupArtifactItem`'s note: a registry without the member parameter + // ignores it, so it answers the bundle's artifact canonical-first. + const { engine, protocol } = await bootWithOverlays(new Map(), DIGEST, (booted) => { + const registry = booted.registry as any; + const artifactOf = registry.getArtifactItem.bind(registry); + vi.spyOn(registry, 'getArtifactItem').mockImplementation((...args: any[]) => artifactOf(args[0], args[1], args[2])); + }); + expect(engine.registry.getArtifactItem(T, DIGEST, undefined, 'zh-CN')?.locale).toBe('en-US'); + expect(protectionOf(engine.registry.getItem(T, DIGEST, undefined, 'zh-CN'))).toEqual(TENANT); + const zh: any = await protocol.getMetaItem({ type: T, name: DIGEST, member: 'zh-CN' }); + expect(zh.item._packageId).toBeUndefined(); + expect(zh.packageId).toBeUndefined(); + // CONTROL — the canonical member is that artifact's own member, and wears it. + expect(protectionOf(engine.registry.getItem(T, DIGEST, undefined, 'en-US'))).toEqual(PACKAGED); + }); + + it('CONTROL: a type with no members keeps its artifact\'s envelope, even where the overlay\'s locale is not the artifact\'s', async () => { + // `translation` carries a `locale`, and is identified by its name + // alone: only a type `ITEM_KEY_DISCRIMINATORS` declares has members. + const TR = 'acme_mail_messages'; + const translation = (locale: string, save: string) => ({ name: TR, label: 'Mail messages', locale, messages: { save } }); + const shipTranslation = (booted: ObjectQL) => { + booted.registry.registerItem('translation', translation('zh-CN', '保存'), 'name', PKG); + }; + const stores = new Map>>(); + const first = await boot(stores, 'environment', shipTranslation); + expect(await first.protocol.saveMetaItem({ type: 'translation', name: TR, item: translation('ja-JP', '保存する') })) + .toMatchObject({ success: true }); + const { engine, protocol } = await boot(stores, 'environment', shipTranslation); + const entry = engine.registry.getItem('translation', TR); + expect(entry?.locale).toBe('ja-JP'); + expect(entry?._packageId).toBe(PKG); + expect(isCodeArtifactBody(entry)).toBe(true); + const read: any = await protocol.getMetaItem({ type: 'translation', name: TR }); + expect(read.item.locale).toBe('ja-JP'); + expect(read.item._packageId).toBe(PKG); + expect(read.packageId).toBe(PKG); + }); +}); diff --git a/packages/objectql/src/registry-i18n-bundle-key.test.ts b/packages/objectql/src/registry-i18n-bundle-key.test.ts index 85a31086c08..7296706aac8 100644 --- a/packages/objectql/src/registry-i18n-bundle-key.test.ts +++ b/packages/objectql/src/registry-i18n-bundle-key.test.ts @@ -272,10 +272,14 @@ describe('SchemaRegistry — i18n bundle keys (#7730)', () => { expect(registry.getItem('email_template', 'auth.welcome', undefined, 'zh-CN')?.subject).toBe('packaged zh'); expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('overlay en'); - // A member the package does NOT ship: its shadow is lifted all the same - // (a packaged artifact of the name remains), and nothing else moves. - registry.registerItem('email_template', tpl('auth.welcome', 'ja-JP', { subject: 'overlay ja' }), 'name'); - expect(registry.removeRuntimeShadow('email_template', 'auth.welcome', 'ja-JP')).toBe(true); + // [#23074] A member the package does NOT ship: no artifact of that + // member takes its place, so the shadow heal declines, and the + // overlay-entry retirement takes it, the tenant row it is (the protocol + // registers it with no artifact envelope). Nothing else moves. + registry.registerItem('email_template', tpl('auth.welcome', 'ja-JP', { subject: 'overlay ja', _provenance: 'org' }), 'name'); + expect(registry.removeRuntimeShadow('email_template', 'auth.welcome', 'ja-JP')).toBe(false); + expect(registry.getItem('email_template', 'auth.welcome', undefined, 'ja-JP')?.subject).toBe('overlay ja'); + expect(registry.removeOverlayEntry('email_template', 'auth.welcome', 'ja-JP')).toBe(true); expect(registry.getItem('email_template', 'auth.welcome', undefined, 'ja-JP')).toBeUndefined(); expect(registry.getItem('email_template', 'auth.welcome', undefined, 'en-US')?.subject).toBe('overlay en'); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b4b1c5441c9..7ed354db71d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -4779,10 +4779,10 @@ export class SchemaRegistry { * * @param member - [#22877] For a type whose identity is a pair, the ONE * bundle member whose shadow to lift, as its discriminator value. Named, - * only that member's shadow goes — whether or not the package ships that - * member, because the test is the one it always was: a packaged artifact - * of the NAME remains, so the name stays resolvable. Omitted, the bundle's - * whole shadow group, as before. + * only that member's shadow goes, and only when the package ships that + * member [#23074]: a packaged artifact of the MEMBER remains, so the + * member stays resolvable. Omitted, the bundle's whole shadow group, when + * any member's artifact remains, as before. */ removeRuntimeShadow(type: string, name: string, member?: string): boolean { const collection = this.metadata.get(type); @@ -4805,11 +4805,18 @@ export class SchemaRegistry { const { bare, local, other } = collectBundle(collection, name, disc); const shadows = member === undefined ? bare : entriesOfMember(bare, disc, member); if (shadows.length === 0) return false; - // The bundle's artifact, any member's: a hydrated overlay of a member - // the package does NOT ship still wears the bundle's artifact envelope - // (`_packageId`, `_provenance`), so the overlay-entry retirement that - // would otherwise take it refuses it as code — this tier must lift it. - const artifact = [...local, ...other].find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); + // [#23074] A named member's OWN artifact, the test this tier has always + // made for a name: a shadow is lifted only where a packaged artifact + // takes its place. An overlay of a member the package does NOT ship has + // none, so this tier declines, and the heal's last tier retires the + // entry (`removeOverlayEntry`): the protocol grafts the artifact + // envelope onto the shipped member alone, so that overlay is registered + // as the tenant row it is and the retirement takes it. (#22877 stage 2 + // lifted it here on ANY member's artifact, because the bundle's envelope + // grafted onto it made the retirement refuse it as code.) The group + // sweep, naming no member, keeps the bundle's artifact, any member's. + const artifacts = member === undefined ? [...local, ...other] : entriesOfMember([...local, ...other], disc, member); + const artifact = artifacts.find(({ item }) => item?._packageId && item._packageId !== 'sys_metadata'); if (!artifact) return false; for (const { key } of shadows) { collection.delete(key); From dcfc58b0ab0942475016b4e01bb8c291fffe9b5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:57:32 +0000 Subject: [PATCH 2/3] chore(changeset): the unshipped-member envelope fix Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23074-member-envelope.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/23074-member-envelope.md diff --git a/.changeset/23074-member-envelope.md b/.changeset/23074-member-envelope.md new file mode 100644 index 00000000000..638aa287f17 --- /dev/null +++ b/.changeset/23074-member-envelope.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/objectql': patch +--- + +A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code + +Clause-②: no + +An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version: + +- the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact; +- `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version; +- the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code. + +- `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged. +- `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before. + +A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language. From 8dc94d3bb16a75317c31bf5311661cb41eec632e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:24:22 +0000 Subject: [PATCH 3/3] test(objectql): the unshipped-member pins and their CONTROLs are separate cases Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...nment-overlay-email-locale-restart.test.ts | 129 +++++++++++------- 1 file changed, 76 insertions(+), 53 deletions(-) diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 3e47a7dc857..67031179c3a 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -612,7 +612,7 @@ async function bootWithOverlays( } describe('[#23074] the artifact envelope is stamped only on the member the package ships', () => { - it('the boot hydration: an unshipped member\'s overlay registers as the tenant row it is; CONTROL the shipped member\'s wears its artifact\'s envelope', async () => { + it('the boot hydration: an unshipped member\'s overlay registers as the tenant row it is', async () => { const { engine } = await bootWithOverlays(new Map(), DIGEST); const zh = engine.registry.getItem(T, DIGEST, undefined, 'zh-CN'); expect(zh?.subject).toBe(`ADMIN ${DIGEST} zh-CN`); @@ -620,22 +620,25 @@ describe('[#23074] the artifact envelope is stamped only on the member the packa expect(isCodeArtifactBody(zh)).toBe(false); // The package ships no `zh-CN` member, and the overlay is not one. expect(engine.registry.getArtifactItem(T, DIGEST, undefined, 'zh-CN')).toBeUndefined(); - // CONTROL — the bundle's canonical member, which the package ships. + }); + + it('CONTROL the boot hydration: an overlay of a shipped member wears its artifact\'s envelope', async () => { + // The bundle's canonical member, beside the unshipped one. + const { engine } = await bootWithOverlays(new Map(), DIGEST); const en = engine.registry.getItem(T, DIGEST, undefined, 'en-US'); expect(en?.subject).toBe(`ADMIN ${DIGEST} en-US`); expect(protectionOf(en)).toEqual(PACKAGED); expect(isCodeArtifactBody(en)).toBe(true); - - // CONTROL — a non-canonical member the package ships. + // A non-canonical member the package ships. const shipped = (await bootWithOverlays(new Map(), NAME)).engine; - const shippedZh = shipped.registry.getItem(T, NAME, undefined, 'zh-CN'); - expect(shippedZh?.subject).toBe(`ADMIN ${NAME} zh-CN`); - expect(protectionOf(shippedZh)).toEqual(PACKAGED); - expect(isCodeArtifactBody(shippedZh)).toBe(true); + const zh = shipped.registry.getItem(T, NAME, undefined, 'zh-CN'); + expect(zh?.subject).toBe(`ADMIN ${NAME} zh-CN`); + expect(protectionOf(zh)).toEqual(PACKAGED); + expect(isCodeArtifactBody(zh)).toBe(true); expect(shipped.registry.getArtifactItem(T, NAME, undefined, 'zh-CN')?.subject).toBe('PKG zh'); }); - it('the by-name read (?member=): an unshipped member is served as the environment\'s item; CONTROL the shipped member under its package', async () => { + it('the by-name read (?member=): an unshipped member is served as the environment\'s item', async () => { const { protocol } = await bootWithOverlays(new Map(), DIGEST); const zh: any = await protocol.getMetaItem({ type: T, name: DIGEST, member: 'zh-CN' }); expect(zh.item.subject).toBe(`ADMIN ${DIGEST} zh-CN`); @@ -643,62 +646,82 @@ describe('[#23074] the artifact envelope is stamped only on the member the packa expect(isCodeArtifactBody(zh.item)).toBe(false); expect([zh.provenance, zh.packageId, zh.packageVersion]).toEqual([undefined, undefined, undefined]); expect(zh).toMatchObject({ lock: 'none', editable: true, deletable: true }); + }); - const shipped = (await bootWithOverlays(new Map(), NAME)).protocol; - const shippedZh: any = await shipped.getMetaItem({ type: T, name: NAME, member: 'zh-CN' }); - expect(shippedZh.item.subject).toBe(`ADMIN ${NAME} zh-CN`); - expect(protectionOf(shippedZh.item)).toEqual(PACKAGED); - expect(shippedZh).toMatchObject({ + it('CONTROL the by-name read (?member=): a shipped member is served under its package', async () => { + const { protocol } = await bootWithOverlays(new Map(), NAME); + const zh: any = await protocol.getMetaItem({ type: T, name: NAME, member: 'zh-CN' }); + expect(zh.item.subject).toBe(`ADMIN ${NAME} zh-CN`); + expect(protectionOf(zh.item)).toEqual(PACKAGED); + expect(zh).toMatchObject({ provenance: 'package', packageId: PKG, packageVersion: '1.0.0', lock: 'none', editable: true, deletable: true, }); }); - it('the list, served and for execution: an unshipped member is listed as the environment\'s item; CONTROL the shipped members under their package', async () => { - const unshipped = (await bootWithOverlays(new Map(), DIGEST)).protocol; - const shipped = (await bootWithOverlays(new Map(), NAME)).protocol; + /** Each listed member of `name` by locale, from the served list or the execution list. */ + const listedMembers = async (protocol: ObjectStackProtocolImplementation, audience: 'served' | 'execution', name: string) => { + const res: any = audience === 'served' + ? await protocol.getMetaItems({ type: T }) + : await protocol.getMetaItemsForExecution({ type: T }); + return new Map(((res?.items ?? []) as any[]).filter((it) => it?.name === name).map((it) => [String(it.locale), it])); + }; + + it('the list, served and for execution: an unshipped member is listed as the environment\'s item', async () => { + const { protocol } = await bootWithOverlays(new Map(), DIGEST); for (const audience of ['served', 'execution'] as const) { - const list = async (protocol: ObjectStackProtocolImplementation) => (((audience === 'served' - ? await protocol.getMetaItems({ type: T }) - : await protocol.getMetaItemsForExecution({ type: T })) as any).items ?? []) as any[]; - const member = (items: any[], name: string, locale: string) => - items.find((it) => it?.name === name && it?.locale === locale); - const items = await list(unshipped); - const zh = member(items, DIGEST, 'zh-CN'); + const zh = (await listedMembers(protocol, audience, DIGEST)).get('zh-CN'); expect(zh?.subject, audience).toBe(`ADMIN ${DIGEST} zh-CN`); expect(protectionOf(zh), audience).toEqual({ _packageId: undefined, _packageVersion: undefined, _provenance: undefined }); - expect(protectionOf(member(items, DIGEST, 'en-US')), audience).toEqual(PACKAGED); - expect(protectionOf(member(await list(shipped), NAME, 'zh-CN')), audience).toEqual(PACKAGED); } }); - it('the reset (?member=): an unshipped member is retired by the overlay-entry tier; CONTROL the shipped member is un-shadowed onto its artifact', async () => { - // The registry's two heal verbs over the hydrated entries, as the - // delete's heal (`restoreArtifactRegistryView`) calls them. - const unshipped = (await bootWithOverlays(new Map(), DIGEST)).engine; - expect(unshipped.registry.removeRuntimeShadow(T, DIGEST, 'zh-CN')).toBe(false); - expect(unshipped.registry.removeOverlayEntry(T, DIGEST, 'zh-CN')).toBe(true); - expect(unshipped.registry.getItem(T, DIGEST, undefined, 'zh-CN')).toBeUndefined(); - expect(unshipped.registry.getItem(T, DIGEST, undefined, 'en-US')?.subject).toBe(`ADMIN ${DIGEST} en-US`); - - const shipped = (await bootWithOverlays(new Map(), NAME)).engine; - expect(shipped.registry.removeOverlayEntry(T, NAME, 'zh-CN')).toBe(false); - expect(shipped.registry.removeRuntimeShadow(T, NAME, 'zh-CN')).toBe(true); - expect(shipped.registry.getItem(T, NAME, undefined, 'zh-CN')?.subject).toBe('PKG zh'); - expect(shipped.registry.getItem(T, NAME, undefined, 'en-US')?.subject).toBe(`ADMIN ${NAME} en-US`); - - // Through the door, both members, at once and after a restart. - for (const [name, after] of [[DIGEST, undefined], [NAME, 'PKG zh']] as const) { - const stores = new Map>>(); - const { engine, protocol } = await bootWithOverlays(stores, name); - expect(await protocol.deleteMetaItem({ type: T, name, member: 'zh-CN' }), name).toMatchObject({ success: true }); - expect(engine.registry.getItem(T, name, undefined, 'zh-CN')?.subject, name).toBe(after); - expect(engine.registry.getItem(T, name, undefined, 'en-US')?.subject, name).toBe(`ADMIN ${name} en-US`); - expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject, name).toBe(after); - const restarted = await boot(stores); - expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject, name).toBe(after); + it('CONTROL the list, served and for execution: shipped members are listed under their package', async () => { + const unshipped = (await bootWithOverlays(new Map(), DIGEST)).protocol; + const shipped = (await bootWithOverlays(new Map(), NAME)).protocol; + for (const audience of ['served', 'execution'] as const) { + expect(protectionOf((await listedMembers(unshipped, audience, DIGEST)).get('en-US')), audience).toEqual(PACKAGED); + const zh = (await listedMembers(shipped, audience, NAME)).get('zh-CN'); + expect(zh?.subject, audience).toBe(`ADMIN ${NAME} zh-CN`); + expect(protectionOf(zh), audience).toEqual(PACKAGED); } }); + /** + * A by-name reset of the `zh-CN` member of `name`, through the delete door, + * read back at once and after a restart: `after` is the `zh-CN` subject the + * registry and the by-name read then serve. Before the door, each of the + * registry's two heal verbs that the delete's heal + * (`restoreArtifactRegistryView`) calls is asked about the hydrated entry, + * on a boot of its own. + */ + async function resetZh(name: string, verbs: { shadow: boolean; overlayEntry: boolean }, after: string | undefined) { + const shadowBoot = (await bootWithOverlays(new Map(), name)).engine; + expect(shadowBoot.registry.removeRuntimeShadow(T, name, 'zh-CN'), 'removeRuntimeShadow').toBe(verbs.shadow); + const overlayEntryBoot = (await bootWithOverlays(new Map(), name)).engine; + expect(overlayEntryBoot.registry.removeOverlayEntry(T, name, 'zh-CN'), 'removeOverlayEntry').toBe(verbs.overlayEntry); + + const stores = new Map>>(); + const { engine, protocol } = await bootWithOverlays(stores, name); + expect(await protocol.deleteMetaItem({ type: T, name, member: 'zh-CN' })).toMatchObject({ success: true }); + expect(engine.registry.getItem(T, name, undefined, 'zh-CN')?.subject).toBe(after); + expect(engine.registry.getItem(T, name, undefined, 'en-US')?.subject).toBe(`ADMIN ${name} en-US`); + expect(((await protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(after); + const restarted = await boot(stores); + expect(((await restarted.protocol.getMetaItem({ type: T, name, member: 'zh-CN' })) as any)?.item?.subject).toBe(after); + } + + it('the reset (?member=): an unshipped member is retired by the overlay-entry tier, and nothing is left serving it', async () => { + // No `zh-CN` artifact takes its place, so the shadow heal declines and + // the overlay-entry retirement takes the tenant row. + await resetZh(DIGEST, { shadow: false, overlayEntry: true }, undefined); + }); + + it('CONTROL the reset (?member=): a shipped member is un-shadowed onto its artifact', async () => { + // The shadow heal lifts the entry onto the `zh-CN` artifact, and the + // retirement refuses it as packaged code. + await resetZh(NAME, { shadow: true, overlayEntry: false }, 'PKG zh'); + }); + it('a registry that answers another member\'s artifact: the graft keeps it off the body', async () => { // `lookupArtifactItem`'s note: a registry without the member parameter // ignores it, so it answers the bundle's artifact canonical-first. @@ -708,12 +731,12 @@ describe('[#23074] the artifact envelope is stamped only on the member the packa vi.spyOn(registry, 'getArtifactItem').mockImplementation((...args: any[]) => artifactOf(args[0], args[1], args[2])); }); expect(engine.registry.getArtifactItem(T, DIGEST, undefined, 'zh-CN')?.locale).toBe('en-US'); + // CONTROL — the canonical member is that artifact's own member, and wears it. + expect(protectionOf(engine.registry.getItem(T, DIGEST, undefined, 'en-US'))).toEqual(PACKAGED); expect(protectionOf(engine.registry.getItem(T, DIGEST, undefined, 'zh-CN'))).toEqual(TENANT); const zh: any = await protocol.getMetaItem({ type: T, name: DIGEST, member: 'zh-CN' }); expect(zh.item._packageId).toBeUndefined(); expect(zh.packageId).toBeUndefined(); - // CONTROL — the canonical member is that artifact's own member, and wears it. - expect(protectionOf(engine.registry.getItem(T, DIGEST, undefined, 'en-US'))).toEqual(PACKAGED); }); it('CONTROL: a type with no members keeps its artifact\'s envelope, even where the overlay\'s locale is not the artifact\'s', async () => {