Skip to content

Commit ddd5af5

Browse files
committed
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 <noreply@anthropic.com>
1 parent 62ce45d commit ddd5af5

6 files changed

Lines changed: 263 additions & 30 deletions

‎packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
7070
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
7171
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
7272
why: 'the definition itself' },
73-
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item)', noPackage: true, disposition: 'served-package envelope',
74-
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule)' },
75-
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope',
76-
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' },
73+
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope',
74+
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' },
75+
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope',
76+
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' },
7777
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
7878
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
7979
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no',
@@ -94,8 +94,8 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
9494
why: 'the lock\'s artifact layer (the one item-lock resolution), never an envelope on a served body' },
9595
{ member: 'shippedArtifactsOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',
9696
why: 'scoped to a named package, and kept only when it is that package\'s own' },
97-
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined', noPackage: true, disposition: 'no',
98-
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package' },
97+
{ member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data)', noPackage: true, disposition: 'no',
98+
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package; [#23074] at the row\'s own bundle member' },
9999
{ member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
100100
why: 'not called without a package, and kept only when it is the container\'s own package\'s' },
101101
{ member: 'shippedViewContainerOf', args: 'type, name, packageId', noPackage: false, disposition: 'no',

‎packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const SITES: ReadonlyArray<Site & {
9595
+ 'and a row bound to another package is refused (D9.9, OBJECT_OVERLAY_PACKAGE_MISMATCH)',
9696
},
9797
{
98-
member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), \'name\' as any',
98+
member: 'hydrateOverlayIntoRegistry', args: 'type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), \'name\' as any',
9999
disposition: 'yes, view only: skips a name another package ships',
100100
path: 'the list\'s hydration and the write-through on an unscoped kernel; the boot\'s loadMetaFromDb on every kernel',
101101
why: 'a stored row: a view row bound to a package is not registered where another package ships its name, and the '

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,8 +1777,31 @@ type ShippingPackages = {
17771777
* stands in for the overlay layer over `artifactItem` alone, as before: the
17781778
* registry's hydration and a previewed draft, neither of which is a read's
17791779
* lock answer.
1780+
*
1781+
* [#23074] An artifact lends its envelope only to a body of its OWN identity.
1782+
* For a type whose identity is a pair ({@link ITEM_KEY_DISCRIMINATORS}:
1783+
* `email_template`'s `(name, locale)`), that is the same bundle member, read
1784+
* by {@link bundleMemberOf}, the identity's one declaration; an artifact of
1785+
* another member is treated as no artifact at all. So an environment overlay
1786+
* of a member the package does not ship (a `zh-CN` template beside a shipped
1787+
* `en-US`) keeps the tenant authorship it was registered with, and is never
1788+
* read as packaged code: `isCodeArtifactBody` false, no `_packageId` /
1789+
* `_packageVersion` / `_provenance` stamp, no artifact `_lock*`. Grafted, it
1790+
* read as the bundle's own artifact of that member to the registry's artifact
1791+
* lookup and its overlay-entry retirement, and the read doors served it under
1792+
* the package's provenance. The shipped member's overlay is unchanged: each
1793+
* caller looks the artifact up at the body's own member
1794+
* (`lookupArtifactItem`'s `member`), so it still wears that member's
1795+
* artifact's envelope. A registry that ignores `member` can answer another
1796+
* member's artifact, and this check is what keeps that answer off the body.
1797+
* Every other type has no members, so `bundleMemberOf` is `undefined` on both
1798+
* sides and the merge is exactly what it was. ⛔ The lock family a read
1799+
* resolved (`itemLock`) is applied before this check and is not moved by it:
1800+
* a lock binds the NAME, at the read and at the `_lock` gate alike
1801+
* ({@link resolveItemLock} over every package that ships the name).
17801802
*/
17811803
function mergeArtifactProtection(
1804+
type: string,
17821805
item: unknown,
17831806
artifactItem: unknown,
17841807
itemLock?: ItemLock,
@@ -1788,6 +1811,7 @@ function mergeArtifactProtection(
17881811
if (artifactItem === undefined || artifactItem === null) return item;
17891812
const a = artifactItem as Record<string, unknown>;
17901813
if (typeof a !== 'object') return item;
1814+
if (bundleMemberOf(type, a) !== bundleMemberOf(type, item)) return item;
17911815
const out: Record<string, unknown> = { ...(item as Record<string, unknown>) };
17921816
if (itemLock === undefined && resolveItemLock({ artifact: [a], overlay: item }).layer === 'artifact') {
17931817
if (a._lock !== undefined) out._lock = a._lock;
@@ -1801,6 +1825,18 @@ function mergeArtifactProtection(
18011825
return out;
18021826
}
18031827

1828+
/**
1829+
* [#23074] The bundle member a body IS, for a type whose identity is a pair
1830+
* ({@link ITEM_KEY_DISCRIMINATORS}): `itemDiscriminator` under the type's
1831+
* canonical spelling, so the canonical member for a body that declares none.
1832+
* `undefined` for every other type, which has no members. The member
1833+
* {@link mergeArtifactProtection} matches an artifact against, and the one
1834+
* each of its callers looks the artifact up at.
1835+
*/
1836+
function bundleMemberOf(type: string, body: unknown): string | undefined {
1837+
return itemDiscriminator(canonicalMetaType(type), body);
1838+
}
1839+
18041840
/**
18051841
* [#22024, ADR-0048, ADR-0010 §3.3] The package whose artifact envelope
18061842
* ({@link mergeArtifactProtection}: `_packageId`, `_packageVersion`,
@@ -9650,7 +9686,9 @@ export class ObjectStackProtocolImplementation implements
96509686
// takes priority; else the item's own `_packageId`.) [#22024]
96519687
// The by-name read takes the same rule ({@link envelopePackageId}).
96529688
const itemPackageId = envelopePackageId(packageId, it);
9653-
const a = this.lookupArtifactItem(request.type, itemName, itemPackageId);
9689+
// [#23074] …and to the item's own bundle member, whose artifact
9690+
// alone may lend it an envelope ({@link mergeArtifactProtection}).
9691+
const a = this.lookupArtifactItem(request.type, itemName, itemPackageId, bundleMemberOf(request.type, it));
96549692
let itemLock: ItemLock | undefined;
96559693
if (typeof itemName === 'string' && (it as any)?._draft !== true) {
96569694
const address: ItemAddress = {
@@ -9671,7 +9709,7 @@ export class ObjectStackProtocolImplementation implements
96719709
// is the other exit a client reads field metadata from, and
96729710
// an overlay row wins over the (already-governed) registry
96739711
// entry in the merge above, so it carries the same lie.
9674-
governed.push(this.governServedObject(request.type, mergeArtifactProtection(it, a, itemLock)));
9712+
governed.push(this.governServedObject(request.type, mergeArtifactProtection(request.type, it, a, itemLock)));
96759713
}
96769714
return {
96779715
type: request.type,
@@ -10436,8 +10474,11 @@ export class ObjectStackProtocolImplementation implements
1043610474
// serves. Before, a read naming no package took the first-registered
1043710475
// package's envelope, so a name two packages ship answered one
1043810476
// package's stored copy under the other package's `_packageId`.
10477+
// [#23074] …and at the served item's own bundle member, whose artifact
10478+
// alone may lend it an envelope ({@link mergeArtifactProtection}): a
10479+
// member the package does not ship is served as the environment's own.
1043910480
const artifactItem = this.lookupArtifactItem(
10440-
request.type, request.name, envelopePackageId(request.packageId, item),
10481+
request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item),
1044110482
);
1044210483
// [#21738] The lock is the one item-lock resolution's, over the layers
1044310484
// this read resolved from its address: [#21803] every installed
@@ -10458,7 +10499,7 @@ export class ObjectStackProtocolImplementation implements
1045810499
request.type,
1045910500
this.governServedObject(
1046010501
request.type,
10461-
mergeArtifactProtection(item, artifactItem, itemLock),
10502+
mergeArtifactProtection(request.type, item, artifactItem, itemLock),
1046210503
),
1046310504
);
1046410505
// ADR-0047 — list views additionally get reference-integrity
@@ -18698,7 +18739,15 @@ export class ObjectStackProtocolImplementation implements
1869818739
this.hydrateExpandedViewItems(type, data, options, registry);
1869918740
return true;
1870018741
}
18701-
const artifact = this.lookupArtifactItem(type, (data as any).name, options.packageId ?? undefined);
18742+
// [#23074] The artifact of the row's OWN bundle member: an overlay of
18743+
// a member the package does not ship has none, so it registers as the
18744+
// tenant row it is ({@link mergeArtifactProtection}). It used to take
18745+
// the bundle's artifact canonical-first, and a `zh-CN` overlay beside
18746+
// a shipped `en-US` was registered wearing that envelope, which every
18747+
// registry reader then took for the package's own `zh-CN` artifact.
18748+
const artifact = this.lookupArtifactItem(
18749+
type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data),
18750+
);
1870218751
// [#16702] Say what this row IS before the artifact envelope is grafted
1870318752
// on top of it. Every body reaching this hydrator came out of a
1870418753
// `sys_metadata` write and is therefore tenant-authored by definition
@@ -18720,7 +18769,7 @@ export class ObjectStackProtocolImplementation implements
1872018769
// and its receipt rendered both as the package. Registered as the
1872118770
// tenant row it is, it is reported as one and never armed.
1872218771
const envelope = canonicalType === 'flow' ? undefined : artifact;
18723-
registry.registerItem(type, mergeArtifactProtection(stateTenantAuthorship(data), envelope), 'name' as any);
18772+
registry.registerItem(type, mergeArtifactProtection(type, stateTenantAuthorship(data), envelope), 'name' as any);
1872418773
this.hydrateExpandedViewItems(type, data, options, registry);
1872518774
return true;
1872618775
}
@@ -18872,7 +18921,7 @@ export class ObjectStackProtocolImplementation implements
1887218921
// where the item's own artifact exists, its `_provenance` still
1887318922
// wins (ADR-0010 §3.3), as it does on the container.
1887418923
const authored = options.tenantAuthored ? stateTenantAuthorship(item) : item;
18875-
out.push(mergeArtifactProtection(authored, ownArtifact) as Record<string, unknown>);
18924+
out.push(mergeArtifactProtection(type, authored, ownArtifact) as Record<string, unknown>);
1887618925
}
1887718926
return out;
1887818927
}

0 commit comments

Comments
 (0)