From 1540b2d8890100fbafc4a75f2c186dce22621edd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:37:03 +0000 Subject: [PATCH 1/7] test(runtime): pin a disabled package's object-less action on REST /actions/global/NAME Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- .../domains/actions-disabled-package.test.ts | 178 +++++++++++++++++- 1 file changed, 172 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/domains/actions-disabled-package.test.ts b/packages/runtime/src/domains/actions-disabled-package.test.ts index eae8235f3a..cd18c776fc 100644 --- a/packages/runtime/src/domains/actions-disabled-package.test.ts +++ b/packages/runtime/src/domains/actions-disabled-package.test.ts @@ -31,6 +31,23 @@ * * The negative control (red with the fix ablated) is a one-time ablation, * cited in the PR, not a permanent case. + * + * ## [#25308] The package's OBJECT-LESS action + * + * An action that names no object has no object to refuse, so the gate above + * never asks about it: the package's top-level action with no `objectName` + * kept running on `POST /actions/global/NAME` and `POST /actions//NAME` after + * the disable (measured on the base tree `258f4e6aa`, both shapes: `200` with + * its handler's result), while the protocol's by-name read withheld it + * (`GET /meta/action/NAME` answers `404`) and the MCP `run_action` tool + * answered "not found". The owning package comes from the action item. A + * withheld declaration is no declaration: the door answers the "no + * declaration" `404` a name nothing declares gets, byte for byte but the name, + * so the answer discloses nothing the by-name read withholds. The pins hold it + * on both spellings and shapes, on an enabled object's route too (a route the + * resolution lets an object-less action answer on), against an + * environment-authored object-less action that keeps running through the + * disable, and through a re-enable. */ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -39,7 +56,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; import { MetadataPlugin } from '@objectstack/metadata'; -import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { GLOBAL_ACTION_OBJECT_KEY, ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -81,25 +98,43 @@ function actionPackageOf(prefix: string) { }], actions: [ { name: 'bound_done', label: 'Bound done', type: 'script', operation: 'update', patch: { name: 'done' }, objectName: object }, + { name: `${prefix}_gping`, label: 'Global ping', type: 'script', target: `${prefix}_ghandler` }, ], }), + /** [#25308] The package's top-level action that names no object, and its handler. */ + globalAction: `${prefix}_gping`, + globalHandler: `${prefix}_ghandler`, }; } +/** [#25308] An object-less action the environment authored through the metadata door, and its handler. */ +const AUTHORED = { name: 'qa_authored_gping', handler: 'qa_authored_ghandler' } as const; + interface Booted { kernel: ObjectKernel; engine: ObjectQL; root: string; /** The `qa_ping` handler's invocations. */ pings: ReturnType; + /** [#25308] The package's object-less action's handler's invocations. */ + globalPings: ReturnType; + /** [#25308] The environment-authored object-less action's handler's invocations. */ + authoredPings: ReturnType; } /** * One kernel in the shape under test (the `environmentId` one with its options * verbatim from `createStandaloneStack`), with `pkg` registered through the * `manifest` service during the boot and `qa_ping`'s handler registered after it. + * `others` are registered beside it and stay enabled. [#25308] The package's + * object-less handler is registered under `global`, and the environment + * authors {@link AUTHORED} through the protocol's save door. */ -async function bootKernel(environmentId: string | undefined, pkg: ReturnType): Promise { +async function bootKernel( + environmentId: string | undefined, + pkg: ReturnType, + others: ReadonlyArray> = [], +): Promise { const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); const root = mkdtempSync(join(tmpdir(), 'os-actions-disabled-package-')); const engine = new ObjectQL(); @@ -127,7 +162,7 @@ async function bootKernel(environmentId: string | undefined, pkg: ReturnType { - await ctx.getService('manifest').register(pkg.artifact()); + for (const each of [pkg, ...others]) await ctx.getService('manifest').register(each.artifact()); }, }; await kernel.use(host); @@ -135,7 +170,16 @@ async function bootKernel(environmentId: string | undefined, pkg: ReturnType ({ pong: true })); engine.registerAction(pkg.object, 'qa_ping', pings); - return { kernel, engine, root, pings }; + const globalPings = vi.fn(async () => ({ pong: 'package' })); + engine.registerAction(GLOBAL_ACTION_OBJECT_KEY, pkg.globalHandler, globalPings); + const authoredPings = vi.fn(async () => ({ pong: 'authored' })); + engine.registerAction(GLOBAL_ACTION_OBJECT_KEY, AUTHORED.handler, authoredPings); + await (kernel.getService('protocol') as any).saveMetaItem({ + type: 'action', + name: AUTHORED.name, + item: { name: AUTHORED.name, label: 'Authored global ping', type: 'script', target: AUTHORED.handler }, + }); + return { kernel, engine, root, pings, globalPings, authoredPings }; } async function shutdown(booted: Booted | undefined): Promise { @@ -147,8 +191,17 @@ async function shutdown(booted: Booted | undefined): Promise { /** The refusal's `code` in the dispatcher's envelope: nested under `error`, or beside a string `error`. */ const codeOf = (body: any): unknown => (body?.error && typeof body.error === 'object' ? body.error.code : body?.code); +/** The refusal's message in the dispatcher's envelope, as {@link codeOf} reads its code. */ +const messageOf = (body: any): unknown => (body?.error && typeof body.error === 'object' ? body.error.message : body?.error ?? body?.message); + /** What the runtime dispatcher answers `caller` on `method path`, through `dispatch()` and the host's exit for a throw. */ async function dispatchAs(kernel: ObjectKernel, method: string, path: string, body: Record, caller: Record = CALLER) { + const { status, code, data } = await dispatchWithMessage(kernel, method, path, body, caller); + return { status, code, data }; +} + +/** {@link dispatchAs}, with the answer's message beside it. */ +async function dispatchWithMessage(kernel: ObjectKernel, method: string, path: string, body: Record, caller: Record = CALLER) { const dispatcher = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); (dispatcher as any).timedResolveExecutionContext = async () => ({ ...caller }); const raw = new Request(`http://localhost/api/v1${path}`, { @@ -158,11 +211,12 @@ async function dispatchAs(kernel: ObjectKernel, method: string, path: string, bo }); try { const out: any = await dispatcher.dispatch(method, path, body, {}, { request: raw } as any, '/api/v1'); - return { status: out.response?.status, code: codeOf(out.response?.body), data: out.response?.body?.data }; + const answer = out.response?.body; + return { status: out.response?.status, code: codeOf(answer), data: answer?.data, message: messageOf(answer) }; } catch (e) { // A throw out of `dispatch()` is rendered by the host's exit, through this one rule. const thrown = resolveThrownHttpError(e); - return { status: thrown.status, code: thrown.code, data: undefined }; + return { status: thrown.status, code: thrown.code, data: undefined, message: (e as Error)?.message }; } } @@ -185,6 +239,12 @@ const INVOCATIONS: ReadonlyArray<{ label: string; path: (object: string) => stri { label: 'a name the package does not declare', path: (o) => `/actions/${o}/qa_nowhere`, body: {} }, ]; +/** [#25308] The two spellings of an object-less invocation's object segment. */ +const GLOBAL_SPELLINGS: ReadonlyArray<{ label: string; path: (action: string) => string }> = [ + { label: '`global`', path: (a) => `/actions/${GLOBAL_ACTION_OBJECT_KEY}/${a}` }, + { label: 'the empty segment', path: (a) => `/actions//${a}` }, +]; + const SHAPES = [ { shape: 'environmentId env_local (createStandaloneStack)', environmentId: 'env_local' }, { shape: 'no environmentId', environmentId: undefined }, @@ -275,3 +335,109 @@ for (const { shape, environmentId } of SHAPES) { }); }); } + +/** [#25308] The "no declaration" answer with the action's name taken out, so two names' answers compare. */ +function undeclaredShape(answer: { status: unknown; code: unknown; message: unknown }, name: string) { + return { status: answer.status, code: answer.code, message: String(answer.message).split(name).join('NAME') }; +} + +for (const { shape, environmentId } of SHAPES) { + describe(`[#25308] REST /actions and an ENABLED package's object-less action — ${shape} (the control)`, () => { + const pkg = actionPackageOf('qa_gon'); + let booted: Booted | undefined; + + beforeAll(async () => { + booted = await bootKernel(environmentId, pkg); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + }, 60_000); + + for (const { label, path } of GLOBAL_SPELLINGS) { + it(`runs the package's object-less action and the environment-authored one through their handlers, spelled ${label}`, async () => { + const before = [booted!.globalPings.mock.calls.length, booted!.authoredPings.mock.calls.length]; + expect(await dispatchAs(booted!.kernel, 'POST', path(pkg.globalAction), {})) + .toEqual({ status: 200, code: undefined, data: { pong: 'package' } }); + expect(await dispatchAs(booted!.kernel, 'POST', path(AUTHORED.name), {})) + .toEqual({ status: 200, code: undefined, data: { pong: 'authored' } }); + expect([booted!.globalPings.mock.calls.length, booted!.authoredPings.mock.calls.length]) + .toEqual([before[0] + 1, before[1] + 1]); + }); + + it(`answers an object-less name nothing declares the "no declaration" 404, spelled ${label}`, async () => { + const nowhere = await dispatchWithMessage(booted!.kernel, 'POST', path('qa_nowhere'), {}); + expect(nowhere.status).toBe(404); + expect(String(nowhere.message)).toMatch(/^Action 'qa_nowhere' on '[^']*' has no declaration/); + }); + } + }); + + describe(`[#25308] REST /actions and a DISABLED package's object-less action — ${shape}: answered as a name nothing declares, the environment-authored one still runs`, () => { + const pkg = actionPackageOf('qa_goff'); + const side = actionPackageOf('qa_gside'); + let booted: Booted | undefined; + + beforeAll(async () => { + booted = await bootKernel(environmentId, pkg, [side]); + expect((booted.engine as any).registry.disablePackage(pkg.id), 'precondition: disabled').toBeTruthy(); + }, 60_000); + afterAll(async () => { + vi.restoreAllMocks(); + await shutdown(booted); + }, 60_000); + + it('precondition: the protocol\'s by-name read withholds the action, and serves the environment-authored one', async () => { + const protocol: any = booted!.kernel.getService('protocol'); + expect((await protocol.getMetaItem({ type: 'action', name: pkg.globalAction }))?.item).toBeUndefined(); + expect((await protocol.getMetaItem({ type: 'action', name: AUTHORED.name }))?.item?.name).toBe(AUTHORED.name); + }); + + for (const { label, path } of GLOBAL_SPELLINGS) { + it(`answers the package's object-less action as a name nothing declares, spelled ${label}, reaching no handler`, async () => { + const spies = dispatchSpies(booted!.kernel, booted!.engine); + const before = booted!.globalPings.mock.calls.length; + try { + const answer = await dispatchWithMessage(booted!.kernel, 'POST', path(pkg.globalAction), {}); + const nowhere = await dispatchWithMessage(booted!.kernel, 'POST', path('qa_nowhere'), {}); + expect(answer.status).toBe(404); + expect(undeclaredShape(answer, pkg.globalAction)).toEqual(undeclaredShape(nowhere, 'qa_nowhere')); + expect(booted!.globalPings.mock.calls.length, 'the handler never ran').toBe(before); + for (const spy of spies) expect(spy, 'nothing dispatched').not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + it(`runs the environment-authored object-less action through the disable, spelled ${label}`, async () => { + const before = booted!.authoredPings.mock.calls.length; + expect(await dispatchAs(booted!.kernel, 'POST', path(AUTHORED.name), {})) + .toEqual({ status: 200, code: undefined, data: { pong: 'authored' } }); + expect(booted!.authoredPings.mock.calls.length).toBe(before + 1); + }); + } + + it('answers the package\'s object-less action on an ENABLED object\'s route as a name nothing declares there', async () => { + const before = booted!.globalPings.mock.calls.length; + const answer = await dispatchWithMessage(booted!.kernel, 'POST', `/actions/${side.object}/${pkg.globalAction}`, {}); + const nowhere = await dispatchWithMessage(booted!.kernel, 'POST', `/actions/${side.object}/qa_nowhere`, {}); + expect(answer.status).toBe(404); + expect(undeclaredShape(answer, pkg.globalAction)).toEqual(undeclaredShape(nowhere, 'qa_nowhere')); + expect(booted!.globalPings.mock.calls.length, 'the handler never ran').toBe(before); + }); + + it('still answers an anonymous caller the anonymous baseline first', async () => { + const answer = await dispatchAs(booted!.kernel, 'POST', `/actions/${GLOBAL_ACTION_OBJECT_KEY}/${pkg.globalAction}`, {}, { isSystem: false }); + expect(answer.status).toBe(401); + }); + + it('runs the package\'s object-less action again once the package is re-enabled: nothing recorded the answer', async () => { + expect((booted!.engine as any).registry.enablePackage(pkg.id), 'precondition: re-enabled').toBeTruthy(); + const before = booted!.globalPings.mock.calls.length; + for (const { path } of GLOBAL_SPELLINGS) { + expect(await dispatchAs(booted!.kernel, 'POST', path(pkg.globalAction), {})) + .toEqual({ status: 200, code: undefined, data: { pong: 'package' } }); + } + expect(booted!.globalPings.mock.calls.length).toBe(before + GLOBAL_SPELLINGS.length); + }); + }); +} From 57b21c6143a855cbb63c2a65be40befb92949c53 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:39:22 +0000 Subject: [PATCH 2/7] fix(runtime)!: REST's /actions door answers a disabled package's object-less action as undeclared, 404, before any handler runs Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 19 +++++++++++++++++++ packages/runtime/src/domains/actions.ts | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index f2467f5354..4fd410550a 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -409,6 +409,25 @@ export async function refuseDisabledPackageObject(deps: ActionExecutionDeps, } } +/** + * [#25308] Whether a resolved action declaration belongs to a disabled + * package: its own `_packageId`, asked of the engine registry's + * `isPackageDisabled`, the predicate the protocol's by-name and list reads ask + * of every non-object item (and the registry's own `listItems` filters by). + * The owning package comes from the ACTION item, not from an object: an + * object-less action names none, and an object route may resolve one + * ({@link resolveRouteActionDeclaration} accepts an object-less owner on any + * route). The protocol withholds such an item, so its by-name read answers it + * as absent; the `/actions` door answers it as the declaration it does not + * have. A registry that cannot answer withholds nothing, as before; an item + * with no owning package (environment-authored) is never withheld. + */ +export function isActionOfDisabledPackage(ql: any, action: unknown): boolean { + const registry = ql?.registry; + return typeof registry?.isPackageDisabled === 'function' + && registry.isPackageDisabled((action as { _packageId?: unknown } | null | undefined)?._packageId) === true; +} + /** * ── Object-level API exposure gate (ADR-0049, #1889) ───── * Honour the object's `apiEnabled` / `apiMethods` declarations for external diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index f2f7a90154..eb657d0f5f 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -571,6 +571,19 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string actionSchema = declaration?.obj; actionDef = declaration?.action; + // [#25308] A DISABLED PACKAGE'S ACTION IS NOT DECLARED HERE. The gate + // above asks about the object a route names; an object-less action + // (`/actions/global/NAME`, `/actions//NAME`, or an object route the + // resolution lets it answer on) names none, so a disabled package's + // object-less action resolved from the registry, where its items stay, + // and its handler ran, while the protocol's by-name read withheld it + // and the MCP `run_action` tool answered "not found". Its own package + // decides: a withheld declaration is answered as the absent one it is + // everywhere else, by the "no declaration" 404 below, byte for byte the + // answer a name nothing declares gets, so this discloses nothing the + // by-name read withholds. The activation door keeps resolving it. + if (actionDef && actionExec.isActionOfDisabledPackage(ql, actionDef)) actionDef = undefined; + // [ADR-0110 D3] Resolution is a TRICHOTOMY, and only one branch may // dispatch. This block used to be a `try { … } catch { /* no gate to // enforce */ }`, which collapsed three states with opposite meanings From aab7c8e222a1ef419485f2660a81a8f85c583c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:39:39 +0000 Subject: [PATCH 3/7] chore(changeset): REST's /actions door and a disabled package's object-less action Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- ...08-rest-global-actions-disabled-package.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/25308-rest-global-actions-disabled-package.md diff --git a/.changeset/25308-rest-global-actions-disabled-package.md b/.changeset/25308-rest-global-actions-disabled-package.md new file mode 100644 index 0000000000..1dc5645dfd --- /dev/null +++ b/.changeset/25308-rest-global-actions-disabled-package.md @@ -0,0 +1,19 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: REST's `POST /api/v1/actions/global/NAME` answers an object-less action of a disabled package the "no declaration" `404` a name nothing declares gets, before any handler runs + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (the `/actions` door stops running a disabled package's object-less actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write. + +Disabling a package withholds its items: `GET /api/v1/meta/action/NAME` answers `404`, and the MCP `run_action` tool answers "not found". The `/actions` door refuses an action on an object of a disabled package `404 OBJECT_PACKAGE_DISABLED`, but an object-less action names no object to ask about. It resolved the declaration from the engine registry, where a disabled package's items stay, and dispatched. + +- **Before.** On every kernel shape, `POST /api/v1/actions/global/NAME` (and its empty-segment spelling `/actions//NAME`) on a disabled package's top-level action with no `objectName` ran its handler and answered `200` with its result. So did an enabled object's route that resolves the same object-less action. +- **Now.** Each answers the "no declaration" `404`, the same answer, but for the name, that a name nothing declares gets, before any gate, record load or handler. The owning package is read from the action item itself. +- **Unchanged.** An enabled package's object-less actions, and an environment-authored one (no owning package), run as before, through a disable of any package. Re-enabling the package restores its actions at once: nothing records the answer. The activation door (`POST /api/v1/actions/_activation/global/NAME`) still resolves the declaration. +- **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it. From e30b5e8038016b86403a6a292a73ae9eea4dc903 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:47:46 +0000 Subject: [PATCH 4/7] revert: drop the _packageId reading of #25308 (ruling 6353801421 reads through the protocol) Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 19 ------------------- packages/runtime/src/domains/actions.ts | 13 ------------- 2 files changed, 32 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 4fd410550a..f2467f5354 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -409,25 +409,6 @@ export async function refuseDisabledPackageObject(deps: ActionExecutionDeps, } } -/** - * [#25308] Whether a resolved action declaration belongs to a disabled - * package: its own `_packageId`, asked of the engine registry's - * `isPackageDisabled`, the predicate the protocol's by-name and list reads ask - * of every non-object item (and the registry's own `listItems` filters by). - * The owning package comes from the ACTION item, not from an object: an - * object-less action names none, and an object route may resolve one - * ({@link resolveRouteActionDeclaration} accepts an object-less owner on any - * route). The protocol withholds such an item, so its by-name read answers it - * as absent; the `/actions` door answers it as the declaration it does not - * have. A registry that cannot answer withholds nothing, as before; an item - * with no owning package (environment-authored) is never withheld. - */ -export function isActionOfDisabledPackage(ql: any, action: unknown): boolean { - const registry = ql?.registry; - return typeof registry?.isPackageDisabled === 'function' - && registry.isPackageDisabled((action as { _packageId?: unknown } | null | undefined)?._packageId) === true; -} - /** * ── Object-level API exposure gate (ADR-0049, #1889) ───── * Honour the object's `apiEnabled` / `apiMethods` declarations for external diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index eb657d0f5f..f2f7a90154 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -571,19 +571,6 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string actionSchema = declaration?.obj; actionDef = declaration?.action; - // [#25308] A DISABLED PACKAGE'S ACTION IS NOT DECLARED HERE. The gate - // above asks about the object a route names; an object-less action - // (`/actions/global/NAME`, `/actions//NAME`, or an object route the - // resolution lets it answer on) names none, so a disabled package's - // object-less action resolved from the registry, where its items stay, - // and its handler ran, while the protocol's by-name read withheld it - // and the MCP `run_action` tool answered "not found". Its own package - // decides: a withheld declaration is answered as the absent one it is - // everywhere else, by the "no declaration" 404 below, byte for byte the - // answer a name nothing declares gets, so this discloses nothing the - // by-name read withholds. The activation door keeps resolving it. - if (actionDef && actionExec.isActionOfDisabledPackage(ql, actionDef)) actionDef = undefined; - // [ADR-0110 D3] Resolution is a TRICHOTOMY, and only one branch may // dispatch. This block used to be a `try { … } catch { /* no gate to // enforce */ }`, which collapsed three states with opposite meanings From fcc6306f6969109c2b39e32543d375136df6a3ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:47:47 +0000 Subject: [PATCH 5/7] test(runtime): pin a disabled package's action bound to an enabled package's object Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- .../domains/actions-disabled-package.test.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/runtime/src/domains/actions-disabled-package.test.ts b/packages/runtime/src/domains/actions-disabled-package.test.ts index cd18c776fc..12db448de7 100644 --- a/packages/runtime/src/domains/actions-disabled-package.test.ts +++ b/packages/runtime/src/domains/actions-disabled-package.test.ts @@ -40,14 +40,16 @@ * the disable (measured on the base tree `258f4e6aa`, both shapes: `200` with * its handler's result), while the protocol's by-name read withheld it * (`GET /meta/action/NAME` answers `404`) and the MCP `run_action` tool - * answered "not found". The owning package comes from the action item. A - * withheld declaration is no declaration: the door answers the "no + * answered "not found". The declaration resolution now reads a standalone + * action through that same by-name protocol read (ruling 6353801421 on the + * card), so a withheld item is no declaration: the door answers the "no * declaration" `404` a name nothing declares gets, byte for byte but the name, - * so the answer discloses nothing the by-name read withholds. The pins hold it - * on both spellings and shapes, on an enabled object's route too (a route the - * resolution lets an object-less action answer on), against an - * environment-authored object-less action that keeps running through the - * disable, and through a re-enable. + * and discloses nothing the by-name read withholds. The pins hold it on both + * spellings and shapes, on an enabled object's route too (a route the + * resolution lets an object-less action answer on), for the package's action + * bound to an enabled package's object, against an environment-authored + * object-less action that keeps running through the disable, and through a + * re-enable. */ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -76,7 +78,7 @@ interface ManifestDoor { * script action gated by `requiredPermissions`, and a top-level action bound * to the object. */ -function actionPackageOf(prefix: string) { +function actionPackageOf(prefix: string, bindTo?: string) { const object = `${prefix}_obj`; return { id: `com.qa.${prefix}`, @@ -99,11 +101,14 @@ function actionPackageOf(prefix: string) { actions: [ { name: 'bound_done', label: 'Bound done', type: 'script', operation: 'update', patch: { name: 'done' }, objectName: object }, { name: `${prefix}_gping`, label: 'Global ping', type: 'script', target: `${prefix}_ghandler` }, + ...(bindTo ? [{ name: `${prefix}_xping`, label: 'Bound elsewhere', type: 'script', target: `${prefix}_ghandler`, objectName: bindTo }] : []), ], }), /** [#25308] The package's top-level action that names no object, and its handler. */ globalAction: `${prefix}_gping`, globalHandler: `${prefix}_ghandler`, + /** [#25308] With `bindTo`: the package's top-level action bound to that (another package's) object. */ + boundElsewhere: `${prefix}_xping`, }; } @@ -373,8 +378,8 @@ for (const { shape, environmentId } of SHAPES) { }); describe(`[#25308] REST /actions and a DISABLED package's object-less action — ${shape}: answered as a name nothing declares, the environment-authored one still runs`, () => { - const pkg = actionPackageOf('qa_goff'); const side = actionPackageOf('qa_gside'); + const pkg = actionPackageOf('qa_goff', side.object); let booted: Booted | undefined; beforeAll(async () => { @@ -425,6 +430,15 @@ for (const { shape, environmentId } of SHAPES) { expect(booted!.globalPings.mock.calls.length, 'the handler never ran').toBe(before); }); + it('answers the package\'s action bound to an ENABLED package\'s object as a name nothing declares there', async () => { + const before = booted!.globalPings.mock.calls.length; + const answer = await dispatchWithMessage(booted!.kernel, 'POST', `/actions/${side.object}/${pkg.boundElsewhere}`, {}); + const nowhere = await dispatchWithMessage(booted!.kernel, 'POST', `/actions/${side.object}/qa_nowhere`, {}); + expect(answer.status).toBe(404); + expect(undeclaredShape(answer, pkg.boundElsewhere)).toEqual(undeclaredShape(nowhere, 'qa_nowhere')); + expect(booted!.globalPings.mock.calls.length, 'the handler never ran').toBe(before); + }); + it('still answers an anonymous caller the anonymous baseline first', async () => { const answer = await dispatchAs(booted!.kernel, 'POST', `/actions/${GLOBAL_ACTION_OBJECT_KEY}/${pkg.globalAction}`, {}, { isSystem: false }); expect(answer.status).toBe(401); From 5e47882628c033daad8deaabbc36eff3fd629b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:47:47 +0000 Subject: [PATCH 6/7] fix(runtime)!: the /actions door resolves a standalone action through the protocol's by-name read, which withholds a disabled package's Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index f2467f5354..e4eebd83e0 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -3147,6 +3147,11 @@ export function registeredActionHandlerProbe(_deps: ActionExecutionDeps, * 3. the metadata service's standalone `action` rows — the env-scoped * kernels where the registry carries no copy. * + * [#25308] On a host whose `protocol` slot has a by-name read, sources 2 and 3 + * are that read instead: the protocol's merged `action` item, which withholds + * a disabled package's. The two copies are read only where no such read + * exists (structural absence, answered as before). + * * Sources 2 and 3 are what the route was missing: a Studio-authored or * standalone `defineAction` declaration was invisible here, so its declared * `type` (and its `requiredPermissions`) were never read on the REST path @@ -3179,6 +3184,33 @@ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps, return owner === objectName || isObjectLessActionKey(owner); }; + // [#25308] The standalone declaration is the protocol's by-name action + // read, in {@link readExposureDeclaration}'s shape: the execution face + // where the protocol has one, the item under its own name or nothing. That + // read withholds a disabled package's item (it answers it as absent, as + // `GET /api/v1/meta/action/NAME` does), so such an action resolves to no + // declaration and the caller's "no declaration" answer is the one a name + // nothing declares gets. ⛔ The registry and metadata-service copies below + // are not consulted when the read exists: both keep a disabled package's + // item, and the package question is the protocol's alone. A read that + // throws is the metadata plane failing to answer (ADR-0110 D3), never a + // cue to fall back to a copy that would run that item during the outage. + const protocol = await deps.resolveService(requestContext, 'protocol', envId) as ExposureDeclarationRead | undefined; + const byName = typeof protocol?.getMetaItemForExecution === 'function' + ? protocol.getMetaItemForExecution + : typeof protocol?.getMetaItem === 'function' ? protocol.getMetaItem : undefined; + if (byName) { + let envelope: unknown; + try { + envelope = await byName.call(protocol, { type: 'action', name: actionName }); + } catch (err: any) { + return { action: undefined, obj, degraded: true, reason: err?.message ?? String(err) }; + } + const item: any = (envelope as { item?: unknown } | null | undefined)?.item; + const declared = item !== null && typeof item === 'object' && item.name === actionName && ownsRoute(item); + return { action: declared ? item : undefined, obj }; + } + try { const fromRegistry: any = ql?.registry?.getItem?.('action', actionName); if (fromRegistry && ownsRoute(fromRegistry)) return { action: fromRegistry, obj }; From 836c23e3eee88ac4b0a1e6dd62f19f21b8dc7cfe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:49:05 +0000 Subject: [PATCH 7/7] chore(changeset): #25308 reads through the protocol's by-name action read Claude-Session: https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg Co-authored-by: Claude --- .../25308-rest-global-actions-disabled-package.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/25308-rest-global-actions-disabled-package.md b/.changeset/25308-rest-global-actions-disabled-package.md index 1dc5645dfd..9fe6c48900 100644 --- a/.changeset/25308-rest-global-actions-disabled-package.md +++ b/.changeset/25308-rest-global-actions-disabled-package.md @@ -7,13 +7,14 @@ fix(runtime)!: REST's `POST /api/v1/actions/global/NAME` answers an object-less Clause-②: no (narrowing) Metadata-protocol: none - + **BREAKING** (the `/actions` door stops running a disabled package's object-less actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write. -Disabling a package withholds its items: `GET /api/v1/meta/action/NAME` answers `404`, and the MCP `run_action` tool answers "not found". The `/actions` door refuses an action on an object of a disabled package `404 OBJECT_PACKAGE_DISABLED`, but an object-less action names no object to ask about. It resolved the declaration from the engine registry, where a disabled package's items stay, and dispatched. +Disabling a package withholds its items: `GET /api/v1/meta/action/NAME` answers `404`, and the MCP `run_action` tool answers "not found". The `/actions` door refuses an action on an object of a disabled package `404 OBJECT_PACKAGE_DISABLED`, but an object-less action names no object to ask about. It resolved a standalone declaration from the engine registry or the metadata service, where a disabled package's items stay, and dispatched. -- **Before.** On every kernel shape, `POST /api/v1/actions/global/NAME` (and its empty-segment spelling `/actions//NAME`) on a disabled package's top-level action with no `objectName` ran its handler and answered `200` with its result. So did an enabled object's route that resolves the same object-less action. -- **Now.** Each answers the "no declaration" `404`, the same answer, but for the name, that a name nothing declares gets, before any gate, record load or handler. The owning package is read from the action item itself. -- **Unchanged.** An enabled package's object-less actions, and an environment-authored one (no owning package), run as before, through a disable of any package. Re-enabling the package restores its actions at once: nothing records the answer. The activation door (`POST /api/v1/actions/_activation/global/NAME`) still resolves the declaration. +- **Before.** On every kernel shape, `POST /api/v1/actions/global/NAME` (and its empty-segment spelling `/actions//NAME`) on a disabled package's top-level action with no `objectName` ran its handler and answered `200` with its result. So did an enabled object's route that resolves the same object-less action, and the package's top-level action bound to another, enabled package's object. +- **Now.** The door reads a standalone action through the protocol's by-name `action` read (`GET /api/v1/meta/action/NAME`'s read, on its execution face), which withholds a disabled package's item. Each request above answers the "no declaration" `404`, the same status, code and message, but for the name, that a name nothing declares gets, before any gate, record load or handler. A by-name read that fails is the metadata plane failing to answer: the door's existing `503`, never a fall-back to a copy. An action on an object of a disabled package still answers `404 OBJECT_PACKAGE_DISABLED` first. +- **Also.** The activation door (`POST /api/v1/actions/_activation/OBJECT/NAME`) resolves through the same function, so a disabled package's standalone action is answered there by that door's own "no declaration" `404` too, where a flip wrote a ledger row before. Re-enable the package to switch it. +- **Unchanged.** An enabled package's object-less actions, and an environment-authored one (no owning package), run as before, through a disable of any package. Re-enabling the package restores its actions at once: nothing records the answer. A host whose protocol has no by-name read resolves from the registry and the metadata service, as before. - **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it.