Skip to content
10 changes: 10 additions & 0 deletions .changeset/32820-reenable-heal-upgrade-guide-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/spec': patch
---

docs(spec): the protocol upgrade guide names what a holder's registry item answers after a second holder of an overlay name is enabled again: its own item, not the package-less row the hydrator laid in while the second was disabled (ADR-0150 D4)

Clause-②: no
Metadata-protocol: none

The semantic entry `package-less-overlay-row-holder-slot-on-reenable` (`packages/spec/src/migrations/entries/semantic/18.package-less-overlay-row-holder-slot-on-reenable.ts`) registers the narrowing `.changeset/32820-reenable-heals-holder-slot.md` declares (ADR-0087, `adr-0087: registered`): no automatic rewrite, because which holder a package-less row customizes is a judgement only its author can make. It adds no key and no export.
25 changes: 25 additions & 0 deletions .changeset/32820-reenable-heals-holder-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: enabling a second holder of an overlay name takes back the package-less row the read-side hydrator laid into the first holder's registry slot while the second was disabled (ADR-0150 D4)

Clause-②: no (narrowing) — a served answer that guessed a holder goes away: after the enable, a holder's registry item, its scoped list and its registry list answer the holder's own item where they answered the environment's package-less row as the holder's own. Nothing that was refused is now served, and no export is added (the heal is module-internal).
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` and `content/docs/**` teach no registry read of a holder's item after a disable and enable; the prescription ships in the FROM → TO below.

<!-- adr-0087: registered package-less-overlay-row-holder-slot-on-reenable -->

**BREAKING** for a registry reader of a holder's item of an overlay-type name (`email_template`, `dashboard`, `report`) that another installed package also holds, when a package-less `sys_metadata` row of the name is stored: `getPackageScopedItem(type, name, holder)` (what a send-time read takes), the list scoped to the holder (`GET /meta/:type?package=<holder>`) and the registry list scoped to it.

While one of the two holders is disabled, the other is the one served holder (#32183), so the package-less row is its customization: the read-side hydrator (an unscoped list read, the boot load) lays the row into that holder's slot under its envelope. Enabling the disabled holder ran no hydration, so the slot kept the row, and until the next unscoped list read the holder's registry item and its scoped list answered the environment's row as the holder's own, and its registry list listed both. The protocol's exact read (`?package=<holder>`) already refused it `409` naming both holders.

The enable now builds the entry again, as the hydrator builds it for the holders served then: with two served, no holder's envelope, so each holder's slot answers its own item, as when the hydrator ran with both served. It runs inside the enable, so every enable door (`PATCH /packages/:id/enable`, an install's `enableOnInstall`) answers after it. An entry another write replaced after the hydration is that write's, and is not touched.

Not changed: the disabled period itself (the one served holder's slot serves the row, as #32183 rules), the exact reads, and a package installed after the hydration that ships the name (the slot keeps the row until the next unscoped hydration, as before: no registry notice reaches the protocol for a new holder).

No first-party composition has two packages holding one overlay name with a package-less row, so no answer changes in this repository.

The fix:

- FROM a package-less overlay row of a name two packages hold, read as one holder's own through the registry after a disable and enable, TO the declared override of that holder in its paired `.custom` package (`?package=<holder>.custom` with `overrides: { package: <holder>, basedOn }`), saved after deleting the package-less row (a package-less delete removes the one bound row there is, so after the save it would remove the override). Before that delete, keep the package-less row's stored body and clear any row of the name bound to a package, as `package-less-row-exact-address-by-type-class` prescribes: the delete hard-deletes the row, and with a bound row present it addresses that row instead.
105 changes: 105 additions & 0 deletions packages/metadata-protocol/src/protocol-registry-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,102 @@ function soleHolderOf(registry: { packageScopedHoldersOf?(type: string, name: st
return holders[0];
}

/**
* [#32820] A package-less row of a name ONE package held when the read-side
* hydrator laid it in, under that holder's artifact envelope
* ({@link soleHolderOf}): what the registry entry was built from, so it can be
* built again when the holder set changes.
*/
interface SoleHolderEnvelope {
readonly host: Pick<ProtocolHost, 'lookupArtifactItem'>;
readonly type: string;
readonly canonicalType: string;
readonly name: string;
readonly member: string | undefined;
readonly data: unknown;
/** The holder whose envelope the entry wears. */
readonly holder: string;
/** The entry as registered: the heal touches the slot only while it still holds this object. */
readonly registered: unknown;
}

