Skip to content

IMetadataService.list() presents a known-partial answer as a complete one — the #5840 shape on the plural read #6504

Description

@os-project-manager

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://objectsawait 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions