Skip to content

Commit eee2f0d

Browse files
fix(runtime)!: REST's /actions door resolves a standalone action through the protocol's by-name read, so a disabled package's object-less action answers the "no declaration" 404 instead of running (#25318)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuXquM9v2r8ymXj6TzynfjMF)_ Fixes #25308 Clause-②: no (narrowing) Metadata-protocol: none Ruling-ref: 6353801421 REST's `POST /api/v1/actions/global/NAME` (and its empty-segment spelling `/actions//NAME`) no longer runs an object-less action whose owning package is disabled. `resolveRouteActionDeclaration` now reads a standalone action through the protocol's by-name `action` read, in `readExposureDeclaration`'s shape (`getMetaItemForExecution`, else `getMetaItem`; `{ type: 'action', name }`; the item under its own name or nothing; `ownsRoute` kept). That read withholds a disabled package's item, as `GET /api/v1/meta/action/NAME` does, so such an action resolves to no declaration and the door answers its existing ADR-0110 D3 "no declaration" `404`: the same status, code and message, but for the name, that a name nothing declares gets. The registry and metadata-service copies are read only on a host whose protocol has no by-name read. The answer is the one ruling 6353801421 on the card chose (letter B). ## The defect, measured on base `258f4e6aa` #25296 (PR #25305) refuses an action on an object of a disabled package `404 OBJECT_PACKAGE_DISABLED`, and skips object-less keys by design: they name no object to ask about. The declaration resolution then read the engine registry's `action` items (else the metadata service's rows), where a disabled package's items stay, so an object-less action resolved and dispatched. It resolves on an object route too, because `ownsRoute` accepts an object-less owner on any route, and so does a package's top-level action bound to another, enabled package's object (the registry's `boundCodeActions` leaves it off that object, so the embedded read misses and the standalone read found it). Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin` over a better-sqlite3 `SqlDriver`), in the two shapes #25296's pin file already boots (`env_local` with `createStandaloneStack`'s options, and no `environmentId`), driven through the runtime dispatcher's `dispatch()`. The fixture package gains a top-level action with no `objectName` (`type: 'script'`, its handler registered under `global`) and a top-level action bound to a second, enabled package's object; the environment authors one object-less action through `protocol.saveMetaItem`. | request, as a signed-in caller, the package disabled | base, both shapes | this PR, both shapes | | :-- | :-- | :-- | | `GET /meta/action/NAME`'s read (`protocol.getMetaItem`, unchanged) | `item` withheld | same | | `POST /actions/global/NAME` | **`200 { pong: 'package' }`, the handler ran** | the "no declaration" `404` | | `POST /actions//NAME` | **`200`, the handler ran** | the "no declaration" `404` | | `POST /actions/ENABLED_OBJECT/NAME` (the object-less action on an enabled object's route) | **`200`, the handler ran** | the "no declaration" `404` | | `POST /actions/ENABLED_OBJECT/BOUND` (the package's action bound to the enabled package's object) | **`200`, the handler ran** | the "no declaration" `404` | | the environment-authored object-less action, both spellings | `200` | `200` | | `POST /actions/global/NAME`, anonymous | `401` | `401` | | the package's action, both spellings, after `registry.enablePackage` | `200` | `200` | ## The ruling's precondition (item 7), measured before the change With every package ENABLED, on both shapes, both protocol faces (`getMetaItemForExecution` and `getMetaItem`) served under its own name every standalone action the old sources resolved, and REST answered each `200`: a manifest-registered object-less action and a manifest-registered bound action (`_packageId` in the registry), an environment-authored object-less action (no `_packageId`), and a compiled artifact's object-less actions, the project's own (`AppPlugin` over `loadArtifactBundle`, no `_packageId`) and its `packages[]` entry's. With those packages disabled, both faces withheld the packaged ones (`item` absent, envelope `packageId` the disabled package) and still served the authored and the project's own. Scratch probe, not committed; no enabled, declared action was missing from the protocol read, so no fork was raised. ## What changed - **`packages/runtime/src/action-execution.ts`.** `resolveRouteActionDeclaration`: after the object's embedded actions (unchanged), a standalone declaration comes from the protocol's by-name `action` read when the `protocol` slot has one. A read that throws is the metadata plane failing to answer: `degraded`, so the door's existing `503`, ⛔ never a fall-back to a copy, which would run a disabled package's action during the outage. The registry and metadata-service sources stay, below, for a host whose protocol has no by-name read. No runtime call of `isPackageDisabled`, no `_packageId` read, no copy of the protocol's predicate. Signature unchanged; nothing newly published. - **`.changeset/25308-rest-global-actions-disabled-package.md`.** A `patch` for `@objectstack/runtime`, with the BREAKING banner and the ADR-0087 disposition `not-required (no-migration-prescription)`; both declaration lines are in the body. `domains/actions.ts` is unchanged: its "no declaration" `404` answers the absent declaration as it answers any other. ## Contract Every input whose answer changes, old to new, where the `protocol` slot has a by-name read and the action the route names is not embedded in the route's object: 1. **`POST /actions/global/NAME` or `/actions//NAME`, NAME a disabled package's object-less script action.** Old: `200`, its handler's result (the body ran). New: the "no declaration" `404`; the handler is not called. 2. **`POST /actions/OBJECT/NAME`, OBJECT an enabled object, NAME a disabled package's object-less action.** Old: `200`, the handler ran. New: the "no declaration" `404`. 3. **`POST /actions/OBJECT/NAME`, NAME a disabled package's top-level action bound to OBJECT of an enabled package.** Old: dispatched. New: the "no declaration" `404`. 4. **Any of 1 to 3 gated by `requiredPermissions` the caller lacks, switched off in the activation ledger, a declarative update or a `flow` action.** Old: `403`, `409 ACTION_DISABLED`, the record load's answer, or a dispatch. New: the "no declaration" `404`. Not pinned separately: the resolution answers before every gate that reads the declaration, by construction. 5. **The protocol's by-name read throws.** Old: the registry's copy (else the metadata service's) answered, and the action ran. New: the door's `503` ("the metadata plane is unavailable … Refusing rather than running it ungated"). Not pinned: no rig here makes that read throw. 6. **The activation door, `POST /actions/_activation/OBJECT/NAME`, on a disabled package's standalone action.** It resolves through the same function. Old: a ledger row was written. New: that door's own "no declaration" `404` ("there is nothing to switch"). Derived from the shared resolution, not pinned. Re-enable the package to switch it. 7. **A standalone action whose environment row overrides a code one.** The protocol's layered read serves the merged item (environment row over code), where the registry copy served the code item. Derived, not pinned; the probe above found no enabled action where the two disagree on presence. Unchanged: the anonymous baseline (`401`) and the `405` stay first; an action on an object of a disabled package still answers #25296's `404 OBJECT_PACKAGE_DISABLED` first; an object's embedded actions resolve as before; an enabled package's object-less actions and an environment-authored one run as before (pinned on both shapes and spellings); re-enabling the package restores its actions at once, because nothing records the answer (pinned); a host whose protocol has no by-name read. The cost is one by-name protocol read per invocation of a standalone action, the read `/meta/action/NAME` makes. ## Pins **`packages/runtime/src/domains/actions-disabled-package.test.ts`, extended from 24 to 50 cases on real kernels.** The fixture gains the object-less action and its handler under `global`, an environment-authored object-less action, an optional enabled side package, and (with it) the package's action bound to the side package's object. Per shape: - **ENABLED control.** The package's and the authored object-less actions answer `200` with their handlers' results on both spellings. An object-less name nothing declares answers the "no declaration" `404`. - **DISABLED package.** Precondition: the protocol's by-name read withholds the package's action and serves the authored one. On both spellings, the package's action answers exactly what `qa_nowhere` answers (status, code and message with the name taken out). The handler is never called, and `getData`, `updateData` and `executeAction` are never reached. The same holds on the enabled side package's object route, and for the package's action bound to that object. The authored action still answers `200` on both spellings. The anonymous caller still answers `401`. After `enablePackage`, the package's action runs again on both spellings. **Red before, green after.** The directions were predicted before each run, under the verify lock. The test imports the runtime's source, so no `dist/` is involved. | leg | mutation | result | which failed | | :-- | :-- | :-- | :-- | | main | base's `action-execution.ts` and `domains/actions.ts` (`git restore --source=258f4e6a`, worktree only), restored by `git checkout HEAD`, `git diff HEAD` empty | 8 failed, 42 passed | the four DISABLED leak cases (two spellings, the enabled object's route, the cross-package bound action) on both shapes, each `expected 200 to be 404` | | fix | the protocol read skipped (`if (byName) {` to `if (byName && false) {` through `scripts/ablation-replace.mjs`, blob `e4eebd83e059` to `271f6b423b30`; restored blob equals HEAD, `git diff HEAD` empty) | 8 failed, 42 passed | the same eight | | none | none | 50 passed | none | ## Local verification On `836c23e3ee`, over `origin/main` `258f4e6aa`. Every heavy run went through the verify lock; each line cites its `VERDICT`. - `pnpm exec turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/driver-turso --concurrency=3`: `VERDICT command-exit 0` (the runtime's closure; `driver-turso` because the runtime's test typecheck reads it). - The pin file (`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/actions-disabled-package.test.ts`): `VERDICT command-exit 0`, 50 passed. - `@objectstack/runtime` unit suite (`pnpm --filter @objectstack/runtime test`): `VERDICT command-exit 0`; Test Files 437 passed (437); Tests 7019 passed, 19 skipped (7038). The existing action-door suites (`action-activation-*`, `actions-hook-refusal-sentence.ledger`, `anonymous-gate-actions-automation`, `tenancy-posture-outage-gates`, `scoped-service-miss-attribution`, `actions-every-verb.hono.integration`, `action-governance-scope-divergence`, `meta-overlay-read-your-writes`) are in that count, unchanged. - `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit 0` (`tsc --noEmit`, and `check:test-typecheck` OK). - `eslint --no-inline-config` on the changed source and test files: `VERDICT command-exit 0`. - `node scripts/pm/dispatch-gates.mjs --commands`: 68 families derived, each run, every one `exit 0` (`check:type-check-debt` under the lock). - `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)`. - The full dogfood battery is owed once on the final head (the `!` and the `(narrowing)` arm); its counts follow as a PR comment. ## Acceptance notes - **The coded answer.** The card's Shape asked for "the same coded answer #25296 chooses, reusing the same package-disabled read". For an `action` item the two cannot both hold. #25296's read is the by-name OBJECT read, which throws `OBJECT_PACKAGE_DISABLED`. The by-name ACTION read throws nothing: it withholds the item. The choice went to the coordinator as `needs_decision`, and ruling 6353801421 chose the read's own answer (absent: the D3 `404`, no hint, no package code, no new code). This PR implements that ruling as written. - **An interim implementation was replaced, not shipped.** Before the ruling arrived, this branch asked `registry.isPackageDisabled(action._packageId)` at the door (commit `57b21c61`). The ruling forbids that read (item 2), and `e30b5e80` reverts it. The squash carries only the protocol read. - **Branch name.** This PR's branch is `claude/project-thread-vlqmr4`, the name the session was given, not `claude/issue-25308-…`; the Duplicate Fix Guard may say so. The claim (comment 6353642578) names it. --- _Generated by [Claude Code](https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013txgcKJ9EWEZJhQ41e7kZg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b047b4 commit eee2f0d

3 files changed

Lines changed: 239 additions & 7 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- 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). -->
11+
12+
**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.
13+
14+
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.
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
- **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.
20+
- **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.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3147,6 +3147,11 @@ export function registeredActionHandlerProbe(_deps: ActionExecutionDeps,
31473147
* 3. the metadata service's standalone `action` rows — the env-scoped
31483148
* kernels where the registry carries no copy.
31493149
*
3150+
* [#25308] On a host whose `protocol` slot has a by-name read, sources 2 and 3
3151+
* are that read instead: the protocol's merged `action` item, which withholds
3152+
* a disabled package's. The two copies are read only where no such read
3153+
* exists (structural absence, answered as before).
3154+
*
31503155
* Sources 2 and 3 are what the route was missing: a Studio-authored or
31513156
* standalone `defineAction` declaration was invisible here, so its declared
31523157
* `type` (and its `requiredPermissions`) were never read on the REST path
@@ -3179,6 +3184,33 @@ export async function resolveRouteActionDeclaration(deps: ActionExecutionDeps,
31793184
return owner === objectName || isObjectLessActionKey(owner);
31803185
};
31813186

3187+
// [#25308] The standalone declaration is the protocol's by-name action
3188+
// read, in {@link readExposureDeclaration}'s shape: the execution face
3189+
// where the protocol has one, the item under its own name or nothing. That
3190+
// read withholds a disabled package's item (it answers it as absent, as
3191+
// `GET /api/v1/meta/action/NAME` does), so such an action resolves to no
3192+
// declaration and the caller's "no declaration" answer is the one a name
3193+
// nothing declares gets. ⛔ The registry and metadata-service copies below
3194+
// are not consulted when the read exists: both keep a disabled package's
3195+
// item, and the package question is the protocol's alone. A read that
3196+
// throws is the metadata plane failing to answer (ADR-0110 D3), never a
3197+
// cue to fall back to a copy that would run that item during the outage.
3198+
const protocol = await deps.resolveService(requestContext, 'protocol', envId) as ExposureDeclarationRead | undefined;
3199+
const byName = typeof protocol?.getMetaItemForExecution === 'function'
3200+
? protocol.getMetaItemForExecution
3201+
: typeof protocol?.getMetaItem === 'function' ? protocol.getMetaItem : undefined;
3202+
if (byName) {
3203+
let envelope: unknown;
3204+
try {
3205+
envelope = await byName.call(protocol, { type: 'action', name: actionName });
3206+
} catch (err: any) {
3207+
return { action: undefined, obj, degraded: true, reason: err?.message ?? String(err) };
3208+
}
3209+
const item: any = (envelope as { item?: unknown } | null | undefined)?.item;
3210+
const declared = item !== null && typeof item === 'object' && item.name === actionName && ownsRoute(item);
3211+
return { action: declared ? item : undefined, obj };
3212+
}
3213+
31823214
try {
31833215
const fromRegistry: any = ql?.registry?.getItem?.('action', actionName);
31843216
if (fromRegistry && ownsRoute(fromRegistry)) return { action: fromRegistry, obj };

0 commit comments

Comments
 (0)