/** The engine's registry, as the hydrator reads it (its registration is the hydrator's own call, untyped there too). */
type EnvelopeRegistry = {
readonly [member: string]: any;
getItem?(type: string, name: string, currentPackageId?: string, member?: string): unknown;
packageScopedHoldersOf?(type: string, name: string): readonly string[] | undefined;
onPackageEnabled?(listener: (packageId: string) => void): () => void;
};

/**
* [#32820] The overlay types whose package-less row the heal rebuilds: the
* three the narrowing declares (its changeset and ADR-0087 entry
* `package-less-overlay-row-holder-slot-on-reenable`). A package-less
* `permission` row also wears its sole holder's envelope, but two holders of a
* permission set are refused by the registry today; when that rule opens, the
* heal widens to it only in a change that declares it (contract review s1).
*/
const HEALED_OVERLAY_TYPES: ReadonlySet<string> = new Set(['email_template', 'dashboard', 'report']);

/** Per registry, the {@link SoleHolderEnvelope} of each bare-slot key (`type`, `name`, member). */
const soleHolderEnvelopes = new WeakMap<object, Map<string, SoleHolderEnvelope>>();

const envelopeKey = (type: string, name: string, member: string | undefined) => `${type}\u0000${name}\u0000${member ?? ''}`;

/**
* [#32820, ADR-0150 D4] Record (or, with no `holder`, forget) the envelope a
* package-less row's registry entry wears. The first record for a registry
* subscribes {@link healSoleHolderEnvelopes} to its package enables.
*/
function recordSoleHolderEnvelope(registry: EnvelopeRegistry, key: string, envelope: SoleHolderEnvelope | undefined): void {
let records = soleHolderEnvelopes.get(registry);
if (envelope === undefined) {
records?.delete(key);
return;
}
if (records === undefined) {
if (typeof registry.onPackageEnabled !== 'function') return;
const created = new Map<string, SoleHolderEnvelope>();
records = created;
soleHolderEnvelopes.set(registry, created);
registry.onPackageEnabled(() => healSoleHolderEnvelopes(registry, created));
}
records.set(key, envelope);
}

/**
* [#32820, ADR-0150 D4] A package-less overlay row the read-side hydrator laid
* into a holder's slot while that holder alone was served (another holder of
* the name disabled) wears that holder's envelope, so the registry reads it
* as that holder's own item (`getPackageScopedItem`, the scoped list, the
* execution list). An enable that serves a second holder makes it a
* customization no holder can claim: the entry is built again as the
* hydrator builds it for the holders served now (none's envelope with two),
* so each holder's slot answers its own item again, as with no hydration in
* the disabled period. Synchronous, inside the enable, so every enable door
* (`PATCH /packages/:id/enable`, an install's `enableOnInstall`) answers
* after it.
*
* Only an entry the slot still holds as registered: a later write, delete or
* hydration replaced it and owns the slot.
*/
function healSoleHolderEnvelopes(registry: EnvelopeRegistry, records: Map<string, SoleHolderEnvelope>): void {
for (const [key, record] of [...records]) {
if (typeof registry.getItem !== 'function'
|| registry.getItem(record.type, record.name, undefined, record.member) !== record.registered) {
records.delete(key);
continue;
}
const holder = soleHolderOf(registry, record.canonicalType, record.name);
if (holder === record.holder) continue;
const artifact = holder === null ? undefined : record.host.lookupArtifactItem(record.type, record.name, holder ?? undefined, record.member);
const registered = mergeArtifactProtection(record.type, stateTenantAuthorship(withRowBinding(record.data, undefined)), artifact);
registry.registerItem(record.type, registered, 'name');
if (typeof holder === 'string' && artifact) records.set(key, { ...record, holder, registered });
else records.delete(key);
}
}

