Repository navigation
Commit eee2f0d
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
- .changeset
- packages/runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3147 | 3147 | | |
3148 | 3148 | | |
3149 | 3149 | | |
| 3150 | + | |
| 3151 | + | |
| 3152 | + | |
| 3153 | + | |
| 3154 | + | |
3150 | 3155 | | |
3151 | 3156 | | |
3152 | 3157 | | |
| |||
3179 | 3184 | | |
3180 | 3185 | | |
3181 | 3186 | | |
| 3187 | + | |
| 3188 | + | |
| 3189 | + | |
| 3190 | + | |
| 3191 | + | |
| 3192 | + | |
| 3193 | + | |
| 3194 | + | |
| 3195 | + | |
| 3196 | + | |
| 3197 | + | |
| 3198 | + | |
| 3199 | + | |
| 3200 | + | |
| 3201 | + | |
| 3202 | + | |
| 3203 | + | |
| 3204 | + | |
| 3205 | + | |
| 3206 | + | |
| 3207 | + | |
| 3208 | + | |
| 3209 | + | |
| 3210 | + | |
| 3211 | + | |
| 3212 | + | |
| 3213 | + | |
3182 | 3214 | | |
3183 | 3215 | | |
3184 | 3216 | | |
| |||
0 commit comments