Skip to content

Commit 7dae3b3

Browse files
fix(metadata-protocol): a .custom pair's published override of a dashboard, report or email template no longer wears the base's envelope in the registry's bare slot (#29640)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi92Y3Af8UHfhCRCgvN9XxUC5)_ Fixes #29600 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`.custom` 配对包对基础包仪表盘、报表、邮件模板的已发布覆盖,不再以基础包的信封注册进注册表的裸槽位(沿用 #22057 对 view 的规则,扩到这三种类型)。 - 为什么改:基础包的按包层(导出读取它)会把覆盖项和基础包自己的项并列列出,注册表直读也会把覆盖项当成基础包的项。 - 风险与回滚:所有协议服务读取(按名读、有效列表、分层读)前后逐项测量不变;只有注册表直读不再看到覆盖。回滚即还原本 PR,无需迁移。 - 先看:`packages/metadata-protocol/src/protocol-package-bindings.ts` 中的 `declinesBorrowedEnvelope`,以及新测试 `protocol-custom-override-bare-slot-envelope.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/protocol-custom-override-bare-slot-envelope.test.ts src/environment-overlay-email-locale-restart.test.ts` - 需要你做的:无,席位落地 ## What changes A published override of a base's dashboard, report or email template, saved in the base's `.custom` pair, was registered in the registry's bare slot wearing the BASE's artifact envelope (`_packageId` = BASE, `_provenance: 'package'`). The base's per-package layer, which the base's export reads, then listed the override beside the base's own item, and every registry-direct read of the base's item answered the override. The hydrator (`hydrateOverlayIntoRegistry`) now declines the bare slot for a row of these three types when the row's bound package ships no artifact of the name while another package does. This is the #22057 view rule, widened per seat ruling A to dashboard, report and email_template. The rule lives in `declinesBorrowedEnvelope` in `protocol-package-bindings.ts`, a closed list of three types. - View is unchanged (it keeps its own #22057 limb). - The #4624 case keeps its bare entry: a row of a package that ships its own artifact of the name. - page, app, dataset, doc and book are left out on purpose. For them the bare slot IS the served override (getMetaItem, getMetaItems, GET /me/apps), so they need a row-served path first. That is follow-up **#29613**, which also carries the two class-b findings from the measurement. - flow is left out: no envelope (Regime C), and the boot pull arms its declared override from the bare slot. - The delete-heal limb has no twin of the rule, because it registers the baseline's own body and grafts no artifact envelope (docblock note only, no code change). Files (5, +424 / −17): - `packages/metadata-protocol/src/protocol-package-bindings.ts`: `declinesBorrowedEnvelope` and its type list. - `packages/metadata-protocol/src/protocol.ts`: one guard line in the hydrator, plus the header and heal docblocks. - `packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts` (NEW): the per-type pins. It sits in objectql because the pins need the real ObjectQL engine, the in-memory driver and the real registry, and a metadata-protocol test cannot import objectql (that would be a dependency cycle). It reads metadata-protocol from `dist/`, like the sibling `protocol-per-package-layer-provenance.test.ts` whose harness it copies. No objectql source is edited. - `packages/objectql/src/environment-overlay-email-locale-restart.test.ts`: forced edits, listed below. - `.changeset/29600-pair-override-bare-slot-envelope.md`: patch for `@objectstack/metadata-protocol`. ## Tests New file `packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts`, 9 tests. Per type (view, dashboard, report, email_template) two tests: - (a) The base's per-package layer lists the base's item alone, and the pair's lists the override alone. The bare slot holds no entry for the name (`name@en-US` for the email template). `registry.getItem` answers the base's own item. - (b) The served reads still serve the override: getMetaItem with no package, BASE and PAIR; getMetaItems with no package and BASE; getMetaItemLayered(BASE).effective. A control pins #4624: OTHER also ships the dashboard and owns a stored row, so its bare entry stays with OTHER's envelope and `_provenance: 'package'`. Forced edits in `packages/objectql/src/environment-overlay-email-locale-restart.test.ts` (seat-approved, on condition that no non-test email_template registry reader moves; the audit is under Acceptance notes). These four cases pinned the registry entry of a pair's override of a SHIPPED member, which is exactly the entry this PR removes. Each now pins the served read, and keeps the registry pin on the shipped body: 1. "DELETE by member: resetting zh-CN removes its row only, and the en-US overlay stays served — the registry included": the registry pins become `PKG en` / `PKG zh`. The served reads go through the protocol (`subjectOf(protocol, 'en-US')`), and after the restart `subjectOf(after.protocol, 'en-US')` = `ADMIN en`. 2. "DELETE by member of a member NO artifact ships: its overlay leaves the registry, and the canonical overlay stays": adds the by-name read `getMetaItem({ type, name, member: 'en-US' })` = `ADMIN digest en`; the registry entry for en-US is `PKG digest en`. 3. "DELETE with no member": the registry pin on zh-CN `ADMIN zh` is replaced by `subjectOf(protocol, 'zh-CN')` = `ADMIN zh`. 4. "the cluster fan-out: a peer converges on the member a write changed, and a member reset heals only that member": the peer converges through `vi.waitFor` on `subjectOf(peer.protocol, 'zh-CN')` = `ADMIN zh`; the registry pins are `PKG zh` / `PKG en` and stay `PKG zh` on both nodes after the delete. Ablation of the guard back to view-only. The anchor line in `declinesBorrowedEnvelope` is replaced so that it returns false for every type. The marker is confirmed in `dist/`, and the restore is proved by an empty `git diff HEAD` and `ablation-dist-preflight --absent` exit 0. - New pin file alone: 3 red (dashboard, report, email_template per-package tests), 6 green. - New pin file plus the email-locale file: 6 red (those 3 plus forced-edit cases 1, 2 and 4), 44 green. - The (b) served-read tests stay green under the ablation by design: those reads never came from the bare slot. The view pin stays green because the #22057 limb guards view on its own. - A first ablation attempt failed the build and ran no tests. It was restored and is not counted. <details><summary>Ablation reading (both files)</summary> ``` ✓ dist/: marker present in 2 built files -- the ablation is live in the artifact the suite consumes. × dashboard: the base's per-package layer lists the base's item alone, the pair's the override alone × report: the base's per-package layer lists the base's item alone, the pair's the override alone × email_template: the base's per-package layer lists the base's item alone, the pair's the override alone × DELETE by member: resetting zh-CN removes its row only, and the en-US overlay stays served — the registry included × DELETE by member of a member NO artifact ships: its overlay leaves the registry, and the canonical overlay stays × the cluster fan-out: a peer converges on the member a write changed, and a member reset heals only that member Test Files 2 failed (2) Tests 6 failed | 44 passed (50) DIFF-HEAD-EMPTY=yes ✓ dist/: marker absent from all 24 built files ABSENT-EXIT=0 ``` </details> ## Acceptance notes Bar from the ruling: no protocol read moves. Every reader was measured before (main 0ab4a09) and after (this branch) with the same harness: real ObjectQL, the in-memory driver and the protocol from `dist/`. The base is registered by `registerApp`, the pair installed by `installPackage`, and the override is a draft save into the pair declaring the override, then the publish. S is the shipped body, O the override; the envelope is label / `_packageId` / `_provenance`. | Reader | view | dashboard / report / email_template | page / app / dataset / doc / book | |---|---|---|---| | bare slot entry | none → none | O/BASE/package → **none** | O/BASE → O/BASE | | registry.getItem (bare, BASE, PAIR) | unchanged | O → **S** | unchanged | | registry.listItems(BASE) | unchanged | [S, O] → **[S]** | unchanged | | per-package layer, BASE | [S] → [S] | [S, O] → **[S]** | [S, O] → [S, O] | | per-package layer, PAIR | [O] → [O] | [O] → [O] | [] → [] | | per-package layer, unscoped | unchanged | [O, O] → **[S, O]** | unchanged | | getMetaItem (none, BASE, PAIR) | O → O | O → O | O → O | | getMetaItems (none, BASE) | [O] → [O] | [O] → [O] | [O] → [O] | | getMetaItemLayered(BASE).effective | O → O | O → O | S → S | | GET /me/apps | n/a | n/a | O → O (app) | Only the bold cells move, and none of them is a protocol served read. translation was not measurable through the pair (the save is refused NOT_OVERRIDABLE in this harness). flow and action are outside the card. email_template non-test registry readers, per the condition on the forced edits: | Reader | Reads | Moves? | |---|---|---| | plugin-email `template-loader.ts` | the protocol first (by-name read with member); the registry only when no protocol is wired | no: the protocol read serves O before and after | | cli `organization-ownership.ts` TemplatePort | stored rows plus `getMetaItemsForExecution` | no: neither reads the bare slot | | objectql registry direct (`getItem`, `listItems`) | the registry | yes, O → S (the intended change, bold above) | | protocol by-name / list / layered | stored rows via `findServedOverlayRow` and the list merge | no | The plugin-email suite (48 files) and the cli ownership tests pass on the fixed head, as listed under Verification. Follow-up: **#29613** holds page, app, dataset, doc and book (row-served override path first, then the guard), and the two class-b findings: the pair's per-package layer is empty for those types, and their layered effective serves S while getMetaItem serves O. ## Verification Head c58da12 (`c58da128a9b58e5d34788a9cabe48c1a87c1590f`) (main merged in). All heavy runs went through `scripts/pm/os-verify-lock.sh`, and each VERDICT is command-exit 0. - Full build `./packages/**` and `./examples/**`: 79/79 tasks. - `@objectstack/metadata-protocol` test: 308 files passed (4 skipped), 22257 tests passed (26 skipped). - `@objectstack/objectql` test: 542 files, 10517 tests passed. - Typecheck of metadata-protocol plus 11 dependents (cli, client, cloud-connection, objectql, plugin-approvals, plugin-security, rest, runtime, service-automation, service-messaging, service-package): 12/12 tasks. - Dogfood, 17 files touching email templates, metadata reads, package customization and overrides: 17 files, 138 tests passed. - Cross-package pair-override suites: runtime 6 files / 59 tests (including `packages-export-base-unchanged-per-type.integration.test.ts` from #29597), cloud-connection 3 / 27, cli 2 / 118 (organization-ownership apply and withdrawal-pair), plugin-email 48 / 785. - eslint `--no-inline-config` on packages/metadata-protocol and packages/objectql: exit 0. - `dispatch-gates --commands`: 62 derived, 62 run, every one exit 0; `dispatch-gates --ran` reports 0 NOT-MEASURED, 0 UNRUN. <details><summary>Dogfood files</summary> - `test/email-template-materialization.dogfood.test.ts` - `test/email-template-overlay-survives-boot.dogfood.test.ts` - `test/email-template-two-locales-survive-boot.dogfood.test.ts` - `test/meta-read-doors-live.dogfood.test.ts` - `test/package-commit-revert-path-package.dogfood.test.ts` - `test/public-form-withdrawal-walled.dogfood.test.ts` - `test/showcase-public-form-redirect.dogfood.test.ts` - `test/showcase-public-form-walled-intake.dogfood.test.ts` - `test/showcase-public-form-withdrawal-layers.dogfood.test.ts` - `test/showcase-public-form-withdrawal.dogfood.test.ts` - `test/email-template-boot-sweep.test.ts` - `test/email-template-dependent-unshipped-locale-read.dogfood.test.ts` - `test/email-template-override-unshipped-locale.dogfood.test.ts` - `test/managed-content-sealed.dogfood.test.ts` - `test/view-container-cross-package-default.dogfood.test.ts` - `test/package-first-authoring.dogfood.test.ts` - `test/wall-authoring-trail-doors.dogfood.test.ts` </details> <details><summary>Gate reconciliation</summary> ``` Run reconciliation — 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3). ``` </details> _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQWkCUmHFTi97EGNu1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b391feb commit 7dae3b3