export class ProtocolRegistryHydrationFacet {
/**
* Mirror an object-type overlay write into the in-memory engine
Expand Down Expand Up @@ -676,6 +772,15 @@ export class ProtocolRegistryHydrationFacet {
if (!incomingTakesStoredFlowSlot(held, registered, shippers, installedManifestsOf(registry))) return true;
}
registry.registerItem(type, registered, 'name' as any);
// [#32820] A package-less row wearing the envelope of the one holder
// served now is recorded, so an enable serving a second holder builds
// it again ({@link healSoleHolderEnvelopes}); any other registration
// of the key forgets it.
const member = bundleMemberOf(type, data);
recordSoleHolderEnvelope(registry, envelopeKey(type, rowName, member),
!options.packageId && typeof envelopePackage === 'string' && envelope && HEALED_OVERLAY_TYPES.has(canonicalType)
? { host, type, canonicalType, name: rowName, member, data, holder: envelopePackage, registered }
: undefined);
await host.hydrateExpandedViewItems(type, data, options);
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly dispos
why: '[#25467 cut 15] the class member\'s host closure: it forwards the moved body\'s two calls (the rows above) unchanged, so its disposition is theirs' },
{ file: 'protocol-registry-hydration.ts', member: 'hydrateOverlayIntoRegistry', args: 'type, (data as any).name, envelopePackage ?? undefined, bundleMemberOf(type, data)', noPackage: true, disposition: 'no',
why: 'scoped to the row\'s own package; [#32568] for a package-less row of a type ADR-0150 addresses by (package, name), its one holder, and no lookup at all with two (never the first-registered\'s); undefined only for another type\'s package-less row, whose body names no package; [#23074] at the row\'s own bundle member' },
{ file: 'protocol-registry-hydration.ts', member: 'healSoleHolderEnvelopes', args: 'record.type, record.name, holder ?? undefined, record.member', noPackage: true, disposition: 'no',
why: '[#32820] the hydrator\'s own lookup, run again on an enable for the holders served then: the one served holder\'s envelope on a package-less row, and no lookup at all with two (holder null); undefined only when no package holds the name, as in the hydrator; at the row\'s own bundle member' },
{ file: 'protocol-exact-qualifier.ts', member: 'declinedCopyReplaced', args: 'request.type, request.name, holder, wantedMember', noPackage: false, disposition: 'no',
why: '[#32568] the admitted holder\'s own shipped item, served in place of a declined package-less copy at that holder\'s exact address: body and envelope are one artifact of the address\'s holder' },
{ file: 'protocol-registry-hydration.ts', member: 'expandRuntimeViewContainer', args: 'type, String(item.name), ownPackageId', noPackage: false, disposition: 'no',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ const SITES: ReadonlyArray<Site & {
+ 'reads answer it from its row; a package-less row, and every other type, register as before (#4624)',
viewOnlyBy: 'const bound = canonicalType === \'view\' ? boundPackageOf(options.packageId) : undefined;',
},
{
file: 'protocol-registry-hydration.ts', member: 'healSoleHolderEnvelopes', args: 'record.type, registered, \'name\'', disposition: 'no',
path: '[#32820] the registry\'s enable notice (onPackageEnabled), for an entry the read-side hydrator built under a sole holder\'s envelope that the slot still holds',
why: 'the same package-less row the hydrator registered, built again for the holders served now: its body binds to no '
+ 'package (withRowBinding with none), and it wears the one served holder\'s envelope or, with two, none',
},
{
// [#23198] Moved from hydrateExpandedViewItems into the one reconcile, which that hydration calls.
// [#30147] The reconcile's body moved to its facet module by the codemod's members pass; the disposition is unchanged.
Expand Down
Loading
Loading