Skip to content

Commit 2a4c003

Browse files
fix(metadata-protocol)!: the adopt-orphans door asks the save door's owner judges (another package owns the object; a permission set naming another owner) before the __c judge (#28808)
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XUSbzmMRfCQ13hL4Jffkmqp)_ Fixes #28776 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no file under `skills/**` teaches the adopt-orphans door (0 hits for `adopt-orphans` and `reassignOrphanedMetadata`). ## 维护者速读 - **改了什么**:「收养孤儿元数据」(`POST /api/v1/packages/:id/adopt-orphans`) 现在也问保存门的两个「归属」判官,顺序和保存门一致(先归属,再 #28735 加的 `__c`)。两种情况整次收养被拒、一行都不动:一个不属于任何包的对象,名字已被**另一个包**占有(422 `OBJECT_OVERLAY_PACKAGE_MISMATCH`);一个不属于任何包的权限集,正文 `packageId` 写的是别的包(400 `VALIDATION_ERROR`)。普通孤儿对象照常收养。 - **为什么改**:保存门和发布门都问这两个判官,收养门不问。对象那一种被收养后,存储里成了两行、注册表仍归原主人,正是 D9.9 要拒绝的形态。 - **风险与回滚**:实测 CRM、showcase 新启动 0 行 `sys_metadata`,测试夹具里 0 个会被拒的可收养行。不新增错误码,不动 spec。命名空间前缀判官**不在**本 PR:它会拒掉几乎所有真实孤儿对象,按席位裁决交给你定(拒绝、收养时改名,还是不判)。回滚即 revert。 - **先看**:`packages/metadata-protocol/src/protocol.ts` 的 `reassignOrphanedMetadata`(`__c` 块之前新加的归属判官循环,注意跳过 `sys_metadata` 哨兵主人那一句);`packages/metadata-protocol/src/protocol.adopt-orphans-owner-judges.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.adopt-orphans-owner-judges.test.ts`,预期 `Tests 6 passed (6)`(main 上 3 条红,3 条对照两边都绿)。 - **需要你做的**:无,席位落地(合同审查后)。前缀问题另行裁决。 ## What changes - **The owner judges at the adopt door** (`reassignOrphanedMetadata`). After the orphan list is read, and before #28735's `__c` call and the first storage `update`, every ACTIVE orphan row is judged against the TARGET package, in the save door's order: - **object** → `classifyObjectContribution(name, target)`. A `mismatch` whose owner is ANOTHER package throws `overlayPackageMismatchError(name, target, owner)`: `422 OBJECT_OVERLAY_PACKAGE_MISMATCH`, the save door's message ("Cannot layer object 'N': the overlay is bound to package 'T', but the object is owned by package 'O'. …"). ⛔ A `mismatch` whose owner is the `'sys_metadata'` sentinel is skipped. That sentinel is the orphan's own registration, the owner `releaseAdoptedObjectOwner` hands over. Asked unchanged, the judge refuses every object adoption, as #28776's step-1 measurement showed on a real CRM boot. - **permission** → `permissionSetOwnerBindingRefusal(type, body, name, target, 'save')`: `400 VALIDATION_ERROR`, `issues: [{ path: 'packageId' }]`, the save door's words. An unparseable body is left to the loop. - The first refusal throws, and the adoption is refused whole: no row moves and `applyRegistryWriteThrough` never runs. - **Drafts** stay judged at their promotion, which asks both judges against its binding, as #28735 left the `__c` judge. - **Not asked: the namespace-prefix judge.** Per the seat's ruling (#28776, comment `6563043440`), that question goes to the maintainer. `reassignOrphanedMetadata`'s header says so. - **Changeset** `.changeset/28776-adopt-orphans-owner-judges.md`: `minor` on `@objectstack/metadata-protocol` (kind `narrowed`), BREAKING banner, both declaration lines, migration paragraph, ADR-0087 `not-required (no-migration-prescription)`. - No new error code, no `packages/spec/**`, no ui, no new gate. **Why `Metadata-protocol: narrowed`.** An adoption that admitted these two rows is now refused: one more write door applies two requirements the save door already enforces. That is the same reading as #28735. ## Tests New `packages/metadata-protocol/src/protocol.adopt-orphans-owner-judges.test.ts`, 6 tests. The engine double stores rows and answers `registry.getObjectOwner` from a table the test sets; the registry write-through is spied out. - **D9.9 refused:** `probe_clean` (sentinel-owned) beside `probe_taken` (owned by `app.probe-other`), adopted into `app.probe-target`: `OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422, message naming both packages. `engine.update` and the write-through are never called, and both rows stay package-less. - **Order:** `probe_taken` carrying `x__c` answers the owner refusal (422), not the `__c` one (400). - **CONTROL, the sentinel pin:** a sentinel-owned orphan and an unowned one are admitted and both move (`reassignedCount: 2`). - **CONTROL:** an orphan the target itself owns is admitted. - **D3 refused:** a package-less set whose body says `packageId: 'app.probe-other'`, beside a clean object: `VALIDATION_ERROR` / 400, `issues[0].path = 'packageId'`, message "Drop `packageId` from permission set …". Nothing moves. - **CONTROL:** a set naming the target, and one naming no owner, are admitted and move. <details><summary>Red on base, green on head; ablations</summary> - **Base** (`protocol.ts` from `8f294e04`, the new file at head): `Tests 3 failed | 3 passed (6)`. The three refusal tests are red: the two D9.9 tests and the D3 test. The three controls are green both ways. Restore `git checkout HEAD -- protocol.ts`, `git diff HEAD` empty. - **Head:** `Tests 6 passed (6)`; with `protocol.extension-field-suffix.test.ts` and `protocol.reassign-orphans-durability.test.ts`: 35/35. - Ablations via `node scripts/ablation-replace.mjs` from committed HEAD (the suite reads `src`): - **object arm off** (condition `&& (globalThis as any).ABLATE_28776_OBJ === 1`): blob `bc2fc3da1611` → `47279aca0276`, 2 failed (the two D9.9 tests), restored blob == HEAD, diff empty. - **permission arm off** (`void ownerRefusal;`): → `38214b741154`, 1 failed (the D3 test), restored clean. - **sentinel exclusion off** (`if (layer.kind === 'mismatch')`): → `a96b1a46d286`, 3 failed. The red is the sentinel pin, plus the two refusal tests whose sentinel-owned `probe_clean` is now refused first with the wrong owner. Restored clean. </details> ## Acceptance notes - **An existing pin the narrowing moves.** `packages/objectql/src/protocol-adopt-orphans-object-registry.test.ts` (#23646): its CONTROL "a package-less row of an object another package's row owns is rebound in storage, and that owner stays" pinned the rebind this PR refuses. It now reads "[#28776] … the adoption is refused whole (D9.9), nothing is rebound, and that owner stays": `OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422 naming the owner, both rows keep their bindings, and the #23646 half (the owner stays: no `registerObject` / `unregisterObject`, `getObjectOwner` and the served definition unchanged) is kept. Head `7e37e4c2`: metadata-protocol 22074, objectql 9983, runtime 8232 passed; 70/70 derived gates; dogfood 3114 passed. - **Measurement (step 1):** `pm-orders/server/28776-step1-measurement.md`. Fresh CRM and showcase boots store 0 `sys_metadata` rows. The 9 `sys_metadata` seed sites in `packages/qa/**` hold 0 adoptable active rows either judge refuses. A control orphan saved through the real door showed the prefix judge refusing it into every installed target. - **The D3 words** use the save door's `'save'` door verb, because the spec's `PermissionSetOwnerBindingDoor` has no adopt verb and `packages/spec/**` is out of scope. The sentence reads "Drop `packageId` from permission set 'N' …, then send it again. Nothing was written. Its body declares `packageId` 'X', but this save writes it into package 'T' …": the remedy is right for an adopt, and "this save" names the act loosely. - **`releaseAdoptedObjectOwner`'s "another package owns it" row** (answers `false`, leaves the row rebound in storage only) is no longer reached in the ordinary run, since such an adoption is now refused first (and a draft never reaches it). It is left as it is, as the answer when the registry's owner changes between the judge and the loop, which the loop's `await`s allow. ## Verification All readings at head `df5ce323` (base `8f294e04`; the fix `01a8dcb8`, changeset `27886745`, and `df5ce323`, which routes the test double's `findOne` through `assertEngineFindOnePredicate` and adds the file to `scripts/engine-double-contract.pinned.json` via `--write`, the gate's own prescription). Full `pnpm turbo run build --concurrency=2` at `27886745`: 118/118; metadata-protocol rebuilt at `df5ce323`. <details><summary>Package tests, typecheck, lint</summary> - `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 296 passed | 4 skipped (300)`, `Tests 22058 passed | 26 skipped (22084)` (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0 (tests included; `check:type-check-debt` OK, 0 packages hiding tests). - `eslint --no-inline-config` on `protocol.ts` and the new test file: exit 0. </details> <details><summary>Full dogfood battery (the `(narrowing)` arm)</summary> - Head `df5ce323`: `pnpm --filter @objectstack/dogfood test` under the lock, VERDICT command-exit 0, `Test Files 378 passed | 3 skipped (381)`, `Tests 3105 passed | 28 skipped (3133)`. No failure. </details> <details><summary>Derived gates (`dispatch-gates.mjs --commands`, 69 families)</summary> - All 69 exit 0 at `df5ce323`; `dispatch-gates --ran`: `69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:type-check-debt`: `OK — 126/129 workspace packages type-checked`, `--re-measure: OK`. - The first pass at `27886745` read `check:engine-double-contract` exit 1: the new double's `findOne` did not route through the predicate, and the ledger did not know the file. Both were fixed in `df5ce323` and the gate is green there. That first pass and its dogfood run were stopped and re-run whole on `df5ce323`. - Repo-wide `pnpm lint` and the CI-only families are CI's; see the PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mTYb1BTJNRqf3bdJ8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1caf672 commit 2a4c003

