Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/28746-override-gate-bundle-member.md
Original file line number Diff line number Diff line change
@@ -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 `<base>.custom` override of a locale the base does not ship is refused (#28746)

Clause-②: yes (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: the override write gate (ADR-0146 D2) now counts the packages that ship an email template at the bundle member the body addresses, as the read side already serves it. A stored row this gate would refuse is not converted: it is served as before, and its next save meets the gate's own refusal with its remedy (drop `overrides`). -->

**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 `<base>.custom` pair declaring `overrides: { package: <base> }` 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 `<objectName>:<name>`), 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.
44 changes: 44 additions & 0 deletions packages/cli/src/utils/override-declarations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -576,14 +576,20 @@ function listedItems(answer: unknown): Record<string, unknown>[] {
: [];
}

/** An item's registry identity — `overrideWriteItemsOf`'s: an action is `<objectName>:<name>`. */
/**
* An item's registry identity — `overrideWriteItemsOf`'s: an action is
* `<objectName>:<name>`, 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, unknown>): 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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/metadata-protocol/src/protocol-artifact-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArtifactLookupHost, 'lookupArtifactItem' | 'shippingPackagesOf'>,
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;
Expand Down
64 changes: 58 additions & 6 deletions packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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
* `<objectName>:<name>`) 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). */
Expand All @@ -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[];
}

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -438,7 +481,9 @@ export interface OverrideWriteItem {
readonly name: string;
/**
* The registry identity two packages collide on: an `action` is
* `<objectName>:<name>` (the registry's key), every other item its name.
* `<objectName>:<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. */
Expand Down Expand Up @@ -480,7 +525,7 @@ export function overrideWriteItemsOf(body: Record<string, unknown>, 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;
Expand All @@ -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<PackagedBaseRefusalHost, 'isWritablePackage' | 'resolveWritePackageScope' | 'shippedArtifactsOf'>,
Expand All @@ -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
Expand Down
Loading
Loading