From 67c0843714afe7b0abe82e43f44c752df8fe6133 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:44:04 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): the adopt-orphans door asks the __c judge against the target; the promotion's suffix block is seal-gated reassignOrphanedMetadata judges every active object row it would rebind against the target package id with judgeExtensionFieldSuffix, before the first storage update, and refuses the adoption whole with the save door's VALIDATION_ERROR / 400 envelope (ADR-0146 D10 case a). The promotion's suffix block is not asked of sealed managed content, like the D9.9 and namespace-prefix blocks above it. Co-Authored-By: Claude --- .../protocol.extension-field-suffix.test.ts | 81 ++++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 45 ++++++++++- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts b/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts index 76049241915..32eaeb7cb95 100644 --- a/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts +++ b/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts @@ -4,7 +4,8 @@ * [ADR-0146 D10, #28564] The `__c` extension-field reservation at every door * of this package — one judge (`protocol-extension-field-suffix.ts`), asked by * the save door, the restore doors, the draft promotion (`publishMetaItem` and - * the `publishPackageDrafts` batch that shares it) and `duplicatePackage`; and + * the `publishPackageDrafts` batch that shares it), `duplicatePackage` and + * ([#28735]) `reassignOrphanedMetadata`, the adopt-orphans door; and * `installPackage`, which its closed manifest already covers. * * Maintainer, verbatim: 「扩展字段用 __c 后缀,只有 .custom 包能用。」 @@ -532,6 +533,34 @@ describe('[ADR-0146 D10] the draft promotion refuses an object draft carrying a }); }); +describe('[#28735] the promotion\'s suffix block is not asked of sealed managed content', () => { + /** A code package's shipped object, as `SchemaRegistry.getArtifactItem` stamps it. */ + const SHIPPED = 'probe_sealed'; + const sealedHarness = () => { + const h = makeProtocol(); + h.engine.registry.getArtifactItem = (type: string, name: string) => (type === 'object' && name === SHIPPED + ? { name, label: 'Sealed', _packageId: 'com.acme.shipper', _provenance: 'package' } + : undefined); + return h; + }; + + it('a draft of a shipped object carrying `x__c`, stored before the rule, answers the store\'s NOT_OVERRIDABLE, not "rename the field"', async () => { + const h = sealedHarness(); + // The save door refuses this draft (`refusePackagedBaseOverride`), so it is staged as a row stored before the gate. + const row: Row = { + id: 'r_sealed', type: 'object', name: SHIPPED, organization_id: null, package_id: BOUND, state: 'draft', + metadata: JSON.stringify(obj(SHIPPED, 'Sealed', 'x__c')), + }; + h.rows.set(keyOf(row as unknown as Record), row); + + const { error } = await attempt(() => h.protocol.publishMetaItem({ type: 'object', name: SHIPPED, packageId: BOUND })); + + expect(error).toBeInstanceOf(Error); + expect({ code: error.code, status: error.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(storedObjects(h)).toEqual([{ row: SHIPPED, state: 'draft', binding: BOUND, fields: ['title', 'x__c'] }]); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // 4. duplicatePackage, judged against the target id // ═══════════════════════════════════════════════════════════════════════════ @@ -567,6 +596,56 @@ describe('[ADR-0146 D10] duplicatePackage from a `.custom` pair into a plain id }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// 4b. reassignOrphanedMetadata (the adopt-orphans door), judged against the target id +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[ADR-0146 D10, #28735] the adopt-orphans door judges each object row it rebinds against the target', () => { + /** Package-less active objects through the real save door (not judged: no package), then the write-through spied. */ + async function orphans(h: Harness, ...bodies: Array>) { + for (const item of bodies) await h.protocol.saveMetaItem({ type: 'object', name: item.name, item }); + h.saveDoor.mockClear(); + return vi.spyOn(h.protocol, 'applyRegistryWriteThrough'); + } + + it('a package-less object with `x__c` adopted into a plain package is refused with case (a); nothing moves, nothing registers', async () => { + const h = makeProtocol(); + const writeThrough = await orphans(h, obj('probe_env', 'Env', 'x__c')); + const update = vi.spyOn(h.engine, 'update'); + + const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: BOUND })); + + expectReservedRefusal(error, 'x__c', 'probe_env', BOUND, 'objects.probe_env.fields.x__c'); + expect(update).not.toHaveBeenCalled(); + expect(writeThrough).not.toHaveBeenCalled(); + expect(storedObjects(h)).toEqual([{ row: 'probe_env', state: 'active', binding: null, fields: ['title', 'x__c'] }]); + }); + + it('a mixed adoption (one clean row, one `x__c` row) into a plain package is refused whole: the clean row does not move either', async () => { + const h = makeProtocol(); + const writeThrough = await orphans(h, obj('probe_clean', 'Clean'), obj('probe_env', 'Env', 'x__c')); + + const { error } = await attempt(() => h.protocol.reassignOrphanedMetadata({ targetPackageId: BOUND })); + + expectReservedRefusal(error, 'x__c', 'probe_env', BOUND, 'objects.probe_env.fields.x__c'); + expect(writeThrough).not.toHaveBeenCalled(); + expect(storedObjects(h).map((r) => [r.row, r.binding])).toEqual([['probe_clean', null], ['probe_env', null]]); + }); + + it('CONTROL: the same `x__c` row adopted into the base\'s `.custom` pair is admitted and moves; a clean row into the plain package too', async () => { + const pairH = makeProtocol(); + await orphans(pairH, obj('probe_env', 'Env', 'x__c')); + const res = await pairH.protocol.reassignOrphanedMetadata({ targetPackageId: PAIR }); + expect(res).toMatchObject({ success: true, reassignedCount: 1, targetPackageId: PAIR }); + expect(storedObjects(pairH)).toEqual([{ row: 'probe_env', state: 'active', binding: PAIR, fields: ['title', 'x__c'] }]); + + const plainH = makeProtocol(); + await orphans(plainH, obj('probe_clean', 'Clean', 'region')); + expect((await plainH.protocol.reassignOrphanedMetadata({ targetPackageId: BOUND })).reassignedCount).toBe(1); + expect(storedObjects(plainH)).toEqual([{ row: 'probe_clean', state: 'active', binding: BOUND, fields: ['title', 'region'] }]); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // 5. installPackage — covered by its closed manifest // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6bba325425b..065792a3d3d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -23881,8 +23881,10 @@ export class ObjectStackProtocolImplementation implements // [ADR-0146 D10, #28564] …and an object draft's fields against the // `__c` reservation, judged against the package promoted into, with the // save door's judge and envelope, before `repo.promoteDraft` writes. See - // {@link objectBodyExtensionFieldSuffixRefusal}. - if (draftForGate) { + // {@link objectBodyExtensionFieldSuffixRefusal}. [#28735] ⛔ Not asked + // of SEALED managed content, for the D9.9 block's reason: the store's + // `NOT_OVERRIDABLE` is that row's one answer, not "rename the field". + if (draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name)) { const suffixRefusal = objectBodyExtensionFieldSuffixRefusal( singularType, draftForGate.body, request.name, promoteInto, ); @@ -26757,6 +26759,21 @@ export class ObjectStackProtocolImplementation implements * new binding) until the next boot dropped it again, against the * write-through's own rule that a draft never leaks into the runtime * (ADR-0048). One gate before both limbs now admits active rows only. + * + * ## [ADR-0146 D10, #28735] The `__c` reservation, judged against the target + * + * A package-unbound row is not judged by the reservation (it is the + * environment's own), so the save door admits an object carrying `x__c` + * into no package. Rebinding that row makes `x__c` a field the TARGET + * package declares on its own object, which case (a) refuses outside a + * `.custom` pair. So every active `object` row this adoption would rebind + * is judged against the target id with the save door's judge + * ({@link judgeExtensionFieldSuffix}) before the first storage update, and + * any refused field refuses the adoption whole, with the save door's + * envelope ({@link extensionFieldSuffixRefusalError}, `VALIDATION_ERROR` / + * 400, `issues[]` locating each field): no row moves and nothing is + * written through to the registry. An adopted draft is judged when it is + * promoted (the promotion asks the same judge against its binding). */ async reassignOrphanedMetadata(request: { targetPackageId: string; @@ -26789,6 +26806,30 @@ export class ObjectStackProtocolImplementation implements && !this.isArtifactBacked(PLURAL_TO_SINGULAR[r.type] ?? r.type, String(r.name)), ); + // [ADR-0146 D10, #28735] ⭐ THE `__c` RESERVATION, JUDGED AGAINST THE + // TARGET, before the first update below: each active object row this + // adoption rebinds becomes the target's own object. Refused whole, with + // the save door's judge and envelope; see the header. An unparseable + // row is left to the loop, as `duplicatePackage` leaves it. + { + const adoptedObjects: Record = {}; + for (const row of orphans) { + if (canonicalMetaType(String(row?.type)) !== 'object' || row.state !== 'active') continue; + if (typeof row.name !== 'string') continue; + let body: unknown; + try { + body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + } catch { + continue; + } + if (body && typeof body === 'object' && !Array.isArray(body)) { + adoptedObjects[row.name] = { ...(body as Record), name: row.name }; + } + } + const suffixRefusals = judgeExtensionFieldSuffix({ packageId: request.targetPackageId, objects: adoptedObjects }); + if (suffixRefusals.length > 0) throw extensionFieldSuffixRefusalError(suffixRefusals); + } + const reassigned: Array<{ type: string; name: string }> = []; // [#12981] The refusal channel this loop used to lack entirely. // From 6f6d2f3516d2486355e61baaff8ad6fa31d6e1ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:46:14 +0000 Subject: [PATCH 2/2] chore(changeset): metadata-protocol minor for the adopt-orphans __c refusal Co-Authored-By: Claude --- ...35-adopt-orphans-extension-field-suffix.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/28735-adopt-orphans-extension-field-suffix.md diff --git a/.changeset/28735-adopt-orphans-extension-field-suffix.md b/.changeset/28735-adopt-orphans-extension-field-suffix.md new file mode 100644 index 00000000000..cd735d1b217 --- /dev/null +++ b/.changeset/28735-adopt-orphans-extension-field-suffix.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +**BREAKING** fix(metadata-protocol): the adopt-orphans door refuses to rebind a package-less object carrying a `__c` field into a plain package (ADR-0146 D10, #28735) + +Clause-②: no (narrowing) +Metadata-protocol: narrowed +Skills-docs: none — no file under `skills/**` teaches the adopt-orphans door or a field spelled `__c` (0 hits for `adopt-orphans`, `reassignOrphanedMetadata` and `[a-z0-9]__c`). + + + +`reassignOrphanedMetadata` (REST `POST /api/v1/packages/:id/adopt-orphans`) rebinds every active package-unbound row into the target package. The save door does not judge a package-unbound object against the `__c` reservation (a row bound to no package is the environment's own), so an object carrying `x__c` is stored package-less; adopted into a plain package, `x__c` became that package's own field, the case (a) every other door refuses. + +The door now asks the save door's judge (`judgeExtensionFieldSuffix`) for every active `object` row it would rebind, judged against the TARGET package id, before the first storage update. Any refused field refuses the adoption whole: `400 VALIDATION_ERROR`, the message case (a)'s sentence ("the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field."), `issues[]` locating each field at `objects.OBJECT.fields.FIELD`. No row moves and nothing is written through to the registry. An adoption into the base's `.custom` pair is admitted, since its objects are the pair's own. An adopted draft is judged when it is promoted, by the promotion's own call to the same judge. No new error code. + +Also, the draft promotion's suffix block is no longer asked of sealed managed content (an object a code package ships), like the two promotion blocks above it: such a draft gets the store's one `NOT_OVERRIDABLE` / 403 refusal instead of "rename the field". + +**Migration.** None needed for this repository: 0 fields affected (measured above). An environment holding a package-less object with a `__c` field renames the field before adopting into a plain package, or adopts into the base's `.custom` pair.