5 files changed

Lines changed: 424 additions & 17 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a `.custom` pair's published override of a dashboard, report or email template no longer sits in the registry under the base's envelope
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A published override of a base's dashboard, report or email template, saved in the base's `<base>.custom` pair, was registered in the registry's bare slot wearing the BASE's artifact envelope (`_packageId` = the base, `_provenance: 'package'`). The base's per-package layer, which the base's export reads, then listed the override beside the base's own item. Views already stayed out of that slot. The override is now kept out of it for these three types as well, and is still served from its stored row. The by-name read, the effective list and the layered read serve exactly what they served before. The base's per-package layer lists only the base's own item, and the pair's layer lists only the override. A registry read that names the base's item directly answers the base's own item. A row of a package that ships its own artifact of the name keeps its entry as before. Nothing to migrate: a restart re-hydrates the registry under the new rule.

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,41 @@ function boundPackageOf(packageId: unknown): string | undefined {
296296
return typeof packageId === 'string' && packageId !== '' && packageId !== 'sys_metadata' ? packageId : undefined;
297297
}
298298

299+
/**
300+
* [#29600] The types whose stored row stays out of the registry's bare slot
301+
* when it is bound to a package that ships no artifact of its name while
302+
* another package does: the row would wear the other package's envelope
303+
* there, and the per-package layer would list the copy under that package.
304+
* Every protocol read of these types serves such a row from the row itself
305+
* (the by-name read, both lists, the layered read), so the bare entry served
306+
* no reader the row does not. `view` is not here: it has its own, wider rule
307+
* (#22057, `anotherPackageShips`).
308+
*
309+
* ⛔ A closed list, judged on the canonical type. `page`, `app`, `dataset`,
310+
* `doc` and `book` are left out on purpose: their reads serve a `.custom`
311+
* pair's override FROM the bare entry (the by-name read, the list, and
312+
* `registry.getAllApps`), so they need a row-served path before their bare
313+
* entry can go. `flow` registers with no artifact envelope at all (Regime C,
314+
* #20913), and the boot pull arms its declared override from the bare slot.
315+
*/
316+
const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet<string> = new Set(['dashboard', 'report', 'email_template']);
317+
318+
/**
319+
* [#29600] Whether the hydrator keeps a stored row of `canonicalType`, bound
320+
* to `packageId`, out of the registry's bare slot because `artifact` (the
321+
* envelope it would graft on) is ANOTHER package's: the artifact lookup
322+
* scopes to the row's package and falls back to whichever package ships the
323+
* name. A row of a package that ships its own artifact of the name keeps its
324+
* bare entry with its own envelope (#4624, ADR-0048); a package-less row, and
325+
* a row of a name (or bundle member) no package ships, register as before.
326+
*/
327+
function declinesBorrowedEnvelope(canonicalType: string, packageId: unknown, artifact: unknown): boolean {
328+
if (!BORROWED_ENVELOPE_DECLINED_TYPES.has(canonicalType)) return false;
329+
const bound = boundPackageOf(packageId);
330+
if (bound === undefined || !artifact || typeof artifact !== 'object') return false;
331+
return (artifact as { _packageId?: unknown })._packageId !== bound;
332+
}
333+
299334
/**
300335
* [#16702] ADR-0010 §3.3 — the three protection keys that are READ-SIDE
301336
* DERIVED, and therefore must never be persisted from a caller's body.
@@ -422,6 +457,7 @@ function isShippedViewItem(artifact: unknown): boolean {
422457
export {
423458
boundPackageOf,
424459
bundleMemberOf,
460+
declinesBorrowedEnvelope,
425461
envelopePackageId,
426462
isShippedViewItem,
427463
mergeArtifactProtection,

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ export {
533533
import {
534534
boundPackageOf,
535535
bundleMemberOf,
536+
declinesBorrowedEnvelope,
536537
isShippedViewItem,
537538
mergeArtifactProtection,
538539
stateTenantAuthorship,
@@ -10820,16 +10821,39 @@ export class ObjectStackProtocolImplementation implements
1082010821
* it registered nothing on a save then ([#23298]: a kernel that hydrates
1082110822
* its own store now registers a save as its boot does, by this rule).
1082210823
*
10823-
* ⛔ `view` only, judged on the canonical type. Every other type registers
10824-
* as before: a row bound to one package, of a name two packages ship, holds
10825-
* the bare entry with its own body and its own package's envelope (#4624,
10826-
* ADR-0048, pinned in `objectql`'s `protocol-boot-hydration-scoped.test.ts`).
10824+
* ⛔ This rule is `view` only, judged on the canonical type. Every other type
10825+
* registers as before, save the borrowed-envelope rule below: a row bound
10826+
* to one package, of a name two packages ship, holds the bare entry with
10827+
* its own body and its own package's envelope (#4624, ADR-0048, pinned in
10828+
* `objectql`'s `protocol-boot-hydration-scoped.test.ts`).
1082710829
* Registry readers that name no package read those entries (the declared
1082810830
* security-metadata bootstraps, the action router, the picklist read), and
1082910831
* which body they should see for such a name is not ruled here. A
1083010832
* package-less view row, and a view row of a name only its own package
1083110833
* ships or no package ships, register as before too.
1083210834
*
10835+
* ## [#29600] …and a dashboard, report or email-template row never wears another package's envelope
10836+
*
10837+
* A row of these three types bound to a package that ships no artifact of
10838+
* its name (or bundle member), under a name another package ships, is not
10839+
* registered either ({@link declinesBorrowedEnvelope}). The artifact lookup
10840+
* falls back to the package that ships the name, so the bare entry wore
10841+
* THAT package's envelope (`_packageId`, `_provenance: 'package'`) and the
10842+
* per-package layer listed it as that package's own item: after a `.custom`
10843+
* pair published its declared override of a base's dashboard, the base's
10844+
* layer (and with it the base's export) carried two items of the name.
10845+
* Every protocol read of these types answers such a row from the row
10846+
* itself — the by-name read, the effective list, the layered read and the
10847+
* pair's per-package layer — so they served the same body before and
10848+
* after, measured for each reader. What changes is the registry-direct
10849+
* read of the name, which answers the shipping package's own item.
10850+
*
10851+
* ⛔ Those three types only. A row of a package that ships its own artifact
10852+
* of the name keeps its bare entry (#4624). `page`, `app`, `dataset`, `doc`
10853+
* and `book` still register: their reads serve a pair's override from the
10854+
* bare entry, so it can go only once they serve it from the row. `flow`
10855+
* registers with no artifact envelope (below).
10856+
*
1083310857
* ## [#23233] The entry carries the row's package binding
1083410858
*
1083510859
* The registered body is stamped with the row's package binding as
@@ -10984,6 +11008,12 @@ export class ObjectStackProtocolImplementation implements
1098411008
const artifact = this.lookupArtifactItem(
1098511009
type, (data as any).name, options.packageId ?? undefined, bundleMemberOf(type, data),
1098611010
);
11011+
// [#29600] See the header: a dashboard, report or email-template row
11012+
// bound to a package that ships no artifact of its name, under a name
11013+
// another package ships, is not registered under the bare name, where
11014+
// it would wear that other package's envelope. Its reads answer it from
11015+
// its row. ⛔ Those three types only.
11016+
if (declinesBorrowedEnvelope(canonicalType, options.packageId, artifact)) return true;
1098711017
// [#16702] Say what this row IS before the artifact envelope is grafted
1098811018
// on top of it. Every body reaching this hydrator came out of a
1098911019
// `sys_metadata` write and is therefore tenant-authored by definition
@@ -11821,7 +11851,9 @@ export class ObjectStackProtocolImplementation implements
1182111851
* ships, is not re-registered here: under the bare name it would
1182211852
* answer every package's read of the name. The walk stops there, as
1182311853
* for any baseline found. ⛔ View only, as at the hydration: every
11824-
* other type re-registers as before.
11854+
* other type re-registers as before. [#29600] The hydration's
11855+
* borrowed-envelope rule has no twin here: this limb registers the
11856+
* baseline's own body and grafts no artifact envelope onto it.
1182511857
* 3. [#5079] When NEITHER layer has anything, the deleted row was the
1182611858
* whole item — so the plain-key entry is retired too
1182711859
* ({@link SchemaRegistry.removeOverlayEntry}).

‎packages/objectql/src/environment-overlay-email-locale-restart.test.ts‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,12 @@ const T = 'email_template';
401401
const subjectOf = async (protocol: ObjectStackProtocolImplementation, member?: string) =>
402402
((await protocol.getMetaItem({ type: T, name: NAME, ...(member !== undefined ? { member } : {}) })) as any)?.item?.subject;
403403

404-
/** The registry's own entry for one member — what a peer, a send or a bare-name engine read is served. */
404+
/**
405+
* The registry's own entry for one member — what a bare-name engine read is
406+
* served. [#29600] A pair's override of a member the package ships is not
407+
* registered (it is served from its row, which the send reads through the
408+
* execution list), so this answers the package's member there.
409+
*/
405410
const registrySubject = (engine: ObjectQL, member: string) =>
406411
(engine.registry.getItem<any>(T, NAME, undefined, member))?.subject;
407412

@@ -445,21 +450,25 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2)
445450
it('DELETE by member: resetting zh-CN removes its row only, and the en-US overlay stays served — the registry included', async () => {
446451
const stores = new Map<string, Map<string, Record<string, unknown>>>();
447452
const { engine, protocol } = await bootWithBothSaved(stores);
448-
expect(registrySubject(engine, 'en-US')).toBe('ADMIN en');
449-
expect(registrySubject(engine, 'zh-CN')).toBe('ADMIN zh');
453+
expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'ADMIN zh' });
454+
// [#29600] The pair's override of a member the package ships is served
455+
// from its row and never registered under the package's envelope: the
456+
// registry's entry is the package's own member.
457+
expect(registrySubject(engine, 'en-US')).toBe('PKG en');
458+
expect(registrySubject(engine, 'zh-CN')).toBe('PKG zh');
450459

451460
expect(await protocol.deleteMetaItem({ type: T, name: NAME, member: 'zh-CN' })).toMatchObject({ success: true, reset: true });
452461
expect(overlayRows(stores).map((r) => r.bundle_member ?? null)).toEqual([null]);
453462
// The reset member is back on the package wording; the other member's
454-
// overlay is still the registry's entry — not swept with the group.
463+
// overlay is still served — not swept with the group.
455464
expect(registrySubject(engine, 'zh-CN')).toBe('PKG zh');
456-
expect(registrySubject(engine, 'en-US')).toBe('ADMIN en');
465+
expect(await subjectOf(protocol, 'en-US')).toBe('ADMIN en');
457466
expect(await servedSubjects(protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' });
458467
expect(await subjectOf(protocol, 'zh-CN')).toBe('PKG zh');
459468

460469
const after = await boot(stores);
461470
expect(await servedSubjects(after.protocol)).toEqual({ 'en-US': 'ADMIN en', 'zh-CN': 'PKG zh' });
462-
expect(registrySubject(after.engine, 'en-US')).toBe('ADMIN en');
471+
expect(await subjectOf(after.protocol, 'en-US')).toBe('ADMIN en');
463472
});
464473

