Skip to content

Commit 713b0fa

Browse files
fix(metadata-protocol)!: a metadata body's stored content hash is served and compared only in keyed form, never copied, never evaluated (#21207) (#21436)
Fixes #21207 Clause-②: yes (narrowing) Exit two of #21207, under the maintainer's ruling B (`5942670275`) and its execution forks A / A / A (`5950183039`). One PR closes the whole hash-serving exit family enumerated in the exit-two report `5946577002` (members 1 to 13), plus one member this PR's own measurement found (14, below). Exit one already landed as #21228. The stored content hash of a metadata body stays the canonical hash at rest: the repository contract, its producers, the filesystem layer and the parent links are untouched. What changes is what a caller is given and what a caller may evaluate: - **Served** — every door that hands the hash out hands out a keyed digest of it: the crypto provider's, or, while no provider is registered, one under a process-scoped ephemeral key. The one exception is the MCP stdio reader, which omits the two hash columns on a host with no provider. - **Inbound** — every door that takes a version token back compares it in keyed form against the current stored head and hands the stored value to the repository's own lock. A raw stored hash and a stale token are refused with `409 METADATA_CONFLICT`. With no provider, a token this process served is accepted, and an empty, withheld, raw or stale token is refused the same way. - **Evaluated** — filter, sort and group on the two stored content-hash columns are refused with `400 INVALID_FIELD` before the engine, at the data door, the MCP stdio reader and the analytics door. A data-door search over the two stored-metadata tables no longer scans them. - **Copied** — the ledger snapshot and diff, the activity copy and the decision-audit note carry no hash. `os migrate audit-metadata-bodies` (dry run by default, idempotent) now also rewrites the copies already at rest. The version history stays the lineage. Disclosure discipline: this body names classes, doors, roles, codes and statuses only. ## The exit family, member by member | # | Exit (class) | Door(s) | Disposition | |:--|:--|:--|:--| | 1 | save receipt version token | `/meta` save door, runtime dispatcher save door | keyed at the protocol; both transports inherit it | | 2 | publish receipt version token | `/meta` publish door | keyed | | 3 | package batch-publish version tokens | package publish door | keyed per element (the stored value stays internal) | | 4 | rollback receipt version token | `/meta` rollback door | keyed | | 5 | history read: event hash and parent hash | `/meta` history door | keyed per event | | 6 | conflict refusal: text and attributes | save, publish, rollback, reset doors | keyed values or none | | 7 | decision-audit note of a conflict | written by the protocol, served by the `/meta` audit door and the data door | names no hash; a side is `(withheld)` or `null` | | 8 | the two stored content-hash columns on both stored-metadata tables | data door get and list | keyed | | 9 | evaluate shapes on those columns | data door filter, sort, group, and search | `400 INVALID_FIELD`, naming the usable columns | | 10 | MCP stdio engine-only reader | bridge query, get and aggregate; the record resource | keyed; group, filter and sort refused | | 11 | audit ledger copies | plugin-audit writer | the two columns are dropped at write time; at rest via the migration | | 12 | activity copies | plugin-audit writer | same as 11 | | 13 | analytics members on those columns | analytics door | `400 INVALID_FIELD` in either role | | 14 | the version history's change note | history read, data door, MCP stdio reader, copies | see below | **Member 14, found by the after-measurement.** A draft promotion that stated no message of its own recorded the draft's stored hash in the history row's change note. That note was served by the history read, the data door and the MCP stdio reader, and the audit writer copied it. The fix: - The publish door now always states a hash-free message. - A note written before this change is served with each quoted hash in keyed form (under the process key while no provider is registered). Only the MCP stdio reader serves `(withheld)` in its place, on a host with no provider. - The note is never evaluated: filter, sort, group and search are refused, and it is refused as an analytics member. - Copies withhold the quote, at write time and through the migration. The history row itself is not rewritten: the history table stays the lineage. This member is outside the ruling's literal enumeration, so it is flagged for the contract review. **Not exits (unchanged):** the HTTP cache validator (measured: it never carries the stored hash), and realtime record events (out of scope by the ruling; no public channel route in this repository). **The engine** gains one additive read accessor beside `setCryptoProvider`, for the registered provider's keyed digest. It is read at each use, because a host registers the provider after the kernel starts. It is narrower than the provider itself: no consumer is handed `decrypt`. ## Measured on a real boot, before and after Composition: showcase + automation + SQLite file database + audit plugin + the three connector plugins. Administrator and member API keys were minted through the key door (201 / 201). The verify harness registers the local crypto provider, as `os serve` does. Before is base `ecb6ca0258`; after is this branch. | Door, administrator | Before | After | |:--|:--|:--| | save, publish, rollback receipts | 200, token equals the stored head | 200, token is keyed and is not the stored head | | history read | 200, every event hash and parent hash a stored hash | 200, all keyed, none stored | | save and reset doors, raw stored hash sent back | 200, accepted | 409 `METADATA_CONFLICT` | | save door, served token sent back | 200 | 200 | | conflict refusal | 409, body carries the current stored hash | 409, no stored hash | | data door list and get, both tables | 200, stored values; on a credential-bearing row, the served hash plus the projected body confirm a right guess and reject a wrong one | 200, keyed; the guess no longer confirms; stable across reads; a credential-only change still moves it | | data door filter, sort, group on the hash columns | filter: right guess 1 row, wrong guess 0 rows; group serves stored values | 400 `INVALID_FIELD` on each | | data door search over the hash or body column | a right hash prefix and a right credential prefix each match their row | no match; explicit search fields naming one: 400 `INVALID_FIELD` | | decision-audit note (`/meta` audit door, data door) | carries stored hashes | none | | ledger and activity copies written after the change | carry the stored hashes | none (0 rows) | | analytics grouped by a hash column | 200, serves stored values | 400 `INVALID_FIELD` | | MCP stdio reader: query, get, record resource (both tables) | stored values | keyed | | MCP stdio reader: group, filter, sort on a hash column | run | refused, `INVALID_FIELD` | | history change note (member 14), stock row | — | served keyed by the history read and the data door; filter and search refused | Member, before and after alike: data door 403 `PERMISSION_DENIED`, history door 403, ledger 403, analytics 403 `PERMISSION_DENIED`, and MCP `PERMISSION_DENIED` on every member. **Copies at rest**, measured through the CLI door on a database the base code wrote: | Step | Ledger copies with a hash | Activity copies with a hash | Decision notes with a hash | |:--|:--|:--|:--| | before | 25 of 38 | 25 of 38 | 1 | | dry run (exit 0) | unchanged; it reports 53 rows to rewrite | unchanged | unchanged | | `--apply --yes` (exit 0) | 0 of 39 | 0 of 39 | 0 | | second dry run (exit 0) | 0 to rewrite | 0 to rewrite | 0 to rewrite | The 39th row is the ledger copy of the migration's own rewrite of the note, and it carries no hash. The history lineage keeps its 9 stored hashes. On a stock database before the migration runs, the served copies still carry the hash. That is the ruled path: operators run the migration once after upgrading. ## Tests Red first: the new pins were committed on the unfixed tree and run there. - metadata-protocol: 25 failed, 5 passed - mcp: 11 failed, 3 passed - plugin-audit: 14 failed, 88 passed - service-analytics: 6 failed, 7 passed Every red is a door serving or accepting the stored value. The controls stayed green. The member-14 pins and the decision-note copy pin were written after the fix, and their red is shown by ablation legs L06, L10, L15 and L17. Green, at the fix: | Package | Result | |:--|:--| | metadata-protocol | full suite 3013 passed before the merge, then re-run on the touched files after it | | objectql | full suite 7358 passed; one conformance pin now registers a crypto provider | | rest | 4982 passed | | runtime | 5081 passed | | mcp | 380 passed | | plugin-audit | 598 passed | | service-analytics | 3793 passed | | cli | unit project 3489 passed; the migrate preview integration file 6 passed, 1 skipped (the live PG cell) | | dogfood | 17 affected files passed, among them the flow, metadata-route, package-authoring, audit-log, activity, MCP and permission-projection files | `typecheck` exited 0 for metadata-protocol, objectql, mcp, plugin-audit, service-analytics, rest and cli. **Superseded pins updated:** - Two decision-note pins used to assert that the note carries the caller's token. They now assert the note withholds it. - The batch-publish conformance pin asserts a non-empty token with no provider registered. The first cut changed its composition. It is back to its base bytes and passes as written. - The absent-database audit pin that #21432 added (a dry run of the audit-metadata-bodies migration on a database that does not exist) counted two tables unread. The audit now also reads the decision-audit trail, so the pin counts every audited table: three, each named, none scanned, exit 1. A control that removes the decision-audit table from the run turns it red. **Ablations.** The fix was committed first. Each of 17 legs went through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, the targeted pin went red, and the restore showed blob == HEAD with an empty `git diff HEAD`. | Leg | Mutation | Red | |:--|:--|:--| | L01 | receipt served raw | 8 of 11 | | L02 | raw token accepted inbound | 2 of 11 | | L03 | history served raw | 3 of 11 | | L04 | conflict carries the stored hash | 2 of 11 | | L05 | note carries the token | 1 of 11 | | L06 | publish door states no message | 1 of 11 | | L07 | data-door columns served raw | 5 of 27 | | L08 | data-door evaluate shapes unrefused | 12 of 27 | | L09 | search not narrowed | 5 of 27 | | L10 | quoted hash in a note served raw | 2 of 38 | | L11 | MCP columns served raw | 3 of 16 | | L12 | MCP evaluate shapes unrefused | 8 of 16 | | L13 | analytics unrefused | 7 of 14 | | L14 | writer copies the hash | 4 of 93 | | L15 | writer copies a decision note's hash | 1 of 93 | | L16 | migration keeps the columns | 7 of 12 | | L17 | migration keeps a note's hashes | 5 of 12 | **Patch round (CI falsified option A).** The fix lands at `7660d811a7`. Validation and ablation results are in the os-dev-report for this round. The SDK and CLI reset-door pins pass unedited. Restoring the empty token, with dist rebuilt, turns them red again: 3 of 20 and 6 of 20, the exact CI failures. **Gates.** At `1ad5a0099e`: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 84 commands. All 84 ran, every exit code recorded, all 0. - `--ran` reconciles 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN. - `check:error-code-casing` and `check:nul-bytes` exited 0. - `pnpm lint` (the whole repository) exited 0. Gate hygiene this needed: - the new pinned engine doubles recorded through `check-engine-double-contract --write`; - one test double now holds the caller's bound; - one where-matcher now refuses the combinators it does not implement; - the migration reads the decision-audit code through an operator-form predicate, because it is a read and not a stamp; - the persisted audit vocabulary is marked in the pins. ## Acceptance notes - **No crypto provider registered (option A falsified by CI, replaced).** The first cut served an empty version token on a host with no crypto provider. CI falsified that: two real reset-door pins, one in the SDK and one in the CLI, showed that every save then handed out the same empty token. A client that sends no pin for an empty token turned a pinned reset into an unpinned one, so the optimistic lock failed open. Replaced in this PR: while no provider is registered, the doors key under a process-scoped ephemeral key (32 random bytes drawn on first use, never written, logged or served). A token is always served, differs when the content differs, and is never the stored hash. An empty or withheld token sent back is refused with `409 METADATA_CONFLICT`, never read as "no pin". A token held across a restart, or across a provider's first registration, is refused once with the same 409. No stored value carries a served token, so nothing persisted dies with the key. The MCP stdio reader has no version-token door; it still omits the hash columns on a host with no provider. - **Where the hash-column list lives.** The family's natural home is beside the body column's primitives in the spec kernel module, which is outside this claim. metadata-protocol, mcp, plugin-audit and service-analytics each name the same columns. The family enumeration pin holds metadata-protocol's list equal to the columns the two object definitions declare, and each other package's copy is pinned by its own behaviour tests. - **Stale spec descriptions.** The spec's descriptions of the save, publish and batch-publish tokens still say the token is "currently emitted as" an unkeyed hash. The format is declared outside the contract, so this is prose drift for the spec seat. - **metadata-core's base conflict text** still prints both stored values. No door serves it: every door converts the conflict, and the revert door withholds undeclared failures. So it is untouched. - **Serial constraint.** #21377 landed while this branch was in flight, and origin/main was merged in (`41a3c8df15`). It adds no hash exit. origin/main was merged again (`8ca49662e8`, which carries #21432), and that PR's absent-database audit pin was stacked with this one (see Superseded pins). Changeset: `minor`, with a BREAKING banner and one ADR-0087 disposition (`not-required (no-migration-prescription)`). It states the three consequences: a held token gets one 409; filter, sort and group on the hash columns and the change note answer 400; operators run the extended migration once, dry run first. An independent contract review is owed before landing, per the ruling. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cfa4d74 commit 713b0fa

