feat(spec,metadata,mcp): let a plural metadata read say it is known-partial (#6504) - #7721
Conversation
…artial (#6504) `IMetadataService.list(type)` returns an array whether every loader answered or one was down, so a consumer receiving a short list could not ask which it was. `MetadataManager.readListUncached()` has computed the `degraded` verdict since #5184 and `list()` spent it entirely on a cache TTL. This is the #5840 / PR #6051 shape on the plural read, and sharper there: `list` is the read whose answer carries a count, and a count is the strongest positive claim a read can make. - spec: new optional `IMetadataService.listDiagnosed?(type)` returning `{ items, degraded, errors }` — the plural counterpart of `getDiagnosed`. - metadata: `MetadataManager.listDiagnosed()`, sharing `list()`'s cache entry and single-flight slot, so the verdict costs no extra loader walk and the two members cannot drift. `list()` is unchanged in every direction. - mcp: the two measured consumers, classified individually per PR #6051. `objectstack://objects` mis-described, so a degraded read now withholds `totalCount` while still serving the objects it could read; the skill bridge is a snapshot and reports its incompleteness to the operator instead. Part of #6504 — the wider consumer sweep (metadata-protocol, rest, runtime, plugins) is deliberately excluded while #7674 is in flight on `packages/metadata-protocol/src/protocol.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoUxMErFzTQVpQzjNgDAGm
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Part of #6504
IMetadataService.list(type)returns an array whether every loader answered or one of them was down. A consumer receiving a short list therefore had no way to ask whether it was short because that is all anyone declared, or because a loader was unreachable.The verdict already existed and was already being discarded:
MetadataManager.readListUncached()has computed adegradedflag since #5184, andlist()spent it entirely on picking a cache TTL.reportLoaderReadFailure's own message says what that costs — "every list served from now on is a PARTIAL set presented as a complete one, and the server keeps reporting healthy" — but until now that sentence was addressed to a log reader only, because no caller had a way to ask.Why this needed no new maintainer decision
The card asks whether adding
listDiagnosed?(type)toIMetadataServiceis a maintainer call. It is not an open question, on two independent grounds:MetadataManager.get()by addinggetDiagnosed(type, name). The boundary check that would invalidate inheritance — whether the parent's reasoning was specific to the singular branch — does not hold: MetadataManager.get() 丢弃 loadDiagnosed 的 degraded 判定:loader 读不到与「这一项没声明」在 6 个消费点上不可分辨 #5840's reasoning is that a MISS and an OUTAGE reach consumers as the same value, and on the plural that reasoning is stronger, becauselistis the read that carries a count, and a count is the strongest positive claim a read can make.What changed
packages/spec/src/contracts/metadata-service.ts— new optionallistDiagnosed?(type)returning{ items, degraded, errors }, following thegetDiagnosedshape already in the file. Optional for the same reason its singular twin is: an implementation that predates it cannot report the distinction, so a consumer probes for it and falls back.list's TSDoc now names the ambiguity and points at it, asget's does.packages/metadata/src/metadata-manager.ts— the producer.list()andlistDiagnosed()are now one read seen at two widths: a privatereadList()owns the cache entry and the single-flight slot,list()narrows to.items, andlistDiagnosed()returns the record. Consequences worth naming:listDiagnosedcaller joining an in-flight read receives that read's verdict instead of having to start a second walk — theinflightListReadscontract already promised sharers "the outcome", which was true oflist()alone;ListCacheEntrygainserrors, so a consumer served from the memoized entry learns the same thing as one served by the read that filled it;readListUncached()collects the loader messages it was already producing and handing only to a logger that speaks once per outage episode;list()is unchanged in every direction — same items, same array instance, same best-effort posture — which is what keeps every existing caller working. The two members cannot drift because they are the same read.packages/mcp/src/mcp-server-runtime.ts— the two measured consumers. Per PR #6051, each is qualified separately rather than by one rule, and they are genuinely different cases:objectstack://objects{ objects, totalCount }, byte-identical to before. Degraded: sameobjects,totalCountabsent, pluspartial: true,returnedCount, awarningsentence, and thecode: 'SERVICE_UNAVAILABLE'/status: 503envelope the sibling resource already carries.agent_promptskill bridgewarn.The resource emitted a confident
totalCountthat is wrong during an outage — the one place here where a count is the strongest thing a read can wrongly say. Dropping the key rather than reporting a smaller number is the point: a client readingbody.totalCountnow getsundefined, which fails or renders as nothing, where a plausible-looking integer would have been believed.returnedCountsays the one thing that is known, in a name that cannot be mistaken for a total. The objects themselves are still served — withholding them would be a new functional regression rather than the removal of a false statement.The skill bridge is not that case. It publishes no count and makes no completeness claim to any client; what a degraded read costs it is skills that are silently not registered as prompts. There is nobody on the wire to tell — the reply it shapes is the SDK's own
prompts/list, and inventing a placeholder entry would put a fabrication in a list whose entire purpose is to say what exists. So it reports to the operator atwarn(a functional degradation, which is the level AGENTS.md prescribes), naming the loader, that the skills are missing rather than undeclared, and the consequence that makes it worth saying at all: the stdio transport takes the snapshot once at bridge time, so it stays short until restart even after the loader heals, while the HTTP transport rebuilds per request and self-heals.Contract-first note. The resource keeps
listObjects()as its resolver and askslistDiagnosed('object')only for the verdict.listObjectsis its own contract member and declares no equivalence tolist('object'), so presuming one at a consumer would be the private dialect Prime Directive #12 forbids. This is the plural instance of the fork #6055 recorded forgetObject/diagnoseEmptyRead, taken the same way. OnMetadataManagerthe two coincide (listObjects()islist('object')) and share one cache entry, so the probe is a cache hit rather than a second walk.Scope — deliberately narrower than the card
Shipped: the declaration, the producer, and the two measured
packages/mcpconsumers. Not the wider sweep acrossmetadata-protocol,rest,runtimeand the plugins — #7674 is in flight onpackages/metadata-protocol/src/protocol.ts, which carries twolist/listObjectscall sites, so that sweep would collide head-on. It becomes a follow-up card once #7674 lands.Hence
Part of #6504, notFixes: merging this withFixeswould close a card whose remaining half nobody would see again.The two mcp consumers are in scope rather than deferred with the rest for a specific reason — shipping
listDiagnosedwith nothing calling it would produce exactly the declared-but-unconsumed surface ADR-0049 exists to retire.Tests
Two new files, 21 cases, all green.
packages/metadata/src/metadata-manager-list-diagnosed.test.tsdrives a real loader failure: aDatabaseLoaderover a driver whosefind()throwsECONNRESET, soreadListUncached()'scatchis what producesdegradedand the verdict is computed rather than injected. The load-bearing case pins the count claim in both directions — two managers, one whose loader is down and one that genuinely holds nothing more, producelist()answers that are deep-equal and equal in length, whilelistDiagnosed()separates them. A healthy load yields the full count of 3 withdegraded: falseand no errors; the outage yields 1,degraded: true, and the loader's message.packages/mcp/src/mcp-server-runtime.list-outage.test.tspins what each consumer does with such a record, including that a degraded body has nototalCountkey at all, that the healthy body is unchanged, and that a service withoutlistDiagnosedbehaves exactly as before.Stated plainly rather than papered over: the mcp file uses doubles, not a live
MetadataManager.packages/mcpdepends on spec/core/types/formula and deliberately not on@objectstack/metadata, so adding that dependency to drive a test would be a larger architectural change than the fix. The real failure is driven where the loader lives, and the metadata file asserts the exact record shape the mcp doubles return — the same split #6055 used forgetDiagnosed.Reverse verification — direction predicted before running, and measured
Both predictions were written into the test headers before either was run, and both came out exactly as predicted.
listDiagnosed()from the producerTypeError: manager.listDiagnosed is not a function)The predicted-green cases are invariant pins, not gaps, and each is green in both directions on purpose:
list()'s answer and the healthy resource body are deliberately unchanged by this PR, and "a degraded listing still serves the objects it could read" is what would go red if a future change here started withholding data instead of withholding the claim.The consumer revert is defined behaviourally, not textually. Reverting the whole file to
origin/mainwould delete the extracted builder's export, fail the import, and turn all eleven cases red — a result that measures the extraction rather than the decision, and so proves nothing about either.Gates run locally
@objectstack/metadata+@objectstack/mcpsuites: 728 passed (603 + 125), 42 files.--filter '...@objectstack/spec'= downstream consumers): 61 packages green, zero failures.pnpm --filter @objectstack/spec check:generated: all 13 generated artifacts up to date (build run first, noOS_SKIP_DTS).pnpm check:type-check-debt: OK, none above its recorded number. Both new test files were then measured individually and contribute 0 errors each. The mcp file initially added 8 (TS2345, an untyped logger double) — under the recorded ceiling and therefore invisible to the gate, which is precisely the surplus [finding][devx]check:type-check-debt的 ledger 余量会让新写的 pin 变哑:mongodb 曾有 33 条余量吞掉一次真实回退,另有 5 条目前带 4–19 余量 #6376 warns about, so they were fixed rather than spent.@objectstack/mcpTEST_DEBT measures 53 against a recorded 63;@objectstack/metadataDEBT measures 89 against a recorded 92. Neither ledger number was raised, and neither was lowered: those surpluses are pre-existing and not this PR's to claim.eslinton all five changed files: clean.node scripts/check-nul-bytes.mjs: OK, plus a control-character self-scan over the changed files.check-adr-0087-registration: no declared-breaking changeset, so no disposition marker is owed.Changeset added (
minoron spec/metadata/mcp), naming the one thing a consumer may need to act on: branch onpartialbefore treating any count fromobjectstack://objectsas a total.Generated by Claude Code