465474
it('DELETE by member of a member NO artifact ships: its overlay leaves the registry, and the canonical overlay stays', async () => {
@@ -479,7 +488,10 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2)
479488

480489
expect(await protocol.deleteMetaItem({ type: T, name: DIGEST, member: 'zh-CN' })).toMatchObject({ success: true, reset: true });
481490
expect(digestEntry('zh-CN')).toBeUndefined();
482-
expect(digestEntry('en-US')).toBe('ADMIN digest en');
491+
// [#29600] The canonical member's override (a member the package
492+
// ships) is served from its row; the registry holds the package's.
493+
expect(((await protocol.getMetaItem({ type: T, name: DIGEST, member: 'en-US' })) as any)?.item?.subject).toBe('ADMIN digest en');
494+
expect(digestEntry('en-US')).toBe('PKG digest en');
483495
const listed: any = await protocol.getMetaItemsForExecution({ type: T });
484496
const digest = (listed?.items ?? []).filter((it: any) => it?.name === DIGEST).map((it: any) => `${it.locale}:${it.subject}`);
485497
expect(digest).toEqual(['en-US:ADMIN digest en']);
@@ -491,7 +503,8 @@ describe('the by-name doors address ONE member by the selector (#22877, stage 2)
491503
expect(await protocol.deleteMetaItem({ type: T, name: NAME })).toMatchObject({ success: true, reset: true });
492504
expect(overlayRows(stores).map((r) => r.bundle_member)).toEqual(['zh-CN']);
493505
expect(registrySubject(engine, 'en-US')).toBe('PKG en');
494-
expect(registrySubject(engine, 'zh-CN')).toBe('ADMIN zh');
506+
// [#29600] The zh-CN override stays served, from its row.
507+
expect(await subjectOf(protocol, 'zh-CN')).toBe('ADMIN zh');
495508
expect(await subjectOf(protocol)).toBe('PKG en');
496509
});
497510