22 files changed

Lines changed: 2765 additions & 138 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/mcp': minor
5+
'@objectstack/plugin-audit': minor
6+
'@objectstack/service-analytics': minor
7+
'@objectstack/cli': minor
8+
---
9+
10+
fix(metadata-protocol)!: a metadata body's stored content hash is served and compared only in keyed form, never copied, and never evaluated (#21207)
11+
12+
Clause-②: yes (narrowing)
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) the stored content hash of a metadata body stays the canonical hash at rest and no metadata body, authorable key, spelling or export moves; what changes is the form a door serves the hash in (a keyed digest: the crypto provider's, or a process-scoped ephemeral key's when none is registered), the form an inbound version token is compared in, and which query shapes the doors accept over the two hash columns, so `objectstack migrate meta` has nothing to rewrite. The operator-run rewrite this release asks for is of audit, activity and decision-audit copies, not of metadata. The other categories are closed on facts: every package here publishes (not `unpublished`); no ADR-0087 id covers a served version token or a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
15+
16+
**BREAKING**: this narrows what the metadata doors serve and accept for the stored content hash of a metadata body — a hash over the whole stored body, withheld credential material included. Served beside the projected body it let a reader confirm a guess at that material offline; filtered on, it confirmed one online. It ships as `minor` under the launch-window convention for accept-set narrowings.
17+
18+
**Three things change for callers and operators.**
19+
20+
1. **A held version token gets one `409 METADATA_CONFLICT`.** Every door that hands out a metadata version token — the save, publish, package-publish and rollback receipts and the history read — now hands out a keyed digest of the stored hash instead of the hash itself, and the save and reset doors compare a token they are sent in that same form. The key is the crypto provider's; a host that registers none keys under a process-scoped ephemeral key instead, so a token is always issued and never empty. A token a client held from before the upgrade is refused once; take the token from the next read or receipt and retry. On a host with no provider the same happens after a restart, and on any host when a provider is first registered. An empty, withheld, raw or stale token is refused with the same `409`; it is never read as "no pin".
21+
2. **Filter, sort and group on the two stored content-hash columns, and on the version history's change note, now answer `400 INVALID_FIELD`** — on the generic data door, the MCP stdio reader and the analytics door, before the engine runs. The change note is included because a draft promotion that stated no message of its own recorded the draft's stored hash in it; the publish door now always states a hash-free message, and a note written before this release is served with the quoted hash in keyed form. A data-door search over the two stored-metadata tables no longer scans those columns or the stored body column, and an explicit search-field list naming one answers the same `400`. Every other column of the two tables is served, filtered, sorted and grouped as before, and every other object is unchanged.
22+
3. **Operators run `os migrate audit-metadata-bodies` once after upgrading, dry run first.** The audit ledger, the activity feed and the metadata decision-audit trail no longer copy the stored hash. The extended command drops it from the copies already written and withholds it in the decision-audit notes and their copies: a dry run by default, `--apply` to rewrite, idempotent. The version history stays the lineage.
23+
24+
**What else changes.** The data door serves the two hash columns of the stored-metadata tables in keyed form, under the same key as the version tokens. The MCP stdio reader serves them keyed under the crypto provider's key, and omits them on a host with no provider. A `409` conflict refusal carries keyed values or none. The ObjectQL engine gains a read accessor for the registered provider's keyed digest; it is additive. A member's read of these tables is refused as before.

