Skip to content
22 changes: 22 additions & 0 deletions .changeset/29613-sealed-pair-override-row-served.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/plugin-hono-server': patch
---

fix(metadata-protocol): a `.custom` pair's published override of a page, app, dataset, doc or book is served from its stored row by every read, and no longer sits in the registry under the base's envelope

Clause-②: no
Metadata-protocol: none

Pages, apps, datasets, docs and books take no environment overlay, so every read declined their stored rows as overlays of a package's item. That included the override a base's `<base>.custom` pair publishes under ADR-0146. The override was served only from a copy the registry kept under the item's bare name, wearing the base's envelope. On a server with a metadata service (every real boot), the by-name read answered the base's shipped item instead, while the lists answered the override.

The reads now serve the pair's declared override from its row, as they already did for dashboards and reports. Every other stored row of such a name is still not served. The registry no longer keeps the copy under the base's envelope. What moves, for each of the five types, all toward ADR-0146 D8:

- `GET /meta/TYPE/NAME` with no package, the base or the pair: the override on every server (item 2). Before, it served the base's shipped item whenever a metadata service was present.
- The layered read naming the base, `effective`: the override (item 2). Before, the base's shipped item.
- The per-package layer naming the pair: the override (item 3). Before, empty. So the pair's export (`GET /packages/PAIR/export`) now carries the override with its `overrides` block, as it does for a dashboard.
- The per-package layer naming the base: the base's own item only (item 3). Before, it also listed the override, or listed only the override when a metadata service was present.
- `GET /me/apps` (`@objectstack/plugin-hono-server`): it now lists the protocol's served apps (what `GET /meta/app` serves) when the protocol service is present, so the pair's override of an app is listed. With no protocol service it reads the registry as before.
- A registry read that names the item directly answers the base's own item.

Reads that already served the override still do: the effective list (no package, the base). Nothing to migrate: a restart re-hydrates the registry under the new rule.
11 changes: 10 additions & 1 deletion packages/metadata-protocol/src/protocol-lock-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { canonicalMetaType } from './protocol.js';
import type { ItemLock } from './item-lock.js';
import { isOriginGatedType } from './packaged-base-regime.js';
import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js';
import { servesSealedOverrideRow } from './protocol-package-bindings.js';
import { isCodeArtifactBody } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import {
Expand Down Expand Up @@ -147,7 +148,15 @@ export function declinesStoredRow(
return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string }));
}
if (typeof name !== 'string' || name === '') return false;
return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name);
if (this.isDeclaredCodeDatasource(type, name)) return true;
if (!this.isUnservedSealedOverlay(type, name)) return false;
// [#29613, ADR-0146 D8 item 2] …except, for a page, app, dataset, doc or
// book, the shipping package's declared override: judged as the flow's
// above, on the row's own `package_id` and body, against the package
// that ships the name. The reads then serve it from its row, as they
// serve a dashboard's. Every other sealed row of the name stays declined.
return !(servesSealedOverrideRow(type)
&& isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name })));
}

/** The body of `ObjectStackProtocolImplementation#isUnservedSealedOverlay`; the method's docblock there is its contract. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#29613] The two closed type lists the row-served override path rests on:
* the sealed types whose declared `.custom` override the reads serve from its
* row ({@link servesSealedOverrideRow}), and the types whose such row stays out
* of the registry's bare slot ({@link declinesBorrowedEnvelope}). Every type of
* the first is in the second, so no override loses its bare entry before its
* row is served, and none keeps a bare entry wearing the base's envelope after.
*
* The readings themselves (every reader, per type, with and without a
* metadata service) are pinned in `@objectstack/objectql`'s
* `protocol-custom-override-sealed-row-served.test.ts`, over the real registry.
*/
import { describe, expect, it } from 'vitest';
import { declinesBorrowedEnvelope, servesSealedOverrideRow } from './protocol-package-bindings.js';

const ROW_SERVED = ['page', 'app', 'dataset', 'doc', 'book'] as const;
/** The base's code artifact the hydrator's lookup falls back to for a pair's row. */
const BASE_ARTIFACT = { _packageId: 'com.example.showcase' };

describe('[#29613] the sealed row-served override types', () => {
it.each(ROW_SERVED)('%s: its declared override is served from its row, and its row takes no borrowed envelope', (type) => {
expect(servesSealedOverrideRow(type)).toBe(true);
expect(declinesBorrowedEnvelope(type, 'com.example.showcase.custom', BASE_ARTIFACT)).toBe(true);
});

it('folds a plural spelling to the canonical type', () => {
expect(servesSealedOverrideRow('pages')).toBe(true);
expect(servesSealedOverrideRow('apps')).toBe(true);
});

it.each(['mapping', 'action', 'flow', 'object', 'permission', 'view', 'dashboard', 'report', 'email_template'])(
'%s: not a sealed row-served override type',
(type) => {
expect(servesSealedOverrideRow(type)).toBe(false);
},
);

it.each(ROW_SERVED)('%s: the #4624 case keeps its bare entry (a row of the package that ships the artifact)', (type) => {
expect(declinesBorrowedEnvelope(type, 'com.example.showcase', BASE_ARTIFACT)).toBe(false);
expect(declinesBorrowedEnvelope(type, null, BASE_ARTIFACT)).toBe(false);
});
});
52 changes: 46 additions & 6 deletions packages/metadata-protocol/src/protocol-package-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,14 +306,53 @@ function boundPackageOf(packageId: unknown): string | undefined {
* no reader the row does not. `view` is not here: it has its own, wider rule
* (#22057, `anotherPackageShips`).
*
* ⛔ A closed list, judged on the canonical type. `page`, `app`, `dataset`,
* `doc` and `book` are left out on purpose: their reads serve a `.custom`
* pair's override FROM the bare entry (the by-name read, the list, and
* `registry.getAllApps`), so they need a row-served path before their bare
* entry can go. `flow` registers with no artifact envelope at all (Regime C,
* ⛔ A closed list, judged on the canonical type. [#29613] It includes the
* five {@link SEALED_ROW_SERVED_OVERRIDE_TYPES} since their reads serve a
* `.custom` pair's declared override from its row, as these three types'
* reads do. `flow` registers with no artifact envelope at all (Regime C,
* #20913), and the boot pull arms its declared override from the bare slot.
*/
const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet<string> = new Set(['dashboard', 'report', 'email_template']);
const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet<string> = new Set([
'dashboard',
'report',
'email_template',
// [#29613] The SEALED_ROW_SERVED_OVERRIDE_TYPES, spelled out (`protocol-package-bindings.sealed-override.test.ts` pins both lists).
'page',
'app',
'dataset',
'doc',
'book',
]);

/**
* [#29613, ADR-0146 D8 item 2] The SEALED types (ADR-0131 D6: a registry
* entry with `allowEnvironmentOverlay: false`) whose stored row the reads
* serve when it is the shipping package's declared `.custom` override
* ({@link isServedOverrideRowOf}): `page`, `app`, `dataset`, `doc` and
* `book`, the overridable types of ADR-0146 D2 that open no environment
* overlay. Every other stored row of a name a managed package ships stays
* unserved for them ({@link isUnservedSealedOverlay}).
*
* Before, the reads declined the override's row with the rest, and the
* override was served only from the registry's bare slot, where the hydrator
* registered it wearing the base's envelope. With a metadata service (every
* real boot) the by-name read answered the service's copy of the base's body
* instead, while the lists answered the override, and the layered read's
* effective layer and the pair's per-package layer never saw it.
*
* ⛔ A closed list, judged on the canonical type. `mapping` is overridable and
* sealed too, and is not here: no card has measured its reads. `action` and
* `flow` wait for ADR-0146 D9's conditions (`flow` has its own admission,
* `declinesStoredRow`'s first branch). Every type here is also in
* {@link BORROWED_ENVELOPE_DECLINED_TYPES}: the bare entry served no reader
* the row does not serve now.
*/
const SEALED_ROW_SERVED_OVERRIDE_TYPES: ReadonlySet<string> = new Set(['page', 'app', 'dataset', 'doc', 'book']);

/** [#29613] Whether `type` is one of {@link SEALED_ROW_SERVED_OVERRIDE_TYPES}, folded to the canonical singular. */
function servesSealedOverrideRow(type: string): boolean {
return SEALED_ROW_SERVED_OVERRIDE_TYPES.has(canonicalMetaType(type));
}

/**
* [#29600] Whether the hydrator keeps a stored row of `canonicalType`, bound
Expand Down Expand Up @@ -462,6 +501,7 @@ export {
isShippedViewItem,
mergeArtifactProtection,
perPackageLayerEnvelope,
servesSealedOverrideRow,
stateTenantAuthorship,
stripDerivedProvenance,
};
Expand Down
17 changes: 10 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8761,7 +8761,10 @@ export class ObjectStackProtocolImplementation implements
* regime D6 recognises — the package definition wins. Such rows were
* written through the `OS_METADATA_WRITABLE` hatch before the seal; they
* stay at rest, untouched, and the boot report names them per type with
* the two remedies ({@link reportSealedOverlayRows}).
* the two remedies ({@link reportSealedOverlayRows}). [#29613] Except,
* for a `page`, `app`, `dataset`, `doc` or `book`, the shipping
* package's declared `.custom` override (`row`, below): ADR-0146 D8
* item 2 serves it, as a dashboard's.
*
* ⛔ Never a row's, slot's or body's `origin` — the caller sets it.
*
Expand Down Expand Up @@ -10848,11 +10851,11 @@ export class ObjectStackProtocolImplementation implements
* after, measured for each reader. What changes is the registry-direct
* read of the name, which answers the shipping package's own item.
*
* ⛔ Those three types only. A row of a package that ships its own artifact
* of the name keeps its bare entry (#4624). `page`, `app`, `dataset`, `doc`
* and `book` still register: their reads serve a pair's override from the
* bare entry, so it can go only once they serve it from the row. `flow`
* registers with no artifact envelope (below).
* ⛔ Those three types, and [#29613] `page`, `app`, `dataset`, `doc` and
* `book`, whose reads serve a pair's declared override from its row since
* {@link declinesStoredRow} admits it. A row of a package that ships its own
* artifact of the name keeps its bare entry (#4624). `flow` registers with
* no artifact envelope (below).
*
* ## [#23233] The entry carries the row's package binding
*
Expand Down Expand Up @@ -11012,7 +11015,7 @@ export class ObjectStackProtocolImplementation implements
// bound to a package that ships no artifact of its name, under a name
// another package ships, is not registered under the bare name, where
// it would wear that other package's envelope. Its reads answer it from
// its row. ⛔ Those three types only.
// its row. ⛔ Those three types, and [#29613] page, app, dataset, doc and book.
if (declinesBorrowedEnvelope(canonicalType, options.packageId, artifact)) return true;
// [#16702] Say what this row IS before the artifact envelope is grafted
// on top of it. Every body reaching this hydrator came out of a
Expand Down
Loading
Loading