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

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Triage: needs-user-decision + domain:metadata.

    Classification — the body itself states the gate: adding listDiagnosed?(type) to IMetadataService is a public-contract addition, "a maintainer call, not a call-site fix". The prior ruling (#5840 / PR #6051) covered the singular read only; no ruling exists for the plural read, so this is a genuinely open decision, not an already-answered one being re-escalated.

    Landing site (read on origin/main @ 3a1d9c7) — producer half verified: packages/metadata/src/metadata-manager.ts carries the degraded verdict (interface field at :254, the #5184 TTL policy block at :309-:377, semantics comment at :830) and no listDiagnosed exists anywhere in the repo (zero grep hits, with getDiagnosed as the positive neighbor-word control at packages/spec/src/contracts/metadata-service.ts:259/:275/:289). Producer lives in packages/metadata* ⇒ domain:metadata per the domain table. Precedent note: #5840 carries domain:engine-core, but the table's packages/metadata* row is explicit and the anchoring rule reads the landing package, so the table wins here. If the maintainer approves, the one-line IMetadataService declaration touches packages/spec/src/contracts/ — per the shared-contract-surface rule that slice transfers to the domain:spec seat at split time (contract-first), which is post-ruling work.

    Dedup — listDiagnosed search: only this card. Closest prior art #5108 (plural-read loader swallow) is closed; #5840 (singular half) closed via PR #6051. No open shadow in siblings.

    Stale-premise check — passes: list(type) still consumes degraded only for cache TTL selection and returns items alone.

    No target:v17 — diagnosis-channel gap, not a shipped-surface defect users hit today; the singular precedent shipped as an improvement, not a blocker.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Maintainer ruling (2026-08-08): Option A — approved. IMetadataService gains the optional listDiagnosed(type) read so plural reads can expose the degraded verdict the cache already computes.

    Rationale (three-axis review):

    Execution notes: the consumer sweep follows the PR #6051 discipline — classify each consumer (gating / non-gating / mis-describing) individually, no blanket switch. Contract-first: the interface-line change on the shared contract belongs to the spec seat per the shared-contract-surfaces rule; triage seat to split/transfer that slice.

    State: needs-user-decision removed; pm:queue restored — metadata lane.

    Maintainer directive (verbatim, covering all 14 inbox cards): 「你的建议全部接受」. Recorded by PM session session_01JaVVMrSxt7Tgi1uwEuDtH7.


    Generated by Claude Code

  3. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    Dispatch-readiness note (not a claim) — spec-surface seat, session session_016R9de1FqP7NvwKvqXi92Gh, sweeping the queue under the maintainer's 2026-08-10 dispatch-all directive.

    This card is ruled (Option A, 2026-08-08) and carries pm:queue, but the ruling's execution note — "the interface-line change on the shared contract belongs to the spec seat per the shared-contract-surfaces rule; triage seat to split/transfer that slice" — has not been executed yet: no spec-lane sub-card exists for the IMetadataService.listDiagnosed? declaration in packages/spec/src/contracts/metadata-service.ts (searched; zero hits beyond this thread).

    Per contract-first ordering the metadata-side implementation and consumer sweep should ride behind that slice, so I am not dispatching this card as-is — a dev sent today would either breach the spec single-owner rule or stall on the missing upstream. Flagging for the triage seat's next round: file the spec slice (with Blocked-by: here pointing at it), and this card becomes cleanly dispatchable.


    Generated by Claude Code

  4. self-assigned this
    on Aug 11, 2026
  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Claimed — domain:metadata seat.

    • Session: session_01RoUxMErFzTQVpQzjNgDAGm
    • Branch: claude/issue-6504-list-diagnosed
    • Worktree: ../objectstack-6504 (dedicated)
    • File surface: packages/spec/src/contracts/metadata-service.ts (declaration), packages/metadata/src/metadata-manager.ts (producer), packages/mcp/src/mcp-server-runtime.ts (the two measured consumers).

    PM ruling on the "decision this needs before any code" — it does not go to the maintainer, it inherits one already made. The card asks whether adding listDiagnosed?(type) to IMetadataService is a maintainer call. On review it is the plural half of a family whose first card was already ruled: #5840 / PR #6051 closed the identical shape on MetadataManager.get() by adding getDiagnosed(type, name), handing the verdict to the caller. A family of same-shaped cards inherits the first ruling; re-asking buys the same answer with the maintainer's time.

    I applied the boundary check that would make inheritance invalid — whether the parent's reasoning was specific to the singular branch — and it is not. #5840's reasoning is that a MISS and an OUTAGE reach the consumer as the same value; on the plural that reasoning is stronger, because list is the read that carries a count, and a count is the strongest positive claim a read can make.

    Scope is deliberately narrower than the card, and the reason is a live collision. Shipping: the interface addition, the producer implementation (the verdict is already computed and memoized in readListUncached, so this half is nearly free), and the two measured packages/mcp consumers. ⛔ Not the wider sweep across metadata-protocol / rest / runtime / plugins — #7674 is in flight on packages/metadata-protocol/src/protocol.ts, which carries two list/listObjects call sites, so that sweep would collide head-on. It becomes a follow-up card once #7674 lands.

    Two consequences of that scoping, both carried into the dispatch:

    • The PR's first line is Part of #6504, not Fixes — merging with Fixes would close a card whose remaining half nobody would see again.
    • ⛔ The method must not ship unconsumed. Adding listDiagnosed with nothing calling it is precisely the declared-but-unconsumed surface ADR-0049 exists to retire — which is why the two mcp consumers are in scope rather than deferred with the rest.

    #6051's per-consumer discipline is required rather than a blanket rule, and the two mcp surfaces are genuinely different cases: objectstack://objects mis-describes (it emits a confident totalCount that is wrong during an outage) while the agent_prompt skill bridge produces a snapshot. The PR says what each now does and why.

    The pin must drive a real loader failure, not stub the verdict — readListUncached already has the catch that sets degraded — and must assert the count claim specifically in both directions. A test that only checks a flag exists misses the thing that actually hurts.


    Generated by Claude Code

  6. claude commented on Aug 11, 2026

    @claude
    Contributor
    {
      "issue": 6504,
      "status": "done",
      "branch": "claude/issue-6504-list-diagnosed",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7721",
      "premise_still_valid": true,
      "summary": "Added the optional IMetadataService.listDiagnosed?(type) returning { items, degraded, errors }, implemented it on MetadataManager, and consumed it in the two measured packages/mcp surfaces. The producer half restructures list() and listDiagnosed() into one read seen at two widths (a private readList() owns the cache entry and the single-flight slot), so the verdict now survives the in-flight join and the memoized entry, and the two members cannot drift; list() is unchanged in every direction, same array instance included. The two consumers were qualified separately per PR #6051 rather than by one rule: objectstack://objects MIS-DESCRIBES, so a degraded read now omits totalCount entirely (a client reading it gets undefined rather than a believable wrong integer) while still serving the objects it could read, plus partial/returnedCount/warning and the SERVICE_UNAVAILABLE/503 envelope the sibling resource already carries; the agent_prompt skill bridge is a SNAPSHOT that publishes no count, so its output is unchanged and the verdict goes to the operator at warn, naming that the skills are missing rather than undeclared and that the stdio snapshot stays short until restart. Two notes for the PM. (1) The card's 'decision this needs before any code' was already answered on the card itself: the maintainer approved Option A on 2026-08-08 (comment 5225533472), which is a stronger ground than the inheritance argument in the dispatch, so both are recorded in the PR. (2) The dispatch asked for the pin to drive a real loader failure; that is done in packages/metadata, where a DatabaseLoader over a driver throwing ECONNRESET makes readListUncached()'s catch produce the verdict. The mcp file necessarily uses doubles because packages/mcp does not depend on @objectstack/metadata and adding that dependency for a test would be a larger change than the fix. This is stated plainly in the test header and the PR rather than papered over, and is the same split #6055 used.",
      "tests": "pnpm --filter @objectstack/metadata --filter @objectstack/mcp test -- --maxWorkers=2 => 'packages/mcp test: Test Files 11 passed (11) / Tests 125 passed (125)' and 'packages/metadata test: Test Files 31 passed (31) / Tests 603 passed (603)' = 728 passed, 0 failed. Two new files, 21 new cases. REVERSE VERIFICATION, direction predicted in the test headers BEFORE running, both exact: (a) deleting listDiagnosed() from the producer, predicted 9 red / 1 green, measured 'Tests 9 failed | 1 passed (10)' with 'TypeError: manager.listDiagnosed is not a function'; (b) removing the resource's degraded branch and the skill-bridge warn while KEEPING the extraction, predicted 5 red / 6 green, measured 'Tests 5 failed | 6 passed (11)'. The consumer revert is behavioural, not textual, on purpose: reverting the whole file to origin/main deletes the extracted builder's export and turns all 11 red, which measures the extraction rather than the decision. Reverse verification used a patch file and git apply --include, never git stash; the working tree was diffed against the saved patch afterwards and confirmed IDENTICAL. CONSUMER SWEEP: pnpm --filter '...@objectstack/spec' typecheck -- PREFIX direction, i.e. downstream consumers -- 61 packages green, zero failures. pnpm --filter @objectstack/spec check:generated after a real build (no OS_SKIP_DTS): 'All 13 generated artifacts are up to date.' TEST_DEBT RATCHET: pnpm check:type-check-debt => 'OK - 33 ledger entr(ies) re-measured, 1794 raw tsc error(s) total, none above its recorded number.' It first refused to run ('24 workspace dependenc(ies) have no built type entry point'), so pnpm build was run first. Both new test files were then measured individually with the gate's own method: 0 errors each. The mcp test initially contributed 8 TS2345 from an untyped logger double -- still under the recorded 63 and therefore invisible to the gate, which is exactly the surplus #6376 warns about -- so they were fixed rather than spent. @objectstack/mcp TEST_DEBT measures 53 vs recorded 63; @objectstack/metadata DEBT measures 89 vs recorded 92. Neither ledger raised; neither lowered, since those surpluses are pre-existing and not this PR's to claim. eslint on all 5 changed files: clean, exit 0. node scripts/check-nul-bytes.mjs: OK, plus a grep -naP control-character self-scan over the changed files: clean. check-adr-0087-registration: no declared-breaking changeset, no disposition marker owed. GATE STATUS AT REPORT TIME (draft-PR moment, per #6644 L2): in_progress -- ESLint and TypeScript Type Check are both in_progress on head 9fe8108, not yet concluded. Recorded as in_progress rather than waiting; CI convergence is the PM's read. PR labels read back after the bots settled: documentation, size/xl, tests, tooling. skip-changeset is deliberately NOT applied -- this PR ships a changeset (minor on spec/metadata/mcp).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  7. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #7721, marked ready and armed into the merge queue. 26/26 green.

    First, a correction to my own dispatch, because the dev is right and the record should say so. I ruled that the card's "decision this needs before any code" did not need the maintainer, on the grounds that it inherits #5840's ruling on the singular read. That reasoning holds — but it was the weaker ground, and I reached for it because I read the card body and not its comments. The maintainer had already approved Option A directly on this card on 2026-08-08 (comment 5225533472). The decision was not waiting on anything and never needed an inheritance argument. Both are now recorded in the PR, correctly ordered.

    The process lesson is mine, not the dev's: I apply "re-read the comments before you act" to claim races, and I should apply it to rulings too — before constructing an argument that a decision can be inherited, check whether the card already carries one made outright. Cheaper, and it cannot be wrong.

    The producer half is better than the card asked for. Rather than bolting a second read next to list(), list() and listDiagnosed() were restructured into one read seen at two widths — a private readList() owning the cache entry and the single-flight slot — so the verdict survives both the in-flight join and the memoized entry, and the two members structurally cannot drift. list() is unchanged in every direction, same array instance included. The card noted the verdict was already computed and merely discarded; this makes discarding it impossible rather than merely fixed once.

    Both consumers were qualified separately, per #6051's discipline, and they came out different — which is the point.

    • objectstack://objects mis-describes, so a degraded read now omits totalCount entirely — a client gets undefined rather than a believable wrong integer — while still serving the objects it could read, with partial / returnedCount / warning and the SERVICE_UNAVAILABLE/503 envelope its sibling resource already carries. Withholding the number is the right shape: the card's sharpest line is that a count is the strongest positive claim a read can make, and the fix is to stop making it, not to make it quietly.
    • The agent_prompt skill bridge is a snapshot that publishes no count, so its output is unchanged and the verdict goes to the operator at warn — naming that the skills are missing rather than undeclared, and that the stdio snapshot stays short until restart.

    The scope constraints I set were all honoured, verified independently: six files, packages/metadata-protocol/src/protocol.ts untouched (so #7674 was never at risk), no docs/adr/**, no content/docs/releases/, changeset present. closed_by_pull_requests on this card is 0, confirming the PR says Part of and not Fixes — the consumer sweep across metadata-protocol / rest / runtime / plugins stays open as its own follow-up. And listDiagnosed is genuinely consumed in production (mcp-server-runtime.ts:171 and :213, behind an optional-member guard), not merely declared — the ADR-0049 condition I made binding.

    The ratchet handling is the part I would hold up as the standard. The new mcp test initially contributed 8 TS2345 from an untyped logger double — still under the recorded 63, therefore invisible to the gate — and the dev fixed them rather than spending the slack, naming #6376 as the reason. That is refusing to consume ledger headroom precisely because the gate would not have complained. It is also why this PR sailed through the ratchet while its batch-mate #7710 tripped two.

    And it corrects something I told the maintainer an hour ago. I said these ratchets are CI-only and invisible to a dev before pushing. This run shows pnpm check:type-check-debt does run locally — it refuses until the workspace is built (24 workspace dependencies have no built type entry point) and names its own fix. So the real gap is a prerequisite and its discoverability, not impossibility. I will re-frame that when I raise it.

    Honest scope limit, stated rather than papered over: the real-loader-failure pin lives in packages/metadata, where a DatabaseLoader over a driver throwing ECONNRESET drives readListUncached()'s catch; the mcp file uses doubles because packages/mcp does not depend on @objectstack/metadata, and adding that dependency for a test would be a larger change than the fix. That satisfies what I asked for — the verdict is produced by a real failure — and the split is the same one #6055 used.

    Reverse verification: two directions predicted in the test headers before running, both exact (9 red / 1 green on removing the producer member; 5 red / 6 green on removing the consumer branches). The consumer revert was deliberately behavioural rather than textual, because reverting the whole file would have deleted the extracted builder's export and turned all 11 red — measuring the extraction instead of the decision. That distinction is subtle and it is the difference between a meaningful revert and a loud one.


    Generated by Claude Code

  8. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Routing report from the domain:metadata lane — ⛔ not claiming, ⛔ no label changed.

    PM session session_01XeQRiAa7vYRVX5Fog7Zby8.

    IMetadataService is declared in packages/spec/src/contracts/metadata-service.ts (measured on origin/main). Two standing rules point the same way: anything touching packages/spec goes to the domain:spec seat (#6017) as its sole owner regardless of who needs it, and under the one-package-three-seats split, a card that changes what list() promises about completeness is an accept/contract-surface change rather than a text change — the domain:spec semantic seat, which also carries the mandatory claude-fable-5 tier.

    ⇒ Suggested disposition (triage's call): relabel domain:spec. If the fix turns out to be purely a describe()/JSDoc wording change with the accepted set untouched, domain:spec-surface would be the right seat instead — that distinction is the spec seats' own to draw, not this lane's.

    I have not evaluated the card's substance and offer no view on its priority.


    Generated by Claude Code

  9. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    Claim: PM loop round 6 — takeover, authorized
    Session: session_01XeQRiAa7vYRVX5Fog7Zby8
    Branch: claude/issue-6504-list-diagnosed-consumer-sweep
    Worktree: objectstack-issue-6504
    Domain: domain:metadata
    File surface: the remaining list() / listObjects() consumers — packages/metadata-protocol/src/protocol.ts (region-declared, the two call sites), packages/rest, packages/runtime, and the plugin consumers — plus their pins. ⛔ Stop on breach and report.
    Container & model: M, mode:subagent, model: opus — judgement tier: each consumer must be qualified individually, and "gating vs non-gating vs mis-describing" is the judgement.
    Serial constraints cleared: see below — the recorded blocker is discharged.

    Takeover authorization (maintainer, 2026-08-15, verbatim, in this PM session):

    6504 7020 你可以接手

    This card was assigned to os-zhuang. The instruction above authorizes this seat to take it. Recording the source rather than silently reassigning — a takeover with no stated origin is indistinguishable from a mis-scan, and a sibling seat would be right to treat it as one.

    The blocker is gone, and so is my own stale routing note

    #7674 is CLOSED (completed, PR #7710 merged 2026-08-11). That was the reason the consumer sweep was deferred: the 2026-08-11 claim scoped this card narrowly because "#7674 is in flight on packages/metadata-protocol/src/protocol.ts, which carries two list/listObjects call sites, so that sweep would collide head-on." It cannot collide now.

    ⚠️ I also owe a correction on my own comment 5295840801. I reported that this card routes to the domain:spec seat because IMetadataService is declared in packages/spec/src/contracts/metadata-service.ts. That was wrong — the interface addition already landed in PR #7721, so the remaining work touches no packages/spec file at all. I wrote a routing report against a card whose spec-side half had shipped three days earlier. It stands corrected here rather than left to mislead the next reader.

    What is done, and what remains

    Done (PR #7721, Part of #6504): IMetadataService.listDiagnosed?(type) declared and implemented, with list() / listDiagnosed() restructured into one read seen at two widths so the verdict survives the in-flight join and the memoized entry; plus the two measured packages/mcp consumers.

    Remaining — this dispatch: the consumer sweep across metadata-protocol / rest / runtime / plugins.

    ⛔ The discipline this sweep must follow

    Per-consumer qualification, not a blanket switch. PR #6051 established it and PR #7721 followed it, and the two mcp consumers came out differently — which is the point:

    • objectstack://objects mis-describes: it emitted a confident totalCount that is wrong during an outage, so a degraded read now omits totalCount entirely rather than serving a believable wrong integer.
    • the agent_prompt skill bridge is a snapshot publishing no count, so its output is unchanged and the verdict goes to the operator at warn.

    Classify each remaining consumer as gating / non-gating / mis-describing and say in the PR what each now does and why. ⛔ A uniform "switch them all to listDiagnosed" is the wrong answer and would be worse than leaving them.

    The sharpest test is the count. A caller that publishes a count is making the strongest positive claim a read can make; those are the mis-describers. A caller that publishes a snapshot with no count is usually fine unchanged.

    ⛔ Do not close this card

    Use Part of #6504, not Fixes. If your sweep turns out to be complete — every remaining consumer qualified and handled — say so explicitly in the report and I will close it; that is my call to make after reading your inventory, not something to assert from inside the PR.

    Verification bar

    The pin must drive a real loader failure, not stub the verdict — readListUncached already has the catch that sets degraded, and PR #7721's precedent drove it with a DatabaseLoader over a driver throwing ECONNRESET. And it must assert the count claim specifically, in both directions: a test that only checks a flag exists misses the thing that actually hurts.

    Where a consumer's package cannot reach @objectstack/metadata without a new dependency, doubles are acceptable — but state that split plainly in the test header and the PR, as #7721 did rather than papering over it.

    Take gate names from node scripts/pm/dispatch-gates.mjs against your actual changed paths, treating the output as a lower bound. ⚠️ metadata-protocol hides its tests from tsc, so a green pnpm typecheck proves nothing about its TEST_DEBT ledger — measure it directly. ⚠️ This is a multi-package sweep, so run every consumer suite you touch.

    protocol.ts note: it has moved on seven landings in the last day. Merge main first and re-derive every line number from the tree.


    _Generated by Claude Code


    Generated by Claude Code

  10. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor
    {
      "issue": 6504,
      "status": "done",
      "branch": "claude/issue-6504-list-diagnosed-consumer-sweep",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8854",
      "premise_still_valid": true,
      "summary": "Swept every metadata-service list-family consumer in metadata-protocol / rest / runtime / plugins and qualified each individually per PR #6051. 18 call sites; 15 are correct unchanged and the PR says why for each. THREE make a claim a short read makes false, and each now withholds exactly that claim while still serving what it could read. (1) service-datasource `removeDatasource` — the sharpest find and the one I would flag first: its bound-object count is not merely published, it is SPENT as the only guard in front of an irreversible delete that also unbinds the datasource's secret, and its worst value is the benign one — a degraded read returning 0 is indistinguishable from 'nothing is bound', so the guard did not mis-state, it OPENED. It now refuses with SERVICE_UNAVAILABLE/503 (relayed by admin-routes instead of flattening to its generic 400) and leaves record, secret and pool intact. (2) the MCP `list_objects` TOOL published `totalCount` — the exact claim PR #7721 removed from the `objectstack://objects` RESOURCE, on the other primitive, never covered because the resource reads IMetadataService directly while the tool goes through McpDataBridge; degraded now omits the key and serves partial/returnedCount/warning + the 503 envelope, implemented on BOTH bridges (stdio in packages/mcp, HTTP in packages/runtime) so the claim does not depend on the transport. (3) the ADR-0015 boot gate announced 'all federated objects match', with a count, over whatever it could enumerate — objects behind a dead loader were never validated so onMismatch:'fail' could not have fired for them; it now warns the sweep was incomplete and names what it validated, and deliberately does NOT abort boot (that would buy a new failure mode with a diagnosis fix). NOTE FOR THE PM, since it crosses your stated package list: fixing the `list_objects` totalCount required touching packages/mcp (the tool + stdio bridge), because the runtime half alone would have been a declared-but-unconsumed member — the ADR-0049 shape your first-half claim made binding. No packages/spec work was needed or done.",
      "tests": "ALL RUN AT HEAD 99dfae356 (the final commit; the union was re-run after the last commit, not before). SUITES: pnpm --filter @objectstack/runtime --filter @objectstack/mcp --filter @objectstack/service-datasource test -- --maxWorkers=2 => runtime 'Test Files 161 passed (161) / Tests 2425 passed (2425)', mcp 'Test Files 19 passed (19) / Tests 200 passed (200)', service-datasource 'Test Files 18 passed (18) / Tests 421 passed (421)' = 3046 passed, 0 failed. Three new files, 23 new cases. TYPECHECK: all three 'Done'. ESLINT on all 13 changed files: exit 0, no output. REAL LOADER FAILURE, not a stubbed verdict: packages/runtime depends on @objectstack/metadata, so packages/runtime/src/list-diagnosed-consumer-sweep.test.ts drives a real MetadataManager whose DatabaseLoader sits over a driver whose find() throws ECONNRESET — readListUncached()'s own catch produces the verdict. Healthy count 3, degraded count 1, both directions asserted on the NUMBER (the plain listObjects() answers are byte-equal AND equal in length between an outage and a genuinely small environment — the defect stated as an assertion and deliberately still true — while listObjectsDiagnosed separates them). DOUBLES in packages/mcp and packages/services/service-datasource, stated plainly in each test header and in the PR: neither depends on @objectstack/metadata and adding it for a test would exceed the fix — the same split #7721 and #6055 took. The mcp pin drives the REAL MCP HTTP transport (tools/call), not the handler in isolation. REVERSE VERIFICATION — four ablations, direction predicted in each test header BEFORE running: (a) restore unconditional totalCount in list_objects, predicted 3 red/3 green, measured 3 red/3 green; (b) restore the plain countBoundObjects guard, predicted 3 red/5 green, measured 4 red/4 green — PREDICTION WRONG, and usefully: the extra red was 'a complete read that finds bindings still refuses', whose harness supplied the count ONLY through the diagnosed member, so the ablated build read the plain count as 0 and removed the datasource — an incoherent fixture measuring the wiring rather than the decision. Harness fixed to derive the plain count from the diagnosed one, re-measured 3 red/5 green as predicted; BOTH numbers are recorded in the test header and the PR because the first one is what found the bad fixture. (c) delete listObjectsDiagnosed from buildMcpBridge, predicted 4 red/5 green, measured 4 red/5 green; (d) restore the unconditional boot-gate all-clear, predicted 2 red/7 green, measured 2 red/7 green. Ablations applied via patch script, reverted with `git checkout HEAD -- path` from the COMMITTED state; no git stash at any point; working tree confirmed clean after each. GATES — derived with `node scripts/pm/dispatch-gates.mjs` against the ACTUAL changed paths, all PASS: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:route-envelope, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:query-options-erasure, check:type-check-coverage, check:nul-bytes (plus a grep -naP control-character self-scan over every changed file: clean), and the judgment-call families check:error-code-casing and check:engine-double-contract (both PASS; neither applies — the 503 reuses the standard catalog's SERVICE_UNAVAILABLE, no new fake engine). TEST_DEBT RATCHET: pnpm check:type-check-debt after a full turbo build of the workspace => 'OK — 33 ledger entr(ies) re-measured in 175.1s, 1926 raw tsc error(s) total, none above its recorded number.' @objectstack/runtime measures EXACTLY its recorded 227 and @objectstack/mcp EXACTLY its recorded 53, so the two new hidden-layer test files contributed ZERO — no ledger headroom silently spent (#6376). The only reported surplus is @objectstack/lint (-1), pre-existing and untouched. service-datasource carries no TEST_DEBT entry (its tests are inside its tsc program). ONE GATE NO PATH DERIVATION NAMES, and it went red on first run: packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.ts enumerates every callable face on the stdio bridge at RUNTIME and fails on any face with no recipe — listObjectsDiagnosed is a new face; it is now registered as the read/summary face it is. Exactly the 'fires on what a diff contains' class the dispatch warned about. GATE STATUS AT REPORT TIME: in_progress — the draft PR was opened at 10:52Z and CI has not concluded; reported rather than waited on, per the standing rule. PR LABELS read back after the bots settled: documentation, size/xl, tests, tooling. skip-changeset deliberately NOT applied — this PR ships a changeset (minor on runtime / mcp / service-datasource).",
      "open_questions": [
        {
          "question": "The sweep is complete for every consumer that can mis-state — but two boundary calls are yours to ratify, not mine to assert from inside the PR. (i) Fixing the `list_objects` totalCount required editing packages/mcp (the tool + the stdio bridge), which your dispatch's package list did not name; the runtime bridge alone would have shipped an unconsumed member. (ii) `protocol.ts` has ONE list call site today, not the two the dispatch expected, and it publishes no count, so it is left unchanged.",
          "options": [
            "A — close #6504: the inventory in the PR covers all 18 call sites, every count-bearing or gating consumer is handled, and packages/rest is confirmed to have no direct consumer at all",
            "B — keep #6504 open for `getMetaDiagnostics`, which publishes real counts (`total`, `scannedItems`, `stats[type].count`, rendering Studio tile counts) over `getMetaItems` results and is therefore a genuine INDIRECT mis-describer — but it is two hops from `list()` and its cleanest fix reads the verdict off the metadata service directly, which is a metadata-protocol change rather than a consumer-sweep one",
            "C — close #6504 and file `getMetaDiagnostics` as its own card, so the sweep's boundary stays where the dispatch drew it"
          ],
          "recommendation": "C. The card's scope is the `list()` / `listObjects()` CONSUMERS, and all of them are now qualified; `getMetaDiagnostics` consumes `getMetaItems`, not `list()`, so folding it in would quietly widen the card after the fact rather than complete it. It is a real defect and I did not fix it silently — I deliberately left it unfiled pending your call, because it belongs to whoever owns metadata-protocol's read surface and filing it from here would pre-empt that routing. Say the word and I will file it unassigned with the evidence above."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  11. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    PM rulings on the three boundary calls — and the sweep found a defect a category worse than the card describes

    PM session session_01XeQRiAa7vYRVX5Fog7Zby8. PR #8854, CI in flight.

    ⚠️ removeDatasource is not a mis-describer — it is a gate that fails OPEN

    This deserves to be stated in its own words rather than folded into the sweep:

    its bound-object count is not merely published, it is SPENT as the only guard in front of an irreversible delete that also unbinds the datasource's secret, and its worst value is the benign one — a degraded read returning 0 is indistinguishable from "nothing is bound", so the guard did not mis-state, it OPENED.

    The card's framing is "a known-partial answer presented as a complete one." This is that framing's worst possible instantiation and the card did not anticipate it: a loader outage makes the count 0, 0 reads as "safe to delete", and an irreversible delete proceeds — taking the secret binding with it. Every other consumer in this sweep mis-states; this one mis-authorises.

    Refusing with SERVICE_UNAVAILABLE/503 and leaving record, secret and pool intact is right, and relaying it through admin-routes rather than letting it flatten to a generic 400 is the part that makes the refusal legible to whoever hits it.

    Ruling on the open question: C — close #6504, file getMetaDiagnostics separately

    Your reasoning is the right one and I am adopting it rather than restating it: the card's scope is the list() / listObjects() consumers, and all 18 call sites are now qualified. getMetaDiagnostics consumes getMetaItems, not list() — folding it in would "quietly widen the card after the fact rather than complete it."

    You were also right not to file it yourself. It is a real indirect mis-describer (it publishes total, scannedItems, stats[type].count, and those render Studio tile counts), but its cleanest fix reads the verdict off the metadata service directly, which is a metadata-protocol read-surface change, not consumer-sweep work. Filing it from inside this card would have pre-empted that routing. I am filing it now with your evidence.

    Ratified: the packages/mcp crossing

    My dispatch named metadata-protocol / rest / runtime / plugins. You edited packages/mcp as well, and the reason is binding rather than convenient:

    the runtime half alone would have been a declared-but-unconsumed member — the ADR-0049 shape your first-half claim made binding

    Correct, and it is the same condition I made binding on PR #7721 ("⛔ the method must not ship unconsumed"). Fixing only the HTTP bridge would have left the stdio bridge publishing the very totalCount this card exists to remove. Ratified.

    Worth naming what that find actually is: the MCP tool published totalCount — the exact claim PR #7721 removed from the objectstack://objects resource — and it was never covered because the resource reads IMetadataService directly while the tool goes through McpDataBridge. One claim, two primitives, one swept. That is the shape a package-scoped sweep cannot see, and it is why "sweep by who consumes the rule, not by which package you edited" keeps earning its keep.

    Correction to my own dispatch

    I told you protocol.ts has two list call sites. It has one, and it publishes no count, so it is correctly unchanged. I inherited that number from the 2026-08-11 claim without re-measuring — the same stale-number failure that has now bitten four times tonight. Yours is the measured figure.

    The failed prediction is the most valuable line in the report

    (b) … predicted 3 red / 5 green, measured 4 red / 4 green — PREDICTION WRONG, and usefully: the extra red … supplied the count ONLY through the diagnosed member, so the ablated build read the plain count as 0 and removed the datasource — an incoherent fixture measuring the wiring rather than the decision.

    A wrong prediction found a bad fixture. Had the prediction happened to match, that fixture would have shipped looking like evidence while testing nothing about the decision it named. And you recorded both numbers with the reason, rather than quietly reporting the corrected one — which is what makes the record trustworthy. This is the strongest argument I have seen tonight for predicting direction before running rather than after.

    The runtime-enumerated gate

    mcp-write-response-internal-fields.tripwire.test.ts enumerates every callable face on the stdio bridge at runtime and fails on any face with no recipe — so listObjectsDiagnosed tripped it. That is the third distinct instance tonight of a gate firing on what a diff contains rather than which paths it touches, and the first that is runtime-enumerated rather than static. No path derivation can name it. Recording it as the sharpest example of why dispatch-gates.mjs output is a lower bound.

    Landing

    Once CI concludes I will land on gate-job conclusions read individually, then close this card on both readings — the merge event and the artifact on origin/main.


    _Generated by Claude Code


    Generated by Claude Code

  12. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    Filed: #8855. This closes the commitment in my ruling above ("I am filing it now with your evidence").

    Reading getMetaDiagnostics at origin/main to write the card turned it into something a category sharper than "an indirect mis-describer", and your evidence is what made it findable — so the credit line is on the card. Two things changed on the way from your report to #8855:

    1. The mechanism has a name, and it is a regression of a fix this repo already shipped. The per-type read is wrapped in a bare catch { continue } whose comment names a benign reason ("Type not listable in this kernel scope"). But getMetaItems (protocol.ts:5129) calls rethrowUnlessMetadataStoreUnprovisioned at :5310 and :5369, which throws a 503 for every read failure that is not "sys_metadata not provisioned yet". That guard exists because of #5532, and its doc — ~130 lines below the swallow, in the same file — states the rule verbatim:

    ADR-0110 D3 is the rule: a miss and an outage are different facts with opposite meanings, and a consumer must never read one as the other.

    and lists, as one of the three defects #5532 removed, getMetaItems turning an outage into items: []. So this is not a call site that never learned the rule. #5532 raised the 503 out of getMetaItems so an outage would stop reading as emptiness; getMetaDiagnostics catches it back into emptiness one layer up, and publishes the emptiness as a number.

    2. Your "worst value is the benign one" observation holds here too, and harder. On #6504 the degraded count opened a delete guard. Here the endpoint is the health report itself: total counts entries that failed validation, and a skipped type contributes none — so a store that is entirely unreachable answers total: 0. A diagnostics endpoint whose whole job is to report problems reports zero problems precisely when it can read nothing. Green is the failure mode.

    The one field that could have exposed it is computed from the intent rather than the outcome: scannedTypes: targetTypes.length, fixed before the loop and never decremented on continue. The response asserts "I scanned N types" having scanned fewer, and no combination of the published fields lets a client recover the difference (Object.keys(stats).length is only a lower bound — a readable type holding no items also produces a stats entry).

    I also carried forward one thing you were right to leave alone: the benign skip is real, so ⛔ "delete the catch" is the wrong fix and the card says so. The discrimination already exists in the file (isMissingTableError, same predicate DatabaseLoader #5108 and SysMetadataRepository #4867 ask); whether the outage then propagates the 503 or becomes a per-type degradation marker in the payload is the real design call — and it is a metadata-protocol read-surface change, which is exactly why folding it into #6504 would have widened this card after the fact rather than completed it.

    #8855 is filed unassigned and ungraded — finding only, no domain:*, no type. Grading and routing are triage's single-producer field, and filing it pre-labelled from this seat would have produced the same after-the-fact widening one level up.

    This card stays open only until #8854 lands; the close is on that, not on this comment.


    Generated by Claude Code

  13. hotlong commented on Aug 15, 2026

    @hotlong
    Contributor

    Closing — PR #8854 merged, confirmed on both readings. PM session session_01XeQRiAa7vYRVX5Fog7Zby8.

    Reading 1: merged 2026-08-15T11:29Z. Reading 2, on origin/main 20067c56b:

    • packages/mcp/src/metadata-completeness.ts — the shared vocabulary, extracted rather than copied so the two transports answer in one sentence
    • packages/runtime/src/list-diagnosed-consumer-sweep.test.ts — the real-loader pin (a MetadataManager over a driver whose find() throws ECONNRESET, so the verdict is computed rather than injected)
    • listObjectsDiagnosed in 7 files, countBoundObjectsDiagnosed in 3, against a listDiagnosed control of 20 — the members are present and consumed, not declared-and-orphaned

    Together with PR #7721 (the IMetadataService.listDiagnosed? contract and the first two packages/mcp consumers), the card is complete under ruling C: all 18 call sites qualified individually, 15 correct unchanged with the reason stated for each, 3 changed.

    What this card actually bought, stated once for the record

    The headline is not the three fixes. It is that the sweep's deliverable was the qualification, and "upgrade them all to listDiagnosed" would have been the wrong answer — a caller publishing a snapshot with no count has nothing to mis-state. Two of the untouched fifteen were real decisions rather than skips:

    • resolvePermissionSets reads list('permission') to decide authorization — the sharpest-sounding consumer in the sweep — and is deliberately untouched because it is already fail-closed: a degraded read there costs access, never grants it. Adding a refusal would have traded a safe direction for a new outage-driven denial.
    • The bootstrap-declared-* family publishes { seeded, updated }, which describes what the bootstrap did, not what the environment contains, and none prunes rows absent from the list — so a short read cannot delete anything.

    And the one that graded the card above polish: removeDatasource's guard did not merely mis-state a count, it spent it in front of an irreversible delete that also unbinds the datasource's secret — with the worst value being the benign-looking one, since 0 reads exactly as "nothing is bound". It did not mis-describe; it opened.

    The failed prediction, kept

    Recording it here because it is the most transferable thing in the PR. Ablation (b) predicted 3 red / 5 green and measured 4 red / 4 green. The extra red exposed an incoherent fixture — the harness supplied that case's count only through the diagnosed member, so it was measuring the wiring rather than the decision. The dev fixed the harness, re-measured to the predicted 3/5, and recorded both numbers with the reason instead of quietly keeping the second.

    Not folded in — filed as #8855

    getMetaDiagnostics was surfaced during this sweep and is deliberately its own card. It is two hops from list() (it consumes getMetaItems), and reading it at origin/main to write the card showed it is a category sharper than this one: a bare catch { continue } swallows the 503 that #5532 raised out of getMetaItems, so a store it cannot read is published as total: 0 — a diagnostics endpoint reporting zero problems precisely when it can read nothing. Its fix is a metadata-protocol read-surface change, which is why folding it here would have widened this card after the fact rather than completed it.

    Closing as completed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions