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 62afb34be97..99f1f919dfb 100644
--- a/packages/cli/src/utils/override-declarations.test.ts
+++ b/packages/cli/src/utils/override-declarations.test.ts
@@ -134,4 +134,48 @@ 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([]);
+ });
+
+ 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'],
+ ]);
+ });
});
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 ac823791abf..884739f5b85 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
@@ -233,4 +233,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/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 6f7da219c85..929db56426c 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 2943bd077b6..3027ff4155c 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 —
@@ -266,6 +267,40 @@ export function isPairRowRemoval(type: string, basePackageId: unknown, packageId
return pair.ok && pair.value === packageId;
}
+/**
+ * [#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). */
@@ -287,7 +322,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[];
}
@@ -350,7 +389,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.
*/
@@ -406,8 +446,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 };
@@ -438,7 +481,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. */
@@ -480,7 +525,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;
@@ -505,6 +550,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,
@@ -522,10 +572,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.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 = {
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
new file mode 100644
index 00000000000..0dd2073c415
--- /dev/null
+++ b/packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts
@@ -0,0 +1,133 @@
+// 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;
+/** 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;
+ 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(codeOf(saved.json)).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(codeOf(published.json)).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}}' } });
+ });
+});