Found while implementing #6055 (MCP consumer of getDiagnosed). Filed per Prime Directive #10, unassigned, severity for PM triage. Not fixed in PR for #6055 — that PR is scoped to packages/mcp/src/mcp-server-runtime.ts, and this needs a contract addition.
The fact (verified on origin/main)
#5840 / PR #6051 closed the singular half of ADR-0110 D3: MetadataManager.get() discarded loadDiagnosed's degraded verdict, so a MISS and an OUTAGE reached every consumer as the same undefined. getDiagnosed(type, name) now hands the verdict over.
The plural read has exactly the same shape and was not covered.
packages/metadata/src/metadata-manager.ts, readListUncached(type) — computes the verdict:
let degraded = false;
for (const loader of this.loaders.values()) {
try { /* ... loader.loadMany(type) ... */ }
catch (e) {
degraded = true;
this.reportLoaderReadFailure(loader.contract.name, type, e);
}
}
return { items: Array.from(items.values()), degraded };
list(type) then consumes degraded only to pick a cache TTL and returns items alone:
const shared: Promise< unknown[] > = this.readListUncached(type).then(({ items, degraded }) => {
if (this.inflightListReads.get(type) === shared) {
this.cacheListResult(type, items, degraded);
}
return items;
});
The comment right above it (#5184) already names the fact — "the memoized answer carries the fact that it is known-partial instead of being indistinguishable from a complete one" — but that fact is carried inside the cache, never to a caller. IMetadataService declares no listDiagnosed counterpart (grep: none exists anywhere in the repo).
Why this is not merely cosmetic
A consumer receiving a short list has no way to ask whether it is short because that is all anyone declared, or because a loader was down. Two measured consumers, both in packages/mcp/src/mcp-server-runtime.ts:
objectstack://objects — await metadataService.listObjects() is rendered as { objects, totalCount }. During a loader outage an MCP client is told, positively and with a count, that the environment contains fewer objects than it does.
agent_prompt's sibling skill bridge — (await metadataService.list('skill')) ?? [] becomes the prompts/list snapshot.
Neither widens access (both are read surfaces), so like #6055 this is a diagnosis defect rather than a security one. But unlike the singular read, list is the one that carries a count, and a count is the strongest positive claim a read can make.
The blast radius is wider than packages/mcp: list/listObjects/listNames are read across metadata-protocol, rest, runtime and the plugins. This issue does not pre-judge which of those should change behaviour — PR #6051's discipline was to qualify each consumer separately (gating / non-gating / mis-describing) rather than apply one blanket rule, and the same is owed here.
Decision this needs before any code
Adding listDiagnosed?(type) to IMetadataService is a public-contract addition, so it is a maintainer call, not a call-site fix. The cheaper alternative worth pricing against it: list() already memoizes the verdict, so a listDiagnosed would be near-free on the producer, and the cost is entirely in the consumer sweep.
Refs #5840, PR #6051, #6055, ADR-0110 D3, #5184 (where the verdict was first computed), #5253.
Found while implementing #6055 (MCP consumer of
getDiagnosed). Filed per Prime Directive #10, unassigned, severity for PM triage. Not fixed in PR for #6055 — that PR is scoped topackages/mcp/src/mcp-server-runtime.ts, and this needs a contract addition.The fact (verified on
origin/main)#5840 / PR #6051 closed the singular half of ADR-0110 D3:
MetadataManager.get()discardedloadDiagnosed'sdegradedverdict, so a MISS and an OUTAGE reached every consumer as the sameundefined.getDiagnosed(type, name)now hands the verdict over.The plural read has exactly the same shape and was not covered.
packages/metadata/src/metadata-manager.ts,readListUncached(type)— computes the verdict:list(type)then consumesdegradedonly to pick a cache TTL and returnsitemsalone:The comment right above it (#5184) already names the fact — "the memoized answer carries the fact that it is known-partial instead of being indistinguishable from a complete one" — but that fact is carried inside the cache, never to a caller.
IMetadataServicedeclares nolistDiagnosedcounterpart (grep: none exists anywhere in the repo).Why this is not merely cosmetic
A consumer receiving a short list has no way to ask whether it is short because that is all anyone declared, or because a loader was down. Two measured consumers, both in
packages/mcp/src/mcp-server-runtime.ts:objectstack://objects—await metadataService.listObjects()is rendered as{ objects, totalCount }. During a loader outage an MCP client is told, positively and with a count, that the environment contains fewer objects than it does.agent_prompt's sibling skill bridge —(await metadataService.list('skill')) ?? []becomes theprompts/listsnapshot.Neither widens access (both are read surfaces), so like #6055 this is a diagnosis defect rather than a security one. But unlike the singular read,
listis the one that carries a count, and a count is the strongest positive claim a read can make.The blast radius is wider than
packages/mcp:list/listObjects/listNamesare read acrossmetadata-protocol,rest,runtimeand the plugins. This issue does not pre-judge which of those should change behaviour — PR #6051's discipline was to qualify each consumer separately (gating / non-gating / mis-describing) rather than apply one blanket rule, and the same is owed here.Decision this needs before any code
Adding
listDiagnosed?(type)toIMetadataServiceis a public-contract addition, so it is a maintainer call, not a call-site fix. The cheaper alternative worth pricing against it:list()already memoizes the verdict, so alistDiagnosedwould be near-free on the producer, and the cost is entirely in the consumer sweep.Refs #5840, PR #6051, #6055, ADR-0110 D3, #5184 (where the verdict was first computed), #5253.