From 0a5aa5399f382980a93e43c9ecc12663faece9be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:56:05 +0000 Subject: [PATCH 1/5] fix(rest,runtime): the layered doors answer a disabled package's non-object item absent One step in the shared layered chain (createMetaLayeredAnswer), after the no-layer absence and before the per-caller gate, answers a disabled package's item of every type but object and package as the missing name's absence. The owner is the plain read's kept envelope packageId under the same ?package= scope, asked of the engine registry's isPackageDisabled, through one optional source both transports wire. getMetaItemLayered is unchanged and keeps serving its resolution readers. Claude-Session: https://claude.ai/code/session_01JeRdtdgEYYdtYB4isWp3bh Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 41 +++++++++++++++++++++++- packages/rest/src/rest-server.ts | 31 +++++++++++++++++- packages/runtime/src/domains/meta.ts | 15 ++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 2f3a66a710..fa463bfd46 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -165,6 +165,21 @@ export interface MetaItemReadGateSources extends MetaReadGateAudienceSources, Me serviceProbe(caller: MetaReadGateCaller | undefined): ((name: string) => Promise) | null; /** Where the nav-servability prune log records what it has already said (one line per key). */ readonly navPruneLogged: Set; + /** + * [#7557, #24210] Does the by-name read withhold this item because its + * owning package is disabled? The owner is the one the plain read's kept + * protection envelope names (`packageId`, the served item's `_packageId`, + * read under the same `?package=` scope), asked of the engine registry's + * `isPackageDisabled`: the field and predicate the protocol's list and + * by-name reads ask. The layered answer cannot say it itself: a + * runtime-saved item's layers carry no owner. + * + * Read by the layered chain ({@link createMetaLayeredAnswer}) only. + * Absent, or a registry that cannot answer, is "not withheld": the answer + * before #24210. A REJECTION is a fault and propagates, ⛔ never "not + * withheld". + */ + isItemOfDisabledPackage?(item: { type: string; name: string; packageId?: string }): Promise; } // ── The caller's organization ───────────────────────────────────────────────── @@ -3670,6 +3685,12 @@ export interface MetaLayeredRequest { /** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */ readonly metaType: string; readonly name: string; + /** + * [ADR-0048] The `?package=` scope the transport read the layers under, + * absent for none. The same name can be stored in two packages, so the + * owner step 1b asks about is the one this scope resolves. + */ + readonly packageId?: string; /** * [ADR-0106 D2/D3] The caller's field-visibility posture for this item, * resolved by the transport BEFORE the read, the not-applicable passthrough @@ -3738,6 +3759,17 @@ export type MetaLayeredAnswer = * that difference told a member which unpublished apps exist. The * protocol's `effective` is never null while another layer is present, so * this is the name resolving to nothing at all, for every caller. + * 1b. [#7557, #24210] A disabled package's item — every type but `object` + * and `package` — is that same `absent`: the list read omits it and the + * by-name read answers it as a name with nothing behind it, so both + * spellings on both transports take the missing name's exit. Asked of + * {@link MetaItemReadGateSources.isItemOfDisabledPackage}, BEFORE the + * gate, so an author who may write the item is not served it whole + * either. An object is the protocol's own `OBJECT_PACKAGE_DISABLED` + * refusal, thrown by the read before this chain; `package` is never + * hidden. The protocol's layered read keeps serving every layer, because + * its resolution readers (the security catalog echo, the + * packaged-permission-set lock gate) need them: the door answers here. * 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first * (it is what the plain read serves, so its refusal is the plain read's * own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only @@ -3763,7 +3795,7 @@ export function createMetaLayeredAnswer( sources: MetaItemReadGateSources, request: MetaLayeredRequest, ): (layered: unknown) => Promise { - const { metaType, name, maskPosture } = request; + const { metaType, name, packageId, maskPosture } = request; return async (raw) => { const layered = raw as Record | null | undefined; const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null); @@ -3772,6 +3804,13 @@ export function createMetaLayeredAnswer( // the plain read's absence, answered before the gate. if (present.length === 0) return { kind: 'refuse', refusal: { reason: 'absent' } }; + // 1b. [#7557, #24210] A disabled package's item is that same absence, + // for every caller: before the gate, so no author is served it. + if (metaType !== 'object' && metaType !== 'package' + && await sources.isItemOfDisabledPackage?.({ type: metaType, name, ...(packageId ? { packageId } : {}) }) === true) { + return { kind: 'refuse', refusal: { reason: 'absent' } }; + } + // 2. [#20156] THE per-caller gate, on every present layer. const served = new Map(); { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d249a23bf3..dd04bfa180 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3501,6 +3501,13 @@ export class RestServer { * `withItemWriteVerdict` — [#20156] the door's policy honours the author * exemption, so the caller carries its `mayWriteItem` (see * {@link metaReadAudienceSources}). + * + * [#7557, #24210] `isItemOfDisabledPackage`, the layered chain's owner + * step: the plain read's kept envelope `packageId`, asked of the engine + * registry's `isPackageDisabled`, as `/published` asks it. The engine is + * reached through {@link wiredEngineOrLoud}: none wired withholds nothing, + * as before; one that cannot be resolved is the loud outage, never "not + * withheld". */ private metaItemReadGateSources( environmentId: string | undefined, @@ -3513,6 +3520,23 @@ export class RestServer { ...this.metaListSource(p, environmentId), serviceProbe: (caller) => this.serviceProbeFor((caller as any)?.__kernel), navPruneLogged: this.navPruneLogged, + isItemOfDisabledPackage: async ({ type, name, packageId }) => { + if (typeof p.getMetaItem !== 'function') return false; + const ql = await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + () => this.objectQLProvider!(environmentId), + ); + const registry = (ql as any)?.registry; + if (typeof registry?.isPackageDisabled !== 'function') return false; + const itemRequest: TransportScopedMetaRequest = { + type, + name, + ...(packageId ? { packageId } : {}), + ...(environmentId ? { environmentId } : {}), + }; + const envelope = await p.getMetaItem(itemRequest) as { packageId?: unknown } | undefined; + return registry.isPackageDisabled(envelope?.packageId) === true; + }, }; } @@ -4161,7 +4185,12 @@ export class RestServer { // admission (`metaItemReadGateSources(…, true)`). const answer = await metaReadGate.createMetaLayeredAnswer( this.metaItemReadGateSources(environmentId, req, p, true), - { metaType: RestServer.metaTypeSingular(req.params.type), name: req.params.name, maskPosture }, + { + metaType: RestServer.metaTypeSingular(req.params.type), + name: req.params.name, + ...(layeredPackageId ? { packageId: layeredPackageId } : {}), + maskPosture, + }, )(layered); switch (answer.kind) { case 'refuse': diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 3d333a7ec3..b0980637ed 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -498,6 +498,12 @@ const NAV_PRUNE_LOGGED = new Set(); * author exemption (the `?state=draft` read): this transport's own save-door * answer (`saveVerdict` in {@link handleMetadataRequest}), carried on a COPY of * the context — the same object serves every other consumer of this request. + * + * [#7557, #24210] `isItemOfDisabledPackage`, the layered chain's owner step: + * the plain read's kept envelope `packageId`, asked of the engine registry + * ({@link isServiceItemOfDisabledPackage}), as `/published` asks it. A host + * with no registry that can answer withholds nothing, as before; a read that + * rejects is that fault, never "not withheld". */ function metaItemReadGateSources( deps: DomainHandlerDeps, @@ -520,6 +526,13 @@ function metaItemReadGateSources( } }, navPruneLogged: NAV_PRUNE_LOGGED, + isItemOfDisabledPackage: async ({ type, name, packageId }) => { + if (typeof protocol?.getMetaItem !== 'function') return false; + const registry = (await deps.getObjectQL(context))?.registry; + if (typeof (registry as { isPackageDisabled?: unknown } | undefined)?.isPackageDisabled !== 'function') return false; + const owner = (await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) }))?.packageId; + return isServiceItemOfDisabledPackage(registry, { _packageId: owner }); + }, }; } @@ -869,7 +882,7 @@ async function answerMetaLayered( }); answer = await createMetaLayeredAnswer( metaItemReadGateSources(deps, context, protocol, mayWriteItem), - { metaType: pluralToSingular(type), name, maskPosture }, + { metaType: pluralToSingular(type), name, ...(packageId ? { packageId } : {}), maskPosture }, )(layered); } catch (e: any) { return withHeaders(deps.errorFromThrown(e, 500), headers); From 04fd79b0f39417c609376a9acd88c95aae760bde Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:02:53 +0000 Subject: [PATCH 2/5] test(rest,runtime,objectql): pin the layered doors' answer for a disabled package's item Both spellings on both transports, before, during and after the disable, for a code-shipped, an overlay-only and a combined item, with a control, an author and a member caller, the ?package= scope, the object row, and the engine slot's outage and absence. The #7557 table gains the producer and plugin-security's direct layered readers as resolution rows, and the objectQLProvider census counts the fifth consumer. Claude-Session: https://claude.ai/code/session_01JeRdtdgEYYdtYB4isWp3bh Co-authored-by: Claude --- .../src/package-disable-enforcement.test.ts | 21 ++ .../src/meta-disabled-package-doors.test.ts | 203 +++++++++++++++++- .../src/objectql-slot-consumer-census.test.ts | 20 +- .../meta-disabled-package-doors.test.ts | 106 ++++++++- 4 files changed, 332 insertions(+), 18 deletions(-) diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts index 4f6a46c442..f3f1679b84 100644 --- a/packages/objectql/src/package-disable-enforcement.test.ts +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -419,6 +419,27 @@ const READERS: Array<{ return ((await (protocol as any).getOverlayRepo().listDrafts({ packageId: PKG })) ?? []).length > 0; }, }, + { + reader: 'getMetaItemLayered({ type: "app", name }) — the producer under GET /meta/app/:name/layers and ?layers=true', + enforces: false, + why: '[#24210] the doors answer the absence (the shared layered chain\'s owner step, pinned per transport in @objectstack/rest and @objectstack/runtime); the producer keeps serving its resolution readers', + serves: async ({ protocol }) => + (await protocol.getMetaItemLayered({ type: 'app', name: 'demo_app' }))?.effective != null, + }, + { + reader: 'getMetaItemLayered({ type: "permission", name }) — the security catalog echo and the packaged-permission-set lock gate', + enforces: false, + why: '[#24210] plugin-security reads the layers directly, not through a door: a disabled package\'s permission set still echoes and stays locked', + serves: async ({ protocol }) => + (await protocol.getMetaItemLayered({ type: 'permission', name: shippedName('permission', PKG) }))?.effective != null, + }, + { + reader: 'getMetaItem(…).packageId — the owner the layered doors\' step and readServedPackageBinding read', + enforces: false, + why: '[#24210] the by-name read withholds the item but its kept envelope still names the owner, which is how the layered doors and the echo learn it', + serves: async ({ protocol }) => + (await protocol.getMetaItem({ type: 'app', name: 'demo_app' }))?.packageId === PKG, + }, { reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)', enforces: true, diff --git a/packages/rest/src/meta-disabled-package-doors.test.ts b/packages/rest/src/meta-disabled-package-doors.test.ts index d2519cf0cd..e3134a49e8 100644 --- a/packages/rest/src/meta-disabled-package-doors.test.ts +++ b/packages/rest/src/meta-disabled-package-doors.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#7557, #24210] Two `RestServer` doors that read past the by-name read for a + * [#7557, #24210] The `RestServer` doors that read past the by-name read for a * DISABLED package's item, each pinned before, during and after the disable, * beside a control (another package's item, still served): * @@ -12,6 +12,13 @@ * - `GET /meta/object/:name/state/:field`: the protocol's own refusal, `404 * OBJECT_PACKAGE_DISABLED`, byte for byte what `GET /meta/object/:name` * answers, and the registry is never asked for the schema. + * - the layered view, `GET /meta/:type/:name/layers` and the deprecated + * `?layers=true`, a non-object item: answered as a name with nothing behind + * it, status, body and headers, for a code-shipped, an overlay-only and a + * combined item, to an author as to any other caller. The owner is the plain + * read's kept envelope `packageId` under the same `?package=` scope, asked + * of the registry by the shared chain's step 1b; the protocol's layered read + * keeps serving its layers. * * The protocol and the registry are the REAL ones (the predicate lives on the * registry, so a double would measure the double); only `sys_metadata` is @@ -114,16 +121,31 @@ async function seed(protocol: any, registry: SchemaRegistry) { } } +/** + * [#24210] The layered doors' extra specimens, beside {@link seed}'s: a + * dashboard the disabled package ships whose name also carries an unpackaged + * overlay row (the combined item: a code layer and an overlay), and one name + * stored in BOTH packages, which `?package=` tells apart. + */ +async function seedLayered(protocol: any, registry: SchemaRegistry) { + registry.registerItem('dashboard', { name: 'demo_both', label: 'shipped', widgets: [] } as any, 'name', PKG); + await protocol.saveMetaItem({ type: 'dashboard', name: 'demo_both', item: { name: 'demo_both', label: 'customised', widgets: [] } }); + for (const [pkg, label] of [[PKG, 'disabled copy'], [OTHER_PKG, 'healthy copy']] as const) { + await protocol.saveMetaItem({ type: 'dashboard', name: 'twin_dash', packageId: pkg, item: { name: 'twin_dash', label, widgets: [] } }); + } +} + function makeRes() { const res: any = { statusCode: 200, body: undefined }; res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); res.json = vi.fn((b: any) => { res.body = b; return res; }); - res.header = vi.fn(() => res); + res.headers = {} as Record; + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); return res; } -function setup(objectQLProvider?: () => Promise) { +function setup(objectQLProvider?: () => Promise, caller: Record = { userId: 'u1' }) { const h = makeHarness(); const snapshot = { getPublished: vi.fn(async (type: string, name: string) => @@ -136,16 +158,20 @@ function setup(objectQLProvider?: () => Promise) { listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), }; const rest = new RestServer(server as any, h.protocol as any, { api: { requireAuth: false } } as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + (rest as any).resolveExecCtx = async () => caller; (rest as any).metadataServiceProvider = async () => snapshot; (rest as any).objectQLProvider = objectQLProvider ?? (async () => ({ registry: h.registry })); rest.registerRoutes(); - const get = async (pattern: string, path: string, params: Record, query: Record = {}) => { + const call = async (pattern: string, path: string, params: Record, query: Record = {}) => { const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}${pattern}`); if (!route) throw new Error(`GET ${pattern} is not registered`); const res = makeRes(); await route.handler({ method: 'GET', path: `${META}${path}`, params, query, body: {}, headers: {} }, res); - return { status: res.statusCode, body: res.body }; + return { status: res.statusCode, body: res.body, headers: res.headers as Record }; + }; + const get = async (pattern: string, path: string, params: Record, query: Record = {}) => { + const { status, body } = await call(pattern, path, params, query); + return { status, body }; }; return { ...h, @@ -153,6 +179,11 @@ function setup(objectQLProvider?: () => Promise) { published: (type: string, name: string) => get('/:type/:name/published', `/${type}/${name}/published`, { type, name }), state: (name: string) => get('/object/:name/state/:field', `/object/${name}/state/status`, { name, field: 'status' }, { from: 'open' }), plain: (type: string, name: string) => get('/:type/:name', `/${type}/${name}`, { type, name }), + // [#24210] The layered view's two spellings, with every header each answer carries. + layers: (type: string, name: string, query: Record = {}) => + call('/:type/:name/layers', `/${type}/${name}/layers`, { type, name }, query), + layersFlag: (type: string, name: string, query: Record = {}) => + call('/:type/:name', `/${type}/${name}`, { type, name }, { ...query, layers: 'true' }), }; } @@ -225,3 +256,163 @@ describe('[#24210] RestServer: /published and /state of a disabled package\'s it expect(await absent.published('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { label: 'demo board' } }); }, 60_000); }); + +/** + * [#24210] The flag's answer with its `Link` set aside: that header names the + * item's own path, so it differs from the missing name's by the name alone. + */ +const withoutLink = (r: { status: number; body: any; headers: Record }) => { + const { Link: _link, ...headers } = r.headers; + return { ...r, headers }; +}; + +describe('[#24210] RestServer: the layered view of a disabled package\'s item, both spellings', () => { + const SPECIMENS = [ + ['page', 'demo_page'], // code-shipped: a code layer only + ['dashboard', 'demo_dash'], // runtime-saved: an overlay only, whose layers name no owner + ['dashboard', 'demo_both'], // combined: the shipped layer and an overlay row + ] as const; + + it('served, then the missing name\'s answer on /layers and ?layers=true, then served again', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + await seedLayered(host.protocol, host.registry); + + const before = new Map(); + for (const [type, name] of SPECIMENS) { + const layers = await host.layers(type, name); + expect(layers.status).toBe(200); + expect(layers.body?.effective).toMatchObject({ name }); + const flag = await host.layersFlag(type, name); + expect(flag.status).toBe(200); + expect(flag.headers.Deprecation).toBe('true'); + // One body behind both spellings. + expect(flag.body).toEqual(layers.body); + before.set(`${type}/${name}`, { layers, flag }); + } + + host.registry.disablePackage(PKG); + const missingPlain = await host.plain('dashboard', 'no_such_dash'); + expect(missingPlain.status).toBe(404); + const missingLayers = await host.layers('dashboard', 'no_such_dash'); + const missingFlag = await host.layersFlag('dashboard', 'no_such_dash'); + // The two spellings' missing name is the plain read's own absence. + expect({ status: missingLayers.status, body: missingLayers.body }).toEqual(missingPlain); + expect({ status: missingFlag.status, body: missingFlag.body }).toEqual(missingPlain); + for (const [type, name] of SPECIMENS) { + // Status, body and headers, byte for byte the missing name's. + expect(await host.layers(type, name)).toEqual(missingLayers); + const flag = await host.layersFlag(type, name); + expect(withoutLink(flag)).toEqual(withoutLink(missingFlag)); + expect(flag.headers.Link).toBe(`<${META}/${type}/${name}/layers>; rel="successor-version"`); + // …and the plain read's, which the list read agrees with. + expect(await host.plain(type, name)).toEqual(missingPlain); + // The producer keeps serving its layers to its resolution readers. + expect((await host.protocol.getMetaItemLayered({ type, name }))?.effective).toMatchObject({ name }); + } + // Control: another package's items, both spellings, still served. + for (const [type, name] of [['page', 'healthy_page'], ['dashboard', 'healthy_dash']] as const) { + expect(await host.layers(type, name)).toMatchObject({ status: 200, body: { effective: { name } } }); + expect(await host.layersFlag(type, name)).toMatchObject({ status: 200, body: { effective: { name } } }); + } + + host.registry.enablePackage(PKG); + for (const [type, name] of SPECIMENS) { + const { layers, flag } = before.get(`${type}/${name}`) as any; + expect(await host.layers(type, name)).toEqual(layers); + expect(await host.layersFlag(type, name)).toEqual(flag); + } + }, 60_000); + + it('an author is answered the same absence: the owner step precedes the per-caller gate', async () => { + const author = { userId: 'u_author', systemPermissions: ['manage_metadata', 'studio.access'] }; + expect((RestServer as any).metaSaveVerdict(author).allowed).toBe(true); + const authorHost = setup(undefined, author); + const memberHost = setup(undefined, { userId: 'u_member' }); + for (const host of [authorHost, memberHost]) { + await seed(host.protocol, host.registry); + await host.protocol.saveMetaItem({ + type: 'app', name: 'demo_gated_app', packageId: PKG, + item: { + name: 'demo_gated_app', label: 'Gated', + navigation: [ + { id: 'gated_entry', type: 'url', label: 'Gated', url: 'https://example.com/a', requiredPermissions: ['gate_24210_cap'] }, + { id: 'open_entry', type: 'url', label: 'Open', url: 'https://example.com/b' }, + ], + }, + }); + } + const navIds = (r: any) => (r.body?.effective?.navigation ?? []).map((e: any) => e?.id); + // Enabled: the per-caller gate decides. The author reads the stored + // version whole (ruling 5856774816); the member reads it pruned. + const authorServed = await authorHost.layers('app', 'demo_gated_app'); + expect(authorServed.status).toBe(200); + expect(navIds(authorServed)).toEqual(['gated_entry', 'open_entry']); + const memberServed = await memberHost.layers('app', 'demo_gated_app'); + expect(memberServed.status).toBe(200); + expect(navIds(memberServed)).toEqual(['open_entry']); + + for (const host of [authorHost, memberHost]) { + host.registry.disablePackage(PKG); + const missing = await host.layers('app', 'no_such_app'); + expect(missing.status).toBe(404); + // Both callers, both spellings: the missing name's answer, not the + // author's whole view, and not the member's pruned one. + expect(await host.layers('app', 'demo_gated_app')).toEqual(missing); + expect(withoutLink(await host.layersFlag('app', 'demo_gated_app'))) + .toEqual(withoutLink(await host.layersFlag('app', 'no_such_app'))); + host.registry.enablePackage(PKG); + } + expect(await authorHost.layers('app', 'demo_gated_app')).toEqual(authorServed); + expect(await memberHost.layers('app', 'demo_gated_app')).toEqual(memberServed); + }, 60_000); + + it('an object keeps the protocol\'s own 404 OBJECT_PACKAGE_DISABLED, the plain read\'s refusal', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + host.registry.disablePackage(PKG); + const plain = await host.plain('object', 'demo_gadget'); + expect({ status: plain.status, code: plain.body?.code ?? plain.body?.error?.code }) + .toEqual({ status: 404, code: 'OBJECT_PACKAGE_DISABLED' }); + const layers = await host.layers('object', 'demo_gadget'); + expect({ status: layers.status, body: layers.body }).toEqual(plain); + const flag = await host.layersFlag('object', 'demo_gadget'); + expect({ status: flag.status, body: flag.body }).toEqual(plain); + expect(await host.layers('object', 'healthy_gadget')).toMatchObject({ status: 200, body: { effective: { name: 'healthy_gadget' } } }); + }, 60_000); + + it('?package= picks the owner: the healthy package\'s copy of a shared name is served, the disabled one\'s is absent', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + await seedLayered(host.protocol, host.registry); + host.registry.disablePackage(PKG); + const missing = await host.layers('dashboard', 'no_such_dash', { package: OTHER_PKG }); + expect(missing.status).toBe(404); + expect(await host.layers('dashboard', 'twin_dash', { package: OTHER_PKG })) + .toMatchObject({ status: 200, body: { effective: { label: 'healthy copy' } } }); + expect(await host.layersFlag('dashboard', 'twin_dash', { package: OTHER_PKG })) + .toMatchObject({ status: 200, body: { effective: { label: 'healthy copy' } } }); + expect(await host.layers('dashboard', 'twin_dash', { package: PKG })).toEqual(missing); + }, 60_000); + + // The objectQLProvider slot's consumer census (`objectql-slot-consumer-census.test.ts`) + // cites this case: the layered owner step keeps a failed engine and an + // absent one apart, as every consumer of the slot must. + it('the owner step reads the objectQLProvider slot through wiredEngineOrLoud: a rejecting engine is loud, an absent one serves as before', async () => { + const rejecting = setup(async () => { throw new Error('driver handshake failed'); }); + await seed(rejecting.protocol, rejecting.registry); + for (const read of [rejecting.layers, rejecting.layersFlag]) { + const failed = await read('dashboard', 'demo_dash'); + expect({ status: failed.status, code: failed.body?.code ?? failed.body?.error?.code }) + .toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(JSON.stringify(failed.body)).not.toContain('demo board'); + } + + const absent = setup(async () => undefined); + await seed(absent.protocol, absent.registry); + absent.registry.disablePackage(PKG); + // No engine to ask, so nothing is withheld: the doors' answer before #24210. + expect(await absent.layers('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { effective: { label: 'demo board' } } }); + expect(await absent.layersFlag('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { effective: { label: 'demo board' } } }); + }, 60_000); +}); diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index 8b24ec6d33..ae86aa84a1 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -37,6 +37,13 @@ * ask, so nothing is withheld). Its behavioural pin is * `meta-disabled-package-doors.test.ts`, cited in §3. * + * [#24210] ⭐ And a FIFTH, examined on arrival the same way: the layered view + * (`GET /meta/:type/:name/layers` and the deprecated `?layers=true`) asks the + * same registry question through the shared layered chain's owner step, wired + * in `metaItemReadGateSources`. It too reaches the slot through + * `wiredEngineOrLoud` (a rejecting provider is a loud `503`, an absent engine + * withholds nothing), pinned in the same file, cited in §3. + * * ## What is asserted about the answers * * §2 drives the third consumer on the wiring where it decides anything, and @@ -190,18 +197,19 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () expect(invocationSites(SOURCE, 'objectQLProvider').some((s) => s.text.startsWith('//'))).toBe(false); }); - it('⭐ FOUR consumers: three, not two, was the count the two prose censuses got wrong, and #24210 adds /published', () => { + it('⭐ FIVE consumers: three, not two, was the count the two prose censuses got wrong, and #24210 adds /published and the layered view', () => { // [#23050 slice 2] 3 UNCHANGED, over two files: 2 in `rest-server.ts` // and the batch door's 1 in `batch-routes.ts`. [#24210] 4: `/published`'s - // disabled-owner check joins `rest-server.ts`. + // disabled-owner check joins `rest-server.ts`; 5: the layered chain's + // owner step (`metaItemReadGateSources`) joins it too. const sites = censusInvocationSites('objectQLProvider'); // The message carries the sites, so a failure here hands the next // reader the new census instead of a bare number. expect(sites.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBeTruthy(); - expect(sites).toHaveLength(4); + expect(sites).toHaveLength(5); }); - it('⭐ [#18559] ALL FOUR consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { + it('⭐ [#18559] ALL FIVE consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { const sites = censusInvocationSites('objectQLProvider'); const viaSeam = sites.filter((s) => { // The helper is named on the line that OPENS the call, two lines up @@ -214,7 +222,7 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // the site rather than a bare number. expect(direct.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBe(''); expect(direct).toHaveLength(0); - expect(viaSeam).toHaveLength(4); + expect(viaSeam).toHaveLength(5); // ⭐ CONTROL for the window predicate itself, on BOTH axes. It reads a // WINDOW of source text, so "3 of 3" is only a reading if the same @@ -532,7 +540,7 @@ describe('[#18546] §3 the census names where the other consumers are pinned', ( it.each([ ['execctx-authz-input-seam-reachability.test.ts', 'the computeExecCtx authorization-input seam'], ['meta-state-route-engine-outage.test.ts', 'GET /meta/object/:name/state/:field'], - ['meta-disabled-package-doors.test.ts', '[#24210] GET /meta/:type/:name/published'], + ['meta-disabled-package-doors.test.ts', '[#24210] GET /meta/:type/:name/published, /layers and ?layers=true'], ])('%s still exists and still names this slot (%s)', (file) => { const path = resolve(HERE, file); expect(existsSync(path)).toBe(true); diff --git a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts index b2513b0809..ad9f4de2f6 100644 --- a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts +++ b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts @@ -19,7 +19,12 @@ * - `/meta/:type/:name/published` of a non-object item reads the stored * overlay row or the snapshot, neither of which knows a disable: a disabled * package's item is absent there too, byte for byte the missing name's - * answer (the owner is the plain read's kept envelope `packageId`). + * answer (the owner is the plain read's kept envelope `packageId`); + * - the layered view, `/meta/:type/:name/layers` and the deprecated + * `?layers=true`, of a non-object item: the shared layered chain's owner + * step answers a disabled package's item as the missing name, byte for + * byte, asking the plain read's envelope under the request's `?package=` + * scope; the object row stays the protocol's own refusal. */ import { describe, it, expect, vi } from 'vitest'; @@ -49,8 +54,17 @@ const refusal = (object: string) => Object.assign( * stored overlay row, `*_snap` only in the code/package snapshot. */ const OWNERS: Record = { - demo_page: DISABLED_PKG, demo_snap: DISABLED_PKG, - healthy_page: HEALTHY_PKG, healthy_snap: HEALTHY_PKG, + demo_page: DISABLED_PKG, demo_snap: DISABLED_PKG, demo_code: DISABLED_PKG, + healthy_page: HEALTHY_PKG, healthy_snap: HEALTHY_PKG, healthy_code: HEALTHY_PKG, +}; +/** + * [#24210] Code-shipped pages: the layered read answers a code layer and no + * overlay. `twin_page` is stored in BOTH packages, so its owner is the one the + * request's `?package=` names (unscoped, the disabled package's). + */ +const CODE_ROWS: Record = { + demo_code: { name: 'demo_code', label: 'Demo (shipped)' }, + healthy_code: { name: 'healthy_code', label: 'Healthy (shipped)' }, }; const PUBLISHED_ROWS: Record = { demo_page: { name: 'demo_page', label: 'Demo (published row)' }, @@ -65,19 +79,21 @@ const SERVICE_ITEMS: Record = { function boot(opts: { getMetaTypes?: boolean } = {}) { const disabled = { on: true }; - const getMetaItem = vi.fn(async ({ type, name }: any) => { + const getMetaItem = vi.fn(async ({ type, name, packageId }: any) => { if (type === 'object' && name === DISABLED) throw refusal(name); if (type === 'object' && name === HEALTHY) return { type, name, item: schema(name) }; // The protocol answers the disabled package's page as absent (#24210), // and holds no copy of the healthy one: the service fallback serves it. + const owner = name === 'twin_page' ? (packageId ?? DISABLED_PKG) : OWNERS[name]; return { type, name, item: undefined, lock: 'none', editable: true, - ...(OWNERS[name] ? { packageId: OWNERS[name] } : {}), + ...(owner ? { packageId: owner } : {}), }; }); const getMetaItemLayered = vi.fn(async ({ type, name }: any) => { if (type === 'object' && name === DISABLED) throw refusal(name); - const row = PUBLISHED_ROWS[name] ?? null; + if (CODE_ROWS[name]) return { type, name, code: CODE_ROWS[name], overlay: null, effective: CODE_ROWS[name] }; + const row = name === 'twin_page' ? { name, label: 'Twin' } : PUBLISHED_ROWS[name] ?? null; return { type, name, code: null, overlay: row, effective: row }; }); const protocol: Record = { @@ -118,6 +134,7 @@ function boot(opts: { getMetaTypes?: boolean } = {}) { registry, metadata, disabled, + getMetaItem, getMetaItemLayered, }; } @@ -231,3 +248,80 @@ describe('#24210 — /published of a disabled package\'s non-object item', () => expect((await published(stack, 'healthy_snap')).response?.body?.data).toEqual({ name: 'healthy_snap', label: 'healthy_snap (snapshot)' }); }); }); + +describe('#24210 — the layered view of a disabled package\'s non-object item, both spellings', () => { + const layers = (stack: ReturnType, name: string, query: Record = {}) => + stack.dispatcher.dispatch('GET', `/meta/page/${name}/layers`, {}, query, SESSION()); + const flag = (stack: ReturnType, name: string, query: Record = {}) => + stack.dispatcher.dispatch('GET', `/meta/page/${name}`, {}, { ...query, layers: 'true' }, SESSION()); + const SPECIMENS = ['demo_page', 'demo_code']; // an overlay-only and a code-shipped page + + it('served while enabled, then the missing name\'s answer, then served again', async () => { + const stack = boot(); + stack.disabled.on = false; + const before = new Map(); + for (const name of SPECIMENS) { + const viaLayers = await layers(stack, name); + const viaFlag = await flag(stack, name); + expect(viaLayers.response?.status, name).toBe(200); + expect(viaLayers.response?.body?.data?.effective?.name, name).toBe(name); + expect(viaFlag.response?.body?.data, name).toEqual(viaLayers.response?.body?.data); + expect(viaFlag.response?.headers?.Deprecation, name).toBe('true'); + before.set(name, JSON.stringify([viaLayers.response, viaFlag.response])); + } + + stack.disabled.on = true; + const missingLayers = await layers(stack, 'no_such_page'); + const missingFlag = await flag(stack, 'no_such_page'); + expect(missingLayers.response?.status).toBe(404); + // The missing name's answer is the plain read's own absence. + const missingPlain = await stack.dispatcher.dispatch('GET', '/meta/page/no_such_page', {}, {}, SESSION()); + expect(JSON.stringify(missingLayers.response?.body)).toBe(JSON.stringify(missingPlain.response?.body)); + for (const name of SPECIMENS) { + // Status, body and headers, byte for byte the missing name's. + expect(JSON.stringify((await layers(stack, name)).response), name).toBe(JSON.stringify(missingLayers.response)); + expect(JSON.stringify((await flag(stack, name)).response), name).toBe(JSON.stringify(missingFlag.response)); + } + // Control: another package's pages, both spellings, still served. + for (const name of ['healthy_page', 'healthy_code']) { + expect((await layers(stack, name)).response?.body?.data?.effective?.name, name).toBe(name); + expect((await flag(stack, name)).response?.body?.data?.effective?.name, name).toBe(name); + } + + stack.disabled.on = false; + for (const name of SPECIMENS) { + expect(JSON.stringify([(await layers(stack, name)).response, (await flag(stack, name)).response]), name) + .toBe(before.get(name)); + } + }); + + it('?package= is the scope the owner is read under: the healthy package\'s copy is served', async () => { + const stack = boot(); + const missing = await layers(stack, 'no_such_page'); + expect((await layers(stack, 'twin_page', { package: HEALTHY_PKG })).response?.body?.data?.effective?.label).toBe('Twin'); + expect((await flag(stack, 'twin_page', { package: HEALTHY_PKG })).response?.body?.data?.effective?.label).toBe('Twin'); + expect(stack.getMetaItem).toHaveBeenCalledWith({ type: 'page', name: 'twin_page', packageId: HEALTHY_PKG }); + expect(JSON.stringify((await layers(stack, 'twin_page', { package: DISABLED_PKG })).response)) + .toBe(JSON.stringify(missing.response)); + expect(JSON.stringify((await layers(stack, 'twin_page')).response)).toBe(JSON.stringify(missing.response)); + }); + + it('an object keeps the layered read\'s own 404 OBJECT_PACKAGE_DISABLED, on both spellings', async () => { + const stack = boot(); + expectObjectRefusal(await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}/layers`, {}, {}, SESSION())); + expectObjectRefusal(await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}`, {}, { layers: 'true' }, SESSION())); + }); + + it('a plain read that rejects is that fault, never the layered view; a registry that cannot answer withholds nothing', async () => { + const stack = boot(); + stack.getMetaItem.mockImplementationOnce(async () => { + throw Object.assign(new Error('metadata store unavailable'), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + }); + const failed = await layers(stack, 'demo_page'); + expect(failed.response?.status).toBe(503); + expect(JSON.stringify(failed.response?.body)).not.toContain('published row'); + + (stack.registry as { isPackageDisabled?: unknown }).isPackageDisabled = undefined; + expect((await layers(stack, 'demo_page')).response?.body?.data?.effective?.name).toBe('demo_page'); + }); +}); From d59b0d2d4e33a16810c6dcee4a33e17f2335c47d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:03:11 +0000 Subject: [PATCH 3/5] chore(changeset): the layered doors of a disabled package's item Claude-Session: https://claude.ai/code/session_01JeRdtdgEYYdtYB4isWp3bh Co-authored-by: Claude --- ...24210-rest-layered-doors-disabled-package.md | 17 +++++++++++++++++ ...10-runtime-layered-doors-disabled-package.md | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .changeset/24210-rest-layered-doors-disabled-package.md create mode 100644 .changeset/24210-runtime-layered-doors-disabled-package.md diff --git a/.changeset/24210-rest-layered-doors-disabled-package.md b/.changeset/24210-rest-layered-doors-disabled-package.md new file mode 100644 index 0000000000..dc5e6c5b5e --- /dev/null +++ b/.changeset/24210-rest-layered-doors-disabled-package.md @@ -0,0 +1,17 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: `/meta/:type/:name/layers` and the deprecated `?layers=true` answer a disabled package's non-object item as a missing name, on both transports + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- **Absent now, byte for byte the missing name's answer (`404 RESOURCE_NOT_FOUND`):** the layered view of an item of every type but `object` and `package` whose owning package is disabled, on `GET /meta/:type/:name/layers` and on `GET /meta/:type/:name?layers=true`, to every caller, an author who may write the item included. One step in the shared layered chain (`createMetaLayeredAnswer`) answers it, so the runtime dispatcher's two spellings answer the same. The owner is the one the by-name read's protection envelope names (`packageId`), read under the request's `?package=` scope, asked of the engine registry's `isPackageDisabled`. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. +- **Unchanged:** items of enabled packages, a name with nothing behind it, and the protocol's `getMetaItemLayered`, which keeps serving every layer to the readers that resolve with it (the security catalog echo, the packaged-permission-set lock gate). +- **Added, optional:** `MetaItemReadGateSources.isItemOfDisabledPackage` and `MetaLayeredRequest.packageId`. A host that supplies neither answers as before. + +What changes for you: re-enable the package to read its items' layered views again. diff --git a/.changeset/24210-runtime-layered-doors-disabled-package.md b/.changeset/24210-runtime-layered-doors-disabled-package.md new file mode 100644 index 0000000000..0ba74a2750 --- /dev/null +++ b/.changeset/24210-runtime-layered-doors-disabled-package.md @@ -0,0 +1,16 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the dispatcher's `/meta/:type/:name/layers` and `?layers=true` answer a disabled package's non-object item as a missing name + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/layers` and `GET /meta/:type/:name?layers=true` (with its `Deprecation` header) for an item of every type but `object` and `package` whose owning package is disabled, to every caller. The owner is the one the by-name read's protection envelope names (`packageId`) under the request's `?package=` scope, as `RestServer` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. +- **Unchanged:** items of enabled packages, and a name with nothing behind it. + +What changes for you: re-enable the package to read its items' layered views again. From 527c7bc4baf0d13f544787aaff673e37e1a6b737 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:49:32 +0000 Subject: [PATCH 4/5] fix(rest,runtime): the layered owner step follows the plain read's own decision of what it hides The step asked the owner's disable state whatever the plain read answered and restated the protocol's never-hidden type in the chain, which the item-read census counted as a type with no parity cell. The source now answers withheld only when the plain read serves no item and its envelope names a disabled owner, so which types are hidden stays the protocol's. Claude-Session: https://claude.ai/code/session_01JeRdtdgEYYdtYB4isWp3bh Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 31 ++++++++++--------- packages/rest/src/rest-server.ts | 9 +++--- .../meta-disabled-package-doors.test.ts | 10 ++++++ packages/runtime/src/domains/meta.ts | 9 +++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index fa463bfd46..c88089b570 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -167,12 +167,14 @@ export interface MetaItemReadGateSources extends MetaReadGateAudienceSources, Me readonly navPruneLogged: Set; /** * [#7557, #24210] Does the by-name read withhold this item because its - * owning package is disabled? The owner is the one the plain read's kept - * protection envelope names (`packageId`, the served item's `_packageId`, - * read under the same `?package=` scope), asked of the engine registry's - * `isPackageDisabled`: the field and predicate the protocol's list and - * by-name reads ask. The layered answer cannot say it itself: a - * runtime-saved item's layers carry no owner. + * owning package is disabled? Withheld means the plain read, under the + * same `?package=` scope, serves no `item` while its kept protection + * envelope names an owner (`packageId`, the served item's `_packageId`) + * the engine registry's `isPackageDisabled` answers `true` for: the field + * and predicate the protocol's list and by-name reads ask, and the + * protocol's own decision of which types it hides (`package` never is). + * The layered answer cannot say it itself: a runtime-saved item's layers + * carry no owner. * * Read by the layered chain ({@link createMetaLayeredAnswer}) only. * Absent, or a registry that cannot answer, is "not withheld": the answer @@ -3759,15 +3761,16 @@ export type MetaLayeredAnswer = * that difference told a member which unpublished apps exist. The * protocol's `effective` is never null while another layer is present, so * this is the name resolving to nothing at all, for every caller. - * 1b. [#7557, #24210] A disabled package's item — every type but `object` - * and `package` — is that same `absent`: the list read omits it and the - * by-name read answers it as a name with nothing behind it, so both - * spellings on both transports take the missing name's exit. Asked of - * {@link MetaItemReadGateSources.isItemOfDisabledPackage}, BEFORE the + * 1b. [#7557, #24210] A disabled package's item — every type the by-name + * read withholds for it — is that same `absent`: the list read omits it + * and the by-name read answers it as a name with nothing behind it, so + * both spellings on both transports take the missing name's exit. Asked + * of {@link MetaItemReadGateSources.isItemOfDisabledPackage}, BEFORE the * gate, so an author who may write the item is not served it whole * either. An object is the protocol's own `OBJECT_PACKAGE_DISABLED` - * refusal, thrown by the read before this chain; `package` is never - * hidden. The protocol's layered read keeps serving every layer, because + * refusal, thrown by the read before this chain; which other types are + * hidden is the plain read's decision, never restated here. The + * protocol's layered read keeps serving every layer, because * its resolution readers (the security catalog echo, the * packaged-permission-set lock gate) need them: the door answers here. * 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first @@ -3806,7 +3809,7 @@ export function createMetaLayeredAnswer( // 1b. [#7557, #24210] A disabled package's item is that same absence, // for every caller: before the gate, so no author is served it. - if (metaType !== 'object' && metaType !== 'package' + if (metaType !== 'object' && await sources.isItemOfDisabledPackage?.({ type: metaType, name, ...(packageId ? { packageId } : {}) }) === true) { return { kind: 'refuse', refusal: { reason: 'absent' } }; } diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 90262a50d0..669f0b8a15 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3507,8 +3507,9 @@ export class RestServer { * {@link metaReadAudienceSources}). * * [#7557, #24210] `isItemOfDisabledPackage`, the layered chain's owner - * step: the plain read's kept envelope `packageId`, asked of the engine - * registry's `isPackageDisabled`, as `/published` asks it. The engine is + * step: the plain read serves no `item` and its kept envelope `packageId` + * is one the engine registry's `isPackageDisabled` answers for, the owner + * `/published` asks about. The engine is * reached through {@link wiredEngineOrLoud}: none wired withholds nothing, * as before; one that cannot be resolved is the loud outage, never "not * withheld". @@ -3538,8 +3539,8 @@ export class RestServer { ...(packageId ? { packageId } : {}), ...(environmentId ? { environmentId } : {}), }; - const envelope = await p.getMetaItem(itemRequest) as { packageId?: unknown } | undefined; - return registry.isPackageDisabled(envelope?.packageId) === true; + const envelope = await p.getMetaItem(itemRequest) as { item?: unknown; packageId?: unknown } | undefined; + return envelope?.item == null && registry.isPackageDisabled(envelope?.packageId) === true; }, }; } diff --git a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts index ad9f4de2f6..19037ffca8 100644 --- a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts +++ b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts @@ -306,6 +306,16 @@ describe('#24210 — the layered view of a disabled package\'s non-object item, expect(JSON.stringify((await layers(stack, 'twin_page')).response)).toBe(JSON.stringify(missing.response)); }); + it('the plain read decides which types are hidden: an item it still serves keeps its layered view', async () => { + // As the protocol serves a `package` item whatever its owner: the + // envelope names the disabled package, and the item is there. + const stack = boot(); + stack.getMetaItem.mockImplementation(async ({ type, name }: any) => + ({ type, name, item: { name, label: 'still served' }, lock: 'none', editable: true, packageId: DISABLED_PKG })); + expect((await layers(stack, 'demo_page')).response?.body?.data?.effective?.name).toBe('demo_page'); + expect((await flag(stack, 'demo_page')).response?.body?.data?.effective?.name).toBe('demo_page'); + }); + it('an object keeps the layered read\'s own 404 OBJECT_PACKAGE_DISABLED, on both spellings', async () => { const stack = boot(); expectObjectRefusal(await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}/layers`, {}, {}, SESSION())); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index b0980637ed..7406fd2c61 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -500,8 +500,9 @@ const NAV_PRUNE_LOGGED = new Set(); * the context — the same object serves every other consumer of this request. * * [#7557, #24210] `isItemOfDisabledPackage`, the layered chain's owner step: - * the plain read's kept envelope `packageId`, asked of the engine registry - * ({@link isServiceItemOfDisabledPackage}), as `/published` asks it. A host + * the plain read serves no `item` and its kept envelope `packageId` is one the + * engine registry disables ({@link isServiceItemOfDisabledPackage}), the owner + * `/published` asks about. A host * with no registry that can answer withholds nothing, as before; a read that * rejects is that fault, never "not withheld". */ @@ -530,8 +531,8 @@ function metaItemReadGateSources( if (typeof protocol?.getMetaItem !== 'function') return false; const registry = (await deps.getObjectQL(context))?.registry; if (typeof (registry as { isPackageDisabled?: unknown } | undefined)?.isPackageDisabled !== 'function') return false; - const owner = (await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) }))?.packageId; - return isServiceItemOfDisabledPackage(registry, { _packageId: owner }); + const envelope = await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) }); + return envelope?.item == null && isServiceItemOfDisabledPackage(registry, { _packageId: envelope?.packageId }); }, }; } From 9d275b85e3c5f624998f6047adc7c5b196c63730 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:14:22 +0000 Subject: [PATCH 5/5] test(runtime): type the layered-door double's plain read as the override it takes Claude-Session: https://claude.ai/code/session_01JeRdtdgEYYdtYB4isWp3bh Co-authored-by: Claude --- .../runtime/src/domains/meta-disabled-package-doors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts index 19037ffca8..8a9a70b0cc 100644 --- a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts +++ b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts @@ -79,7 +79,7 @@ const SERVICE_ITEMS: Record = { function boot(opts: { getMetaTypes?: boolean } = {}) { const disabled = { on: true }; - const getMetaItem = vi.fn(async ({ type, name, packageId }: any) => { + const getMetaItem = vi.fn(async ({ type, name, packageId }: any): Promise => { if (type === 'object' && name === DISABLED) throw refusal(name); if (type === 'object' && name === HEALTHY) return { type, name, item: schema(name) }; // The protocol answers the disabled package's page as absent (#24210),