diff --git a/.changeset/29613-sealed-pair-override-row-served.md b/.changeset/29613-sealed-pair-override-row-served.md
new file mode 100644
index 00000000000..50894842c24
--- /dev/null
+++ b/.changeset/29613-sealed-pair-override-row-served.md
@@ -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 `.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.
diff --git a/packages/metadata-protocol/src/protocol-lock-state.ts b/packages/metadata-protocol/src/protocol-lock-state.ts
index 57797410d47..421ddb1e6d2 100644
--- a/packages/metadata-protocol/src/protocol-lock-state.ts
+++ b/packages/metadata-protocol/src/protocol-lock-state.ts
@@ -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 {
@@ -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. */
diff --git a/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts b/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts
new file mode 100644
index 00000000000..4db880c19f1
--- /dev/null
+++ b/packages/metadata-protocol/src/protocol-package-bindings.sealed-override.test.ts
@@ -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);
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol-package-bindings.ts b/packages/metadata-protocol/src/protocol-package-bindings.ts
index abf747635a7..4b9fe938e69 100644
--- a/packages/metadata-protocol/src/protocol-package-bindings.ts
+++ b/packages/metadata-protocol/src/protocol-package-bindings.ts
@@ -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 = new Set(['dashboard', 'report', 'email_template']);
+const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet = 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 = 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
@@ -462,6 +501,7 @@ export {
isShippedViewItem,
mergeArtifactProtection,
perPackageLayerEnvelope,
+ servesSealedOverrideRow,
stateTenantAuthorship,
stripDerivedProvenance,
};
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index acae72bf506..48efa7122b2 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -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.
*
@@ -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
*
@@ -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
diff --git a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
index 48986a84724..852f435a184 100644
--- a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
+++ b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
@@ -35,6 +35,40 @@
*
* Test-path resolution: the protocol is `@objectstack/metadata-protocol`'s
* `dist/`, so a change there is read here after that package is rebuilt.
+ *
+ * ## [#29613] The second describe: page, app, dataset, doc and book
+ *
+ * A `.custom` pair's published override of a base's
+ * page, app, dataset, doc or book is served from its ROW, as a dashboard's
+ * is, and no longer from a copy in the registry's bare slot wearing the
+ * base's envelope.
+ *
+ * These five types are SEALED (ADR-0131 D6: `allowEnvironmentOverlay:
+ * false`), so every read declined their stored row as an overlay of managed
+ * content (`declinesStoredRow`), the pair's declared override with the rest.
+ * The override was served only from the bare slot, where the hydrator
+ * registered it under the base's envelope. Measured on main 744211fc, the
+ * #29600 harness run without and with a metadata service (S = shipped,
+ * O = the override):
+ *
+ * - by-name (no package, BASE, PAIR): O from the bare slot without a
+ * metadata service; S with one (a real boot), the service's copy of the
+ * base's body answering before the registry;
+ * - the effective list (no package, BASE): O, from the bare slot;
+ * - the layered read's effective layer naming BASE: S;
+ * - the per-package layer naming PAIR: []; naming BASE: [S, O] without a
+ * metadata service, [O] with one;
+ * - `registry.getAllApps` (the app): [S, O].
+ *
+ * Pinned here, per type, with and without a metadata service, at once and
+ * after a restart: the served reads all answer O (ADR-0146 D8 item 2), the
+ * per-package layer lists S under BASE and O under PAIR (D8 item 3), and the
+ * registry's bare slot holds no copy of the override, so its direct reads
+ * (and `registry.getAllApps`) answer the base's own item. CONTROL: a sealed
+ * row that is not the pair's declared override (a package-less environment
+ * row of the base's name) stays unserved. Its boot is
+ * `environment-overlay-email-locale-restart.test.ts`'s: the hydration
+ * `ObjectQLPlugin.start()` runs, then the pair.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ServiceObject } from '@objectstack/spec/data';
@@ -49,6 +83,10 @@ const OTHER = 'com.example.other';
const DECLARATION = { package: BASE, basedOn: '1.0.0' };
const SHIPPED = 'Shipped label';
const EDITED = 'Edited label';
+/** [#29613] A sealed row that is not the pair's declared override. */
+const RESIDUE = 'Residue label';
+/** Unique across boots: a restart is a new driver over the same stores. */
+let nextId = 0;
const sysMetadataObject: ServiceObject = {
name: 'sys_metadata',
@@ -70,14 +108,13 @@ const sysMetadataObject: ServiceObject = {
},
};
-function makeMemoryDriver() {
- const stores = new Map>>();
+/** [#29613] `stores` shared by two boots = a restart over the same database. */
+function makeMemoryDriver(stores: Map>> = new Map()) {
const storeFor = (obj: string) => {
let s = stores.get(obj);
if (!s) { s = new Map(); stores.set(obj, s); }
return s;
};
- let nextId = 0;
const matchesWhere = (row: Record, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
for (const [k, v] of Object.entries(where)) {
@@ -308,3 +345,145 @@ describe('[#29600] a pair\'s published override never takes the base\'s envelope
expect(bare._provenance).toBe('package');
});
});
+
+const pageBody = (label: string) => ({ name: 'showcase_settings', label, type: 'app', regions: [] });
+const appBody = (label: string) => ({ name: 'showcase', label });
+const datasetBody = (label: string) => ({
+ name: 'showcase_task_metrics', label, object: 'showcase_task',
+ dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }],
+ measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }],
+});
+const docBody = (label: string) => ({ name: 'showcase_index', label, content: '# Index' });
+const bookBody = (label: string) => ({
+ name: 'showcase_manual', label, groups: [{ key: 'start', label: 'Start', pages: ['showcase_index'] }],
+});
+
+/** Per type: its body and the manifest collection that ships it. */
+const SEALED_TYPES = [
+ { type: 'page', body: pageBody, collection: 'pages' },
+ { type: 'app', body: appBody, collection: 'apps' },
+ { type: 'dataset', body: datasetBody, collection: 'datasets' },
+ { type: 'doc', body: docBody, collection: 'docs' },
+ { type: 'book', body: bookBody, collection: 'books' },
+] as const;
+
+function sealedBaseManifest(): Record {
+ const manifest: Record = {
+ id: BASE, name: 'showcase', version: '1.0.0', namespace: 'showcase',
+ objects: [{
+ name: 'showcase_task', label: 'Task', sharingModel: 'private',
+ fields: {
+ title: { name: 'title', label: 'Title', type: 'text' },
+ status: { name: 'status', label: 'Status', type: 'text' },
+ },
+ }],
+ };
+ for (const t of SEALED_TYPES) manifest[t.collection] = [t.body(SHIPPED)];
+ return manifest;
+}
+
+type Stores = Map>>;
+
+/**
+ * One boot over `stores`: engine, the base, the protocol, the hydration
+ * `ObjectQLPlugin.start()` runs, then the pair. Called twice over the same
+ * stores = a restart. `withService` registers a metadata service holding the
+ * code package's bodies, as every real boot's MetadataService holds them: the
+ * by-name read consults it before the registry.
+ */
+async function bootSealed(stores: Stores, withService: boolean) {
+ const engine = new ObjectQL();
+ engine.registerDriver(makeMemoryDriver(stores).driver, true);
+ await engine.init();
+ engine.registry.registerObject(sysMetadataObject, 'test-package');
+ for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
+ engine.registry.registerObject(o as any, 'test-package');
+ }
+ engine.registerApp(sealedBaseManifest());
+ const services = new Map();
+ if (withService) {
+ const shipped = new Map(SEALED_TYPES.map((t) => [t.type as string, { ...t.body(SHIPPED), _packageId: BASE }]));
+ services.set('metadata', {
+ async get(type: string, name: string) {
+ const item = shipped.get(type);
+ return item && item.name === name ? item : undefined;
+ },
+ async list(type: string) {
+ const item = shipped.get(type);
+ return item ? [item] : [];
+ },
+ });
+ }
+ const protocol: any = new ObjectStackProtocolImplementation(engine, () => services as any);
+ const load = await protocol.loadMetaFromDb();
+ expect(load.errors, 'the boot hydration').toBe(0);
+ await protocol.ensureCustomizationPackage({ basePackageId: BASE });
+ return { engine, protocol };
+}
+
+const byNameReading = async (protocol: any, type: string, name: string, packageId?: string): Promise =>
+ reading((await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) }))?.item);
+
+/** Every reading the card names, for one type and name. */
+async function sealedReadings(engine: ObjectQL, protocol: any, type: string, name: string) {
+ return {
+ byName: [await byNameReading(protocol, type, name), await byNameReading(protocol, type, name, BASE), await byNameReading(protocol, type, name, PAIR)],
+ list: [await effectiveList(protocol, type, name), await effectiveList(protocol, type, name, BASE)],
+ layered: (await protocol.getMetaItemLayered({ type, name, packageId: BASE }))?.effective?.label,
+ perPackage: {
+ base: await perPackage(protocol, type, name, BASE),
+ pair: await perPackage(protocol, type, name, PAIR),
+ unscoped: await perPackage(protocol, type, name),
+ },
+ bareSlot: reading((engine.registry as any).metadata.get(type)?.get(name)),
+ registryGetItem: reading(engine.registry.getItem(type, name)),
+ ...(type === 'app' ? { getAllApps: engine.registry.getAllApps().filter((a: any) => a?.name === name).map(reading) } : {}),
+ };
+}
+
+/** What every reading answers once the override is published (ADR-0146 D8 items 2 and 3). */
+const servedReadings = (type: string) => ({
+ byName: [`${EDITED}/${BASE}`, `${EDITED}/${BASE}`, `${EDITED}/${BASE}`],
+ list: [[EDITED], [EDITED]],
+ layered: EDITED,
+ perPackage: { base: [SHIPPED], pair: [EDITED], unscoped: [EDITED, SHIPPED] },
+ bareSlot: null,
+ registryGetItem: `${SHIPPED}/${BASE}`,
+ ...(type === 'app' ? { getAllApps: [`${SHIPPED}/${BASE}`] } : {}),
+});
+
+describe('[#29613] a pair\'s published override of a sealed type is served from its row', () => {
+ for (const withService of [false, true]) {
+ const shape = withService ? 'with a metadata service (a real boot)' : 'with no metadata service';
+ for (const { type, body } of SEALED_TYPES) {
+ const name = body(SHIPPED).name;
+
+ it(`${type}, ${shape}: every reader serves the override, at once and after a restart`, async () => {
+ const stores: Stores = new Map();
+ const first = await bootSealed(stores, withService);
+ await publishOverride(first.protocol, type, name, body(EDITED));
+ expect(await sealedReadings(first.engine, first.protocol, type, name), `${type} at once`).toEqual(servedReadings(type));
+
+ const restarted = await bootSealed(stores, withService);
+ expect(await sealedReadings(restarted.engine, restarted.protocol, type, name), `${type} after a restart`).toEqual(servedReadings(type));
+ });
+ }
+ }
+
+ for (const { type, body } of SEALED_TYPES) {
+ const name = body(SHIPPED).name;
+ it(`CONTROL, ${type}: a package-less environment row of the base's name is not the pair's override and stays unserved`, async () => {
+ const stores: Stores = new Map();
+ await bootSealed(stores, true);
+ // A row written through the hatch before the ADR-0131 D6 seal: bound to no package, no declaration.
+ stores.get('sys_metadata')!.set(`residue_${type}`, {
+ id: `residue_${type}`, type, name, organization_id: null, package_id: null, state: 'active', version: 1,
+ metadata: JSON.stringify(body(RESIDUE)),
+ });
+ const { protocol } = await bootSealed(stores, true);
+ expect(await byNameReading(protocol, type, name), `${type} by name`).toBe(`${SHIPPED}/${BASE}`);
+ expect(await byNameReading(protocol, type, name, BASE), `${type} by name, the base`).toBe(`${SHIPPED}/${BASE}`);
+ expect((await protocol.getMetaItemLayered({ type, name, packageId: BASE }))?.effective?.label, `${type} layered`).toBe(SHIPPED);
+ });
+ }
+});
diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts
index 96cdee29815..b93d0c2602f 100644
--- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts
+++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-delegated-resolution.test.ts
@@ -315,3 +315,108 @@ describe('[#7616] a failed resolution fails CLOSED, as the contract requires', (
expect(await appNamesOf(mount({ security: 'throws' }))).toEqual(['open']);
});
});
+
+// [#29613, ADR-0146 D8 item 2] `/me/apps` lists the apps the protocol SERVES
+// when the protocol service is present, so a `.custom` pair's published
+// override of a base's app is listed over the base's app.
+//
+// The override used to reach `/me/apps` only through the registry: the
+// hydrator registered it in the registry's bare slot wearing the base's
+// envelope, and `registry.getAllApps()` listed it there beside the base's own
+// app. Since the override is served from its row (`declinesStoredRow` admits
+// the pair's declared override), the bare slot holds no copy of it, and the
+// registry lists the base's app alone. The protocol's served list
+// (`getMetaItems`, what `GET /meta/app` serves) answers the override.
+//
+// Pinned both ways in the describe below: with a protocol service the
+// served list is the source, and the registry is not read; with none,
+// today's registry path is unchanged.
+// A protocol whose list read fails is answered from the registry.
+
+/** The registry's list: the base's app alone (no bare-slot copy of the override). */
+const REGISTRY_APPS = [{ name: 'showcase', label: 'Shipped label', _packageId: 'com.example.showcase' }];
+/** The protocol's served list: the pair's override, served as the base's item. */
+const SERVED_APPS = [{ name: 'showcase', label: 'Edited label', _packageId: 'com.example.showcase' }];
+
+function mountAppsSource(protocol: 'served' | 'throws' | 'absent' | 'no-list-read') {
+ let registryReads = 0;
+ const warnings: string[] = [];
+ const services: Record = {
+ auth: {
+ api: {
+ getSession: async () => ({
+ user: { id: USER, email: 'member@example.com' },
+ session: { activeOrganizationId: 'org_active' },
+ }),
+ },
+ },
+ objectql: {
+ find: async () => [],
+ registry: {
+ getAllApps: () => {
+ registryReads += 1;
+ return REGISTRY_APPS;
+ },
+ getAllObjects: () => [],
+ },
+ getSchema: () => undefined,
+ },
+ metadata: { list: async () => [] as unknown[] },
+ };
+ if (protocol === 'served') {
+ services.protocol = { getMetaItems: async ({ type }: { type: string }) => ({ type, items: type === 'app' ? SERVED_APPS : [] }) };
+ } else if (protocol === 'throws') {
+ services.protocol = {
+ getMetaItems: async () => {
+ throw new Error('metadata store unavailable');
+ },
+ };
+ } else if (protocol === 'no-list-read') {
+ services.protocol = { getDiscovery: async () => ({}) };
+ }
+ const app = new Hono();
+ registerCurrentUserEndpoints({
+ rawApp: app,
+ ctx: {
+ logger: { debug() {}, warn(message: string) { warnings.push(message); } },
+ getService: (name: string): T => {
+ if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
+ return services[name] as T;
+ },
+ },
+ });
+ return { app, registryReads: () => registryReads, warnings };
+}
+
+const labelsOf = async (app: Hono): Promise => {
+ const res = await app.request(`http://localhost${ME_APPS}`);
+ expect(res.status).toBe(200);
+ return ((await res.json()) as { apps: Array<{ label: string }> }).apps.map((a) => a.label);
+};
+
+describe('[#29613] /me/apps lists the protocol\'s served apps', () => {
+ it('with a protocol service: the served list, the pair\'s override over the base\'s app; the registry is not read', async () => {
+ const m = mountAppsSource('served');
+ expect(await labelsOf(m.app)).toEqual(['Edited label']);
+ expect(m.registryReads()).toBe(0);
+ });
+
+ it('with no protocol service: the registry, as before', async () => {
+ const m = mountAppsSource('absent');
+ expect(await labelsOf(m.app)).toEqual(['Shipped label']);
+ expect(m.registryReads()).toBe(1);
+ });
+
+ it('with a protocol service that has no list read: the registry, as before', async () => {
+ const m = mountAppsSource('no-list-read');
+ expect(await labelsOf(m.app)).toEqual(['Shipped label']);
+ expect(m.registryReads()).toBe(1);
+ });
+
+ it('a protocol list read that fails: answered from the registry, and warned', async () => {
+ const m = mountAppsSource('throws');
+ expect(await labelsOf(m.app)).toEqual(['Shipped label']);
+ expect(m.registryReads()).toBe(1);
+ expect(m.warnings.some((w) => w.includes('/me/apps'))).toBe(true);
+ });
+});
diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts
index cd998afd6b3..91b6c15387f 100644
--- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts
+++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts
@@ -336,6 +336,34 @@ type AnyQueueReader = Pick;
*
* It never throws.
*/
+/** The one protocol member `/me/apps` reads: its served list (`ObjectStackProtocolImplementation.getMetaItems`). */
+interface ServedAppLister {
+ getMetaItems(request: { type: string }): Promise<{ items?: unknown } | undefined>;
+}
+
+/**
+ * [#29613, ADR-0146 D8 item 2] The apps the protocol SERVES — the list
+ * `GET /meta/app` answers, a `.custom` pair's published override over the
+ * base's app — or `undefined` when no protocol service is registered (or it
+ * has no list read), and `/me/apps` reads the registry as it always has.
+ *
+ * A protocol list read that fails is warned once per request and answered
+ * from the registry, the degraded source this endpoint already falls back on;
+ * the caller still gets the apps it may enter.
+ */
+async function servedAppsOf(ctx: CurrentUserEndpointsContext): Promise {
+ let protocol: ServedAppLister | undefined;
+ try { protocol = ctx.getService('protocol'); } catch { return undefined; }
+ if (typeof protocol?.getMetaItems !== 'function') return undefined;
+ try {
+ const answer = await protocol.getMetaItems({ type: 'app' });
+ return Array.isArray(answer?.items) ? answer.items : undefined;
+ } catch (err: any) {
+ ctx.logger?.warn?.('[hono] /me/apps: the protocol app list failed; answering from the registry', { err: err?.message });
+ return undefined;
+ }
+}
+
async function organizationHasAnyQueue(
ctx: CurrentUserEndpointsContext,
organizationId: string | null,
@@ -1101,7 +1129,9 @@ export function registerCurrentUserEndpoints(
// AppSchema.requiredPermissions with no enforced consumer. Source
// from `registry.getAllApps()` (the same authority the meta routes
// use, nav contributions merged), with the metadata service kept as
- // an additive fallback for runtime-draft-published apps. Filters:
+ // an additive fallback for runtime-draft-published apps. [#29613] With
+ // the protocol service present, its served list stands in for the
+ // registry's ({@link servedAppsOf}). Filters:
// 1. AppSchema.requiredPermissions ⊆ ctx.systemPermissions
// 2. ctx.tabPermissions[app.name] !== 'hidden'
// Anonymous users get an empty array. When SecurityPlugin is absent
@@ -1111,7 +1141,18 @@ export function registerCurrentUserEndpoints(
if (!execCtx?.userId) return c.json({ apps: [] });
try {
const byName = new Map();
- try {
+ // [#29613, ADR-0146 D8 item 2] With the protocol service present,
+ // the apps are the protocol's SERVED list (`getMetaItems`, what
+ // `GET /meta/app` serves, nav contributions merged), so a `.custom`
+ // pair's published override of a base's app is listed over the
+ // base's app. The registry's list holds the base's app alone under
+ // the name since the override no longer takes its bare slot.
+ const served = await servedAppsOf(ctx);
+ if (served !== undefined) {
+ for (const app of served) {
+ if ((app as { name?: unknown })?.name) byName.set(String((app as { name: unknown }).name), app);
+ }
+ } else try {
// The PUBLIC accessor. This read used to reach ObjectQL's
// private `_registry` while `/auth/me/permissions` read the
// `registry` getter over the same field, on the same object,
diff --git a/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts b/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts
index 2abb61fa6e5..b0270993cb2 100644
--- a/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts
+++ b/packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts
@@ -32,10 +32,10 @@
* item in it carries an `overrides` block;
* - the same holds on a restart over the same database;
* - the pair's export carries the overriding item with its `overrides` block
- * (view, dashboard, report; see `pairServed`);
- * - CONTROL: the pair's override row is published, and for view, dashboard
- * and report the effective read of the base's name serves it, so the base
- * export's answer is the export's own.
+ * (every type since #29613; see `pairServed`);
+ * - CONTROL: the pair's override row is published, and the effective read of
+ * the base's name serves it, so the base export's answer is the export's
+ * own.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
@@ -116,20 +116,21 @@ const SERVICE_COLLECTIONS = {
/**
* `pairServed`: whether the pair's export and the effective read serve the
- * override. For the five types #29640 left in the bare slot neither does on
- * this boot shape (the pair's per-package layer lists none of them, and the
- * effective read serves the base's document; #29613), so only the base's half
- * is pinned for them.
+ * override. [#29613] True for every type: page, app, dataset, doc and book
+ * were false while the reads declined their pair override as a sealed overlay
+ * (the pair's per-package layer listed none of them, and on this boot shape,
+ * with a `MetadataManager`, the effective read served the base's document).
+ * The reads now serve it from its row, as a dashboard's.
*/
const TYPES = [
{ type: 'view', plural: 'viewItems', name: VIEW, pairServed: true },
{ type: 'dashboard', plural: 'dashboards', name: DASHBOARD, pairServed: true },
{ type: 'report', plural: 'reports', name: REPORT, pairServed: true },
- { type: 'page', plural: 'pages', name: 'showcase_start_here', pairServed: false },
- { type: 'app', plural: 'apps', name: 'showcase_app', pairServed: false },
- { type: 'dataset', plural: 'datasets', name: 'showcase_task_metrics', pairServed: false },
- { type: 'doc', plural: 'docs', name: 'showcase_index', pairServed: false },
- { type: 'book', plural: 'books', name: 'showcase_manual', pairServed: false },
+ { type: 'page', plural: 'pages', name: 'showcase_start_here', pairServed: true },
+ { type: 'app', plural: 'apps', name: 'showcase_app', pairServed: true },
+ { type: 'dataset', plural: 'datasets', name: 'showcase_task_metrics', pairServed: true },
+ { type: 'doc', plural: 'docs', name: 'showcase_index', pairServed: true },
+ { type: 'book', plural: 'books', name: 'showcase_manual', pairServed: true },
] as const;
/** The base's body, as the showcase's `defineStack` carries it: one fresh copy per reader. */