Skip to content

Commit 2f837a5

Browse files
fix(runtime)!: refuse the stored-metadata family evaluate shapes and serve write returns at the reader-context seams (#21539)
Part of #21454 Clause-②: yes (narrowing) The follow-on to PR #21513, on the same reader-context seam. #21513 served the stored-metadata-body family's reads (body projected, content hash keyed) at the in-process reader contexts. This PR closes the two positions triage routed to the card's next claim, through the generic data door's own code — no copy: - **Evaluate-shape refusals** (triage `5964426684` item 2, with `5963299937`). A filter, sort, grouping or search that would EVALUATE the stored body or a content-hash column of the family, arriving through a reader context (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`), is now refused with the door's own `INVALID_FIELD` / 400 before the query runs. A `count` carrying such a predicate — the oracle verb — is refused too (it serves no row). A default search is NARROWED to the door's served field set (the body and content-hash columns removed, judged field by field by the door's own search predicate) rather than refused, so a reader context may still search a family table by its scalar columns exactly as the door serves it; a search that narrows to nothing is refused. - **Serve what a write verb returns** (triage `5964426684` item 4). A write whose return carries the family's body or content hash is served projected and keyed, the same way a read is — a returned row is a serve. Both are executed through the four refusal predicates the door uses, now exported from `@objectstack/metadata-protocol` (`storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal`, `storedMetadataSearchRefusal`). The search narrowing consumes the spec's own `resolveSearchFieldResolution` and the door's search predicate as the authority on which columns a search may never scan. Field collection for the filter/sort refusals uses `@objectstack/plugin-security`'s `collectConditionFields` (the door's sibling collector; the door's own `collectFilterFieldKeys` is internal to `protocol.ts`, PR #21473's file, and unreachable here). That collector gates on a dotted head and reads a cross-field reference, so it refuses MORE than the door — a dotted or cross-field reference to a family column the door's collector would miss — strictly in the safe direction, never a legitimate scalar-column query. ### The engine action verb (`ScopedRepo.execute`), measured (triage `5964836549` item 1) The reach reading: `execute` is UNREACHABLE from a served body. The sandbox VM bridge exposes only `find` / `findOne` / `count` / `aggregate` and the writes to a body — no `execute`, no `sudo`, no `withRunAs` — so a served body can never hand a raw scoped context to a nested action. The seam therefore leaves `execute` untouched and records the reading (pinned: a body's `typeof ctx.api.object(...).execute` is `undefined`). ### Where #21520's write-verb refusal attaches The maintainer approved #21520 option A (an app-authored body may not write the family's tables). That refusal is a SEPARATE card and is NOT implemented here. It attaches on the same write verbs this seam wraps, in `serveRepository`'s write branch, as a throw BEFORE the write runs — it needs no reshaping of this seam. A code comment names that point. Measured on `main` today (pre-#21520): an elevated body's family-table write is not refused and returns the stored row, so the write-return serve in this PR carries real content; it also covers the host-handler write path, which #21520's body refusal does not reach. ### Reverse verification (ablation) Each new behavior ablated on disk (`scripts/ablation-replace.mjs`, mutation proven by blob hash + anchor count, restored to the HEAD blob), src-resolved (the pins import the seam by relative path): - disabling the evaluate refusal turned the 6 refusal / narrowing pins red; the serve, write-return, scalar and unreachability pins stayed green; - disabling the write-return serve turned exactly the write-return pin red; all else green. ### Tests and gates - New pins: `stored-metadata-reader-seam.test.ts` (17) against a scoped-API double, and `stored-metadata-reader-contexts.pin.test.ts` (26) end to end through a booted kernel, for administrator and member alike. - `@objectstack/runtime` suite 4413 passed / 19 skipped; `@objectstack/metadata-protocol` suite 3151 passed / 19 skipped; both typecheck clean. - All 70 dispatch-derived gate families green (`check:engine-double-contract` pinned-ledger entry added through the gate's own `--write`). - Lint narrowing: the 4 changed TS files lint 0 errors / 0 warnings; the `.md` and `.json` have no matching eslint config; `eslint.config.mjs` enables no type-aware linting and no cross-file import rules, so this diff cannot move the verdict on any untouched file (the farm-wide `pnpm lint` is CI's). --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e901c27 commit 2f837a5

6 files changed

Lines changed: 520 additions & 46 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(runtime)!: the in-process reader contexts refuse the stored-metadata-body family's EVALUATE shapes and serve what a write returns, the way the generic data door does (#21454)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which query shapes the in-process reader contexts accept over the two stored-metadata tables, and the form in which a write's returned row is served, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what an action or hook body's object API, an action handler's scoped API and an action handler's engine handle accept when they read the two stored-metadata tables. A read there that filters, sorts or groups on the stored body column or on a content-hash column, a read that names one of those columns in an explicit search-field list, and a `count` carrying such a filter, ran before this release and now answer the generic data door's `400 INVALID_FIELD` before the query runs. The route: filter, sort, group and search those tables by their scalar columns (the type, the name, the state and the like), and read the bodies with a plain list, which is served projected — the body as its type's read projection, the content hash in keyed form. A default search with no field list is not refused: it is narrowed to the columns the door serves. Every other column of the two tables, and every other object, is unchanged. It ships as `minor` under the launch-window convention for accept-set narrowings.
13+
14+
- **`@objectstack/metadata-protocol`** now exports the generic data door's four evaluate-refusal predicates — `storedMetadataBodyGroupingRefusal`, `storedMetadataBodyPredicateRefusal`, `storedMetadataHashEvaluateRefusal` and `storedMetadataSearchRefusal` — so the `@objectstack/runtime` reader-context seam refuses the same shapes through the door's own predicates rather than a second copy. Additive: nothing that imported the package before is changed.
15+
- **`@objectstack/runtime`** extends the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`): a filter, sort, grouping or search that would evaluate the stored body or content hash of `sys_metadata` / `sys_metadata_history` is refused with the door's `INVALID_FIELD` / 400 before the query runs (a `count` with such a predicate included); a default `$search` is narrowed to the door's served field set rather than refused; and the row a write verb returns is served projected and keyed. The engine's own action verb (`ScopedRepo.execute`) is unreachable from a served body and is left untouched.

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,20 @@ export {
207207
} from './metadata-redaction.js';
208208
export type { StoredHashDigest } from './metadata-redaction.js';
209209

210+
// [#21454] The generic data door's EVALUATE refusals on the same family
211+
// (#21086 grouping, #21120 body filter / sort, #21207 content-hash evaluate and
212+
// search). Exported so the in-process reader contexts in `@objectstack/runtime`
213+
// refuse the evaluate shapes the way the door does — each through the door's
214+
// OWN predicate, never a copy: a second definition of which shapes leak a
215+
// stored body or hash is exactly the drift the family's one rule exists to
216+
// prevent.
217+
export {
218+
storedMetadataBodyGroupingRefusal,
219+
storedMetadataBodyPredicateRefusal,
220+
storedMetadataHashEvaluateRefusal,
221+
storedMetadataSearchRefusal,
222+
} from './metadata-redaction.js';
223+
210224
export type { MetadataHostEngine } from './host-engine.js';
211225

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

‎packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,64 @@ const PIN_APP: any = {
118118
{ name: 'handler_engine_reads_family', label: 'Handler engine read', type: 'script' },
119119
{ name: 'handler_engine_reads_history', label: 'Handler engine history read', type: 'script' },
120120
{ name: 'handler_api_reads_family', label: 'Handler api read', type: 'script' },
121+
// [#21454] EVALUATE shapes — each body attempts to evaluate the stored
122+
// body or hash, and each must be refused before the query runs. The
123+
// VALUE in every predicate is an immaterial constant: the query is
124+
// refused unrun, so nothing depends on what it is.
125+
{
126+
name: 'body_filters_body_column',
127+
label: 'Body filters the body column',
128+
type: 'script',
129+
body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ where: { metadata: { $contains: 'z' } } }) };`),
130+
},
131+
{
132+
name: 'body_sorts_body_column',
133+
label: 'Body sorts by the body column',
134+
type: 'script',
135+
body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ orderBy: [{ field: 'metadata', order: 'asc' }] }) };`),
136+
},
137+
{
138+
name: 'body_groups_body_column',
139+
label: 'Body groups by the body column',
140+
type: 'script',
141+
body: actionBody(`return { rows: await ctx.api.object('sys_metadata_history').aggregate({ groupBy: ['metadata'] }) };`),
142+
},
143+
{
144+
name: 'body_filters_hash_column',
145+
label: 'Body filters the hash column',
146+
type: 'script',
147+
body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ where: { checksum: 'z' } }) };`),
148+
},
149+
{
150+
name: 'body_counts_body_column',
151+
label: 'Body counts by the body column',
152+
type: 'script',
153+
body: actionBody(`return { n: await ctx.api.object('sys_metadata').count({ where: { metadata: { $contains: 'z' } } }) };`),
154+
},
155+
{
156+
name: 'body_searches_body_column',
157+
label: 'Body searches an explicit body column',
158+
type: 'script',
159+
body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ search: 'z', searchFields: ['metadata'] }) };`),
160+
},
161+
// A DEFAULT search is narrowed, not refused: a body may still search a
162+
// family table by its scalar columns, served like the door.
163+
{
164+
name: 'body_searches_default',
165+
label: 'Body default search',
166+
type: 'script',
167+
body: actionBody(`return { rows: await ctx.api.object('sys_metadata').find({ search: '${DS_NAME}' }) };`),
168+
},
169+
// The engine action verb is not on a served body's surface at all.
170+
{
171+
name: 'body_calls_execute',
172+
label: 'Body calls execute',
173+
type: 'script',
174+
body: actionBody(`return { typeofExecute: typeof ctx.api.object('sys_metadata').execute };`),
175+
},
176+
// ② the engine handle's evaluate shape — a handler whose ctx.engine.find
177+
// filters the body column is refused the same way.
178+
{ name: 'handler_engine_filters_body', label: 'Handler engine filters body', type: 'script' },
121179
],
122180
},
123181
],
@@ -167,6 +225,13 @@ const PIN_HANDLER_PLUGIN: Plugin = {
167225
async (actionCtx: any) => ({ rows: await actionCtx.api.object('sys_metadata').find({ where }) }),
168226
'pin.reader21454.handler',
169227
);
228+
// [#21454] ② the engine handle's evaluate shape: a filter on the body column.
229+
ql.registerAction(
230+
'pin_note',
231+
'handler_engine_filters_body',
232+
async (actionCtx: any) => ({ rows: await actionCtx.engine.find('sys_metadata', { where: { metadata: { $contains: 'z' } } }) }),
233+
'pin.reader21454.handler',
234+
);
170235
},
171236
};
172237

@@ -461,3 +526,73 @@ describe('[#21454] ② / ③ an action handler\'s engine handle and scoped API',
461526
});
462527
}
463528
});
529+
530+
describe('[#21454] the EVALUATE shapes are refused end to end, the door\'s own refusal', () => {
531+
/**
532+
* Each shape answers the data door's refusal (`INVALID_FIELD` / 400), names
533+
* the offending column, and carries no family content. The envelope's precise
534+
* `param` / `field` are pinned directly on the door's predicate in the unit
535+
* test; across the sandbox boundary only `code`, `status` and the MESSAGE are
536+
* guaranteed (`SANDBOX_ERROR_PASSTHROUGH`), so the column is read from the
537+
* message, which both the sandboxed-body and the host-handler paths carry.
538+
*/
539+
async function expectRefusedAction(action: string, token: string, column: string): Promise<void> {
540+
const res = await as(token, 'POST', `/actions/pin_note/${action}`, { params: {} });
541+
const payload = await readJson(res);
542+
const err = payload?.error ?? payload;
543+
const text = JSON.stringify(payload ?? null);
544+
expect(res.status, `${action}: refused with 400`).toBe(400);
545+
expect(err?.code, `${action}: the data door's INVALID_FIELD`).toBe('INVALID_FIELD');
546+
const named = (Array.isArray(err?.fields) && err.fields.includes(column))
547+
|| String(err?.message ?? '').includes(`'${column}'`);
548+
expect(named, `${action}: the refusal names '${column}'`).toBe(true);
549+
expect(text.includes(SENTINEL), `${action}: the stored credential reached the answer`).toBe(false);
550+
for (const h of storedHashes) expect(text.includes(h), `${action}: a stored hash reached the answer`).toBe(false);
551+
}
552+
553+
for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) {
554+
it(`a body's filter / sort / grouping on the body column, invoked by the ${role}`, async () => {
555+
await expectRefusedAction('body_filters_body_column', token(), 'metadata');
556+
await expectRefusedAction('body_sorts_body_column', token(), 'metadata');
557+
await expectRefusedAction('body_groups_body_column', token(), 'metadata');
558+
});
559+
560+
it(`a body's filter on a hash column, and count as an oracle, invoked by the ${role}`, async () => {
561+
await expectRefusedAction('body_filters_hash_column', token(), 'checksum');
562+
await expectRefusedAction('body_counts_body_column', token(), 'metadata');
563+
});
564+
565+
it(`a body's explicit search of the body column, invoked by the ${role}`, async () => {
566+
await expectRefusedAction('body_searches_body_column', token(), 'metadata');
567+
});
568+
569+
it(`the engine handle's filter on the body column, invoked by the ${role}`, async () => {
570+
await expectRefusedAction('handler_engine_filters_body', token(), 'metadata');
571+
});
572+
}
573+
});
574+
575+
describe('[#21454] a DEFAULT search is narrowed to the door\'s served set, not refused', () => {
576+
for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) {
577+
it(`a body's default search runs and is served like the door, invoked by the ${role}`, async () => {
578+
const res = await as(token(), 'POST', '/actions/pin_note/body_searches_default', { params: {} });
579+
expect(res.status).toBe(200);
580+
// It ran (not refused) and answered the family served, never the stored
581+
// body or hash — the body and hash columns were removed from the scan.
582+
expectServedLikeTheDoor(`default search (${role})`, await readJson(res));
583+
});
584+
}
585+
});
586+
587+
describe('[#21454] the engine action verb is unreachable from a served body', () => {
588+
it('a sandboxed body sees no `execute` on ctx.api.object(...)', async () => {
589+
const res = await as(adminToken, 'POST', '/actions/pin_note/body_calls_execute', { params: {} });
590+
expect(res.status).toBe(200);
591+
const text = JSON.stringify(await readJson(res) ?? null);
592+
// The VM bridge installs only find/findOne/count/aggregate and the writes;
593+
// `execute` is not a function the body can call, so no raw scoped context
594+
// is ever handed to a nested action through a served body. (Read from the
595+
// response text, envelope-agnostic.)
596+
expect(text).toContain('"typeofExecute":"undefined"');
597+
});
598+
});

0 commit comments

Comments
 (0)