From 651478518bfa8a00fae2ee01d67ffbe0227c1d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:43:18 +0000 Subject: [PATCH 1/4] test(metadata-protocol): red-first pins for an override of an unshipped email-template locale A declared override of an email template locale the base does not ship, at the save door, the per-item publish, os validate and install-local, with the shipped-locale and undeclared-addition controls. Claude-Session: https://claude.ai/code/session_01FEkQ5oDNN263d7ffSwgjsY Co-authored-by: Claude --- .../src/utils/override-declarations.test.ts | 29 ++++ ...tplace-install-local-override-gate.test.ts | 31 +++++ ...-override-unshipped-locale.dogfood.test.ts | 131 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts index f40b9f49338..a7787eec083 100644 --- a/packages/cli/src/utils/override-declarations.test.ts +++ b/packages/cli/src/utils/override-declarations.test.ts @@ -131,4 +131,33 @@ describe('#28410 — findOverrideDeclarationRefusals walks the artifact under th expect(rows.map((r) => r.rule)).toEqual(['declared_package_outside_closure']); expect(rows[0].message).toMatch(/Declare a dependency on 'com\.acme\.crm'/); }); + + // [#28746] An email template is one item per `(name, locale)`: the base + // below ships `crm_welcome` in `en-US` only, so each member is judged alone. + const templateBase = pkg(BASE, [], { emailTemplates: [{ name: 'crm_welcome', locale: 'en-US', subject: 'Welcome' }] }); + const declared = { package: BASE, basedOn: '1.0.0' } as const; + + it('[#28746] the pair declaring an override of a locale the base does not ship is refused', () => { + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [templateBase, pkg(PAIR, [BASE], { + emailTemplates: [{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue', overrides: { ...declared } }], + })], + }); + expect(rows.map((r) => [r.path, r.packageId, r.rule])).toEqual([ + ['packages[1].manifest.emailTemplates[0]', PAIR, 'declared_package_ships_no_such_item'], + ]); + }); + + it('[#28746] controls: the pair overrides the shipped locale and adds an undeclared locale of its own', () => { + expect(findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [templateBase, pkg(PAIR, [BASE], { + emailTemplates: [ + { name: 'crm_welcome', locale: 'en-US', subject: 'Ours', overrides: { ...declared } }, + { name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue' }, + ], + })], + })).toEqual([]); + }); }); diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts index 8be40f717c5..f256ad97f61 100644 --- a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts @@ -214,4 +214,35 @@ describe('install-local 2d — the override write gate at the managed install (A ]); expect(collisions[1].message).toMatch(/Declare it/); }); + + // [#28746] An email template is one item per `(name, locale)`: the base + // below ships `crm_welcome` in `en-US` only, so a member is judged alone. + const TEMPLATE_HOLDINGS: Holdings = { + [BASE]: { email_template: [{ name: 'crm_welcome', locale: 'en-US', subject: 'Welcome' }] }, + }; + const DECLARED = { package: BASE, basedOn: '1.0.0' } as const; + + it('[#28746] the paired .custom declaring an override of a locale the base does not ship is refused 403, and nothing is written', async () => { + const { res, registered, ledger } = await install({ + id: PAIR, version: '1.0.0', dependencies: deps(BASE), + emailTemplates: [{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue', overrides: { ...DECLARED } }], + }, [{ id: BASE }], TEMPLATE_HOLDINGS); + expect(res.status, JSON.stringify(res.payload)).toBe(403); + expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(refusedOf(res)).toEqual([['email_template', 'crm_welcome', 'declared_package_ships_no_such_item']]); + expect(registered).toEqual([]); + expect(ledger).toEqual([]); + }); + + it('[#28746] CONTROL: the pair overrides the locale the base ships, and adds an undeclared locale of its own', async () => { + const { res, registered } = await install({ + id: PAIR, version: '1.0.0', dependencies: deps(BASE), + emailTemplates: [ + { name: 'crm_welcome', locale: 'en-US', subject: 'Ours', overrides: { ...DECLARED } }, + { name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue' }, + ], + }, [{ id: BASE }], TEMPLATE_HOLDINGS); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(registered).toEqual([PAIR]); + }); }); diff --git a/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts b/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts new file mode 100644 index 00000000000..f7ad631190b --- /dev/null +++ b/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28746, ADR-0146 D2] A declared override of an email template locale the +// base does not ship is refused at the save door and at the per-item publish, +// over the real showcase composition and its HTTP doors. +// +// ## What was broken +// +// An email template is one item per `(name, locale)` (the bundle identity, +// `ITEM_KEY_DISCRIMINATORS`). The override write gate asked which closure +// packages ship the NAME, across every locale, so "the base ships this item" +// held for any locale. The showcase ships `showcase_task_done_email` in `en-US` +// only, yet its pair `com.example.showcase.custom` could save and publish an +// `fr-FR` body declaring `overrides: { package: 'com.example.showcase' }`, and +// the `fr-FR` "override" was served — an addition dressed as an override, with +// no base member for drift or "Restore package original" to compare against. +// +// ## What each case pins +// +// - the save door refuses the `fr-FR` declaration `403 NOT_OVERRIDABLE`, and +// nothing is stored or served for `fr-FR`; +// - the per-item publish (`?member=fr-FR`) refuses a draft of that shape +// stored before the gate, and still serves nothing for `fr-FR`; +// - CONTROL: `en-US` (the member the base ships) declared from the pair is +// saved, published and served; +// - CONTROL: `fr-FR` WITHOUT `overrides` is the pair's own new member, saved, +// published and served. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const BASE = 'com.example.showcase'; +const PAIR = 'com.example.showcase.custom'; +/** The template the showcase ships, in `en-US` only. */ +const NAME = 'showcase_task_done_email'; +const DECLARATION = { package: BASE, basedOn: '0.1.0' } as const; + +describe('[#28746] a declared override of an email template locale the base does not ship (showcase)', () => { + let stack: VerifyStack | undefined; + let token: string; + let shipped: Record; + + const call = async (method: string, path: string, body?: unknown) => { + const res = await stack!.apiAs(token, method, path, body); + return { status: res.status, json: (await res.json().catch(() => ({}))) as any }; + }; + /** `GET /meta/email_template/:name` at one member — the served document, unwrapped. */ + const served = async (member: string) => { + const read = await call('GET', `/meta/email_template/${NAME}?member=${member}`); + const doc = read.json?.data ?? read.json?.item ?? read.json; + return { status: read.status, item: (doc?.item ?? doc) as Record }; + }; + /** The pair's rows of the template, as the store holds them. */ + const pairRows = async () => { + const ql: any = await stack!.kernel.getServiceAsync('objectql'); + const rows: any[] = await ql.find('sys_metadata', { + where: { type: 'email_template', name: NAME, package_id: PAIR }, + context: { isSystem: true, positions: [], permissions: [] }, + }); + return rows.map((r) => [r.bundle_member ?? null, r.state]).sort((a, b) => String(a).localeCompare(String(b))); + }; + const saveDraft = (body: Record) => + call('PUT', `/meta/email_template/${NAME}?mode=draft&package=${PAIR}`, body); + const publish = (member: string) => + call('POST', `/meta/email_template/${NAME}/publish?package=${PAIR}&member=${member}`, {}); + const body = (locale: string, subject: string, extra: Record = {}) => { + const out: Record = {}; + for (const [k, v] of Object.entries(shipped)) if (!k.startsWith('_')) out[k] = v; + return { ...out, locale, subject, ...extra }; + }; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + const read = await served('en-US'); + expect(read.status).toBe(200); + shipped = read.item; + const ensured = await call('POST', `/packages/${BASE}/ensure-customization`); + expect([200, 201], JSON.stringify(ensured.json)).toContain(ensured.status); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('the save door refuses an `fr-FR` override declared from the pair, and nothing is served for `fr-FR`', async () => { + const saved = await saveDraft(body('fr-FR', 'Tâche terminée : {{title}}', { overrides: { ...DECLARATION } })); + expect(saved.status, JSON.stringify(saved.json)).toBe(403); + expect(saved.json?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(await pairRows()).toEqual([]); + expect((await served('fr-FR')).status).toBe(404); + }); + + it('the per-item publish refuses an `fr-FR` override draft stored before the gate, and nothing is served for `fr-FR`', async () => { + // Lay the draft down as one stored before the gate: the same door with + // the gate's verdict held at `not_concerned` for that one write. + const protocol: any = await stack!.kernel.getServiceAsync('protocol'); + protocol.overrideWriteVerdict = () => ({ verdict: 'not_concerned' }); + try { + const saved = await saveDraft(body('fr-FR', 'Tâche terminée : {{title}}', { overrides: { ...DECLARATION } })); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + } finally { + delete protocol.overrideWriteVerdict; + } + expect(await pairRows()).toEqual([['fr-FR', 'draft']]); + + const published = await publish('fr-FR'); + expect(published.status, JSON.stringify(published.json)).toBe(403); + expect(published.json?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(await pairRows()).toEqual([['fr-FR', 'draft']]); + expect((await served('fr-FR')).status).toBe(404); + }); + + it('control: the pair declares an override of `en-US`, the member the base ships, and it is served', async () => { + const saved = await saveDraft(body('en-US', 'Override: {{title}}', { overrides: { ...DECLARATION } })); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + const published = await publish('en-US'); + expect(published.status, JSON.stringify(published.json)).toBe(200); + expect(await served('en-US')).toMatchObject({ status: 200, item: { subject: 'Override: {{title}}' } }); + }); + + it('control: `fr-FR` without `overrides` is the pair\'s own new member, and it is served', async () => { + // The draft save replaces the refused draft above with the addition. + const saved = await saveDraft(body('fr-FR', 'Ajout : {{title}}')); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + const published = await publish('fr-FR'); + expect(published.status, JSON.stringify(published.json)).toBe(200); + expect(await served('fr-FR')).toMatchObject({ status: 200, item: { subject: 'Ajout : {{title}}' } }); + }); +}); From c5b075731c94ac843df83c5d30ffb2d5a8ed2056 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:48:01 +0000 Subject: [PATCH 2/4] fix(metadata-protocol): judge a bundle type's override by its member The override write gate counted the packages that ship an email template by name across every locale, so a .custom pair could declare and publish an override of a locale its base never shipped. The gate now asks about the member the body addresses: the save door and the publish look the registry up at that member, and os validate and install-local compare items on one shared identity (overrideWriteIdentityOf) that joins the bundle member for a pair-identified type. Claude-Session: https://claude.ai/code/session_01FEkQ5oDNN263d7ffSwgjsY Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 12 +++- packages/metadata-protocol/src/index.ts | 2 +- .../src/protocol-artifact-lookup.ts | 6 +- .../src/protocol-packaged-base-refusal.ts | 64 +++++++++++++++++-- packages/metadata-protocol/src/protocol.ts | 9 ++- ...-override-unshipped-locale.dogfood.test.ts | 6 +- 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 846d03e9bb4..12fe8a838e5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -240,7 +240,7 @@ import type { import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; // [ADR-0146 D2, #28410] The override write gate's one rule and its item walk, // asked at the managed install exactly as the publish doors ask them. -import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol'; +import { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol'; // [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, step 1c''. import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol'; import { resolveThrownHttpError } from '@objectstack/types'; @@ -576,14 +576,20 @@ function listedItems(answer: unknown): Record[] { : []; } -/** An item's registry identity — `overrideWriteItemsOf`'s: an action is `:`. */ +/** + * An item's registry identity — `overrideWriteItemsOf`'s: an action is + * `:`, and [#28746] a pair-identified type's item carries its + * bundle member, through the gate's own `overrideWriteIdentityOf`, so an + * installed base's listed `en-US` template and an incoming `fr-FR` one are two + * items here as at the publish doors. + */ function listedIdentity(type: string, item: Record): string | undefined { const name = typeof item.name === 'string' && item.name !== '' ? item.name : undefined; if (name === undefined) return undefined; if (type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' && !name.includes(':')) { return `${item.objectName}:${name}`; } - return name; + return overrideWriteIdentityOf(type, name, item); } /** A `{ … }` object — not an array, not null. */ diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index f9900cf3456..3d34a2c99ba 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -34,7 +34,7 @@ export type { MetadataAuthoringChannel } from './protocol.js'; // [ADR-0146 D2, #28410] The override write gate's one rule, exported for the // two gate points outside this package — `os validate` and the marketplace // managed install — so all three doors ask one judgement and cannot drift. -export { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; +export { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; // [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, // exported for the doors outside this package — `os validate` and the // marketplace install — so every door that lets a field in asks one judgement. diff --git a/packages/metadata-protocol/src/protocol-artifact-lookup.ts b/packages/metadata-protocol/src/protocol-artifact-lookup.ts index 3a12cec98c9..ddb7e0ccf89 100644 --- a/packages/metadata-protocol/src/protocol-artifact-lookup.ts +++ b/packages/metadata-protocol/src/protocol-artifact-lookup.ts @@ -97,17 +97,17 @@ export function artifactLockLayerAt( /** The body of `ObjectStackProtocolImplementation#shippedArtifactsOf`; the method's docblock there is its contract. */ export function shippedArtifactsOf( this: Pick, - type: string, name: string, shipping?: ShippingPackages, + type: string, name: string, shipping?: ShippingPackages, member?: string, ): unknown[] { const shipped: unknown[] = []; const add = (artifact: unknown): void => { if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact); }; - add(this.lookupArtifactItem(type, name)); + add(this.lookupArtifactItem(type, name, undefined, member)); if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped; const packages = shipping ?? this.shippingPackagesOf(type); for (const packageId of new Set([...(packages.byName.get(name) ?? []), ...packages.anyName])) { - const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined; + const own = this.lookupArtifactItem(type, name, packageId, member) as { _packageId?: unknown } | undefined; if (own?._packageId === packageId) add(own); } return shipped; diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 27271b43e2f..5b2921c51be 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -47,6 +47,7 @@ import { } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec'; +import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator } from '@objectstack/metadata-core'; /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -198,6 +199,40 @@ function isOpenOverrideType(singular: string): boolean { return row?.disposition === 'overridable' && (row.conditions?.length ?? 0) === 0; } +/** + * [#28746, ADR-0146 D2] The bundle member an item IS, for a type whose + * identity is a pair (`ITEM_KEY_DISCRIMINATORS`: an `email_template` is one + * item per `(name, locale)`), the canonical member when the body declares + * none; `undefined` for every other type, whose identity is its name alone. + * + * Read off the identity table the registry keys its bundles on and the read + * side serves a member by (`findServedOverlayRow`, the layered read), never a + * type literal. The save is BODY-addressed, so the member is the body's own. + */ +export function overrideWriteMemberOf(type: string, item: unknown): string | undefined { + return itemDiscriminator(PLURAL_TO_SINGULAR[type] ?? type, item); +} + +/** + * [#28746, ADR-0146 D2] The identity two packages' items collide on under the + * override write gate: `identity` (an item's name; an `action`'s + * `:`) joined with its bundle member + * ({@link overrideWriteMemberOf}) for a pair-identified type, and `identity` + * unchanged for every other type. + * + * The ONE spelling every door that hands the gate its `shippers` compares on: + * {@link overrideWriteItemsOf} (so `os validate` and the install door's + * incoming items), the install door's listing of an installed package, and + * {@link overrideWriteVerdict}'s registry lookup at the same member. A base + * that ships a template in `en-US` alone therefore ships no `fr-FR` member: a + * declared `fr-FR` override is refused (`declared_package_ships_no_such_item`), + * and an undeclared `fr-FR` beside it collides with nothing. + */ +export function overrideWriteIdentityOf(type: string, identity: string, item: unknown): string { + const member = overrideWriteMemberOf(type, item); + return member === undefined ? identity : `${identity}\u0000${member}`; +} + /** One item written into one package — what the override write gate judges. */ export interface OverrideWriteSubject { /** The metadata type, either spelling (folded here). */ @@ -219,7 +254,11 @@ export interface OverrideWriteClosure { * admitting needs the base to be proven inside the closure. */ readonly dependencies: readonly string[] | undefined; - /** The packages of {@link dependencies} that ship an item of this type and name. */ + /** + * The packages of {@link dependencies} that ship an item of this type and + * name — for a pair-identified type, of this bundle member + * ({@link overrideWriteIdentityOf}). + */ readonly shippers: readonly string[]; } @@ -282,7 +321,8 @@ function declarationOf(item: unknown): { present: boolean; base: string | undefi * Order: a declaration is judged on its type first (D10 `object`, D6 and the * other excluded types, then D9's `flow` and `action`), then on who declares * (the pair alone), then on what it names (inside the closure, and shipping - * that item). An undeclared item is judged only when a package of its closure + * that item — for a pair-identified type, that bundle member: the caller's + * `shippers` are counted at {@link overrideWriteIdentityOf}). An undeclared item is judged only when a package of its closure * ships the same name, on a type a declared override applies to; outside the * closure two packages keep ADR-0048 §3.3/§3.4's coexistence on a bare name. */ @@ -337,8 +377,11 @@ export function judgeOverrideWrite( + `Declare a dependency on '${base}' in the \`dependencies\` of '${packageId}''s manifest, or remove \`overrides\` (ADR-0146 D2).`); } if (!closure.shippers.includes(base)) { + // [#28746] A pair-identified type names the member it addressed. + const member = overrideWriteMemberOf(type, subject.item); + const ofMember = member === undefined ? '' : ` with ${ITEM_KEY_DISCRIMINATORS[type]?.field} '${member}'`; return refuse('declared_package_ships_no_such_item', - `${item} declares an override of '${base}', which ships no ${type} named '${name}'. ` + `${item} declares an override of '${base}', which ships no ${type} named '${name}'${ofMember}. ` + `Remove \`overrides\` to keep it as '${packageId}''s own item, or correct the name (ADR-0146 D2).`); } return { verdict: 'admit', base }; @@ -369,7 +412,9 @@ export interface OverrideWriteItem { readonly name: string; /** * The registry identity two packages collide on: an `action` is - * `:` (the registry's key), every other item its name. + * `:` (the registry's key), every other item its name, + * and [#28746] a pair-identified type's item that joined with its bundle + * member ({@link overrideWriteIdentityOf}). */ readonly identity: string; /** The item body the declaration is read from. */ @@ -411,7 +456,7 @@ export function overrideWriteItemsOf(body: Record, at = ''): Ov const identity = type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' ? `${item.objectName}:${declaredName}` : declaredName; - out.push({ type, name: declaredName, identity, item, path }); + out.push({ type, name: declaredName, identity: overrideWriteIdentityOf(type, identity, item), item, path }); } } return out; @@ -436,6 +481,11 @@ export function overrideWriteRefusalError( * registry (`resolveWritePackageScope`, the save door's own) and the shipping * packages from the artifact lookup (`shippedArtifactsOf`, the read's own), * then asks {@link judgeOverrideWrite}. + * + * [#28746] For a pair-identified type the lookup is at the body's bundle + * member ({@link overrideWriteMemberOf}), and an artifact of another member is + * no shipper: a registry that ignores `member` can answer one, as + * `mergeArtifactProtection` guards on the read side. */ export function overrideWriteVerdict( this: Pick, @@ -453,10 +503,12 @@ export function overrideWriteVerdict( // fact about the package, not about the item. if (packageId !== undefined && scope === undefined) return NOT_CONCERNED; const dependencies = scope?.dependencies.filter((id) => id !== packageId); + const member = overrideWriteMemberOf(request.type, request.item); const shippers = dependencies === undefined || dependencies.length === 0 ? [] : Array.from(new Set( - this.shippedArtifactsOf(request.type, request.name) + this.shippedArtifactsOf(request.type, request.name, undefined, member) + .filter((a) => overrideWriteMemberOf(request.type, a) === member) .map((a) => (a as { _packageId?: unknown } | null | undefined)?._packageId) // An override replaces an item of an INSTALLED, read-only base // (ADR-0146 D1): a writable package's item is edited in place diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 188b876f7b2..285c7e1bd19 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -17227,14 +17227,19 @@ export class ObjectStackProtocolImplementation implements * second code package's claim on the name, ADR-0029 D3), and its artifact * lookup reads the owner's layer whatever package is asked for, so the * owner is the whole set. + * + * [#28746] `member` — one bundle member's artifacts, for a type whose + * identity is a pair (the override write gate's question, which asks + * about the member a write addresses). Omitted, the bundle's artifact + * canonical-first, as before: a lock binds the NAME. */ - private shippedArtifactsOf(type: string, name: string, shipping?: ShippingPackages): unknown[] { + private shippedArtifactsOf(type: string, name: string, shipping?: ShippingPackages, member?: string): unknown[] { // [#25467 cut 15] The body moved to `protocol-artifact-lookup.ts` (see `ArtifactLookupHost`). const host: ThisParameterType = { lookupArtifactItem: (...a) => this.lookupArtifactItem(...a), shippingPackagesOf: (...a) => this.shippingPackagesOf(...a), }; - return shippedArtifactsOf.call(host, type, name, shipping); + return shippedArtifactsOf.call(host, type, name, shipping, member); } /** diff --git a/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts b/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts index f7ad631190b..0dd2073c415 100644 --- a/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts @@ -35,6 +35,8 @@ const PAIR = 'com.example.showcase.custom'; /** The template the showcase ships, in `en-US` only. */ const NAME = 'showcase_task_done_email'; const DECLARATION = { package: BASE, basedOn: '0.1.0' } as const; +/** The refusal's code — the save door answers it beside a string `error`, an enveloped door inside `error`. */ +const codeOf = (json: any): unknown => json?.code ?? json?.error?.code; describe('[#28746] a declared override of an email template locale the base does not ship (showcase)', () => { let stack: VerifyStack | undefined; @@ -87,7 +89,7 @@ describe('[#28746] a declared override of an email template locale the base does it('the save door refuses an `fr-FR` override declared from the pair, and nothing is served for `fr-FR`', async () => { const saved = await saveDraft(body('fr-FR', 'Tâche terminée : {{title}}', { overrides: { ...DECLARATION } })); expect(saved.status, JSON.stringify(saved.json)).toBe(403); - expect(saved.json?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(codeOf(saved.json)).toBe('NOT_OVERRIDABLE'); expect(await pairRows()).toEqual([]); expect((await served('fr-FR')).status).toBe(404); }); @@ -107,7 +109,7 @@ describe('[#28746] a declared override of an email template locale the base does const published = await publish('fr-FR'); expect(published.status, JSON.stringify(published.json)).toBe(403); - expect(published.json?.error?.code).toBe('NOT_OVERRIDABLE'); + expect(codeOf(published.json)).toBe('NOT_OVERRIDABLE'); expect(await pairRows()).toEqual([['fr-FR', 'draft']]); expect((await served('fr-FR')).status).toBe(404); }); From 3d6d282a61cd3dd5de85d5a024be025dad6db616 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:55:08 +0000 Subject: [PATCH 3/4] test(cli): a dependent's template collides only at a shipped locale; changeset Claude-Session: https://claude.ai/code/session_01FEkQ5oDNN263d7ffSwgjsY Co-authored-by: Claude --- .../28746-override-gate-bundle-member.md | 20 +++++++++++++++++++ .../src/utils/override-declarations.test.ts | 15 ++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .changeset/28746-override-gate-bundle-member.md diff --git a/.changeset/28746-override-gate-bundle-member.md b/.changeset/28746-override-gate-bundle-member.md new file mode 100644 index 00000000000..ac9d935ec0f --- /dev/null +++ b/.changeset/28746-override-gate-bundle-member.md @@ -0,0 +1,20 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/cloud-connection': minor +--- + +fix(metadata-protocol)!: the override write gate judges an email template by its locale, so a `.custom` override of a locale the base does not ship is refused (#28746) + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing at the override write gate, with one widening beside it. An email template is one item per `(name, locale)` (`ITEM_KEY_DISCRIMINATORS`), but the gate asked which packages of the closure ship the template's NAME, across every locale. + +- **Narrowed.** A `.custom` pair declaring `overrides: { package: }` on a locale the base does not ship is refused `403 NOT_OVERRIDABLE` (`declared_package_ships_no_such_item`, the sentence now naming the locale) at the save door, at the per-item publish (`?member=`) and at a package publish, by `os validate` / `os build`, and at `POST /api/v1/marketplace/install-local`. It was admitted and served as an override, with no base member for drift detection or "Restore package original" to compare against. The remedy is in the sentence: remove `overrides` to keep it as the pair's own locale. +- **Widened.** A locale the base does not ship, saved in the pair WITHOUT `overrides`, is the pair's own new member and is admitted at the same doors. It was refused `undeclared_same_name`, which told the author to declare an override of a member that does not exist. The same holds for a dependent package's template of a name its base ships in another locale: it collides with no base member. +- **Unchanged.** A declared override of a locale the base ships is admitted; an undeclared same-locale item is still refused `undeclared_same_name`; every other type's identity is its name alone, as before; the read side already served per member. +- **At rest.** A pair row stored before this release that declares an override of an unshipped locale keeps being served. Its next save carrying `overrides` is refused with the remedy; saved without `overrides`, it is the pair's own locale. + +`@objectstack/metadata-protocol` exports `overrideWriteIdentityOf(type, identity, item)`, the one identity the gate's callers compare shipped and incoming items on: an item's name (an action's `:`), joined with its bundle member for a pair-identified type. `OverrideWriteItem.identity` (from `overrideWriteItemsOf`) is spelled through it, so an email template's identity now carries its locale. diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts index a7787eec083..aa4d3e4b2c5 100644 --- a/packages/cli/src/utils/override-declarations.test.ts +++ b/packages/cli/src/utils/override-declarations.test.ts @@ -160,4 +160,19 @@ describe('#28410 — findOverrideDeclarationRefusals walks the artifact under th })], })).toEqual([]); }); + + it('[#28746] a dependent\'s template of the base\'s name collides only at a locale the base ships', () => { + const rows = findOverrideDeclarationRefusals({ + manifest: manifest('com.acme.bundle'), + packages: [templateBase, pkg(PARTNER, [BASE], { + emailTemplates: [ + { name: 'crm_welcome', locale: 'en-US', subject: 'Partner' }, + { name: 'crm_welcome', locale: 'fr-FR', subject: 'Partenaire' }, + ], + })], + }); + expect(rows.map((r) => [r.path, r.rule])).toEqual([ + ['packages[1].manifest.emailTemplates[0]', 'undeclared_same_name'], + ]); + }); }); From cc065b04b17e94d9dcdca1a0ac7ecbf26b0036a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:11:18 +0000 Subject: [PATCH 4/4] test(metadata-protocol): record shippedArtifactsOf's member argument at its two lookup sites Claude-Session: https://claude.ai/code/session_01FEkQ5oDNN263d7ffSwgjsY Co-authored-by: Claude --- .../src/protocol.lookup-artifact-item-call-sites.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 2067f17685e..ccf46dd59b0 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 @@ -113,10 +113,10 @@ const SITES: ReadonlyArray