Skip to content

Commit abe8f28

Browse files
fix(runtime): a sandboxed body or an action handler reading the stored-metadata tables is served the data door's form (#21454) (#21513)
Part of #21454 Clause-②: yes The stored-metadata-body family serves a stored metadata body only as its type's read projection, and the stored content hash over it only in keyed form, at every door. Step 1 of this card measured three in-process reader contexts serving the two stored-metadata tables as stored, body and hash alike. The action contexts run elevated, so a member who invokes the action receives the same answer as an administrator. Triage ruled disposition A: apply the family's projection and keyed serve at the reader-context seams, consume the shared functions, and leave the engine alone. This PR does that for every context the measurement found. It is `Part of`, not `Fixes`: the flow reader that triage folded into this card was measured and is reached, but its fix lies outside this claim's surface (see "Not closed by this PR"). Disclosure discipline: classes, doors, roles, codes and forms only. ## How - **`packages/runtime/src/stored-metadata-reader-seam.ts` (new).** One serve, built only from the generic data door's own functions: - the type companion for a projection that names the body without the type; - the body projection, which consumes the family's one redactor in `@objectstack/spec/kernel`; - the keyed serve, under the crypto provider's digest or, when none is registered, the data door's own process-scoped ephemeral key. `serveStoredMetadataRead` serves one read. `serveStoredMetadataReadsThrough` serves a scoped API: `find`, `findOne` and `aggregate` on a family object, through every context the API can derive (`sudo()`, `withRunAs(...)`, a `transaction(...)` callback's context and the context `beginTransaction()` returns). It is idempotent, so a hash is never keyed twice. Writes, `count` and every other object pass through unchanged. - **`sandbox/body-runner.ts`, `buildSandboxApi`.** Both body faces (hook and action) get their `ctx.api` here, every fallback included. That API is now served through the seam, so a hook body that copies what it read can only copy the projected form. - **`action-execution.ts`.** Two call sites are served through the seam: `buildActionApi`, the `ctx.api` an action body and a host code handler both receive, and `buildActionEngineFacade`'s `find`. Both action doors (REST `/actions` and MCP `run_action`) build these two, so both doors are covered. - **`packages/metadata-protocol/src/index.ts`.** Exports the data door's four stored-row serve functions and the digest type, so the runtime consumes them rather than copying them. The exports are additive and change no behaviour. - **`scripts/engine-double-contract.pinned.json`.** One new pinned row: the seam unit test's scoped-API double routes `findOne` through the engine's predicate. Written by the gate's own `--write`. **A declared in-place widening.** The handler's `ctx.api` (③ in the table below) was not one of the two contexts the ruling named. It is the same scoped context object an action body receives (`buildActionApi`), and it was measured reaching (administrator and member: stored body, stored hash). The fix is the same call, in a file this claim already holds. All four in-place conditions hold, so it lands here and the pins cover it. ## Readings: before and after | context | door | role | before | after | |:--|:--|:--|:--|:--| | ① action body, `ctx.api.object(...)` | REST actions | administrator, member | stored body, stored hash (both tables) | projected body, keyed hash | | ① action body, inside `ctx.api.transaction(...)` | REST actions | administrator, member | stored (administrator, measured this run) | projected, keyed | | ① hook body that copies what it read into an ordinary record | data-door insert | administrator | stored content landed in the record | only projected content lands | | ① hook body | data-door insert | member | refused, 403 `PERMISSION_DENIED` | unchanged | | ① action body authored at runtime through `/meta` | metadata, then actions | administrator authors; administrator and member invoke | stored | projected, keyed | | ② handler `ctx.engine.find` | REST actions | administrator, member | stored (both tables) | projected, keyed | | ③ handler `ctx.api.object(...)` | REST actions | administrator, member | stored (measured this run) | projected, keyed | | control: generic data door | data | administrator | projected, keyed | unchanged | | control: generic data door | data | member | refused, 403 `PERMISSION_DENIED` | unchanged | For every row marked "after", the pins assert four things: - no stored credential and no stored hash anywhere in the answer; - the row's non-credential configuration is present, so the body is the projection of this row, not one that was withheld; - the served hash equals the keyed value the data door serves for the same row, which shows one serve and one key. ## Tests - **Pins.** `packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts` is step 1's probe with its context cases flipped from RECORD to ASSERT. It boots the `bootStack` plugin set, in order, once in `beforeAll` (never inside a case) and runs 15 cases. `packages/runtime/src/stored-metadata-reader-seam.test.ts` holds 8 unit cases for the routes around the seam that the composition does not exercise one by one. Both files are green at `532c96d70f` (15/15, 8/8). - **Runtime suite.** At `532c96d70f`, `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` answered: Test Files 311 passed (311), Tests 4393 passed, 19 skipped. `--project repo` at `265f1c1f32`: Test Files 3 passed, Tests 751 passed. - **metadata-protocol suite.** At `265f1c1f32`, `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` answered: Test Files 205 passed, 3 skipped; Tests 3092 passed, 19 skipped. After that sha the package's only change came in through the main merge, and it did not touch this package. - **Typecheck.** At `532c96d70f`, `pnpm --filter @objectstack/runtime typecheck` (`tsc --noEmit` plus `check:test-typecheck`, OK, ledger unchanged) and `pnpm --filter @objectstack/metadata-protocol typecheck` were both green. - **Built artifacts.** Both the CJS and ESM builds of `@objectstack/runtime` load, and both builds of `@objectstack/metadata-protocol` export the four functions. ## Reverse verification (forms only) The fix was committed first, at `265f1c1f32`. Then: - **Mutation leg.** The three exercised seam calls (the sandbox API, the action `ctx.api`, the engine facade's `find`) were replaced through `scripts/ablation-replace.mjs`. Each anchor went from 1 to 0 and each blob changed. The runtime was rebuilt, and `scripts/ablation-dist-preflight.mjs` found the mutation marker in both dist bundles. The pins then answered 10 failed, 13 passed. Every context case failed with "the stored credential reached the answer". The precondition, the three controls, the member hook refusal and the 8 seam unit cases stayed green. - **Restore leg.** A trap ran `git checkout HEAD --` on the two paths. Both blobs equal HEAD, `git diff HEAD` is empty and the porcelain is clean. After a rebuild, the preflight with `--absent` was clean and the pins answered 23/23 green. - **Direction.** The expected direction, red. `buildActionApi`'s malformed-envelope fallback is the one seam call no case exercises, so it was not mutated. ## Gates - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was derived with no paths at `532c96d70f` and gave 72 lines. All 72 were run at that head, each exited 0, and `--ran` reconciled them: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN. - **CJS load gate.** `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET on an earlier head. It was re-run once the tree's packages were built, and it measured 106 entry points across 66 packages. - **`check:engine-double-contract`** asked for the new pinned row above. - **`check:slot-lookup`** asked for the pins' handler plugin to read the engine slot through its contract type. Both are fixed in this PR. - **Lint, a proven narrowing.** `pnpm lint` (the repo-wide `eslint .`) was not run locally; CI runs it. - Population, read from eslint's own config: 6 of the 8 changed paths are linted. The changeset and the JSON ledger answer "no matching configuration". - Count, read from `--format json` at `532c96d70f`: 6 files, 0 errors, 0 warnings. - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules) and carries no cross-file import rules. Its custom plugins and baselines are per file, so this diff cannot move the verdict on any untouched file. ## Not closed by this PR (measured; reported on the card for routing) 1. **The flow record-read node.** Triage folded it into this card, to be measured first. It is reached. Under both run identities, a flow's record-read node answers the stored body and the stored hash. That answer goes into the run's declared output, which the flow-action door returns to its caller, and into any record the flow writes. - How it was measured: in the `service-automation` tier, on an in-process engine with the automation service, with the row seeded in its stored form. No security plugin was loaded, so the run identities are not role-gated there. - Why it is not edited here: the keyed serve cannot be reached from `@objectstack/service-automation`, which does not depend on `@objectstack/metadata-protocol`. Consuming it there needs either that dependency edge or the keyed serve moved beside the body primitives in `@objectstack/spec/kernel`. Both are outside this claim's surface, and the second is `packages/spec`. 2. **Evaluate shapes through these contexts.** A predicate over the body column, run through a body's `count`, answers whether a guess matches (measured, member invoking). The data door refuses these shapes. The ruled disposition changes only what a read serves, and so does this PR. 3. **An object name the engine's registry does not resolve.** The engine's in-process verbs hand such a name to the driver, and the driver can resolve it to a family table. - The seam judges the object by the family's own predicate on the name, exactly as the data door does. The data door, however, also refuses an unregistered name (404 `OBJECT_NOT_FOUND`), and the in-process verbs do not. - Measured after this fix: an action body, invoked by an administrator or a member, still receives the stored body and hash this way. - This bypasses every name-keyed in-process guard, not only this one, so the fix belongs in the engine. 4. **A hook bound to a family object.** It receives the stored row in its own hook context and can copy it (measured: fired by the metadata door's own save). That row does not come through `ctx.api`, and this PR does not project it, because a hook's input is also its write-back channel. 5. **A write through these contexts.** It returns the written row in its stored form (measured for an elevated body's update). Under dispatch assumption A3 this is reported, not widened into this PR. The same measurement shows that an elevated body can write the family tables directly. ## Acceptance notes - The engine's `ScopedRepo.execute()` hands a code handler a raw scoped context that no seam wraps. Its own census found zero callers. That is engine lane: noted, not filed. - Changeset: `.changeset/21454-reader-context-family-serve.md`, patch for `@objectstack/runtime` and `@objectstack/metadata-protocol`. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88fb5e8 commit abe8f28