5 files changed

Lines changed: 281 additions & 7 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
**BREAKING** fix(metadata-protocol): the adopt-orphans door asks the save door's owner judges, before the `__c` judge (ADR-0029 D9.9, ADR-0086 D3, #28776)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — no file under `skills/**` teaches the adopt-orphans door (0 hits for `adopt-orphans` and `reassignOrphanedMetadata`).
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) The narrowing refuses an adoption (`POST /api/v1/packages/:id/adopt-orphans`) that would rebind an active package-less object whose name another package owns in the registry, or an active package-less permission set whose body `packageId` names a package other than the target. Both refusals are the save door's own, asked at one more door with their existing envelopes. No metadata key, accepted value of a key, export or error code is removed or renamed, and no stored shape changes. The remedy is an authoring choice a person makes: bind the object to its owner, delete or rename one of the two rows, or drop or fix the set's `packageId`. There is no mechanical conversion between two metadata versions for `objectstack migrate meta` to apply. Measured at the PR's base: fresh CRM and showcase boots store 0 `sys_metadata` rows, and the 9 sites that seed `sys_metadata` rows in `packages/qa/**` hold 0 adoptable active rows either judge refuses. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no `packages/spec/**` file is touched, so no schema, type or runtime interface moves (not type-surface-only, not runtime-interface-only); and no export is retired (not runtime-export-retired). -->
12+
13+
`reassignOrphanedMetadata` (REST `POST /api/v1/packages/:id/adopt-orphans`) rebinds every active package-unbound row into the target package. Since #28735 it asks the `__c` judge first. It asked neither owner judge the save door asks before that one, so a package-less row could become a package's own item without them.
14+
15+
The door now asks both, in the save door's order (owner first, then the existing `__c` call), against the TARGET package and before the first storage update. Any refusal refuses the adoption whole: no row moves and nothing is written through to the registry.
16+
17+
- **An active orphan object whose name another package owns** (ADR-0029 D9.9): `422 OBJECT_OVERLAY_PACKAGE_MISMATCH`, the save door's message naming the owner. Adopted, the row was rebound in storage while the registry kept the other owner: two rows for one object. The `sys_metadata` sentinel owner is not judged. An orphan object is the sentinel's own registration, the owner the adoption hands over.
18+
- **An active orphan permission set whose body `packageId` disagrees with the target** (ADR-0086 D3): `400 VALIDATION_ERROR`, the save door's message, `issues[0].path = 'packageId'`.
19+
20+
An adopted draft is judged by both when it is promoted, as before. The namespace-prefix judge is not asked at this door. Every installed package declares a namespace, so it would refuse nearly every real orphan object. Whether adoption refuses, renames or leaves the prefix unjudged is an open design question. No new error code.
21+
22+
**Migration.** None needed for this repository: 0 rows affected (measured above). An environment whose adoption is refused fixes the named row, as the message says, and adopts again.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0029 D9.9, ADR-0086 D3, #28776] The adopt-orphans door
5+
* (`reassignOrphanedMetadata`, REST `POST /api/v1/packages/:id/adopt-orphans`)
6+
* asks the save door's two owner judges, in the save door's order (before the
7+
* `__c` judge #28735 added), against the TARGET package, before the first
8+
* storage update, and refuses the adoption whole with each judge's envelope:
9+
*
10+
* - an active orphan `object` whose name ANOTHER package owns in the registry:
11+
* `OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422 (D9.9);
12+
* - an active orphan `permission` set whose body `packageId` disagrees with the
13+
* target: `VALIDATION_ERROR` / 400, `issues[0].path = 'packageId'` (D3).
14+
*
15+
* The D9.9 arm skips the `'sys_metadata'` sentinel owner: an orphan object is
16+
* the sentinel's own registration, the owner the adoption hands over, so the
17+
* judge asked unchanged would refuse EVERY object adoption (measured on a real
18+
* CRM boot for #28776: `classifyObjectContribution` answers `mismatch` with the
19+
* sentinel for a package-less object saved through the real door). The plain
20+
* orphan below pins that.
21+
*
22+
* The namespace-prefix judge is not asked at this door (#28776's ruling), so
23+
* nothing here pins it either way.
24+
*
25+
* The engine double stores rows and answers the registry's owner lookups from a
26+
* table the test sets, the way `SchemaRegistry.getObjectOwner` answers them; the
27+
* write-through to the registry after a rebind is spied out, since what is
28+
* pinned is which rows the adoption moves.
29+
*/
30+
31+
import { afterEach, describe, expect, it, vi } from 'vitest';
32+
// [#5619] The producer's OWN write-verb dispatch decision, from
33+
// `@objectstack/metadata-core` and NOT `@objectstack/objectql` (objectql
34+
// depends on this package, so that import would close a cycle).
35+
import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
36+
import { ObjectStackProtocolImplementation } from './protocol.js';
37+
38+
const TARGET = 'app.probe-target';
39+
const OTHER = 'app.probe-other';
40+
41+
interface Row {
42+
id: string;
43+
type: string;
44+
name: string;
45+
package_id: string | null;
46+
organization_id: string | null;
47+
state: string;
48+
metadata: string;
49+
}
50+
51+
/** Rows stored package-less, and the registry's owner of each object name. */
52+
function makeProtocol(rows: Array<Omit<Row, 'id' | 'package_id' | 'organization_id'>>, owners: Record<string, string>) {
53+
const store = new Map<string, Row>(rows.map((r, i) => [`r_${i + 1}`, {
54+
...r, id: `r_${i + 1}`, package_id: null, organization_id: null,
55+
}]));
56+
const engine: any = {
57+
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
58+
if (table !== 'sys_metadata') return [];
59+
// The one shape the adoption reads: every environment row, no bound.
60+
if (opts?.limit !== undefined || Object.keys(opts?.where ?? {}).length !== 0) {
61+
throw new Error(`fake engine: unsupported read ${JSON.stringify(opts)}`);
62+
}
63+
return [...store.values()].map((r) => ({ ...r }));
64+
},
65+
async findOne(table: string, opts: { where: Record<string, unknown> }) {
66+
assertEngineFindOnePredicate(table, opts);
67+
return null;
68+
},
69+
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
70+
assertEngineUpdateDispatch(data, opts);
71+
const row = store.get(String(opts.where.id));
72+
if (!row) return { id: null };
73+
Object.assign(row, data);
74+
return { id: row.id };
75+
},
76+
registry: {
77+
getObjectOwner: (name: string) => (owners[name] ? { packageId: owners[name] } : undefined),
78+
getArtifactItem: () => undefined,
79+
getPackage: () => undefined,
80+
registerObject: () => {},
81+
unregisterObject: () => true,
82+
},
83+
};
84+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), undefined) as any;
85+
const update = vi.spyOn(engine, 'update');
86+
const writeThrough = vi.spyOn(protocol, 'applyRegistryWriteThrough').mockResolvedValue(undefined);
87+
const bindings = () => Object.fromEntries([...store.values()].map((r) => [`${r.type}/${r.name}`, r.package_id]));
88+
return { protocol, update, writeThrough, bindings };
89+
}
90+
91+
const object = (name: string, ...fields: string[]) => ({
92+
type: 'object', name, state: 'active',
93+
metadata: JSON.stringify({
94+
name, label: name, sharingModel: 'private',
95+
fields: Object.fromEntries(['title', ...fields].map((f) => [f, { type: 'text', label: f }])),
96+
}),
97+
});
98+
const permissionSet = (name: string, packageId?: string) => ({
99+
type: 'permission', name, state: 'active',
100+
metadata: JSON.stringify({ name, label: name, objects: {}, ...(packageId !== undefined ? { packageId } : {}) }),
101+
});
102+
103+
async function attempt<T>(run: () => Promise<T>): Promise<{ result: T | null; error: any }> {
104+
try {
105+
return { result: await run(), error: null };
106+
} catch (error) {
107+
return { result: null, error };
108+
}
109+
}
110+
111+
afterEach(() => vi.restoreAllMocks());
112+
113+
describe('[ADR-0029 D9.9, #28776] the adopt door refuses an orphan object whose name another package owns', () => {
114+
it('refused whole with OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 naming the owner; no row moves, nothing is written through', async () => {
115+
const h = makeProtocol([object('probe_clean'), object('probe_taken')], { probe_clean: 'sys_metadata', probe_taken: OTHER });
116+
117+
const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET }));
118+
119+
expect(error).toBeInstanceOf(Error);
120+
expect({ code: error.code, status: error.status }).toEqual({ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 });
121+
expect(error.message).toContain(
122+
`Cannot layer object 'probe_taken': the overlay is bound to package '${TARGET}', but the object is owned by package '${OTHER}'.`,
123+
);
124+
expect(h.update).not.toHaveBeenCalled();
125+
expect(h.writeThrough).not.toHaveBeenCalled();
126+
expect(h.bindings()).toEqual({ 'object/probe_clean': null, 'object/probe_taken': null });
127+
});
128+
129+
it('is asked before the `__c` judge, in the save door\'s order: a row both judges refuse answers the owner refusal', async () => {
130+
const h = makeProtocol([object('probe_taken', 'x__c')], { probe_taken: OTHER });
131+
132+
const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET }));
133+
134+
expect({ code: error?.code, status: error?.status }).toEqual({ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 });
135+
expect(h.bindings()).toEqual({ 'object/probe_taken': null });
136+
});
137+
138+
it('CONTROL (the sentinel pin): a plain orphan the registry owns under the `sys_metadata` sentinel is admitted and moves; so does one nobody owns', async () => {
139+
const h = makeProtocol([object('probe_plain'), object('probe_unowned')], { probe_plain: 'sys_metadata' });
140+
141+
const res = await h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET });
142+
143+
expect(res).toMatchObject({ success: true, reassignedCount: 2, targetPackageId: TARGET });
144+
expect(h.bindings()).toEqual({ 'object/probe_plain': TARGET, 'object/probe_unowned': TARGET });
145+
});
146+
147+
it('CONTROL: an orphan object the target itself owns is admitted', async () => {
148+
const h = makeProtocol([object('probe_mine')], { probe_mine: TARGET });
149+
150+
const res = await h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET });
151+
152+
expect(res.reassignedCount).toBe(1);
153+
expect(h.bindings()).toEqual({ 'object/probe_mine': TARGET });
154+
});
155+
});
156+
157+
describe('[ADR-0086 D3, #28776] the adopt door refuses an orphan permission set whose body names another owner', () => {
158+
it('refused whole with VALIDATION_ERROR / 400, `issues` at `packageId`; no row moves', async () => {
159+
const h = makeProtocol([object('probe_clean'), permissionSet('probe_set', OTHER)], { probe_clean: 'sys_metadata' });
160+
161+
const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET }));
162+
163+
expect(error).toBeInstanceOf(Error);
164+
expect({ code: error.code, status: error.status }).toEqual({ code: 'VALIDATION_ERROR', status: 400 });
165+
expect(error.issues).toEqual([expect.objectContaining({ path: 'packageId', code: 'custom' })]);
166+
expect(error.message).toContain(`Drop \`packageId\` from permission set 'probe_set'`);
167+
expect(h.update).not.toHaveBeenCalled();
168+
expect(h.bindings()).toEqual({ 'object/probe_clean': null, 'permission/probe_set': null });
169+
});
170+
171+
it('CONTROL: a set whose body names the target, and one that names no owner, are admitted and move', async () => {
172+
const h = makeProtocol([permissionSet('probe_named', TARGET), permissionSet('probe_bare')], {});
173+
174+
const res = await h.protocol.reassignOrphanedMetadata({ targetPackageId: TARGET });
175+
176+
expect(res.reassignedCount).toBe(2);
177+
expect(h.bindings()).toEqual({ 'permission/probe_named': TARGET, 'permission/probe_bare': TARGET });
178+
});
179+
});

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26903,6 +26903,32 @@ export class ObjectStackProtocolImplementation implements
2690326903
* 400, `issues[]` locating each field): no row moves and nothing is
2690426904
* written through to the registry. An adopted draft is judged when it is
2690526905
* promoted (the promotion asks the same judge against its binding).
26906+
*
26907+
* ## [ADR-0029 D9.9, ADR-0086 D3, #28776] …and the owner judges, first
26908+
*
26909+
* The save door asks two owner judges before the suffix judge, and so
26910+
* does this adoption, in the same order and with their own envelopes,
26911+
* before the first storage update: any refusal refuses the adoption whole.
26912+
*
26913+
* - An active `object` row whose name ANOTHER package owns in the
26914+
* registry ({@link classifyObjectContribution}'s `mismatch`) is refused
26915+
* with {@link overlayPackageMismatchError}
26916+
* (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422). Adopted, it was rebound in
26917+
* storage while the registry kept the other owner
26918+
* ({@link releaseAdoptedObjectOwner} answers `false`): two rows for one
26919+
* object, the shape D9.9 exists to refuse. ⛔ Not the `'sys_metadata'`
26920+
* sentinel owner: an orphan object IS the sentinel's registration, the
26921+
* owner this adoption hands over, so asked unchanged the judge would
26922+
* refuse every object adoption.
26923+
* - An active `permission` row whose body `packageId` disagrees with the
26924+
* target is refused with {@link permissionSetOwnerBindingRefusal}
26925+
* (`VALIDATION_ERROR` / 400, the save door's words).
26926+
*
26927+
* The namespace-prefix judge is deliberately NOT asked here: every
26928+
* installed package declares a namespace, so it would refuse nearly every
26929+
* real orphan object, and whether an adoption refuses, renames or leaves
26930+
* the prefix unjudged is the maintainer's question (#28776's ruling). A
26931+
* draft is judged by both owner judges when it is promoted.
2690626932
*/
2690726933
async reassignOrphanedMetadata(request: {
2690826934
targetPackageId: string;
@@ -26935,6 +26961,35 @@ export class ObjectStackProtocolImplementation implements
2693526961
&& !this.isArtifactBacked(PLURAL_TO_SINGULAR[r.type] ?? r.type, String(r.name)),
2693626962
);
2693726963

26964+
// [ADR-0029 D9.9, ADR-0086 D3, #28776] ⭐ THE OWNER JUDGES, in the save
26965+
// door's order (before the `__c` judge below), against the TARGET and
26966+
// before the first update: refused whole, each with its own envelope.
26967+
// The object arm skips the `'sys_metadata'` sentinel owner, the orphan's
26968+
// own registration this adoption hands over; see the header.
26969+
for (const row of orphans) {
26970+
if (row?.state !== 'active' || typeof row.name !== 'string') continue;
26971+
const type = canonicalMetaType(String(row.type));
26972+
if (type === 'object') {
26973+
const layer = this.classifyObjectContribution(row.name, request.targetPackageId);
26974+
if (layer.kind === 'mismatch' && layer.ownerPackageId !== 'sys_metadata') {
26975+
throw ObjectStackProtocolImplementation.overlayPackageMismatchError(
26976+
row.name, request.targetPackageId, layer.ownerPackageId,
26977+
);
26978+
}
26979+
} else if (type === 'permission') {
26980+
let body: unknown;
26981+
try {
26982+
body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
26983+
} catch {
26984+
continue;
26985+
}
26986+
const ownerRefusal = permissionSetOwnerBindingRefusal(
26987+
type, body, row.name, request.targetPackageId, 'save',
26988+
);
26989+
if (ownerRefusal) throw ownerRefusal;
26990+
}
26991+
}
26992+
2693826993
// [ADR-0146 D10, #28735] ⭐ THE `__c` RESERVATION, JUDGED AGAINST THE
2693926994
// TARGET, before the first update below: each active object row this
2694026995
// adoption rebinds becomes the target's own object. Refused whole, with

‎packages/objectql/src/protocol-adopt-orphans-object-registry.test.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@
4444
* refuses to drop it), said once at `error`;
4545
* - CONTROL: an adoption of non-object items leaves the object registry
4646
* untouched, and the view write-through follows the view as before;
47-
* - CONTROL: a package-less row of an object another package's row owns is
48-
* rebound in storage and the registry keeps that owner, as before.
47+
* - a package-less row of an object another package's row owns: the
48+
* registry keeps that owner (#23646), and since #28776 the adoption is
49+
* refused whole with the save door's D9.9 refusal
50+
* (`OBJECT_OVERLAY_PACKAGE_MISMATCH` / 422), so storage no longer rebinds
51+
* the row beside the owner's (this was a CONTROL pinning that rebind).
4952
*
5053
* The negative control (the object limb removed, these pins red) is a one-off
5154
* ablation recorded in the PR body, not a test.
@@ -342,7 +345,7 @@ describe('#23646 — an orphan adoption moves an adopted object\'s registry owne
342345
expect((k.engine.registry.getItem('view', VIEW) as { _packageId?: string } | undefined)?._packageId).toBe(Q);
343346
});
344347

345-
it('CONTROL: a package-less row of an object another package\'s row owns is rebound in storage, and that owner stays', async () => {
348+
it('[#28776] a package-less row of an object another package\'s row owns: the adoption is refused whole (D9.9), nothing is rebound, and that owner stays', async () => {
346349
const stores: Stores = new Map();
347350
const drops: string[] = [];
348351
const k = await boot(stores, drops);
@@ -356,11 +359,16 @@ describe('#23646 — an orphan adoption moves an adopted object\'s registry owne
356359
const registerItem = vi.spyOn(k.engine.registry, 'registerItem');
357360
const unregisterObject = vi.spyOn(k.engine.registry, 'unregisterObject');
358361

359-
const adopted = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q });
362+
const refused = await k.protocol.reassignOrphanedMetadata({ targetPackageId: Q }).then(
363+
() => undefined, (e: any) => e,
364+
);
360365

361-
expect(adopted).toMatchObject({ reassignedCount: 1, reassigned: [{ type: 'object', name: NAME }] });
362-
expect(storedBindings(stores).sort()).toEqual([Q, R].sort());
363-
// Nothing of the residue reaches the registry, in either half.
366+
// The save door's D9.9 refusal, naming the target and the owner.
367+
expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 });
368+
expect(String(refused?.message)).toContain(`owned by package '${R}'`);
369+
// Nothing is rebound: the residue stays package-less beside R's row.
370+
expect(storedBindings(stores).map((b) => b ?? 'none').sort()).toEqual([R, 'none'].sort());
371+
// [#23646] …and that owner stays: nothing reaches the registry, in either half.
364372
expect(unregisterObject).not.toHaveBeenCalled();
365373
expect(registerObject).not.toHaveBeenCalled();
366374
expect(registerItem.mock.calls.filter(([type]) => type === 'object')).toEqual([]);

0 commit comments

Comments
 (0)