‎packages/cli/src/commands/migrate/audit-metadata-bodies.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,26 @@ async function confirm(question: string): Promise<boolean> {
4444
* NEW writes; rows copied before the fix keep their cleartext. This command
4545
* rewrites them, projecting each copied body through the SAME redactor.
4646
*
47+
* [#21207] The same copies also carried the copied row's stored CONTENT HASH
48+
* (`checksum`, and the history row's `previous_checksum`) — a hash over the whole
49+
* stored body, withheld credential material included — and the decision-audit
50+
* note of a refused optimistic-lock write (`sys_metadata_audit`, and its ledger
51+
* and activity copies) named both hashes. The writers no longer copy either;
52+
* this command drops the hash columns from the copies already written and
53+
* withholds the hashes in those notes, in the same pass. Operators run it once
54+
* after upgrading, dry run first.
55+
*
4756
* Dry run by default (writes nothing), `--apply` to rewrite. Idempotent: a
48-
* second run finds nothing — a redacted copy has no credential left — so
49-
* re-running and reading a clean report is the verification. No `sys_migration`
50-
* flag is recorded: nothing gates irreversible behaviour on this rewrite (the
51-
* posture `os migrate summary-nulls` takes).
57+
* second run finds nothing — a redacted copy has no credential and no hash
58+
* left — so re-running and reading a clean report is the verification. No
59+
* `sys_migration` flag is recorded: nothing gates irreversible behaviour on this
60+
* rewrite (the posture `os migrate summary-nulls` takes).
5261
*/
5362
export default class MigrateAuditMetadataBodies extends Command {
5463
static override description =
55-
'Rewrite at-rest cleartext metadata-body copies the audit writer left in sys_audit_log / sys_activity, ' +
56-
'projecting each copied body through the shared credential redactor. Dry run by default; --apply writes.';
64+
'Rewrite the at-rest copies the audit writer left in sys_audit_log / sys_activity: project each copied ' +
65+
'metadata body through the shared credential redactor and drop its stored content hash; withhold the hashes ' +
66+
'a conflict note in sys_metadata_audit (and its copies) names. Dry run by default; --apply writes.';
5767

5868
static override examples = [
5969
'$ os migrate audit-metadata-bodies',
@@ -117,12 +127,12 @@ export default class MigrateAuditMetadataBodies extends Command {
117127
this.exit(1);
118128
return;
119129
}
120-
printWarning('Apply mode rewrites audit/activity rows. Re-run with --yes to confirm, or run without --apply to preview.');
130+
printWarning('Apply mode rewrites audit/activity/decision rows. Re-run with --yes to confirm, or run without --apply to preview.');
121131
this.exit(1);
122132
return;
123133
}
124134
const ok = await confirm(
125-
chalk.bold('\nRewrite every audit/activity row carrying a stored metadata body on this database? [y/N] '),
135+
chalk.bold('\nRewrite every audit/activity/decision row carrying a stored metadata body or content hash on this database? [y/N] '),
126136
);
127137
if (!ok) {
128138
printInfo('Aborted — no changes made.');
@@ -188,14 +198,14 @@ export default class MigrateAuditMetadataBodies extends Command {
188198
printError(`${report.failures} row(s) could not be rewritten — re-run to finish them.`);
189199
} else if (apply && report.rewritten > 0) {
190200
printSuccess(
191-
`Rewrote ${report.rewritten} audit/activity row(s). Re-run any time — it only revisits rows still carrying a body.`,
201+
`Rewrote ${report.rewritten} audit/activity/decision row(s). Re-run any time — it only revisits rows still carrying a body or a hash.`,
192202
);
193203
} else if (apply) {
194-
printSuccess('Nothing to rewrite — no audit/activity row carries a stored metadata body.');
204+
printSuccess('Nothing to rewrite — no audit/activity/decision row carries a stored metadata body or content hash.');
195205
} else if (report.rewritten > 0) {
196206
printInfo(`Dry run only — ${report.rewritten} row(s) would be rewritten. Re-run with --apply.`);
197207
} else {
198-
printSuccess('Nothing to rewrite — no audit/activity row carries a stored metadata body.');
208+
printSuccess('Nothing to rewrite — no audit/activity/decision row carries a stored metadata body or content hash.');
199209
}
200210
console.log(chalk.dim(` ${timer.display()}`));
201211
console.log('');

‎packages/cli/src/commands/migrate/preview-read-only.integration.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,16 +493,22 @@ for (const cell of DIALECT_CELLS) {
493493
expect(payload.error).toContain("'sys_metadata'");
494494
}, cell.timeout);
495495

496-
it('audit-metadata-bodies without --apply on a database that does not exist exits 1 with both tables counted unread', async () => {
496+
// [#21207] The audit reads a third table: the decision-audit trail, whose
497+
// conflict notes named stored content hashes. The #21391 intent is
498+
// unchanged — EVERY audited table is counted unread — so the expected set
499+
// is the whole audited set, stated literally: a widening that is not
500+
// carried here turns this case red instead of passing on a stale count.
501+
it('audit-metadata-bodies without --apply on a database that does not exist exits 1 with every audited table counted unread', async () => {
497502
const absent = join(fixture!.dir, 'data', 'never-started.db');
498503
const { payload, exitCode } = await runJson(auditBodies, ['--database-url', `file:${absent}`]);
504+
const audited = ['sys_activity', 'sys_audit_log', 'sys_metadata_audit'];
499505

500506
expect(exitCode).toBe(1);
501507
expect(payload.apply).toBe(false);
502508
// `failures` counts the tables whose rows were NOT examined.
503-
expect(payload.report.failures).toBe(2);
509+
expect(payload.report.failures).toBe(audited.length);
504510
expect(payload.report.scanned).toBe(0);
505-
expect(Object.keys(payload.report.byObject).sort()).toEqual(['sys_activity', 'sys_audit_log']);
511+
expect(Object.keys(payload.report.byObject).sort()).toEqual(audited);
506512
}, cell.timeout);
507513
}
508514
});

‎packages/mcp/src/plugin.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import {
2929
createStdioDataBridge,
3030
enforceApiExposure,
3131
GATED_ACTIONS,
32+
serveStoredMetadataHashes,
3233
serveStoredMetadataRow,
34+
type StoredHashDigest,
3335
} from './stdio-data-bridge.js';
3436
import type { McpDataBridge } from './mcp-http-tools.js';
3537
import { CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
@@ -377,7 +379,14 @@ export class MCPServerPlugin implements Plugin {
377379
let dataBridge: McpDataBridge | undefined;
378380
if (shouldStart) {
379381
const apiKey = readEnvWithDeprecation('OS_MCP_STDIO_API_KEY', [], { silent: true });
380-
let ql: (IDataEngine & { find: (object: string, opts: unknown) => Promise<unknown> }) | undefined;
382+
let ql:
383+
| (IDataEngine & {
384+
find: (object: string, opts: unknown) => Promise<unknown>;
385+
// [#21207] The engine's keyed-digest accessor (objectql), probed
386+
// per call: an engine without it serves no content hash.
387+
getKeyedDigest?: () => StoredHashDigest | undefined;
388+
})
389+
| undefined;
381390
try {
382391
ql = ctx.getService('objectql');
383392
} catch {
@@ -546,6 +555,10 @@ export class MCPServerPlugin implements Plugin {
546555
// wall that changed mid-session must take effect on the next call rather
547556
// than at the next process restart. See `resolveStdioTenancyPosture` for
548557
// why this is not hoisted next to the localization memo.
558+
// [#21207] The crypto provider's keyed digest, read at each use — the
559+
// host registers the provider after the kernel starts.
560+
const storedHashDigest = (): StoredHashDigest | undefined =>
561+
typeof scopedQl.getKeyedDigest === 'function' ? scopedQl.getKeyedDigest() : undefined;
549562
const resolvePrincipal = async (): Promise<ExecutionContext> => {
550563
const ec = await resolveStdioExecutionContext(
551564
scopedQl,
@@ -561,6 +574,7 @@ export class MCPServerPlugin implements Plugin {
561574
engine: scopedQl,
562575
metadataService,
563576
resolvePrincipal,
577+
keyedDigest: storedHashDigest,
564578
});
565579
} else {
566580
// Functional degradation, said once and naming the remedy: two of the
@@ -611,7 +625,12 @@ export class MCPServerPlugin implements Plugin {
611625
// `sys_metadata_history` row's body reaches this resource as its type's
612626
// read projection, never as the stored bytes — so the tool and the
613627
// resource cannot disagree about what a stored credential is.
614-
return serveStoredMetadataRow(objectName, (row ?? null) as Record<string, unknown> | null);
628+
// [#21207] …and its stored content hash keyed, or not served at all.
629+
return serveStoredMetadataHashes(
630+
objectName,
631+
serveStoredMetadataRow(objectName, (row ?? null) as Record<string, unknown> | null),
632+
storedHashDigest(),
633+
);
615634
};
616635
ctx.logger.info(
617636
`[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity ${initial.userId} (RLS/FLS/tenant applied)`,

0 commit comments

Comments
 (0)