@@ -739,12 +752,18 @@ describe('the package-batch doors and the cluster fan-out carry the member (#228
739752

740753
await writer.protocol.saveMetaItem({ type: T, name: NAME, item: template('en-US', 'ADMIN en') });
741754
await writer.protocol.saveMetaItem({ type: T, name: NAME, item: template('zh-CN', 'ADMIN zh') });
742-
await vi.waitFor(() => expect(registrySubject(peer.engine, 'zh-CN')).toBe('ADMIN zh'));
743-
expect(registrySubject(peer.engine, 'en-US')).toBe('ADMIN en');
755+
// [#29600] A pair's override of a shipped member is served from its
756+
// row and never enters the registry, so the peer converges on its
757+
// served read, and its registry keeps the package's own members.
758+
await vi.waitFor(async () => expect(await subjectOf(peer.protocol, 'zh-CN')).toBe('ADMIN zh'));
759+
expect(await subjectOf(peer.protocol, 'en-US')).toBe('ADMIN en');
760+
expect(registrySubject(peer.engine, 'zh-CN')).toBe('PKG zh');
761+
expect(registrySubject(peer.engine, 'en-US')).toBe('PKG en');
744762

745763
await writer.protocol.deleteMetaItem({ type: T, name: NAME, member: 'zh-CN' });
746-
await vi.waitFor(() => expect(registrySubject(peer.engine, 'zh-CN')).toBe('PKG zh'));
747-
expect(registrySubject(peer.engine, 'en-US')).toBe('ADMIN en');
764+
await vi.waitFor(async () => expect(await subjectOf(peer.protocol, 'zh-CN')).toBe('PKG zh'));
765+
expect(await subjectOf(peer.protocol, 'en-US')).toBe('ADMIN en');
766+
expect(registrySubject(peer.engine, 'zh-CN')).toBe('PKG zh');
748767
});
749768
});
750769

0 commit comments

Comments
 (0)