8 files changed

Lines changed: 872 additions & 4 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
fix(runtime): a sandboxed body or an action handler that reads the stored-metadata tables is served what the generic data door serves (#21454)
7+
8+
Clause-②: yes
9+
10+
The two stored-metadata tables (the current metadata bodies and their version history) hold each body as stored, credential material included, and a content hash computed over it. The generic data door serves such a row with the body as its type's read projection, with the stored credential material withheld, and the hash in keyed form. Three in-process reader contexts served the same rows as stored:
11+
12+
- a sandboxed action or hook body that reads through `ctx.api.object(...)`, inside `ctx.api.transaction(...)` too;
13+
- an action handler that reads through `ctx.engine.find(...)`;
14+
- an action handler that reads through `ctx.api.object(...)`.
15+
16+
An action body and an action handler run elevated, so the stored form reached whoever could invoke the action, a member included.
17+
18+
**What changes.** A read of either table through any of these contexts now answers the data door's form: the projected body, and the content hash under the same key the data door uses. That key is the crypto provider's, or the process-scoped ephemeral key when no provider is registered. `find`, `findOne` and `aggregate` are served this way, and so is every context the scoped API derives: `sudo()`, `withRunAs(...)`, a `transaction(...)` callback's context, and the context `beginTransaction()` returns. A hook body that copies what it read into another record can now copy only the projected form. A projection that names the body column without the type column reads the type beside it and drops it again, as on the data door.
19+
20+
**What does not change.** Every other object, every write and `count` behave as before. The platform's own readers of these tables still read the stored form, because the projection is applied at the reader contexts and not in the engine.
21+
22+
`@objectstack/metadata-protocol` now exports the data door's stored-row serve, so these contexts consume it and keep no copy: `storedMetadataBodyProjection`, `redactStoredMetadataRows`, `serveStoredMetadataHashColumnRows`, `ephemeralStoredHashDigest` and the `StoredHashDigest` type. The exports are additive.
23+
24+
The four functions `storedMetadataBodyProjection`, `redactStoredMetadataRows`, `serveStoredMetadataHashColumnRows` and `ephemeralStoredHashDigest`, and the type `StoredHashDigest`, are new public API of `@objectstack/metadata-protocol`, and `@objectstack/runtime` consumes them.

‎packages/metadata-protocol/src/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,23 @@ export {
190190
redactMetadataItems,
191191
} from './metadata-redaction.js';
192192

193+
// [#21454] The generic data door's serve of a stored-metadata ROW: the body
194+
// projected (`storedMetadataBodyProjection` plus this package's
195+
// `redactStoredMetadataRows`, the `dropType` wrinkle included) and the stored
196+
// content hash keyed (`serveStoredMetadataHashColumnRows`, under the crypto
197+
// provider's digest or, while none is registered, `ephemeralStoredHashDigest`,
198+
// this module's ONE process-scoped key). Exported so the in-process reader
199+
// contexts in `@objectstack/runtime` serve the family exactly as the door does
200+
// by consuming these functions, never a copy: a second ephemeral key would
201+
// serve a second keyed form of the same row.
202+
export {
203+
ephemeralStoredHashDigest,
204+
redactStoredMetadataRows,
205+
serveStoredMetadataHashColumnRows,
206+
storedMetadataBodyProjection,
207+
} from './metadata-redaction.js';
208+
export type { StoredHashDigest } from './metadata-redaction.js';
209+
193210
export type { MetadataHostEngine } from './host-engine.js';
194211

195212
// [#7560] ADR-0070's read-only-package rule. The authoring path (`saveMetaItem`

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import type { FlowRunSummary } from '@objectstack/spec/automation';
4949
// nothing on an assembly where the protocol plugin is absent, which is exactly
5050
// when the fallback runs.
5151
import { recordNotFoundError } from '@objectstack/metadata-protocol';
52+
import { serveStoredMetadataRead, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js';
5253
import { actorUserFromExecutionContext, resolveActorDisplayName } from './security/actor-user.js';
5354
import type { HttpProtocolContext } from './http-dispatcher.js';
5455
import {
@@ -1496,16 +1497,22 @@ export function buildActionExecutionContext(ec: any): Record<string, unknown> {
14961497
* via `engine.createContext()`, which the action path never called), so the
14971498
* facade proxied every call context-less. Returns `undefined` when the engine
14981499
* predates `createContext`, leaving the sandbox's own fallback in charge.
1500+
*
1501+
* [#21454] Served through the stored-metadata reader seam
1502+
* (`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.
14991506
*/
15001507
export function buildActionApi(_deps: ActionExecutionDeps, ql: any, ec: any): any | undefined {
15011508
if (!ql || typeof ql.createContext !== 'function') return undefined;
15021509
try {
1503-
return ql.createContext(buildActionExecutionContext(ec));
1510+
return serveStoredMetadataReadsThrough(ql.createContext(buildActionExecutionContext(ec)), ql);
15041511
} catch {
15051512
// A malformed caller envelope must not sink the action — fall back to
15061513
// the bare elevated context (the same shape hooks default to).
15071514
try {
1508-
return ql.createContext({ isSystem: true });
1515+
return serveStoredMetadataReadsThrough(ql.createContext({ isSystem: true }), ql);
15091516
} catch {
15101517
return undefined;
15111518
}
@@ -1677,8 +1684,14 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
16771684
// (`assertActionEngineFindEnvelope` above says why the engine's own
16781685
// check cannot be the whole of it).
16791686
assertActionEngineFindEnvelope(object, query);
1680-
const rows = await ql.find(object, { ...(query ?? {}), context } as any);
1681-
return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []);
1687+
// [#21454] The answer is served through the stored-metadata reader
1688+
// seam: this read is elevated, so a read of the stored-metadata-body
1689+
// family answers the generic data door's form (the body projected,
1690+
// the content hash keyed), never the stored row.
1691+
return serveStoredMetadataRead(object, query, ql, async (q) => {
1692+
const rows = await ql.find(object, { ...((q as Record<string, unknown> | undefined) ?? {}), context } as any);
1693+
return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []);
1694+
});
16821695
},
16831696
};
16841697
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
resolveRecordTitle,
5858
resolveRelatedTitleTarget,
5959
} from '@objectstack/objectql';
60+
import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
6061

6162
interface FactoryOptions {
6263
ql: any;
@@ -785,7 +786,21 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) {
785786
};
786787
}
787788

789+
/**
790+
* The `ctx.api` a sandboxed body (hook or action) reads and writes through.
791+
*
792+
* [#21454] Served through the stored-metadata reader seam: a read of the
793+
* stored-metadata-body family answers the generic data door's form (the body
794+
* projected, the content hash keyed), never the stored row. This is the one
795+
* place both body faces get their API, so the hook face, the action face and
796+
* every fallback below are served alike, and a body can copy only what it was
797+
* served.
798+
*/
788799
function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) {
800+
return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql);
801+
}
802+
803+
function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) {
789804
const engineApi = engineCtx?.api;
790805
if (engineApi && typeof engineApi.object === 'function') return engineApi;
791806
// [#3914] The host's own execution envelope, when it supplied one. Hooks get

0 commit comments

Comments
 (0)