Skip to content
23 changes: 23 additions & 0 deletions .changeset/32814-disabled-holder-pair-row-bare-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol)!: the read naming no package withholds a customization pair's row of a name every holder of which is disabled (ADR-0150 D3, #32814)

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill and no doc teaches what the read naming no package answers at a member only a customization pair stores (`skills/**` and `content/docs/**` grepped for disabled packages: the hits are action and component `disabled` predicates, package lifecycle references and a release page); `content/docs/deployment/marketplace-handbook.mdx` already says a disabled module is hidden.

<!-- adr-0087: registered disabled-holder-pair-row-bare-read-withheld -->

**BREAKING** answer narrowing at the `/meta` item read that names no package: at a member only a customization pair (an App's `PACKAGE.custom`) stores, for a name every installed package holding it has disabled, the read answers the holder's withheld item (404 `RESOURCE_NOT_FOUND` on the doors), where it served the pair's row. It is the answer the same read gives with no row stored, and the answer the pair's own address (`?package=PACKAGE.custom`) already gave.

The read judged the name's holders (`disabledOnlyHolderOf`) only when it reached nothing. It now also judges them when it reaches a row stamped with a customization pair (`pairsOf`) of a package that holds the name. A pair row is bound over the holders it is a pair of and is never a holder of its own, so it is withheld with them.

**Every moved answer, before → after.** Both save orders (row before and after the disable), with and without the boot's hydration of the row into the registry, for a module of an App and for the App itself as the holder. First-party count 0: no first-party app disables a package at boot.

- Bare `GET /meta/:type/:name?member=MEMBER`: 200 with the pair's row → 404 `RESOURCE_NOT_FOUND`.
- Bare `GET /meta/:type/:name/layers?member=MEMBER`: 200 with the pair's row as the effective layer → 404 `RESOURCE_NOT_FOUND` (the layered chain asks the plain read whether a disable withheld the item).
- The execution face (`getMetaItemForExecution`) of the same name and member: the pair's row → no item.

**Not moved.** Re-enabling a holder serves the pair's row again. Two modules of the App holding the name with one of them enabled: the pair's row is the enabled module's customization and is served as before, in either registration order. A row stored under a holder of the name, a package-less row, a row of a package that is no holder's pair, and every read naming a package are judged as before. The list reads (`getMetaItems`, `getMetaItemsForExecution`) are not changed here.
10 changes: 10 additions & 0 deletions .changeset/32814-disabled-holder-pair-row-semantic-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 the bare item read #32814 narrows: a customization pair's row of a name every holder of which is disabled is withheld instead of served

Clause-②: no
Metadata-protocol: none

The semantic entry `disabled-holder-pair-row-bare-read-withheld` (`packages/spec/src/migrations/entries/semantic/18.disabled-holder-pair-row-bare-read-withheld.ts`) registers the narrowing `.changeset/32814-disabled-holder-pair-row-bare-read.md` declares (ADR-0087, `adr-0087: registered`). There is no automatic rewrite, because whether a disabled package should be enabled again is a judgement about the deployment. It adds no key and no export.
34 changes: 33 additions & 1 deletion packages/metadata-protocol/src/protocol-meta-item-face.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ function isStoredUnder(item: unknown, packageId: string): boolean {
return (item as { _packageId?: unknown } | null | undefined)?._packageId === packageId;
}

/**
* [#32814] Whether `item` is a row of a customization pair (`pairsOf`) of a
* package holding `(type, name)`, a disabled one included: stamped
* (`_packageId`) with such a pair, and with no holder of the name. A pair row
* is bound over every holder it is a pair of, never a holder of its own
* (#32354 contract review N2, #32408), so on the read naming no package it is
* judged as the name's holders are ({@link disabledOnlyHolderOf}): when only
* disabled packages hold the name, it is their customization and withheld
* with them, as the exact address of such a pair is (ruling (c),
* `disabledExactAddressHolder`). A row stamped with a holder is that holder's
* item ({@link isItemOfDisabledPackage} judges it); a row of no holder's pair
* (a package-less row, another package's row) is not judged here.
*/
function isHoldersPairRow(engine: unknown, type: string, name: string, item: unknown): boolean {
const stamp = (item as { _packageId?: unknown } | null | undefined)?._packageId;
if (typeof stamp !== 'string' || stamp === '') return false;
const registry: any = (engine as { registry?: unknown } | undefined)?.registry;
if (typeof registry?.packageScopedHoldersOf !== 'function') return false;
const every: readonly string[] = registry.packageScopedHoldersOf(type, name, null, { includeDisabledPackages: true }) ?? [];
if (every.includes(stamp)) return false;
const installed = installedManifestsOfEngine(engine);
return every.some((holder) => pairsOf(holder, installed).includes(stamp));
}

/**
* [#32183] The package whose stack a read naming no package takes, for a
* name a disabled package also holds: the one holder ADR-0150 D3 resolves
Expand Down Expand Up @@ -419,10 +443,18 @@ export async function readMetaItemFace(
// only disabled packages hold, unless the read reached a row stored under
// that address's own package: a sibling module's address must not serve a
// disabled holder's pair row (row 17).
// [#32814] …and the read naming no package that reached a row of a
// holder's customization pair ({@link isHoldersPairRow}) judges the
// name's holders too, as it does when it reached nothing: a pair row of a
// name only disabled packages hold is their withheld item, the answer the
// same read gives with no row stored.
const withheldHolder = request.type === 'object' || request.type === 'package'
? undefined
: (request.packageId ? disabledExactAddressHolder(this, { type: request.type, name: request.name, packageId: request.packageId }) : undefined)
?? (answer?.item == null || (request.packageId && !isStoredUnder(answer.item, request.packageId))
?? (answer?.item == null
|| (request.packageId
? !isStoredUnder(answer.item, request.packageId)
: isHoldersPairRow(this.engine, request.type, request.name, answer.item))
? disabledOnlyHolderOf(this.engine?.registry, request.type, request.name)
: undefined);
if (withheldHolder === undefined && (request.type === 'object' || request.type === 'package'
Expand Down
92 changes: 92 additions & 0 deletions packages/objectql/src/package-scoped-exact-qualifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2528,3 +2528,95 @@ describe('[#32183] an App\'s pair two modules share, one of them disabled', () =
expect(JSON.stringify(neither ?? null)).not.toMatch(/(suitex|suitey) dashboard/);
});
});

/**
* [#32814] The read naming no package, at the member only a holder's
* customization pair stores, where every package holding the name is
* disabled. FROM (`main` e540baf88, measured): 200 with the pair's row
* (stamped with the pair), in every save and hydration order below, on the
* served and the execution face; with no row stored, the same read answered
* the holder's withheld item. TO: that withheld item, the no-row answer: a pair
* row is bound over the holders it is a pair of, never a holder of its own, so
* it is withheld with them, as `?package=PAIR` already is (R4-C above).
* Re-enabled, and beside an enabled holder of the same pair, as FROM.
*/
describe('[#32814] the read naming no package withholds a pair row of a name only disabled packages hold', () => {
const APP = 'com.acme.suite';
const X = 'com.acme.suitex';
const Y = 'com.acme.suitey';
const PAIR = `${APP}.custom`;
const moduleOf = (id: string) => ({
id, name: id, version: '1.0.0', type: 'module', namespace: id.split('.').pop()!, dependencies: { [APP]: '^1.0.0' },
}) as never;
const tpl = (packageId: string) => registry.registerItem('email_template', {
name: 'welcome', label: `${packageId.split('.').pop()} en-US`, locale: 'en-US', subject: 'en', bodyHtml: '<p>x</p>',
}, 'name' as never, packageId);
const storePairRow = () => rows.push({
id: 'row_fr', type: 'email_template', name: 'welcome', state: 'active', package_id: PAIR, bundle_member: 'fr-FR',
metadata: JSON.stringify({ name: 'welcome', label: 'pair fr-FR', locale: 'fr-FR', subject: 'pair fr-FR', bodyHtml: '<p>p</p>' }),
});
/** The boot's hydration: the list read lays the stored row into the registry's slot. */
const hydrate = () => protocol.getMetaItems({ type: 'email_template' } as never);
const bare = () => protocol.getMetaItem({ type: 'email_template', name: 'welcome', member: 'fr-FR' } as never);
const bareForExecution = () => protocol.getMetaItemForExecution({ type: 'email_template', name: 'welcome', member: 'fr-FR' } as never);

beforeEach(() => {
registry.installPackage(manifest(APP), 'code');
registry.installPackage(moduleOf(X), 'code');
registry.installPackage(moduleOf(Y), 'code');
registry.installPackage(manifest(PAIR), 'code');
});

const HOLDERS = [['a module of the App', X], ['the App itself', APP]] as const;
const ORDERS = [
['the row saved, then the holder disabled', false, false],
['the holder disabled, then the row saved', true, false],
['the row saved and hydrated, then the holder disabled', false, true],
['the holder disabled, then the row saved and hydrated', true, true],
] as const;
const CASES = HOLDERS.flatMap(([holderLabel, holder]) => ORDERS.map(([order, disableFirst, hydrated]) =>
[holderLabel, order, holder, disableFirst, hydrated] as const));

it.each(CASES)('%s holds the name, %s: the bare read at the member answers the holder\'s withheld item (BASE: the pair\'s row); re-enabled, the pair\'s row', async (_holder, _order, holder, disableFirst, hydrated) => {
tpl(holder);
if (disableFirst) registry.disablePackage(holder);
storePairRow();
if (hydrated) await hydrate();
if (!disableFirst) registry.disablePackage(holder);
const answer = await bare();
expect(answer?.item).toBeUndefined();
expect((answer as { packageId?: string } | undefined)?.packageId).toBe(holder);
expect(JSON.stringify(answer ?? null)).not.toContain('pair fr-FR');
expect((await bareForExecution())?.item).toBeUndefined();
// CONTROL — the holder enabled again: the pair's row, as on BASE.
registry.enablePackage(holder);
expect(labelOf(await bare())).toBe('pair fr-FR');
expect(labelOf(await bareForExecution())).toBe('pair fr-FR');
});

// `version` is left out of the comparison: the withheld envelope keeps the
// version of what the read reached, on every withheld arm (a disabled
// holder's own stored row keeps its row's too), and no door serves it.
it.each(HOLDERS)('%s holds the name, disabled: the answer with the pair\'s row stored is the answer with none', async (_holder, holder) => {
tpl(holder);
registry.disablePackage(holder);
const { version: _noRowVersion, ...withoutRow } = (await bare()) as Record<string, unknown>;
storePairRow();
const { version: _rowVersion, ...withRow } = (await bare()) as Record<string, unknown>;
expect(withRow).toEqual(withoutRow);
});

it.each([['the disabled holder registered first', [X, Y]], ['the enabled holder registered first', [Y, X]]] as const)(
'CONTROL — two modules of the App hold the name, %s, X disabled: the bare read serves the pair\'s row (Y\'s customization), as on BASE; both disabled, withheld',
async (_order, order) => {
for (const id of order) tpl(id);
storePairRow();
registry.disablePackage(X);
expect(labelOf(await bare())).toBe('pair fr-FR');
registry.disablePackage(Y);
const neither = await bare();
expect(neither?.item).toBeUndefined();
expect(JSON.stringify(neither ?? null)).not.toContain('pair fr-FR');
},
);
});
53 changes: 53 additions & 0 deletions packages/rest/src/meta-disabled-package-doors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,3 +882,56 @@ describe('[ruling record 6787890900] RestServer: an object the caller may not re
.toEqual({ status: 503, code: 'FIELD_VISIBILITY_UNRESOLVED' });
}, 60_000);
});

