Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/25308-rest-global-actions-disabled-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@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

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is where the `/actions` door reads a standalone action's declaration: through the protocol's by-name `action` read, which already withholds a disabled package's item, so such an action resolves to no declaration. No function is added or published; `resolveRouteActionDeclaration` keeps its signature. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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 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, 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.
32 changes: 32 additions & 0 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading