Skip to content

Commit 316be32

Browse files
fix(runtime)!: an app-authored body may not read the stored-metadata tables; it reaches them through the metadata API only (#21594) (#21660)
Fixes #21594 Clause-②: yes (narrowing) An app-authored body (a sandboxed hook, action or job body) may no longer read the stored-metadata tables (`sys_metadata`, `sys_metadata_history`). Every read verb answers the body boundary's `PERMISSION_DENIED` / 403 before it runs, with a prescription naming the metadata API's read route. With the binding and write refusals already landed, an app-authored body now reaches these tables through the metadata API only. This is the maintainer's ruling, letter B (record `5974479930`). An action body is not **handed** a family row either. The `/actions` door loads an action's subject record before it dispatches. When that record is a family row, the call now answers the same 403 before the body runs, instead of handing the body the row as `ctx.record`. That covers an action declared on a family table and an object-less action addressed under one. The contract review's flag 2 raised this path; it was measured first, at the doors (below). **The handler contexts (A2) are ruled A by the maintainer** (record `5978653398`, 「同意」 on director batch #276). Ruling B covers sandboxed app bodies only; app host handlers registered with `registerAction` keep the projected read. This PR leaves them exactly as they were: their `ctx.api`, `ctx.engine.find` and subject record are unchanged. C (a host-code trust model at the engine entry) is not taken and not filed. **The census's cloud leg is answered.** The same ruling record cites triage's reading `5976321402`: zero app-authored readers of the family in `objectstack-ai/cloud`, neither bodies nor host handlers. That reading is triage's, not this container's. ## What changed - **`packages/runtime/src/stored-metadata-body-boundary.ts`.** Adds `storedMetadataBodyReadRefusal(object, verb)`. It uses the write refusal's own envelope (`STORED_METADATA_BODY_BOUNDARY_CODE` / `_STATUS`, `PERMISSION_DENIED` / 403), so there is no new error code. Its prescription names the read route: `GET /api/v1/meta/:type/:name`, and `.../history` for versions. The binding and write refusals' text is byte-unchanged; the shared constructor takes the prescription as a defaulted parameter. - **`packages/runtime/src/stored-metadata-reader-seam.ts`.** Adds a body READ layer, `refuseStoredMetadataBodyReads`, on the same derive walk as the write layer: `object()`, `sudo()`, `withRunAs()`, a `transaction(fn)` callback and `beginTransaction()`. It refuses exactly the read verbs the seam serves (`find`, `findOne`, `count`, `aggregate`), before the verb runs and before its query is looked at. So a refused read gives the same answer whatever its filter, sort, grouping, search or projection names: no rows, and no oracle. The write layer (`refuseStoredMetadataBodyWrites`) is unchanged in code. It sits over the read layer and passes reads down to it; only its comments now say so. - **`packages/runtime/src/sandbox/body-runner.ts`.** `buildSandboxApi` is the one place every body face gets its API. It is now `refuseStoredMetadataBodyWrites(refuseStoredMetadataBodyReads(source))`. - **The subject record (this round).** `stored-metadata-body-boundary.ts` adds `storedMetadataBodySubjectRecordRefusal(object, action)`. It uses the same envelope (`PERMISSION_DENIED` / 403, operation `record`) and the same read prescription. `sandbox/body-runner.ts` consults it first in `actionBodyRunnerFactory`'s bound handler, before the sandbox context is built and before the body runs. That handler is the one point every action body passes through to run, whichever door bound it, and the one place the handler is known to be a body. The subject is the door-stamped routed object (`params.objectName`, which both action doors write after the caller's params), else the declared object. A record is handed when the call carries a record id or a non-empty record. A family-routed call with neither hands the body nothing and runs. - **Deleted: the read-side serve for bodies.** `serveStoredMetadataReadsThrough` no longer wraps a body's API in `buildSandboxApi`. With every family read and write refused first, it served a body nothing. It is removed, not kept beside the refusal. Nothing else became dead: the seam's projection, evaluate refusals, default-search narrowing and write-return serve still serve the host-handler contexts (ruling A keeps them). No exported symbol is deleted or renamed. - **`packages/runtime/src/action-execution.ts`.** Comment only (`buildActionApi`): an action body's API is built over this context, and the body layers refuse first. - **`.changeset/21594-body-family-read-refusal.md`.** `@objectstack/runtime` `minor`, BREAKING (narrowing), exactly one ADR-0087 marker (`not-required (no-migration-prescription)`), in the shape of the evaluate-refusal changeset. It states the route, and which earlier entries of this release it supersedes for bodies. This round adds a **Subject record** bullet, and names a host handler's subject record among the unchanged. - **`scripts/engine-double-contract.pinned.json`.** One row for the new unit pin's double, whose `findOne` routes through the engine's own predicate (`check-engine-double-contract.mjs --write`). ## Measured first ### A1. Census of app-authored readers (the stop condition): 0 readers - **objectstack `examples/**`** at `15fe567c9c`: - **Family table names in every tracked file:** 3 hits, all prose: the `app-showcase` changelog (2) and one code comment in `app-showcase/src/system/connectors/index.ts`. - **Indirect spellings:** `SystemObjectName.METADATA`, `STORED_METADATA_BODY_OBJECTS` and `isStoredMetadataBodyObject` have 0 hits. A non-literal `.object(...)` argument has 0 hits. - **Literal `.object(...)` targets:** four `showcase_*` objects. - **Non-literal engine reads:** three, in host seed code on the engine directly (`bind-position-sets.ts` ×2, `seed-approval-demo.ts`). Each is called only with non-family objects. - **Population:** 14 files carry a `body:` key; 5 touch `ctx.api`, `ctx.engine` or `registerAction`. - **hotcrm** at `4054ec2680` (a public shallow clone, read only, 924 tracked files): - **Family table names:** 5 hits, all prose or test comments. None is in `src/**` or `apps/**`. - **Non-literal `.object(...)` reads:** 7, in hook bodies. Each is bound to a local list of `crm_*` objects or to the hook's own object. - **Literal targets:** 21 distinct, all `crm_*` or non-family `sys_*` objects. - **Host action handlers:** 0. Its `registerAction` appears in a comment only. - **cloud** (`objectstack-ai/cloud`): **NOT MEASURED from this container** (private, unreachable). Answered by triage's reading `5976321402`, as the ruling record `5978653398` cites: zero app-authored readers. ### Flag 2 (this round). The subject record: can a body be handed a family row? Measured at the doors on the head before the fix (`d5b890226c`), with a temporary probe that was not committed. Each body only returned what it was handed as `ctx.record`; the probe recorded classes (keys present, body column type, hash form), never values. - **The app manifest / `defineStack` / `os validate`.** A strict `defineStack` (the default) refuses an action whose `objectName` is a family table: `STACK_CROSS_REFERENCE_INVALID`, because the table is not an object the stack defines. That is a generic cross-reference check, not the family boundary. `defineStack(…, { strict: false })` accepts it: `os validate` answers `valid: true`, exit 0, with placement warnings only. An object-less body action is accepted in both modes. Measured with `bin/run-dev.js validate --json`. - **Install-local** (`POST /api/v1/marketplace/install-local`). The package with a family-bound action and an object-less body action installs (200, `success: true`). It binds both handlers, `sys_metadata:…` and `global:…`, through the runtime's one binder. Handed a door-shaped context carrying a family row, each body received it. Measured in `@objectstack/cloud-connection` against the built runtime. - **The `/meta` action save door.** `PUT /meta/action/:name` with `objectName` set to a family table answers 200. Once bound, `POST /actions/sys_metadata/:name/:id` handed its body the row. - **Boot** (an `AppPlugin` over a JSON bundle, the composition `os start --artifact` builds). Both actions bind. - **The invoke doors.** - **REST `/actions`, as the administrator.** The body received the door-served family row on both tables: the body column as its projection and the hash in keyed form, with no stored credential. That held for an action declared on the table, for an object-less body action addressed under it, and for the `/meta`-declared action. No family declaration is needed for the object-less route; any installed object-less body action can be addressed under a family table. **Reachable.** - **REST `/actions`, as a member.** `404 RECORD_NOT_FOUND`: the door's own subject load, under the caller's read scope, stops it, and the body never runs. - **MCP `run_action`.** Not reached. The door refuses an action on any `sys_*` object before the record load, and resolves an object-less action only under its own key. The composed kernel's metadata service lists no standalone action, so this door is pinned at unit level. - **A host handler** under the same route received the projected row (unchanged, ruling A). - **An ordinary object's action** received its row (unchanged). **Reachable, so it is refused in this card.** The seam is the action body's own handler, not the door: the doors dispatch body and host handlers alike and cannot tell them apart at the prefetch. A binding-time refusal, the parallel of the hook-binding refusal, would not see the object-less route, so it is not the only coherent seam; it would also be insufficient. After the fix, on `f5d575d072`, the same probe gave: - **REST `/actions`, administrator:** `403 PERMISSION_DENIED` naming the metadata API, for every family case (both tables, the family-bound action, the object-less route, the `/meta`-declared action). - **Host control and ordinary control:** 200, unchanged. - **Family-routed call with no record:** 200, nothing handed. - **Member:** still `404` at the door. - **Install-local's bound handlers:** `PERMISSION_DENIED` / 403. ### A2. Which seam contexts carry app-authored code - **① The sandboxed body (`buildSandboxApi`).** - **Faces and doors:** hook bodies on every data door that fires hooks; action bodies on REST `/actions`, MCP `run_action` and an engine `execute`; job bodies on the job scheduler. - **Authorship:** always app-authored (a code bundle, an installed artifact or the metadata door). The write refusal reaches it. - **Here:** refused. - **② `ctx.engine.find` and ③ `ctx.api` of an action handler (`buildActionEngineFacade`, `buildActionApi`).** - **Doors:** built at two only, REST `/actions` (`domains/actions.ts`) and MCP `run_action` (`action-execution.ts`). - **Platform registrants:** in `packages/**`, the only `registerAction` callers are the two body runners (the objectql metadata-service bind and `app-artifact-handlers.ts`). Their handlers are sandboxed bodies, which never see ② and get ③ only as the source the body layers wrap. - **App registrants:** host-code handlers come from apps. `examples/app-todo` registers 8 host handlers from its `onEnable(ctx)` through `ctx.ql`; hotcrm registers none. - **Reach of the write refusal:** it does not reach these handlers, as pinned by its own unit case ("the read seam ALONE — a host code handler's ctx.api — keeps its family writes"). - **Verdict:** ruled A (`5978653398`). Left served, unchanged. - **Platform internal readers through any seam context: 0.** Every platform reader of the family in `packages/**` reads through the engine or a driver directly. That covers the metadata protocol, the objectql plugin, the core translation fallback, the flow credential channel and the CLI. None reads through a body API or a handler context. Pinned unaffected: - the generic data door; - the metadata API (the route the refusal prescribes); - the engine's own read of the stored form; - handler ② / ③, still served projected and keyed. ### A3. The envelope - **Reuse:** the read refusal rides the same envelope, `PERMISSION_DENIED` / 403, with `object` and `operation` set. There is no new error code. - **Verbs:** every read verb the seam serves is refused (`find`, `findOne`, `count`, `aggregate`). Search is a query shape on a read, and is refused with it. - **Pin:** ten query shapes × four verbs give one answer per verb. ### A4. The deletion and the ledgers - **Deleted:** the body serve wrap only. No exported runtime symbol is deleted or renamed. Two module exports are added (`refuseStoredMetadataBodyReads`, `storedMetadataBodyReadRefusal`); neither is on the package entry. - **Ledger grep:** the touched symbols across `packages/spec/liveness/**`, every `*.ledger.*` file, `scripts/engine-double-contract.pinned.json`, `content/docs` and `docs`. One hit: a liveness note in `hook.json` that cites `buildSandboxApi` reading `ctx.api` from the engine context. That is still true, and the function keeps its name. - **`packages/metadata-protocol`:** no edit. No symbol there is left without a consumer. The seam still consumes each one for the handler contexts, as counted in the report. - **Docs:** `git grep` over hand-written `content/docs` and published `skills/` found no page saying a body can read the family's tables. Zero hits, so nothing was touched. ### A5. Reverse verification (ablation) - **Mutation:** made with `scripts/ablation-replace.mjs` in WRAP mode on `packages/runtime/src/stored-metadata-reader-seam.ts`, from the committed state (`9c87884191`). - **Anchor:** `if (BODY_FAMILY_READS.has(prop)) {`, hit ×1 → ×0. Replaced by `if (false && BODY_FAMILY_READS.has(prop)) {`, ×0 → ×1. - **Blob:** `76eabe5afe1a` → `c56dca3ae3e6`. - **Expected direction:** red, the usual one. Observed: red. - **Result:** 18 red, 86 green, over six files. - **Red:** every read-refusal pin. That is 10 in the new unit file and the 8 body cases of the reader-contexts integration pin. - **What the red showed:** with the refusal ablated, the action bodies fell through to the handler-served read. The hook body's insert landed carrying the stored form, so the refusal is now the hook face's only guard, as the ruling's deletion intends. - **Green, unchanged:** every write and hook-binding pin (`stored-metadata-body-writes.test.ts`, `stored-metadata-body-boundary.test.ts`, `stored-metadata-body-boundary.pin.test.ts`), the reader-seam unit file, the handler ② / ③ cases and the platform-reader controls. - **Restore:** blob after restore == HEAD blob (`76eabe5afe1a`), `git diff HEAD` empty, `git status --porcelain` empty. - **Second leg (this round), the subject-record refusal.** - **Mutation:** on `packages/runtime/src/sandbox/body-runner.ts` at committed `5e8fd37d78`. Anchor `if (subjectRefusal) throw subjectRefusal;`, hit ×1 → ×0. Replaced by `if (false && subjectRefusal) throw subjectRefusal;`, ×0 → ×1. - **Blob:** `522b737bc800` → `3eb2d928aba3`. - **Expected direction:** red. Observed: red, 7 failed and 114 passed over the same six files. - **Red:** the 5 unit subject-record refusal cases, and 2 integration cases (the administrator's family-bound and object-less routes; the `/meta`-declared action). - **Green:** every control (the host handler, the ordinary record, the family-routed call with no record, the member's door-level 404), the MCP-door pins, every read-layer pin, and every write and hook-binding pin. - **Restore:** blob == HEAD (`522b737bc800`), `git diff HEAD` empty, `git status --porcelain` empty. ## The handler contexts (A2): ruled A **Ruling `5978653398`, letter A** (maintainer 「同意」 on director batch #276): ruling B covers sandboxed app bodies only, and app host handlers registered with `registerAction` keep the projected read. The record's stated cost: the reader-context seam's projection and narrowing code stays, for host handlers. The analysis that went to the maintainer is kept below. **Question.** Should the read refusal also reach an action handler's `ctx.api` and `ctx.engine.find`, the host code an app registers with `registerAction`? Or do those stay outside, the same context set as the write refusal? - **A (recommended): keep them outside, the same set as the write refusal.** - Host code holds the engine itself: it registers its handlers through `ctx.ql` in `onEnable`. A refusal on its `ctx.api` would declare a boundary the runtime cannot enforce. - The write ruling already drew this line for writes. - Pull is zero either way: the 8 example host handlers read no family table, and hotcrm has none. - **B: widen the read refusal to the handler contexts.** - This would make the rest of the seam's serve code dead and deletable: the projection, the evaluate refusals, the narrowing and probably the write-return serve. - It would also split host code's rules: writes allowed, reads refused. That holds unless the write ruling is reopened for host code too. ## Tests All at the final head `f5d575d072`: the merge of `origin/main` at `100f68b77f` into this branch, as a merge commit, built whole (`pnpm build --concurrency=2`, 72 of 72 tasks, none cached). - **Unit pins:** `src/stored-metadata-body-reads.test.ts`, 26/26. The first round's 15 cases are unchanged. This round adds: - **Five subject-record cases** on the real QuickJS action face: a family-bound action, and an object-less action routed under each table, are refused with the envelope and never run. With no routed object, the declared family object stands in. Controls: an ordinary subject record is handed as before, and a family-routed call with no record runs. - **Four MCP-door cases:** a family-bound action is refused before any record load or dispatch, and an object-less action named under a family table does not resolve. - **Public-door integration pin:** `src/stored-metadata-reader-contexts.pin.test.ts` (REST `/actions`, the data door, `/meta`), on the built packages, 27/27. This round adds six cases: - the administrator's family-bound and object-less routes on both tables, refused 403 naming the metadata API; - the `/meta`-declared family-bound action, refused once bound; - the member stopped at the door with `404 RECORD_NOT_FOUND`; - control: the host handler's subject record still served like the data door; - control: an ordinary subject record handed as before; - control: a family-routed call with no record runs. - **Family pins together:** with the write and boundary pins and the reader-seam unit file, 6 files, 121/121. - **Full `@objectstack/runtime` suite:** `--project local` 322 files, 4591 passed and 19 skipped; `--project repo` 3 files, 751 passed. The two runs were joined with `&&` under one lock verdict, command-exit 0. - **Typecheck:** `pnpm --filter @objectstack/runtime typecheck` passes, `check:test-typecheck` OK. - **Lint:** full `pnpm lint` exits 0 with no findings. - **Liveness evidence:** `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts`, 42/42. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 71 families against merge base `100f68b77`, all exit 0, with `check:dual-build-cjs-loads` after the full build. Reconciled with `--ran`: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", every exit code recorded. ## Acceptance notes - **The changesets of this release now overlap.** The pending reader-seam changesets (served read; evaluate refusals) and the binding-and-write changeset's "Unchanged: a body's reads" bullet describe the body context as served. This PR's changeset says it supersedes them for bodies. If they should read clean on their own, the binding-and-write changeset's bullet is the one line to amend before the release compiles. Carrier: the release seat. - **A spec comment is incomplete.** The header of `packages/spec/src/kernel/stored-metadata-body-objects.ts` lists what the runtime refuses for a body as binding and writing; it does not mention reading. That file is outside this lane's fence (`packages/spec`). Carrier: none. - **The refusal is now the hook face's only guard.** The deletion leaves nothing behind it, as the ruling intends; the ablation measured this. Both the unit pin and the integration pin go red if it is removed. - **The base is merged.** `origin/main` at `100f68b77f` was merged into the branch as a merge commit (`f5d575d072`), with no rebase and no force-push. It merged cleanly. Main's change to `sandbox/body-runner.ts` (the job face's organization envelope) still builds its API through `buildSandboxApi`, so the job face keeps both body layers. - **A family-bound action still installs and binds.** Install-local, the `/meta` door and a lax `defineStack` all accept an action declared on a family table, and the body runner binds it. The refusal lands at run time, when a family row would be handed over. A binding-time refusal (the parallel of the hook-binding refusal) was not added: it could not see the object-less route, which needs no family declaration, and the run-time refusal covers both. Whether binding should also refuse is not this card's question. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2b7328 commit 316be32

9 files changed

Lines changed: 935 additions & 183 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: an app-authored body may not read the stored-metadata tables either; it reaches them through the metadata API only (#21594)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which reads a sandboxed hook, action or job body may make of the two stored-metadata tables, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a refused read (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what an app-authored body may do with the two stored-metadata tables, `sys_metadata` and `sys_metadata_history`. With the binding and write refusals already in this release, an app-authored body now reaches them through the metadata API only.
12+
13+
- **Reading.** A sandboxed hook, action or job body's read of either table through `ctx.api` answers `PERMISSION_DENIED` / 403 before the read runs. That covers `find`, `findOne`, `count` and `aggregate`, inside a transaction or not, with or without elevation, and whatever filter, sort, grouping, search or projection the read carries. A body is served nothing of these tables, neither the stored row nor a projection of it, and the answer does not depend on what the read asks. A hook body that reads them fails the write that fired it.
14+
- **Subject record.** An action body is not handed a row of either table as its subject record either. The `/actions` door loads an action's subject row before it dispatches. When that row is from either table, the call answers the same `PERMISSION_DENIED` / 403 before the body runs, instead of handing the body the row as `ctx.record`. That covers an action declared on either table (through a bundle, an installed package or the metadata door) and an object-less action addressed under one. A call that carries no record hands the body nothing and runs as before.
15+
- **The route.** A body that read either table through `ctx.api.object(...)` was served the body projected and the content hash keyed; read metadata through the metadata API instead: `GET /api/v1/meta/:type/:name` for a definition, and `GET /api/v1/meta/:type/:name/history` for its versions. The refusal names that route. No shipped example reads either table from a body.
16+
- **This supersedes, for bodies, two earlier entries of this release:** the served read of these tables, and the evaluate-shape refusals (`INVALID_FIELD` / 400) and default-search narrowing of that read. For a body, all of these now give way to this refusal. It also supersedes the binding-and-write entry's note that a body's reads are unchanged.
17+
- **Unchanged:** host code that registers its own action handlers (its `ctx.api`, `ctx.engine.find` and subject record are still served as the generic data door serves these tables, with the door's evaluate refusals); the binding and write refusals; the platform's own readers; the generic data door and the metadata API; and every other object.
18+
19+
It ships as `minor` under the launch-window convention for accept-set narrowings.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,9 +1500,11 @@ export function buildActionExecutionContext(ec: any): Record<string, unknown> {
15001500
*
15011501
* [#21454] Served through the stored-metadata reader seam
15021502
* (`stored-metadata-reader-seam.ts`): this context is elevated, and it is the
1503-
* `ctx.api` a host code handler receives as well as an action body, so a read
1504-
* of the stored-metadata-body family answers the generic data door's form
1505-
* (the body projected, the content hash keyed) and never the stored row.
1503+
* `ctx.api` a host code handler receives, so a read of the stored-metadata-body
1504+
* family answers the generic data door's form (the body projected, the content
1505+
* hash keyed) and never the stored row. [#21594] An action BODY's API is built
1506+
* over this one by the sandbox (`buildSandboxApi`), whose body layers refuse a
1507+
* family read or write before it reaches this seam.
15061508
*/
15071509
export function buildActionApi(_deps: ActionExecutionDeps, ql: any, ec: any): any | undefined {
15081510
if (!ql || typeof ql.createContext !== 'function') return undefined;

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ import {
6161
resolveRecordTitle,
6262
resolveRelatedTitleTarget,
6363
} from '@objectstack/objectql';
64-
import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
64+
import { refuseStoredMetadataBodyReads, refuseStoredMetadataBodyWrites } from '../stored-metadata-reader-seam.js';
6565
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
6666
import {
6767
isWildcardHookTarget,
6868
storedMetadataBodyHookBindingRefusal,
69+
storedMetadataBodySubjectRecordRefusal,
6970
storedMetadataFamilyTableList,
7071
} from '../stored-metadata-body-boundary.js';
7172

@@ -448,6 +449,11 @@ export function actionBodyRunnerFactory(
448449
const body = parsed.data;
449450

450451
return async function boundActionHandler(actionCtx: any): Promise<unknown> {
452+
// [#21594] A body is handed nothing of the stored-metadata family: an
453+
// action whose subject record is a family row is refused here, before
454+
// the body runs and before its sandbox context is built.
455+
const subjectRefusal = actionSubjectRecordRefusal(actionCtx, action);
456+
if (subjectRefusal) throw subjectRefusal;
451457
const sandboxCtx = buildActionSandboxContext(
452458
actionCtx,
453459
opts.ql,
@@ -478,6 +484,38 @@ export function actionBodyRunnerFactory(
478484
};
479485
}
480486

487+
/**
488+
* [#21594] The refusal for an action body about to be handed a row of the
489+
* stored-metadata family as its subject record, or `undefined`.
490+
*
491+
* Both action doors (REST `/actions` and MCP `run_action`) load the subject
492+
* record before they dispatch, through the generic data door, and stamp the
493+
* routed object and record id into `params` AFTER the caller's own params, so
494+
* `params.objectName` is the door's routed object, never the caller's. That
495+
* object is the subject: an action declared on a family table is routed under
496+
* it, and so is an object-less action a caller addresses under it
497+
* (`/actions/<family table>/<action>/<id>`), which no binding-time refusal
498+
* could see. Absent a routed object (an engine `execute` call from host code),
499+
* the action's declared object stands in.
500+
*
501+
* Judged here — the action body's own handler, the one point every action
502+
* body passes through to run, whichever door bound it — because only here is
503+
* the handler known to be a body: the doors dispatch body and host handlers
504+
* alike, and a host code handler's subject record is outside the boundary.
505+
* A record is "handed" when the call carries a record id or a non-empty
506+
* record; a family-routed call with neither hands the body nothing and runs.
507+
*/
508+
function actionSubjectRecordRefusal(actionCtx: any, action: { name: string; object?: string }): Error | undefined {
509+
const params = actionCtx?.params;
510+
const routed = typeof params?.objectName === 'string' && params.objectName.length > 0 ? params.objectName : undefined;
511+
const subject = routed ?? action.object;
512+
if (typeof subject !== 'string' || !isStoredMetadataBodyObject(subject)) return undefined;
513+
const record = actionCtx?.record;
514+
const handed = (typeof params?.recordId === 'string' && params.recordId.length > 0)
515+
|| (record !== null && typeof record === 'object' && Object.keys(record).length > 0);
516+
return handed ? storedMetadataBodySubjectRecordRefusal(subject, action.name) : undefined;
517+
}
518+
481519
/** What {@link judgeJobBody} answers for a `body` that binds, and for one that does not. */
482520
export type JobBodyJudgement =
483521
| { binds: true; body: ScriptBodyParsed }
@@ -970,24 +1008,29 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) {
9701008
}
9711009

9721010
/**
973-
* The `ctx.api` a sandboxed body (hook or action) reads and writes through.
974-
*
975-
* [#21454] Served through the stored-metadata reader seam: a read of the
976-
* stored-metadata-body family answers the generic data door's form (the body
977-
* projected, the content hash keyed), never the stored row. This is the one
978-
* place both body faces get their API, so the hook face, the action face and
979-
* every fallback below are served alike, and a body can copy only what it was
980-
* served.
981-
*
982-
* [#21520] And, layered over that, a body may not WRITE a stored-metadata table
983-
* at all: every write of one is refused before it runs, whatever the body's
984-
* elevation. Applied HERE and nowhere else because this is the one place a
985-
* body gets its API — a host code handler's `ctx.api` is served by the read
986-
* seam but keeps its writes (deployer code, outside the boundary).
1011+
* The `ctx.api` a sandboxed body (hook, action or job) reads and writes through.
1012+
*
1013+
* For an app-authored body, the stored-metadata family is reached through the
1014+
* metadata API only, so this API refuses every touch of a family table before
1015+
* it runs, whatever the body's elevation, with `PERMISSION_DENIED` / 403 and a
1016+
* prescription naming the metadata API:
1017+
*
1018+
* - [#21594] a READ (`find`, `findOne`, `count`, `aggregate`, and every filter,
1019+
* sort, grouping or search one can carry): the body is served nothing of the
1020+
* family, neither the stored row nor a projection of it, so it can copy
1021+
* nothing of it either;
1022+
* - [#21520] a WRITE (every write verb, and any verb not known to be a read).
1023+
*
1024+
* This is the one place every body face gets its API, so the hook face, the
1025+
* action face, the job face and every fallback below are refused alike.
1026+
* Applied HERE and nowhere else: a host code handler's `ctx.api` is not a
1027+
* body's, and is served by the reader-context seam
1028+
* (`serveStoredMetadataReadsThrough`) with its writes kept (deployer code,
1029+
* outside the boundary).
9871030
*/
9881031
function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) {
9891032
return refuseStoredMetadataBodyWrites(
990-
serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql),
1033+
refuseStoredMetadataBodyReads(buildSandboxApiSource(engineCtx, ql, errLabel)),
9911034
);
9921035
}
9931036

‎packages/runtime/src/stored-metadata-body-boundary.ts‎

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,39 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#21520] The stored-metadata family's WRITE boundary for app-authored bodies.
4+
* [#21520, #21594] The stored-metadata family's boundary for app-authored bodies.
55
*
66
* The family's tables (`sys_metadata` / `sys_metadata_history`, the set
7-
* `isStoredMetadataBodyObject` answers for) have one writer for an app-authored
8-
* body: the metadata protocol, where a change is validated and its provenance
9-
* recorded. A sandboxed body — a hook body or an action body, whether it came
10-
* from a code bundle, an installed artifact or the metadata door — may not
11-
* touch those tables any other way:
7+
* `isStoredMetadataBodyObject` answers for) are reached by an app-authored body
8+
* through the metadata API only: the metadata protocol is their one writer,
9+
* where a change is validated and its provenance recorded, and their one
10+
* reader, which serves each definition in its read projection. A sandboxed
11+
* body — a hook body, an action body or a job body, whether it came from a
12+
* code bundle, an installed artifact or the metadata door — may not touch
13+
* those tables any other way:
1214
*
1315
* - **Binding** a body hook to a family table is refused at registration
1416
* ({@link storedMetadataBodyHookBindingRefusal}, consulted by
1517
* `hookBodyRunnerFactory` — the one point every body hook passes through
1618
* to become a handler, whichever door bound it).
1719
* - **Writing** a family table through a body's `ctx.api` is refused before
1820
* the write runs ({@link storedMetadataBodyWriteRefusal}, consulted by the
19-
* reader-context seam's body layer).
21+
* reader-context seam's body write layer).
22+
* - **Reading** a family table through a body's `ctx.api` is refused before
23+
* the read runs ({@link storedMetadataBodyReadRefusal}, consulted by the
24+
* seam's body read layer), whatever the read's query names.
25+
* - **Being handed** a family row as an action's subject record (`ctx.record`,
26+
* which the `/actions` door loads before dispatch) is refused before the
27+
* body runs ({@link storedMetadataBodySubjectRecordRefusal}, consulted by
28+
* `actionBodyRunnerFactory` — the one point every action body passes
29+
* through to run, whichever door bound it).
2030
*
2131
* Platform code is outside this boundary: the metadata protocol and its own
22-
* writers, the platform's internal hooks (registered as code, never as a
23-
* body) and host code a deployer registers all reach the store through their
24-
* own imports, never through a sandboxed body's API.
32+
* readers and writers, the platform's internal hooks (registered as code,
33+
* never as a body) and host code a deployer registers all reach the store
34+
* through their own imports, never through a sandboxed body's API.
2535
*
26-
* Both refusals carry the standard catalog's `PERMISSION_DENIED` / 403: the
36+
* Every refusal carries the standard catalog's `PERMISSION_DENIED` / 403: the
2737
* condition is that this author context is not permitted the operation on this
2838
* table, which is the catalog member's meaning, and the ledger's own admission
2939
* rule sends a generic permission condition to the standard member rather than
@@ -37,14 +47,20 @@ import { isStoredMetadataBodyObject, STORED_METADATA_BODY_OBJECTS } from '@objec
3747
export const STORED_METADATA_BODY_BOUNDARY_CODE = 'PERMISSION_DENIED';
3848
export const STORED_METADATA_BODY_BOUNDARY_STATUS = 403;
3949

40-
/** The one prescription both refusals end with: the door an author uses instead. */
50+
/** The prescription the binding and write refusals end with: the door an author changes metadata through. */
4151
const PRESCRIPTION =
4252
'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), '
4353
+ 'where it is validated and its provenance is recorded. Elevation (`runAs`, a system context) does not '
4454
+ 'change this.';
4555

46-
function refusal(message: string, object: string, operation: string): Error {
47-
const err = new Error(`${message} ${PRESCRIPTION}`) as Error & Record<string, unknown>;
56+
/** The prescription the read refusal ends with: the door an author reads metadata through. */
57+
const READ_PRESCRIPTION =
58+
'Read metadata through the metadata API (`GET /api/v1/meta/:type/:name`, the metadata protocol, and '
59+
+ '`GET /api/v1/meta/:type/:name/history` for its versions). Elevation (`runAs`, a system context) does not '
60+
+ 'change this.';
61+
62+
function refusal(message: string, object: string, operation: string, prescription: string = PRESCRIPTION): Error {
63+
const err = new Error(`${message} ${prescription}`) as Error & Record<string, unknown>;
4864
err.code = STORED_METADATA_BODY_BOUNDARY_CODE;
4965
err.status = STORED_METADATA_BODY_BOUNDARY_STATUS;
5066
err.object = object;
@@ -105,6 +121,44 @@ export function storedMetadataBodyWriteRefusal(object: string, verb: string): Er
105121
);
106122
}
107123

124+
/**
125+
* [#21594] The refusal for a sandboxed body's read verb on a family table, or
126+
* `undefined` for any other object. Thrown before the read runs, whatever its
127+
* query names (a filter, a sort, a grouping, a search, a projection, or none),
128+
* so a refused read reaches no row and answers the same way whatever it asks:
129+
* it serves nothing, and it is no oracle on what the table holds.
130+
*/
131+
export function storedMetadataBodyReadRefusal(object: string, verb: string): Error | undefined {
132+
if (!isStoredMetadataBodyObject(object)) return undefined;
133+
return refusal(
134+
`Cannot ${verb} '${object}' from an app-authored body: the read was not run. '${object}' holds stored `
135+
+ 'metadata, and a body may not read it directly.',
136+
object,
137+
verb,
138+
READ_PRESCRIPTION,
139+
);
140+
}
141+
142+
/**
143+
* [#21594] The refusal for an action body that would be handed a family row as
144+
* its subject record, or `undefined` for any other object. The `/actions` door
145+
* loads an action's subject record before it dispatches, through the generic
146+
* data door, so an action declared on a family table — or an object-less one
147+
* addressed under it — would otherwise reach its body with a family row as
148+
* `ctx.record`. Thrown before the body runs, so the body is handed nothing of
149+
* the row; a host code handler's subject record is not judged here.
150+
*/
151+
export function storedMetadataBodySubjectRecordRefusal(object: string, action: string): Error | undefined {
152+
if (!isStoredMetadataBodyObject(object)) return undefined;
153+
return refusal(
154+
`Action '${action}' was not run: its subject record is a row of '${object}', which holds stored `
155+
+ 'metadata, and an app-authored body may not be handed one.',
156+
object,
157+
'record',
158+
READ_PRESCRIPTION,
159+
);
160+
}
161+
108162
/** The family's table names, for messages and logs that list them. */
109163
export function storedMetadataFamilyTableList(): string {
110164
return [...STORED_METADATA_BODY_OBJECTS].map((n) => `'${n}'`).join(', ');

0 commit comments

Comments
 (0)