/**
* [#32814] The bare doors at the member only a holder's customization pair
* stores, where every package holding the name is disabled: an `email_template`
* one module of an App ships (`en-US`), the App's pair (`<App>.custom`)
* storing an undeclared `fr-FR` row. FROM (`main` e540baf88, measured on the
* protocol): the plain read served the pair's row. TO: the missing name, as
* with no row stored, on the plain read and the layered view; re-enabled, the
* pair's row on both.
*/
describe('[#32814] RestServer: a pair row of a name only a disabled package holds, read naming no package', () => {
const APP = 'com.qa.suite';
const MODULE = 'com.qa.suitex';
const PAIR = `${APP}.custom`;
const install = (host: ReturnType<typeof setup>) => {
host.registry.installPackage({ id: APP, name: APP, version: '1.0.0', type: 'app', namespace: 'suite' } as any, 'code');
host.registry.installPackage({ id: MODULE, name: MODULE, version: '1.0.0', type: 'module', namespace: 'suitex', dependencies: { [APP]: '^1.0.0' } } as any, 'code');
host.registry.registerItem('email_template', {
name: 'welcome', label: 'suitex en-US', locale: 'en-US', subject: 'en', bodyHtml: '<p>x</p>',
} as any, 'name', MODULE);
};
const pairRow = (host: ReturnType<typeof setup>) => (host.protocol as any).engine.insert('sys_metadata', {
type: 'email_template', name: 'welcome', package_id: PAIR, bundle_member: 'fr-FR', state: 'active', scope: 'platform', strategy: 'merge', version: 1,
metadata: JSON.stringify({ name: 'welcome', label: 'pair fr-FR', locale: 'fr-FR', subject: 'pair fr-FR', bodyHtml: '<p>p</p>' }),
});

it.each([['the row saved, then the module disabled', false], ['the module disabled, then the row saved', true]] as const)('%s: the plain read and `/layers` at the member answer 404, never the pair\'s row; re-enabled, the pair\'s row', async (_order, disableFirst) => {
const host = setup();
install(host);
if (disableFirst) host.registry.disablePackage(MODULE);
await pairRow(host);
if (!disableFirst) host.registry.disablePackage(MODULE);
const read = await host.plainWith('email_template', 'welcome', { member: 'fr-FR' });
const layers = await host.layers('email_template', 'welcome', { member: 'fr-FR' });
expect([read.status, read.body?.error?.code], JSON.stringify(read.body)).toEqual([404, 'RESOURCE_NOT_FOUND']);
expect([layers.status, layers.body?.error?.code], JSON.stringify(layers.body)).toEqual([404, 'RESOURCE_NOT_FOUND']);
for (const answer of [read, layers]) expect(JSON.stringify(answer.body ?? null)).not.toContain('pair fr-FR');
// CONTROL — the module enabled again: the pair's row on both doors.
host.registry.enablePackage(MODULE);
const again = await host.plainWith('email_template', 'welcome', { member: 'fr-FR' });
const againLayers = await host.layers('email_template', 'welcome', { member: 'fr-FR' });
expect([again.status, again.body?.item?.label], JSON.stringify(again.body)).toEqual([200, 'pair fr-FR']);
expect([againLayers.status, againLayers.body?.effective?.label], JSON.stringify(againLayers.body)).toEqual([200, 'pair fr-FR']);
}, 60_000);

it('CONTROL — no row stored: the plain read at the member answers the same 404', async () => {
const host = setup();
install(host);
host.registry.disablePackage(MODULE);
const read = await host.plainWith('email_template', 'welcome', { member: 'fr-FR' });
expect([read.status, read.body?.error?.code], JSON.stringify(read.body)).toEqual([404, 'RESOURCE_NOT_FOUND']);
}, 60_000);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// A narrowing of the bare item read, not of any key: ADR-0150 D3 (#32814) has
// the read that names no package withhold a row stored under a customization
// pair of the packages holding a name when every one of them is disabled,
// where it served the row (epic red line 1: a disabled package is not served).
// A pair row is bound over the holders it is a pair of, never a holder of its
// own, so it is withheld with them, as the pair's exact address already was.
// No key is added, removed or re-shaped and no stored row changes shape, so no
// tombstone and no D2 conversion: whether to re-enable the holder is the
// operator's judgement. No relevance question: the move turns on which
// installed packages are disabled, which no question over one stack can name.
export const entry: SemanticMigration = {
id: 'disabled-holder-pair-row-bare-read-withheld',
surface:
'The read of a metadata item that names no package (GET /api/v1/meta/TYPE/NAME?member=MEMBER, '
+ 'GET /api/v1/meta/TYPE/NAME/layers?member=MEMBER, and the execution read of the same name), at a member only a '
+ 'customization pair (an App\'s PACKAGE.custom) stores, for a name every installed package holding it has '
+ 'disabled, of a type the registry reads by package',
replacement:
'Re-enable a package that holds the name: the read naming no package then serves the pair\'s row again. '
+ 'While every holder stays disabled, the read answers what it answers with no row stored: the holder\'s '
+ 'withheld item (404 RESOURCE_NOT_FOUND).',
reason:
'A disabled package\'s items are withheld from the served reads, and so is its customization pair\'s row at '
+ 'the pair\'s own address (?package=PACKAGE.custom). The read naming no package still served that row when '
+ 'it reached it, because it judged the name\'s holders only when it reached nothing. It now judges them when '
+ 'it reaches a row of a holder\'s pair too. A row stored under a holder of the name, a package-less row, and '
+ 'a pair row beside an enabled holder of the same pair are served as before. There is no mechanical '
+ 'rewrite: whether the disabled package should be enabled again is a judgement about the deployment.',
acceptanceCriteria:
'For every name whose holders are all disabled and whose member is stored only under a holder\'s '
+ 'customization pair: GET /api/v1/meta/TYPE/NAME?member=MEMBER answers 404 RESOURCE_NOT_FOUND, never the '
+ 'pair\'s row; once a holder is enabled again, the same read answers the pair\'s row.',
};
Loading