Skip to content

Commit a1e51d6

Browse files
fix(runtime)!: the /mcp bridge reads objects from the protocol's merged view, so a package's objects are listed, described and gated (#25136)
Fixes #25120 Clause-②: no (narrowing) Metadata-protocol: none The `/mcp` HTTP bridge now reads objects from the protocol's merged view, the read REST serves. `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists to the same caller. The API-exposure gate the data verbs ask refuses a package object declared `apiEnabled: false`, and refuses every object of a disabled package `404 OBJECT_PACKAGE_DISABLED` on every verb, `aggregate_records` included, as REST refuses it. Nothing is copied into the `metadata` service: a second copy would be a second thing to keep in sync. ## This round: the review of record on `d19b65bf1c` The contract-tier review of record (comment 6328012432, VERDICT FAIL) found one widening. The gate read the protocol's `OBJECT_PACKAGE_DISABLED` refusal as "no declaration", on the premise that the data plane then refuses with that code. Five of `callData`'s six arms reach the protocol, whose registry gate refuses it. The `aggregate` arm does not: it asks the engine, and the engine serves a disabled package's objects. So on `d19b65bf1c`, `aggregate_records` served a disabled package's object declared `apiEnabled: false` wherever the `metadata` service held a copy, where the copy had refused it `404 OBJECT_API_DISABLED`. Contract item 6 and the changeset's "Also changed" sentence were false for that verb. Closed here. `callData`'s gate refuses a disabled package's object itself, with the protocol's own error, so every arm answers `404 OBJECT_PACKAGE_DISABLED`. The same rows also close a hole that predates this PR (review boundary flag ③ 5): `aggregate_records` served a disabled package's *exposed* object on every kernel, before this PR and after it. Both are pinned below. REST's answer was measured on the same kernels, through a `RestServer` served over each kernel's protocol. It refuses both objects `404 OBJECT_PACKAGE_DISABLED` on every well-formed `/data` record request. It answers a body that is not a record `400 VALIDATION_FAILED` first, because `enforceApiAccess` reads the refusal as "no declaration" and its request gate runs next. The dispatcher's `/data` door asks this gate early too, as the twin of `enforceApiAccess`. With the refusal thrown at that early ask, the door answered those three malformed bodies `404 OBJECT_PACKAGE_DISABLED`, a new divergence from REST (measured on both kernel shapes, both objects). So the early ask keeps REST's reading, `callData` refuses, and the door now answers every request what REST answers. That is pinned too. ## The defect, measured on base `4e2c42f363` `buildMcpBridge` answered `list_objects` and `describe_object` from the `metadata` service (`listObjects()` / `getObject()`). The exposure gate every runtime data door asks (`refuseUnexposedDataAction` in `packages/runtime/src/action-execution.ts`, called by `callData`) read the same service and treated a missing copy as "no declaration", that is, allowed. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId`, the full boot bridge is off. That covers `createStandaloneStack` (`os dev` as `env_local`, `os serve`, `os start`) and the cloud single-environment runtime. So an object that a package or a plugin registers in the `SchemaRegistry` during the boot gets no copy. Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin` over better-sqlite3 `:memory:`). The `environmentId` shape uses `createStandaloneStack`'s plugin options verbatim. The fixture package has two objects, one declared `apiEnabled: false`, registered through the kernel's `manifest` service: | shape, when the package registered | `metadata.getObject` | REST lists | bridge lists | describe | gated `query` | gated `create` | | :-- | :-- | :-- | :-- | :-- | :-- | :-- | | `env_local`, during the boot | absent | 2 | **0** | **null** | **served** | **reached the driver** | | `env_local`, installed after the boot (`source: 'marketplace'`) | copied | 2 | 2 | served | 404 | 404 | | no `environmentId`, during the boot | copied | 2 | 2 | served | 404 | 404 | | no `environmentId`, installed after the boot | copied | 2 | 2 | served | 404 | 404 | The first row is the card's live reading in miniature: a short list presented as a complete `totalCount`, "not found" for every package object, and `query_records` served. The other three rows are controls: the boot bridge and the late-manifest bridge already copy, so they were right and must stay right. **The gate half is real.** The same object declared `apiEnabled: false` was served by `query_records`, and `create_record` got past the gate to the driver. The runtime dispatcher's `/data` door shares the gate, and on that shape it answered `GET /data/qa_pkg_gated` with 200 and `POST` reached the driver. `RestServer` refused the same object, because its gate already reads the protocol's by-name read. **The multi-tenant (`environmentId` kernel) path.** The merged read is per environment there. The bridge resolves `protocol` through `deps.resolveService(context, 'protocol', envId)`, the same scoped seam `metadata` and the skill read use. Each environment kernel's `getMetaItems` reads its own `ObjectQL` registry (`readFlattenedMetaItems`: "the SchemaRegistry is owned by THIS kernel's ObjectQL instance"). Measured: two `environmentId` kernels behind one host each list and gate their own package and none of the other's. That run is red with the fix ablated, because both lists come back empty. **The stdio half measures the same defect.** That transport is held off by the dispatch, so this is reported and not widened. Same fixture, `env_local`, during the boot, driven through the real `MCPServerPlugin` stdio session: `list_objects` gave `{"objects": [], "totalCount": 0}`, `describe_object` gave `Object "qa_pkg_open" not found`, and `query_records` on the `apiEnabled: false` object was served (`total: 0`). The control without an `environmentId` lists, describes and refuses `OBJECT_API_DISABLED`. ## What changed - **`packages/runtime/src/domains/mcp.ts`.** `list_objects` / `listObjectsDiagnosed` and `describe_object` read `getMetaItems({ type: 'object' })` through the existing `McpMergedMetadataRead` seam. This is the shape commit e783e16 gave the skill read. The unwrap moved into one helper (`readMergedItems`) shared by the skill read and the object reads, and the skill read's behaviour is unchanged. `describe_object` answers from the same listing, so every listed object can be described. The three outcomes are the skill read's. With no merged read on the host, the code reads the `metadata` service as before. When the merged read answers, it is served. When the merged read throws, the error goes to the client and nothing falls back. `listObjectsDiagnosed` keeps asking `listDiagnosed('object')` for the `partial` verdict. - **`packages/runtime/src/action-execution.ts`.** The exposure gate reads the object by name through the protocol, the read `RestServer`'s own gate makes (`enforceApiAccess`). It uses the execution face (`getMetaItemForExecution`) where the protocol has one, and `getMetaItem` otherwise. A host whose protocol has no by-name read keeps reading the `metadata` service. A merged read that throws falls back to the `metadata` service's copy, the source this gate read before: an outage must not widen exposure, and the caller's fail-open `catch` is unchanged. - A refusal `OBJECT_PACKAGE_DISABLED` is a refusal, never a read failure. `readExposureDeclaration` throws it (`isObjectPackageDisabledRefusal`), and `refuseUnexposedDataAction` answers it by call site (`disabledPackage`). - `callData` asks with `'refuse'`. The protocol's own error (`code`, `status` 404, `object`) is thrown past the fail-open `catch`, on every arm, the `aggregate` arm included. - The dispatcher's `/data` door asks early, before its request gate, with the default `'defer'`. That is "no declaration", as `enforceApiAccess` reads it, so a malformed body is answered by the request gate first, as on REST. A well-formed request is refused when it reaches `callData`. - **`packages/runtime/src/domains/data-write-request-gate.test.ts`, a fixture re-judged.** Its dispatcher door used to model the gate's old source (a `metadata` fake answered from the registry). The rig's protocol cannot read without a metadata store, so with this change that door passed only through the outage fallback. The registry-backed by-name stub now lives in `bootStore` (`stubObjectReads`) and serves both doors, and the dispatcher door is given no `metadata` service. A gate that stopped reading the protocol reds 12 of its rows (measured on `d19b65bf1c`). 73 of 73 rows are green with the fix. - **`.changeset/25120-mcp-package-objects.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. Its "Also changed" sentence now states the disabled-package refusal on every verb. ## Contract Every input whose answer changes, old to new: 1. **`list_objects` (`POST /api/v1/mcp`) on a kernel with an `environmentId`, for an object a package or plugin registered during the boot.** Old: omitted, with `totalCount` short and no `partial`. New: listed. The non-`sys_` set equals what `GET /api/v1/meta/object` serves the same caller. 2. **`describe_object` and `validate_expression` on such an object.** Old: `Object "NAME" not found`. New: described (fields, label, `enableFeatures`). 3. **`query_records`, `get_record`, `aggregate_records`, `create_record`, `update_record`, `delete_record` on such an object declared `enable.apiEnabled: false`.** Old: served, and the writes reached the store. New: `404 OBJECT_API_DISABLED`, and the store is never asked. With an `apiMethods` whitelist, a verb it leaves out gets `405 OBJECT_API_METHOD_NOT_ALLOWED` with `allowed`. 4. **The same declaration on every other door that asks this gate** (the runtime dispatcher's `/data` door, the `@objectstack/hono` catch-all; `callData` under business actions and declarative endpoints). Old: served. New: the same refusals as item 3, the codes `RestServer` answers. 5. **A disabled package's objects, where the `metadata` service still holds a copy.** For example, a kernel without an `environmentId`. Old: `list_objects` listed them and `describe_object` described them, while REST listed neither. New: omitted and not found, as REST answers. 6. **Each of the six MCP data verbs on an object of a disabled package, whatever the object declares.** New, on every kernel shape: `404 OBJECT_PACKAGE_DISABLED`, the protocol's own error, refused at the gate with the store never asked. That is the code and status `RestServer` answers for the same object on every `/data` record route. Old: - declared `apiEnabled: false`, with a `metadata` copy (a kernel without an `environmentId`; a marketplace install): `404 OBJECT_API_DISABLED` on all six verbs; - declared `apiEnabled: false`, with no copy (an `environmentId` kernel, registered during the boot): `404 OBJECT_PACKAGE_DISABLED` on the five verbs that reach the protocol, and `aggregate_records` **served**; - exposed, on every shape: `404 OBJECT_PACKAGE_DISABLED` on the five, and `aggregate_records` **served**. This hole predates this PR: the `aggregate` arm never asked the protocol. No refusal's status moves, and `aggregate_records` stops serving. 7. **The dispatcher's `/data` door on an object of a disabled package.** A well-formed request: `404 OBJECT_PACKAGE_DISABLED`, as REST answers. Old: the same for an exposed object; `404 OBJECT_API_DISABLED` for one declared `apiEnabled: false` with a copy. A body that is not a record (`POST /data/:object` or `PATCH /data/:object/:id` with an array, `POST /data/:object/query` with `{ "limit": "ten" }`): `400 VALIDATION_FAILED`, as REST answers. Old: the same for an exposed object; `404 OBJECT_API_DISABLED` for one declared `apiEnabled: false` with a copy. Nothing is served either way. Only which refusal comes first moves, to REST's order. 8. **An object whose served definition differs from the `metadata` service's copy** (for example, a stored environment row layered over it). Old: MCP discovery and the gate read the copy. New: they read the definition REST serves. 9. **`list_objects` / `describe_object` while the metadata store cannot be read.** Old: the `metadata` service's listing. New: the protocol's `503 SERVICE_UNAVAILABLE` error envelope, which is what `GET /api/v1/meta/object` answers and what the skill prompt read already answers. Unchanged: objects that already had a copy are listed and gated as before. That covers every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object. A host with no merged read keeps reading the `metadata` service. The gate's outage branch judges the copy, as before. `packages/mcp` is untouched. ## Pins `packages/runtime/src/domains/mcp-package-object-discovery.test.ts`, 70 cases. The pins read the bridge the real `/mcp` entry (`HttpDispatcher.handleMcp`) hands the MCP service, so `list_objects` renders `totalCount` from `listObjectsDiagnosed().objects`: - For each of the four rows above: `list_objects` lists the package's objects, its non-`sys_` set equals `GET /meta/object`'s for the same caller, and it is not `partial`. `describe_object` answers both objects, including `apiEnabled: false`. Each of the six data verbs refuses the gated object with `{ code: 'OBJECT_API_DISABLED', status: 404 }`, and no protocol data method or engine `aggregate` is called. Control: `query_records` serves the exposed object. - A disabled package, on both kernel shapes (the copy's existence a precondition on the shape that copies): - its objects are neither listed nor described; - REST answers both objects `404 OBJECT_PACKAGE_DISABLED` on each of its six well-formed `/data` record requests, read off a `RestServer` served over the same kernel's protocol; - the dispatcher's `/data` door answers each of nine requests what REST answers: the six well-formed ones and three whose body is not a record; - each of the six MCP data verbs refuses both objects, the one declared `apiEnabled: false` and the exposed one, with `{ code: 'OBJECT_PACKAGE_DISABLED', status: 404 }`, and no protocol data method or engine `aggregate` is called. - Two environments behind one host each list, describe and gate their own package and not the other's. **Red before, green after,** on `e471998c5`, whose code and pin blobs `c0fb1c715` carries unchanged (it changes only the changeset). Each leg's direction and count was predicted before running, and each landed as predicted. Mutations were landed and restored by `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed; restored blob equals HEAD, `git diff HEAD` empty), under the verify lock. The whole-file leg ran under a script with an `EXIT`/`INT`/`TERM` trap. It restored with `git checkout HEAD` and checked blob equality and an empty `git diff HEAD`. The runtime's own source is read by relative import, so no leg depends on a `dist/` build. | leg | mutation | result | | :-- | :-- | :-- | | main | both source files at base `abc25a353` (blobs `616639b765`, `5d19bd3b7a`) | 38 failed, 32 passed. Failed: `env_local` during-boot list, describe and six verbs (8); four two-environment pins; disabled package without an `environmentId`: listing, door parity and twelve verb rows (14); disabled package on `env_local`: twelve verb rows (12) | | discovery | `readMergedObjects` answers `undefined` (the `metadata` service read) | 5 failed, 65 passed | | gate | `readExposureDeclaration` always reads the copy | 33 failed, 37 passed. Failed: `env_local` during-boot six verbs; two two-environment gate pins; disabled package without an `environmentId`: twelve verb rows and door parity; disabled package on `env_local`: twelve verb rows | | refusal | `OBJECT_PACKAGE_DISABLED` read as "no declaration" again (the reading on `d19b65bf1c`) | 24 failed, 46 passed. Failed: every disabled-package verb row on both shapes. The four `aggregate_records` rows answer `SERVED`; the other twenty reached the protocol before it refused | | door twin | `disabledPackage` defaults to `'refuse'` (the refusal thrown at every ask) | 2 failed, 68 passed. Failed: door parity on both shapes, where a body that is not a record answers `404 OBJECT_PACKAGE_DISABLED` and REST answers `400 VALIDATION_FAILED` | | fix | HEAD | 70 passed; rig 73 passed | The first attempt at the gate leg was a no-op. Its replacement text already occurred twice in the file, so `ablation-replace.mjs` refused it and ran nothing. It was re-run with a unique replacement, and the row above is that run. The rig leg (12 failed, 61 passed) was measured on `d19b65bf1c` and not re-run: the rig and its healthy path are unchanged. ## Local verification On `c0fb1c715`, the final commit, with `origin/main` `abc25a353` merged in and the workspace built (`turbo run build --filter=!@objectstack/docs`, 76 tasks, exit 0). Every heavy run went through the verify lock, and each line cites the lock's `VERDICT` line. - `@objectstack/runtime` unit layer (`vitest run --project local`), every file in one run: 433 files passed; 6864 tests passed, 19 skipped, 0 failed. That is 29 more than on `d19b65bf1c`, the pin's 41 cases now 70. `test:repo` is CI's: the diff touches none of its files, and the derivation does not name it. - `pnpm --filter @objectstack/runtime typecheck`: exit 0 (`tsc --noEmit`, and `check:test-typecheck` OK: 27 files, 190 errors, 68 pinned signatures held in the ledger). Both edited test files are in the `tsconfig.test.json` program (`--listFilesOnly`). A first run, before the peer `@objectstack/driver-turso` was built, reported TS2307 on an untouched file; that was build state, not this change. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 68 commands, each exit code captured before any pipe, all exit 0. Two first answered 3 (PREREQUISITE NOT MET). `check:dual-build-cjs-loads` had no full build yet; `check:type-check-debt` timed out queueing for the lock behind another session's run. Both were re-run after the full build, exit 0. The 10 families declared WIDE-population were run too, all exit 0. - `--ran` reconciliation: `✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).` - `pnpm lint` (`eslint . --no-inline-config`): exit 0. - `node scripts/check-changeset-no-major.mjs --base origin/main --event PAYLOAD`, with PAYLOAD a `pull_request` event carrying this body: exit 0. `✓ This diff introduces no major bump.` and `✓ LEVEL AXIS: this PR declares clause-② no (narrowing) and metadata-protocol none ...`. `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0, `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` - `node scripts/check-closing-keyword-parity.mjs --body` on this body binds `#25120` alone. - The full dogfood battery, once on this head (`pnpm --filter @objectstack/dogfood test`, no shard): `VERDICT command-exit 0`; Test Files 287 passed, 3 skipped (290); Tests 2395 passed, 18 skipped (2413); no failure. CI on `c0fb1c715`: 41 check runs, 34 `success` and 7 `skipped` by design, 0 failures. The five required contexts are `success`: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core and Governed Surface Queue Guard. ## Acceptance notes - **Card acceptance item 3 (walked on a cloud environment with a marketplace app installed): NOT MEASURED.** The cloud host lives in `objectstack-ai/cloud`, outside this session. In this repo the shape is the `environmentId` kernel, pinned above with `createStandaloneStack`'s options. On that shape, a package registered during the boot is the one that had no copy. This repo's marketplace install and rehydrate register after the boot and were already copied. Whether the cloud runtime registers HotCRM during the boot was not walked. The card's reading (18 `crm_*` and 8 `ai_*` objects missing) fits that path. - **Closed in passing: the `aggregate` arm never enforced `OBJECT_PACKAGE_DISABLED`** (review boundary flag ③ 5). Before this PR, `aggregate_records` served a disabled package's exposed object on every kernel, because `callData`'s `aggregate` arm asks the engine, never the protocol. The gate's refusal closes it. Pinned by the `aggregate_records` rows on the exposed object, which the refusal leg turns to `SERVED`. - **REST's order on a malformed body, measured, not changed.** `RestServer` answers a body that is not a record `400 VALIDATION_FAILED` on a disabled package's object, before its data plane's `404 OBJECT_PACKAGE_DISABLED`, because `enforceApiAccess` reads that refusal as "no declaration". For an object declared `apiEnabled: false` it is the other way round: the exposure refusal comes first. The dispatcher's door now follows REST. Whether both doors should refuse a disabled package's object before the shape verdict is REST's question, and nothing here changes it. - **The outage fallback stays silent** (review judgment 3, non-blocking). `RestServer`'s `loadObjectItem` logs a warn when a thrown by-name read makes it fail open. This gate does not fail open on that throw: it judges the `metadata` service's copy, so REST's line ("failing open") would say something false here. `ActionExecutionDeps` also carries no logger. Left silent, as the old `catch` was. - **Finding, same defect class, not fixed here: `list_actions` / `run_action`.** `collectActionDeclarations` reads `metadata.listObjects()`, and action resolution reads the same service. Measured on the same `env_local` during-boot fixture: an object action declared `operation: 'update'` with `ai: { exposed: true }` is in the registry, but `list_actions` answers `[]` and `run_action` answers `Action 'mark_done' not found on object 'qa_pkg_open'`. Control without an `environmentId`: listed and resolved. Queued as #25139. - **Finding, the stdio half (`packages/mcp/src/stdio-data-bridge.ts`):** the same three answers, measured above. Held off by the dispatch, queued as #25138. Its fix should read `OBJECT_PACKAGE_DISABLED` as this gate now does on the arm that does not reach the protocol. - **Note:** the `GATED_ACTIONS` docblock in `packages/mcp/src/stdio-data-bridge.ts` says the HTTP bridge answers `listObjects` / `describeObject` "straight off the metadata service". After this change, it reads the protocol's merged listing. The parity claim it makes (those two are not gated through `callData`) still holds. Only the wording is stale. It rides on #25138. - **Note, not changed (review note 9):** `describe_object` serves the raw `fields` map and reads the whole served listing per call. --- _Generated by [Claude Code](https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d66021 commit a1e51d6

5 files changed

Lines changed: 815 additions & 62 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)!: the `/mcp` bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`)
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 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 which definition two runtime readers consult: the HTTP MCP bridge's object discovery and the data doors' API-exposure gate now read the protocol's merged view, the definition REST already serves and enforces, instead of the metadata service's copy. An object's own `enable.apiEnabled` / `enable.apiMethods` declaration is now enforced where it was not read; the remedy is the declaration the author already wrote, never a rewrite of a package source or a stored row. 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 data doors' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests a data door serves, not what a metadata author can write.
13+
14+
The `/mcp` bridge answered `list_objects` and `describe_object` from the `metadata` service, and the API-exposure gate every runtime data door asks (`callData`) read the same service. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package or a plugin registers during the boot. So `list_objects` answered a short list as a complete `totalCount`, `describe_object` answered "not found" for every such object, and the gate read the missing copy as "no declaration" and served it. REST listed and refused the same objects.
15+
16+
- **Before.** On that kernel, an object a package or a plugin registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record` and `aggregate_records`, and `create_record`, `update_record` and `delete_record` reached the store. The runtime dispatcher's `/data` door served it the same way.
17+
- **Now.** `list_objects` lists exactly the non-`sys_` objects `GET /api/v1/meta/object` serves the same caller, and `describe_object` answers each of them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, refuses such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` does.
18+
- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Every data verb on `/mcp`, and every well-formed request on the dispatcher's `/data` door, now refuses a disabled package's object `404 OBJECT_PACKAGE_DISABLED` at the exposure gate, whatever the object declares: the code and status `RestServer` answers for it on every `/data` route. On that door, as on REST, a body that is not a record is still answered `400 VALIDATION_FAILED` first. Such an object declared `apiEnabled: false` was refused `404 OBJECT_API_DISABLED` where the copy existed. `aggregate_records`, whose arm asks the engine and never the protocol, served a disabled package's exposed object on every kernel, and its object declared `apiEnabled: false` wherever no copy existed. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing.
19+
- **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before, and `describe_object` answers the definition `GET /api/v1/meta/object` serves for them. A host whose `protocol` service has no merged read keeps reading the `metadata` service. If the merged by-name read fails, the gate judges the `metadata` service's copy, as it did before.
20+
- **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. An agent that read aggregates of a disabled package's objects through `aggregate_records` was relying on the same kind of defect: re-enable the package to restore access, as REST says.

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

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } fr
2121
import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data';
2222
import type { ExecutionContext } from '@objectstack/spec/kernel';
2323
import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts';
24-
import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
24+
import { standardErrorCodeForHttpStatus, type MetadataProtocol } from '@objectstack/spec/api';
2525
// [#15942 / #16293] The confirmation member's SPELLING is the contract, so it
2626
// is imported, never hand-spelled: the door that refuses and the client that
2727
// retries have to agree on it, and two doors each writing their own 'confirm'
@@ -289,6 +289,99 @@ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: H
289289
}
290290
}
291291

292+
/**
293+
* [#25120] The by-name object reads {@link readExposureDeclaration} asks of
294+
* the `protocol` slot, both optional: the slot is unmapped in
295+
* `ServiceSlotContracts`, and a host may fill it with a partial object.
296+
* `getMetaItemForExecution` is the server-only face `RestProtocol` declares the
297+
* same way (`packages/rest/src/rest-server.ts`).
298+
*/
299+
type ExposureDeclarationRead = Partial<Pick<MetadataProtocol, 'getMetaItem'>> & {
300+
getMetaItemForExecution?: MetadataProtocol['getMetaItem'];
301+
};
302+
303+
/**
304+
* [#25120] The definition {@link refuseUnexposedDataAction} judges: the object,
305+
* by name, through the protocol's MERGED read — the read `RestServer`'s own
306+
* exposure gate makes (`enforceApiAccess`), on the execution face where the
307+
* protocol has one (no serving decorations: the gate reads `enable` and serves
308+
* nothing). The metadata service's copy is read only on a host whose protocol
309+
* has no by-name read: structural absence, answered as before.
310+
*
311+
* ── The defect this closes ────────────────────────────────────────────────
312+
*
313+
* This gate read `IMetadataService.getObject(object)`, and an absent copy is
314+
* "no declaration", i.e. allowed. On a kernel with an `environmentId` nothing
315+
* copies into that service the objects a package or a plugin registers in the
316+
* `SchemaRegistry` during the boot, so such an object declared
317+
* `apiEnabled: false` was served: measured on that shape, `query_records`
318+
* answered and `create_record` reached the driver, through the `/mcp` bridge
319+
* and every other door that asks this gate, while `RestServer` refused the
320+
* same object `404 OBJECT_API_DISABLED`. One declaration, two verdicts.
321+
*
322+
* ── Answers, by what the read returns ─────────────────────────────────────
323+
*
324+
* - the item under its own name → judged;
325+
* - nothing, or an item under another name → `undefined`: no declared
326+
* exposure policy, and the data plane's registry gate answers existence
327+
* (#3770), as before;
328+
* - refused `OBJECT_PACKAGE_DISABLED` → that refusal, THROWN
329+
* ({@link isObjectPackageDisabledRefusal}): a refusal, not an outage, so it
330+
* never falls back to the copy. {@link refuseUnexposedDataAction} decides
331+
* who answers it. `RestServer`'s gate reads it as "no declaration"
332+
* (`loadObjectItem`) because every REST data route then reaches the
333+
* protocol, whose registry gate (`assertObjectRegistered`) refuses with that
334+
* same code. `callData`'s `aggregate` arm takes no protocol: it asks the
335+
* engine, which serves a disabled package's objects. So the reading this
336+
* gate first took from REST served `aggregate_records` on such an object,
337+
* one declared `apiEnabled: false` included, where the metadata service's
338+
* copy had refused it `OBJECT_API_DISABLED`;
339+
* - any other throw (the metadata store unreadable) → the metadata service's
340+
* copy, the source this gate read before. An outage of the merged read must
341+
* not WIDEN exposure: failing open at once would serve an object the copy
342+
* still declares off, where this gate refused it before the move. The copy
343+
* can only add a refusal the healthy read would also make or a stale one, never
344+
* an allowance the caller's fail-open `catch` would not already give.
345+
*/
346+
async function readExposureDeclaration(
347+
deps: ActionExecutionDeps,
348+
requestContext: HttpProtocolContext,
349+
object: string,
350+
scopeId?: string,
351+
): Promise<unknown> {
352+
const readCopy = async () => {
353+
const meta = await deps.resolveService(requestContext, 'metadata', scopeId);
354+
return await (meta as any)?.getObject?.(object);
355+
};
356+
const protocol = await deps.resolveService(requestContext, 'protocol', scopeId) as ExposureDeclarationRead | undefined;
357+
const byName = typeof protocol?.getMetaItemForExecution === 'function'
358+
? protocol.getMetaItemForExecution
359+
: typeof protocol?.getMetaItem === 'function' ? protocol.getMetaItem : undefined;
360+
if (!byName) return await readCopy();
361+
let envelope: unknown;
362+
try {
363+
envelope = await byName.call(protocol, { type: 'object', name: object });
364+
} catch (err) {
365+
if (isObjectPackageDisabledRefusal(err)) throw err;
366+
return await readCopy();
367+
}
368+
const item = (envelope as { item?: unknown } | null | undefined)?.item;
369+
return item !== null && typeof item === 'object' && (item as { name?: unknown }).name === object ? item : undefined;
370+
}
371+
372+
/**
373+
* [#25120] Whether `err` is the protocol's refusal of an object whose owning
374+
* package is disabled: the one constructor `@objectstack/metadata-protocol`
375+
* raises from its by-name reads and its data plane (`code`
376+
* `OBJECT_PACKAGE_DISABLED`, `status` 404, `object`). A refusal, not an outage
377+
* and not an absence: {@link readExposureDeclaration} throws it, where every
378+
* other read failure falls back to the copy, and
379+
* {@link refuseUnexposedDataAction} decides who answers it.
380+
*/
381+
function isObjectPackageDisabledRefusal(err: unknown): boolean {
382+
return (err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED';
383+
}
384+
292385
/**
293386
* ── Object-level API exposure gate (ADR-0049, #1889) ─────
294387
* Honour the object's `apiEnabled` / `apiMethods` declarations for external
@@ -314,20 +407,44 @@ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: H
314407
* rendered the message alone, where `RestServer` and the MCP stdio face answer
315408
* the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`.
316409
* The statuses do not move, and neither does the message.
410+
*
411+
* [#25120] An object whose owning package is disabled, whatever it declares:
412+
* the protocol refuses its by-name read `404 OBJECT_PACKAGE_DISABLED`
413+
* ({@link readExposureDeclaration}), and `disabledPackage` says who answers
414+
* that refusal, so every door answers what `RestServer` answers:
415+
*
416+
* - `'defer'`, the default: the dispatcher's `/data` door, which asks this gate
417+
* BEFORE its request-schema gate as the twin of `RestServer`'s
418+
* `enforceApiAccess`. "No declaration", as `enforceApiAccess` reads it
419+
* (`loadObjectItem`), so a body that is not a record is answered `400
420+
* VALIDATION_FAILED` first, and a well-formed request is refused `404
421+
* OBJECT_PACKAGE_DISABLED` when it reaches {@link callData}, as REST's data
422+
* plane refuses it.
423+
* - `'refuse'`: {@link callData}, which every data door reaches where REST
424+
* reaches the protocol, and which the `/mcp` bridge asks alone. The
425+
* protocol's own error, thrown, on every arm: the `aggregate` arm asks the
426+
* engine and never the protocol, and the engine serves a disabled package's
427+
* objects, so reading the refusal as "no declaration" there served
428+
* `aggregate_records` on such an object, one declared `apiEnabled: false`
429+
* included.
317430
*/
318431
export async function refuseUnexposedDataAction(deps: ActionExecutionDeps,
319432
requestContext: HttpProtocolContext,
320433
action: string,
321434
object: unknown,
322435
scopeId?: string,
323436
executionContext?: ExecutionContext,
437+
disabledPackage: 'defer' | 'refuse' = 'defer',
324438
): Promise<void> {
325439
if (executionContext?.isSystem || !object) return;
326440
let def: any;
327441
try {
328-
const meta = await deps.resolveService(requestContext, 'metadata', scopeId);
329-
def = await (meta as any)?.getObject?.(object);
330-
} catch {
442+
def = await readExposureDeclaration(deps, requestContext, String(object), scopeId);
443+
} catch (err) {
444+
// [#25120] The protocol's `404 OBJECT_PACKAGE_DISABLED` is a refusal,
445+
// never a read failure to fall open on: thrown where this gate stands
446+
// in for the data plane, "no declaration" where the data plane follows.
447+
if (isObjectPackageDisabledRefusal(err) && disabledPackage === 'refuse') throw err;
331448
def = undefined; // fall open to schema defaults (apiEnabled=true)
332449
}
333450
const gate = checkApiExposure(def, action);
@@ -349,7 +466,9 @@ export async function callData(deps: ActionExecutionDeps,
349466
// condition's target, asked below on the two arms that hand the engine a
350467
// caller's `where` ({@link refuseUnservedRelationTargets}).
351468
const exposureGated = !executionContext?.isSystem;
352-
await refuseUnexposedDataAction(deps, requestContext, action, params?.object, scopeId, executionContext);
469+
// [#25120] `'refuse'`: a disabled package's object is refused here, on
470+
// every arm, the `aggregate` arm that never reaches the protocol included.
471+
await refuseUnexposedDataAction(deps, requestContext, action, params?.object, scopeId, executionContext, 'refuse');
353472

354473
const protocol = await deps.resolveService(requestContext, 'protocol', scopeId);
355474
const qlService = dataDriver ?? await deps.getObjectQL(requestContext, scopeId);

0 commit comments

Comments
 (0)