Skip to content

Commit 5bb7834

Browse files
fix(metadata-protocol)!: the override write gate judges an email template by its locale, so a pair's override of a locale the base does not ship is refused (#28746) (#28777)
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XKpnR4hU1b8DuBmGsW5R8jA)_ Fixes #28746 Clause-②: yes (narrowing) Metadata-protocol: narrowed Skills-docs: follow-up #28778 ## 维护者速读 - **改了什么**:覆盖写入闸门(ADR-0146 D2)对邮件模板按「名称 + 语言(locale)」判断,而不是只按名称。`BASE.custom` 声明覆盖一个基础包根本没发的语言(比如基础包只发 `en-US`,却声明覆盖 `fr-FR`)现在在保存、单项发布、`os validate` / `os build`、install-local 四个入口一致拒绝 `403 NOT_OVERRIDABLE`,提示「去掉 `overrides` 作为自己的语言保留」。 - **为什么改**:原来闸门只问「基础包发不发这个名字」,任何语言都算发了,于是一个其实是新增的语言冒充覆盖上线,升级时漂移检测和「恢复包原版」找不到可比的基础成员。 - **风险与回滚**:中低。另有一处放宽:在配对包里不带 `overrides` 新增一个基础包没发的语言,原来被误拒(`undeclared_same_name`,叫作者去声明一个不存在的覆盖),现在放行;依赖包里同名、不同语言的模板同理。已存的错标行照旧被读出,下次带 `overrides` 保存才会被拒。回滚即撤销本 PR。 - **先看**:`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts`(新的 `overrideWriteMemberOf` / `overrideWriteIdentityOf`,以及 `overrideWriteVerdict` 按成员查 shippers);`packages/cloud-connection/src/marketplace-install-local-plugin.ts`(`listedIdentity` 改走同一个身份函数);`packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/email-template-override-unshipped-locale.dogfood.test.ts`,预期 4/4 通过(main 上 3 红:`fr-FR` 覆盖保存和发布都答 200,`fr-FR` 新增被拒 403)。 - **需要你做的**:无,席位落地。 ## What changes **The seam.** An email template is one item per `(name, locale)` (`ITEM_KEY_DISCRIMINATORS` in `@objectstack/metadata-core`, the table the registry keys bundles on and the read side serves a member by). The override write gate counted which closure packages ship the template's NAME, across every locale, so "the base ships this item" held for any locale. Every door now counts at the member the body addresses, through one helper. - **`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts`.** - `overrideWriteMemberOf(type, item)`: `itemDiscriminator` over the type's singular, so the canonical member for a body that names none, and `undefined` for every type whose identity is its name. Read from the identity table, never a type literal; `email_template` is the only entry today (measured: `ITEM_KEY_DISCRIMINATORS` at `7a48bf73` holds one key). - `overrideWriteIdentityOf(type, identity, item)` (exported): an item's identity (its name; an action's `objectName:name`) joined with its member for a pair-identified type, unchanged otherwise. `overrideWriteItemsOf` spells `OverrideWriteItem.identity` through it, so `os validate` (`packages/cli/src/utils/override-declarations.ts`, unchanged source) and the install door's incoming items compare on it. - `overrideWriteVerdict` (the save door, the per-item publish and the package publish all ask it) looks the registry up at the body's member and drops an artifact of another member, the guard `mergeArtifactProtection` applies on the read side for a registry that ignores `member`. - `judgeOverrideWrite`: unchanged rules and order; the `declared_package_ships_no_such_item` sentence names the member for a pair-identified type ("ships no email_template named 'X' with locale 'fr-FR'"). Every other type's sentence is byte-identical. - **`packages/metadata-protocol/src/protocol-artifact-lookup.ts` and `protocol.ts`.** The private `shippedArtifactsOf` takes an optional `member` and passes it to both of its `lookupArtifactItem` calls. Omitted, the lookup is what it was, so the lock's artifact layer (a lock binds the NAME) is untouched. The published `.d.ts` lists the private member without a signature, so the surface does not move. - **`packages/cloud-connection/src/marketplace-install-local-plugin.ts`.** `listedIdentity` (an installed package's listed items) returns `overrideWriteIdentityOf(...)`, so an installed base's `en-US` template and an incoming `fr-FR` one are two items at this door as at the publish doors. - **Every door that asks the gate.** Save door, per-item publish (`?member=`), package publish (`promoteDraftForPublish` judges the draft body), `os validate` / `os build`, install-local: each computes its `shippers` at the same identity, so the four answers match. No door was found that cannot know the member: the save is body-addressed, and every caller hands the gate the body. - **Changeset** `.changeset/28746-override-gate-bundle-member.md`: `minor` on `@objectstack/metadata-protocol` and `@objectstack/cloud-connection`, both declaration lines, ADR-0087 disposition `not-required (no-migration-prescription)`. **Narrowed:** a pair's declared override of a locale the base does not ship is refused `403 NOT_OVERRIDABLE` (`declared_package_ships_no_such_item`). **Widened:** an undeclared locale the base does not ship, in the pair or in a dependent, collides with no base member and is admitted; on `main` it was refused `undeclared_same_name`. **Unchanged:** a declared override of a shipped locale; an undeclared item at a shipped locale (still `undeclared_same_name`); every non-bundle type. ## Tests - **Red first, at base `7a48bf73`** (dists built from base): the dogfood file failed 3 of 4 (the `fr-FR` override saved `200` and its draft published `200`, served as an override; the `fr-FR` addition was refused `403` `undeclared_same_name`); the CLI cases failed 2 of 2 (no row for the declared `fr-FR`; an `undeclared_same_name` row for the `fr-FR` addition); the install-local cases failed 2 of 2 (the `fr-FR` override installed `200`; the addition was refused `403`). The `en-US` shipped-locale control was green on base. - **Green** at the head below: dogfood 4/4, CLI file 10/10, install-local file 8/8. - **New pins.** - `packages/qa/dogfood/test/email-template-override-unshipped-locale.dogfood.test.ts` (showcase, HTTP): `PUT ?mode=draft&package=com.example.showcase.custom` of `showcase_task_done_email` at `fr-FR` with `overrides` → `403 NOT_OVERRIDABLE`, no pair row, `GET ?member=fr-FR` 404; a draft of that shape stored before the gate → `POST …/publish?package=…&member=fr-FR` `403`, the row stays a draft, `fr-FR` still 404; controls: `en-US` declared → 200, published, served; `fr-FR` without `overrides` → 200, published, served. - `packages/cli/src/utils/override-declarations.test.ts`: the pair's `fr-FR` declaration → `declared_package_ships_no_such_item`; controls: `en-US` declared plus `fr-FR` undeclared → no row; a dependent's template collides only at `en-US`. - `packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts`: the pair's `fr-FR` declaration → `403`, nothing registered, no ledger entry; control: `en-US` declared plus `fr-FR` undeclared → `200`. - Non-bundle control: every existing pin of the gate for `view` / `dashboard` / `page` (metadata-protocol `protocol.packaged-base-refusal.test.ts`, objectql `protocol-custom-override-effective-read.test.ts`, the CLI and install-local files) is unchanged and green. - **Pin moved.** `protocol.lookup-artifact-item-call-sites.test.ts` records the literal arguments of each `lookupArtifactItem` call; the two rows in the seam module now read `type, name, undefined, member` and `type, name, packageId, member`, with the reason. <details><summary>Ablation (one-off, from committed HEAD c5b0757)</summary> - **Member dropped from the gate's subject.** `node scripts/ablation-replace.mjs` replaced `return itemDiscriminator(PLURAL_TO_SINGULAR[type] ?? type, item);` in `overrideWriteMemberOf` with a guarded `return undefined` (marker `ABLATE_28746`): anchor 1 → 0, blob `5b2921c51bef` → `162d86a93c62`. metadata-protocol and cloud-connection rebuilt; `ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATE_28746`: marker present in 2 built files, exit 0. - **Readings.** CLI file: 2 failed / 7 passed (the `fr-FR` declaration and the combined `en-US` + `fr-FR` control). install-local file: 2 failed / 6 passed (the same two). Dogfood file: 3 failed / 1 passed (the save refusal, the publish refusal and the `fr-FR` addition; the `en-US` shipped-locale control stayed green). So dropping the member reds the refused cases AND the undeclared-addition controls, because on `main` the addition was refused too (Acceptance notes); no other case moved. The CLI file's dependent pin was added after this run and was not ablated. - **Restored.** `git checkout HEAD --` the file: `git diff HEAD` empty, blob equals HEAD `5b2921c51bef`; both packages rebuilt; preflight `--absent`: marker absent from all 24 built files, tree clean, exit 0. </details> ## Acceptance notes - **Premise half falsified: the addition was not unaffected.** The order listed "a new member added in the pair without `overrides`" as unaffected. Measured on base, it was refused `undeclared_same_name` at the save door, `os validate` and install-local, with a sentence telling the author to declare an override of a member the base does not ship. Under the ruled identity `(type, name, member)` it collides with nothing, so this PR admits it, and declares the widening (`Clause-②: yes (narrowing)`). - **Dependent packages follow the same identity.** A dependent's template of a name its base ships in `en-US` only is refused at `en-US` and admitted at `fr-FR`, as the identity table reads it (pinned in the CLI file). Flagged for the contract review: it is the mechanical consequence of the ruling, not a separate decision. - **At rest (ADR-0087).** Measured on the showcase with a pair `fr-FR` override row laid down before the gate: `GET ?member=fr-FR` serves it with `_packageId: com.example.showcase` and its `overrides` block, and the layered read answers `code: null`; `en-US` is unaffected. Its next save carrying `overrides` is refused with the member-naming sentence; saved without `overrides` it publishes as the pair's own `fr-FR`, served under `com.example.showcase.custom`. Nothing re-judges the row at boot or upgrade; if the base later ships `fr-FR`, the row becomes a real override. No conversion entry: no key changes shape. The read serving such a row under the base's package id is dormant once the gate holds (no new row can be written) and is not filed. - **Overlap.** Open PR #28728 edits `marketplace-install-local-plugin.ts` (step 2e, the field-patch check); its hunks are at the import block's metadata-core list and near `:2120` / `:3287`, none of the lines this PR touches (`:243`, `:579`). ## Verification All readings at head `e30f9fc8` (this branch merged with `origin/main` `5493b884`, which carries #28412's flow-override edits to `protocol-packaged-base-refusal.ts` and `protocol.ts`; auto-merged), after `pnpm install --frozen-lockfile` and a rebuild of the dogfood closure plus cli and cloud-connection (`BUILD_EXIT=0`). <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/metadata-protocol test`: 294 files passed (4 skipped), 22022 tests passed. - `pnpm --filter @objectstack/cloud-connection test`: 66 files, 805 tests passed. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 365 files, 5178 tests passed. The `integration` tier is declared to CI (the diff touches no spawn entry). - `typecheck` for metadata-protocol, cloud-connection, cli (with `check:test-typecheck: OK`, ledger held) and dogfood: exit 0. - objectql consumers of the gate, `protocol-custom-override-effective-read.test.ts`, `protocol-packageless-overlay-migration.test.ts`, `registry-i18n-bundle-key.test.ts`: 3 files, 42 tests passed (pre-merge head `c5b07573`). </details> <details><summary>Full dogfood battery (Clause-② narrowing arm)</summary> - `pnpm --filter @objectstack/dogfood test` under the verify lock, no shard, no path: VERDICT command-exit 0; 375 files passed (3 skipped), 3038 tests passed (28 skipped). </details> <details><summary>Gate sweep</summary> - `node scripts/pm/dispatch-gates.mjs --commands`: 69 families at `e30f9fc8`; every one exit 0. Three first answered `PREREQUISITE NOT MET` (exit 3: the teardown self-test's pinned commit absent from the shallow clone; `check:type-check-debt` queued behind the dogfood run; `check:dual-build-cjs-loads` before a full build) and were re-run green: the teardown self-test after fetching its pinned commit, `check:type-check-debt` under the lock, `check:dual-build-cjs-loads` after it (115 require entry points across 70 packages load). - `dispatch-gates --ran`: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. The per-family exit codes are in the seat's project files (`pm-orders/server/28746-gates-ran.txt`). - Every family CI runs on this PR is read from its check-runs, not re-run here. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oDNN263d7ffSwgjsY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56fdd56 commit 5bb7834

10 files changed

Lines changed: 310 additions & 19 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/cloud-connection': minor
4+
---
5+
6+
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)
7+
8+
Clause-②: yes (narrowing)
9+
Metadata-protocol: narrowed
10+
11+
<!-- 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`). -->
12+
13+
**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.
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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.
19+
20+
`@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.

‎packages/cli/src/utils/override-declarations.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,48 @@ describe('#28410 — findOverrideDeclarationRefusals walks the artifact under th
134134
expect(rows.map((r) => r.rule)).toEqual(['declared_package_outside_closure']);
135135
expect(rows[0].message).toMatch(/Declare a dependency on 'com\.acme\.crm'/);
136136
});
137+
138+
// [#28746] An email template is one item per `(name, locale)`: the base
139+
// below ships `crm_welcome` in `en-US` only, so each member is judged alone.
140+
const templateBase = pkg(BASE, [], { emailTemplates: [{ name: 'crm_welcome', locale: 'en-US', subject: 'Welcome' }] });
141+
const declared = { package: BASE, basedOn: '1.0.0' } as const;
142+
143+
it('[#28746] the pair declaring an override of a locale the base does not ship is refused', () => {
144+
const rows = findOverrideDeclarationRefusals({
145+
manifest: manifest('com.acme.bundle'),
146+
packages: [templateBase, pkg(PAIR, [BASE], {
147+
emailTemplates: [{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue', overrides: { ...declared } }],
148+
})],
149+
});
150+
expect(rows.map((r) => [r.path, r.packageId, r.rule])).toEqual([
151+
['packages[1].manifest.emailTemplates[0]', PAIR, 'declared_package_ships_no_such_item'],
152+
]);
153+
});
154+
155+
it('[#28746] controls: the pair overrides the shipped locale and adds an undeclared locale of its own', () => {
156+
expect(findOverrideDeclarationRefusals({
157+
manifest: manifest('com.acme.bundle'),
158+
packages: [templateBase, pkg(PAIR, [BASE], {
159+
emailTemplates: [
160+
{ name: 'crm_welcome', locale: 'en-US', subject: 'Ours', overrides: { ...declared } },
161+
{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue' },
162+
],
163+
})],
164+
})).toEqual([]);
165+
});
166+
167+
it('[#28746] a dependent\'s template of the base\'s name collides only at a locale the base ships', () => {
168+
const rows = findOverrideDeclarationRefusals({
169+
manifest: manifest('com.acme.bundle'),
170+
packages: [templateBase, pkg(PARTNER, [BASE], {
171+
emailTemplates: [
172+
{ name: 'crm_welcome', locale: 'en-US', subject: 'Partner' },
173+
{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Partenaire' },
174+
],
175+
})],
176+
});
177+
expect(rows.map((r) => [r.path, r.rule])).toEqual([
178+
['packages[1].manifest.emailTemplates[0]', 'undeclared_same_name'],
179+
]);
180+
});
137181
});

‎packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,35 @@ describe('install-local 2d — the override write gate at the managed install (A
233233
]);
234234
expect(collisions[1].message).toMatch(/Declare it/);
235235
});
236+
237+
// [#28746] An email template is one item per `(name, locale)`: the base
238+
// below ships `crm_welcome` in `en-US` only, so a member is judged alone.
239+
const TEMPLATE_HOLDINGS: Holdings = {
240+
[BASE]: { email_template: [{ name: 'crm_welcome', locale: 'en-US', subject: 'Welcome' }] },
241+
};
242+
const DECLARED = { package: BASE, basedOn: '1.0.0' } as const;
243+
244+
it('[#28746] the paired .custom declaring an override of a locale the base does not ship is refused 403, and nothing is written', async () => {
245+
const { res, registered, ledger } = await install({
246+
id: PAIR, version: '1.0.0', dependencies: deps(BASE),
247+
emailTemplates: [{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue', overrides: { ...DECLARED } }],
248+
}, [{ id: BASE }], TEMPLATE_HOLDINGS);
249+
expect(res.status, JSON.stringify(res.payload)).toBe(403);
250+
expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE');
251+
expect(refusedOf(res)).toEqual([['email_template', 'crm_welcome', 'declared_package_ships_no_such_item']]);
252+
expect(registered).toEqual([]);
253+
expect(ledger).toEqual([]);
254+
});
255+
256+
it('[#28746] CONTROL: the pair overrides the locale the base ships, and adds an undeclared locale of its own', async () => {
257+
const { res, registered } = await install({
258+
id: PAIR, version: '1.0.0', dependencies: deps(BASE),
259+
emailTemplates: [
260+
{ name: 'crm_welcome', locale: 'en-US', subject: 'Ours', overrides: { ...DECLARED } },
261+
{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue' },
262+
],
263+
}, [{ id: BASE }], TEMPLATE_HOLDINGS);
264+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
265+
expect(registered).toEqual([PAIR]);
266+
});
236267
});

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ import type {
245245
import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol';
246246
// [ADR-0146 D2, #28410] The override write gate's one rule and its item walk,
247247
// asked at the managed install exactly as the publish doors ask them.
248-
import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol';
248+
import { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol';
249249
// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, step 1c''.
250250
import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
251251
import { resolveThrownHttpError } from '@objectstack/types';
@@ -581,14 +581,20 @@ function listedItems(answer: unknown): Record<string, unknown>[] {
581581
: [];
582582
}
583583

584-
/** An item's registry identity — `overrideWriteItemsOf`'s: an action is `<objectName>:<name>`. */
584+
/**
585+
* An item's registry identity — `overrideWriteItemsOf`'s: an action is
586+
* `<objectName>:<name>`, and [#28746] a pair-identified type's item carries its
587+
* bundle member, through the gate's own `overrideWriteIdentityOf`, so an
588+
* installed base's listed `en-US` template and an incoming `fr-FR` one are two
589+
* items here as at the publish doors.
590+
*/
585591
function listedIdentity(type: string, item: Record<string, unknown>): string | undefined {
586592
const name = typeof item.name === 'string' && item.name !== '' ? item.name : undefined;
587593
if (name === undefined) return undefined;
588594
if (type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' && !name.includes(':')) {
589595
return `${item.objectName}:${name}`;
590596
}
591-
return name;
597+
return overrideWriteIdentityOf(type, name, item);
592598
}
593599

594600
/** A `{ … }` object — not an array, not null. */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export type { MetadataAuthoringChannel } from './protocol.js';
3434
// [ADR-0146 D2, #28410] The override write gate's one rule, exported for the
3535
// two gate points outside this package — `os validate` and the marketplace
3636
// managed install — so all three doors ask one judgement and cannot drift.
37-
export { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
37+
export { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
3838
// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge,
3939
// exported for the doors outside this package — `os validate` and the
4040
// marketplace install — so every door that lets a field in asks one judgement.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,17 @@ export function artifactLockLayerAt(
9797
/** The body of `ObjectStackProtocolImplementation#shippedArtifactsOf`; the method's docblock there is its contract. */
9898
export function shippedArtifactsOf(
9999
this: Pick<ArtifactLookupHost, 'lookupArtifactItem' | 'shippingPackagesOf'>,
100-
type: string, name: string, shipping?: ShippingPackages,
100+
type: string, name: string, shipping?: ShippingPackages, member?: string,
101101
): unknown[] {
102102
const shipped: unknown[] = [];
103103
const add = (artifact: unknown): void => {
104104
if (artifact !== undefined && artifact !== null && !shipped.includes(artifact)) shipped.push(artifact);
105105
};
106-
add(this.lookupArtifactItem(type, name));
106+
add(this.lookupArtifactItem(type, name, undefined, member));
107107
if ((PLURAL_TO_SINGULAR[type] ?? type) === 'object') return shipped;
108108
const packages = shipping ?? this.shippingPackagesOf(type);
109109
for (const packageId of new Set([...(packages.byName.get(name) ?? []), ...packages.anyName])) {
110-
const own = this.lookupArtifactItem(type, name, packageId) as { _packageId?: unknown } | undefined;
110+
const own = this.lookupArtifactItem(type, name, packageId, member) as { _packageId?: unknown } | undefined;
111111
if (own?._packageId === packageId) add(own);
112112
}
113113
return shipped;

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
} from '@objectstack/spec/kernel';
4848
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
4949
import { expandViewContainer, isAggregatedViewContainer } from '@objectstack/spec';
50+
import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator } from '@objectstack/metadata-core';
5051

5152
/**
5253
* The `ObjectStackProtocolImplementation` members the functions below read —
@@ -266,6 +267,40 @@ export function isPairRowRemoval(type: string, basePackageId: unknown, packageId
266267
return pair.ok && pair.value === packageId;
267268
}
268269

270+
/**
271+
* [#28746, ADR-0146 D2] The bundle member an item IS, for a type whose
272+
* identity is a pair (`ITEM_KEY_DISCRIMINATORS`: an `email_template` is one
273+
* item per `(name, locale)`), the canonical member when the body declares
274+
* none; `undefined` for every other type, whose identity is its name alone.
275+
*
276+
* Read off the identity table the registry keys its bundles on and the read
277+
* side serves a member by (`findServedOverlayRow`, the layered read), never a
278+
* type literal. The save is BODY-addressed, so the member is the body's own.
279+
*/
280+
export function overrideWriteMemberOf(type: string, item: unknown): string | undefined {
281+
return itemDiscriminator(PLURAL_TO_SINGULAR[type] ?? type, item);
282+
}
283+
284+
/**
285+
* [#28746, ADR-0146 D2] The identity two packages' items collide on under the
286+
* override write gate: `identity` (an item's name; an `action`'s
287+
* `<objectName>:<name>`) joined with its bundle member
288+
* ({@link overrideWriteMemberOf}) for a pair-identified type, and `identity`
289+
* unchanged for every other type.
290+
*
291+
* The ONE spelling every door that hands the gate its `shippers` compares on:
292+
* {@link overrideWriteItemsOf} (so `os validate` and the install door's
293+
* incoming items), the install door's listing of an installed package, and
294+
* {@link overrideWriteVerdict}'s registry lookup at the same member. A base
295+
* that ships a template in `en-US` alone therefore ships no `fr-FR` member: a
296+
* declared `fr-FR` override is refused (`declared_package_ships_no_such_item`),
297+
* and an undeclared `fr-FR` beside it collides with nothing.
298+
*/
299+
export function overrideWriteIdentityOf(type: string, identity: string, item: unknown): string {
300+
const member = overrideWriteMemberOf(type, item);
301+
return member === undefined ? identity : `${identity}\u0000${member}`;
302+
}
303+
269304
/** One item written into one package — what the override write gate judges. */
270305
export interface OverrideWriteSubject {
271306
/** The metadata type, either spelling (folded here). */
@@ -287,7 +322,11 @@ export interface OverrideWriteClosure {
287322
* admitting needs the base to be proven inside the closure.
288323
*/
289324
readonly dependencies: readonly string[] | undefined;
290-
/** The packages of {@link dependencies} that ship an item of this type and name. */
325+
/**
326+
* The packages of {@link dependencies} that ship an item of this type and
327+
* name — for a pair-identified type, of this bundle member
328+
* ({@link overrideWriteIdentityOf}).
329+
*/
291330
readonly shippers: readonly string[];
292331
}
293332

@@ -350,7 +389,8 @@ function declarationOf(item: unknown): { present: boolean; base: string | undefi
350389
* Order: a declaration is judged on its type first (D10 `object`, D6 and the
351390
* other excluded types, then D9's `flow` and `action`), then on who declares
352391
* (the pair alone), then on what it names (inside the closure, and shipping
353-
* that item). An undeclared item is judged only when a package of its closure
392+
* that item — for a pair-identified type, that bundle member: the caller's
393+
* `shippers` are counted at {@link overrideWriteIdentityOf}). An undeclared item is judged only when a package of its closure
354394
* ships the same name, on a type a declared override applies to; outside the
355395
* closure two packages keep ADR-0048 §3.3/§3.4's coexistence on a bare name.
356396
*/
@@ -406,8 +446,11 @@ export function judgeOverrideWrite(
406446
+ `Declare a dependency on '${base}' in the \`dependencies\` of '${packageId}''s manifest, or remove \`overrides\` (ADR-0146 D2).`);
407447
}
408448
if (!closure.shippers.includes(base)) {
449+
// [#28746] A pair-identified type names the member it addressed.
450+
const member = overrideWriteMemberOf(type, subject.item);
451+
const ofMember = member === undefined ? '' : ` with ${ITEM_KEY_DISCRIMINATORS[type]?.field} '${member}'`;
409452
return refuse('declared_package_ships_no_such_item',
410-
`${item} declares an override of '${base}', which ships no ${type} named '${name}'. `
453+
`${item} declares an override of '${base}', which ships no ${type} named '${name}'${ofMember}. `
411454
+ `Remove \`overrides\` to keep it as '${packageId}''s own item, or correct the name (ADR-0146 D2).`);
412455
}
413456
return { verdict: 'admit', base };
@@ -438,7 +481,9 @@ export interface OverrideWriteItem {
438481
readonly name: string;
439482
/**
440483
* The registry identity two packages collide on: an `action` is
441-
* `<objectName>:<name>` (the registry's key), every other item its name.
484+
* `<objectName>:<name>` (the registry's key), every other item its name,
485+
* and [#28746] a pair-identified type's item that joined with its bundle
486+
* member ({@link overrideWriteIdentityOf}).
442487
*/
443488
readonly identity: string;
444489
/** The item body the declaration is read from. */
@@ -480,7 +525,7 @@ export function overrideWriteItemsOf(body: Record<string, unknown>, at = ''): Ov
480525
const identity = type === 'action' && typeof item.objectName === 'string' && item.objectName !== ''
481526
? `${item.objectName}:${declaredName}`
482527
: declaredName;
483-
out.push({ type, name: declaredName, identity, item, path });
528+
out.push({ type, name: declaredName, identity: overrideWriteIdentityOf(type, identity, item), item, path });
484529
}
485530
}
486531
return out;
@@ -505,6 +550,11 @@ export function overrideWriteRefusalError(
505550
* registry (`resolveWritePackageScope`, the save door's own) and the shipping
506551
* packages from the artifact lookup (`shippedArtifactsOf`, the read's own),
507552
* then asks {@link judgeOverrideWrite}.
553+
*
554+
* [#28746] For a pair-identified type the lookup is at the body's bundle
555+
* member ({@link overrideWriteMemberOf}), and an artifact of another member is
556+
* no shipper: a registry that ignores `member` can answer one, as
557+
* `mergeArtifactProtection` guards on the read side.
508558
*/
509559
export function overrideWriteVerdict(
510560
this: Pick<PackagedBaseRefusalHost, 'isWritablePackage' | 'resolveWritePackageScope' | 'shippedArtifactsOf'>,
@@ -522,10 +572,12 @@ export function overrideWriteVerdict(
522572
// fact about the package, not about the item.
523573
if (packageId !== undefined && scope === undefined) return NOT_CONCERNED;
524574
const dependencies = scope?.dependencies.filter((id) => id !== packageId);
575+
const member = overrideWriteMemberOf(request.type, request.item);
525576
const shippers = dependencies === undefined || dependencies.length === 0
526577
? []
527578
: Array.from(new Set(
528-
this.shippedArtifactsOf(request.type, request.name)
579+
this.shippedArtifactsOf(request.type, request.name, undefined, member)
580+
.filter((a) => overrideWriteMemberOf(request.type, a) === member)
529581
.map((a) => (a as { _packageId?: unknown } | null | undefined)?._packageId)
530582
// An override replaces an item of an INSTALLED, read-only base
531583
// (ADR-0146 D1): a writable package's item is edited in place

0 commit comments

Comments
 (0)