Skip to content

fix(mcp)!: the stdio data bridge reads objects from the protocol's merged view, so a package's objects are listed, described and gated - #25243

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-dkk3hd
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-dkk3hd

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Fixes #25138

Clause-②: no (narrowing)
Metadata-protocol: none

The stdio MCP transport (objectstack mcp, OS_MCP_STDIO_ENABLED) now reads objects from the protocol's merged view, as the /mcp HTTP bridge does since #25120. list_objects and describe_object answer from the listing GET /api/v1/meta/object serves. The API-exposure gate every stdio data verb and the record resource ask reads the object by name through the protocol, the read RestServer's own gate makes. So a package object declared apiEnabled: false is refused on stdio as REST refuses it, and every object of a disabled package is refused 404 OBJECT_PACKAGE_DISABLED on every verb. Nothing is copied into the metadata service.

The defect, measured on base 123ff6f4e2

createStdioDataBridge answered list_objects from metadataService.listObjects() and describe_object from getObject(). enforceApiExposure, the gate on all six data verbs, on the record resource (plugin.ts getRecord) and on nested-relation targets, read the same getObject() and treated a missing copy as "no declaration", that is, allowed. That service holds a copy of an object only where something put one there. On a kernel with an environmentId (createStandaloneStack: os dev as env_local, os serve, os start; and the cloud single-environment runtime) the full boot bridge is off, so an object a package registers in the SchemaRegistry during the boot gets no copy.

Real kernels (ObjectKernel + MetadataPlugin + ObjectQLPlugin, the same composition the HTTP pins use), driven through the real MCPServerPlugin stdio session over an in-process transport speaking a stdio client's JSON-RPC. The fixture package has two objects, one declared apiEnabled: false, registered through the kernel's manifest service:

shape, when the package registered list_objects describe_object six data verbs on the apiEnabled: false object record resource
env_local, during the boot { objects: [], totalCount: 0 } Object "qa_pkg_open" not found served: query_records, aggregate_records and create_record answered, get_record read the store, update_record and delete_record reached it (RECORD_NOT_FOUND for the absent id) read the store (Record "r_any" not found)
env_local, installed after the boot (source: 'marketplace') both both 404 OBJECT_API_DISABLED 404
no environmentId, during the boot both both 404 OBJECT_API_DISABLED 404
no environmentId, installed after the boot both both 404 OBJECT_API_DISABLED 404

The first row is the card's reading. The other three are controls: something already copied the package's objects there, so they were right and must stay right.

A disabled package (registry.disablePackage), on both shapes. The protocol omits its objects from its listing and refuses them by name 404 OBJECT_PACKAGE_DISABLED, and REST refuses them on every /data route. Every stdio data verb asks the ENGINE, never the protocol, and the engine serves them. All six verbs and the record resource served the exposed object on both shapes, and the apiEnabled: false object where it had no copy (env_local); where it had one, the copy refused it 404 OBJECT_API_DISABLED. On the shape that copies, the copy outlives the disable, so list_objects listed both objects and describe_object described them. This is the posture the HTTP bridge's aggregate arm had before #25136 closed it, here on every verb.

What changed

  • packages/mcp/src/stdio-data-bridge.ts.
    • readMergedObjects reads protocol.getMetaItems({ type: 'object' }), unwrapped by the same metaItemsArray the skill read on this transport uses. listObjects serves it; describeObject answers from the same listing, so every listed object can be described and a name the listing omits is "not found". A host with no protocol service, or one without getMetaItems, reads the metadata service as before. A merged read that throws travels to the caller, as on the HTTP bridge; nothing falls back. listObjectsDiagnosed keeps its partial verdict from diagnoseObjectListRead(metadataService).
    • readObjectDeclaration reads the object by name through the protocol, on the execution face (getMetaItemForExecution) where the protocol has one and getMetaItem otherwise: the item under its own name is the definition; nothing, or an item under another name, is "no declaration" as before; a refusal OBJECT_PACKAGE_DISABLED is THROWN; any other throw judges the metadata service's copy, the source this gate read before, so an outage never widens exposure. A host whose protocol has no by-name read keeps reading the copy.
    • enforceApiExposure takes StdioObjectReads ({ metadataService, protocol }) in place of the bare service, reads through readObjectDeclaration, and lets the disabled-package refusal through its fail-open catch. The relation-target gate and the write-response strip (internal: true fields omitted from the create_record / update_record echo) read the same definition.
    • The GATED_ACTIONS docblock no longer says the HTTP bridge reads discovery "straight off the metadata service" (the stale wording fix(runtime)!: the /mcp bridge reads objects from the protocol's merged view, so a package's objects are listed, described and gated #25136's notes left on this card).
  • packages/mcp/src/plugin.ts. The bridge and the record resource's gate get the protocol service through one accessor that resolves it at each use (ctx.getService('protocol'), undefined when absent or throwing), never captured in start().
  • packages/mcp/src/mcp-server-runtime.ts. metaItemsArray is exported within the package for the bridge; index.ts does not publish it.
  • packages/mcp/src/stdio-data-bridge.exposure.test.ts, plugin.record-resource-exposure.test.ts. The two direct callers of enforceApiExposure pass { metadataService }; their assertions are unchanged, and on a host with no protocol the gate reads the copy exactly as before.
  • scripts/engine-double-contract.pinned.json. Records the new unit pin file's engine double (written by check-engine-double-contract.mjs --write).
  • .changeset/25138-mcp-stdio-package-objects.md. A patch for @objectstack/mcp, with the BREAKING banner and the ADR-0087 disposition not-required (no-migration-prescription); both declaration lines are in the body.

Contract

Every input whose answer changes, old to new, on the stdio transport:

  1. list_objects on a kernel with an environmentId, for an object a package or plugin registered during the boot. Old: omitted, totalCount short. New: listed; the non-sys_ set is what GET /api/v1/meta/object lists.
  2. describe_object on such an object. Old: Object "NAME" not found. New: described.
  3. query_records, get_record, aggregate_records, create_record, update_record, delete_record and the objectstack://objects/NAME/records/ID resource on such an object declared enable.apiEnabled: false. Old: served, the writes reaching the engine. New: 404 OBJECT_API_DISABLED, the engine never asked; 405 OBJECT_API_METHOD_NOT_ALLOWED for a verb an apiMethods whitelist leaves out. The codes REST and the HTTP bridge answer.
  4. create_record / update_record on such an object with an internal: true field. Old: the field echoed in the response. New: omitted, as on every other object.
  5. A nested-relation condition on such an object whose target is unexposed. Old: the condition was not found (no field map), so its target was not gated. New: the target is gated.
  6. A disabled package's objects, where the metadata service still holds a copy. Old: listed and described. New: omitted and not found, as REST answers.
  7. Every data verb and the record resource on an object of a disabled package, whatever it declares, on every kernel shape. Old: an exposed object served on every shape; one declared apiEnabled: false served where it had no copy and refused 404 OBJECT_API_DISABLED where it had one. New: 404 OBJECT_PACKAGE_DISABLED, the protocol's own error, refused at the gate with the engine never asked. No refusal's status moves.
  8. An object whose served definition differs from the metadata service's copy. Old: discovery and the gate read the copy. New: they read the definition REST serves.
  9. list_objects / describe_object while the protocol's merged listing throws (the metadata store cannot be read). Old: the metadata service's listing. New: the protocol's error travels to the client (its 503 SERVICE_UNAVAILABLE, what GET /api/v1/meta/object answers); nothing falls back. Pinned with a throwing double, not on a real store outage.

Unchanged: objects that already had a copy (every object on a kernel without an environmentId, a marketplace install on any kernel, a runtime-authored object) are listed and gated as before. A host with no protocol service, or one without the merged reads, reads the metadata service. The gate's outage branch judges the copy. A system caller (isSystem) still bypasses the gate.

Pins

  • packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts, 70 cases, real kernels. For each of the four rows above (10 each): list_objects lists both package objects with a complete totalCount; describe_object answers both, enableFeatures.apiEnabled: false included, and a name nothing serves is still "not found"; each of the six data verbs refuses the gated object { code: 'OBJECT_API_DISABLED', status: 404 } with no engine find / findOne / insert / update / delete / count / aggregate call on it; the record resource refuses it; the exposed object is queried and created, and create_record does not echo its internal: true field. For a disabled package on both shapes (15 each, the copy's existence a precondition on the shape that copies): neither object is listed nor described; each of the six verbs and the record resource refuses both objects { code: 'OBJECT_PACKAGE_DISABLED', status: 404 }, the engine never reached.
  • packages/mcp/src/stdio-data-bridge.merged-object-reads.test.ts, 39 cases, doubles. The engine double's findOne, update and delete open with the shared assertEngine* dispatch asserts. Each branch of the two reads: listing from the merged read where the copy is absent, the bare-array answer, the partial verdict kept, a throwing merged read propagated; the gate on all six verbs and the record resource's gate, the served face where there is no execution face, the served definition over a stale copy, an item under another name; the disabled-package refusal on every verb with and without a copy, the system caller still let through; the outage branch judging the copy and falling open with none; the relation-target gate and both write-response strips.

Red before, green after, measured on eb3b93d9bd. The later commits change one pin title (fbb65d51), type two test doubles (2c6cb414) and one lookup (1261513f), and route the unit double through the engine asserts (476460a8); no source blob or assertion moved, and both pin files pass in full on the final head (above). Each leg's direction was predicted before it ran. Mutations were landed and restored by scripts/ablation-replace.mjs (anchor 1 to 0, blob changed; restored blob equals HEAD, git diff HEAD empty); the main leg swapped the three source files to their base blobs under an EXIT/INT/TERM trap and checked blob equality and an empty git diff HEAD after. The dogfood file consumes @objectstack/mcp's dist/, so every leg rebuilt it and scripts/ablation-dist-preflight.mjs proved the leg's marker reached dist/ (and, after the restore, that none remained, exit 0 for each).

leg mutation dogfood (70) unit (39)
main stdio-data-bridge.ts, plugin.ts, mcp-server-runtime.ts at base blobs cf59e76683, 18a0afbe7f, 3ef6ce641e 39 failed, 31 passed: all 10 env_local during-boot rows; all 28 disabled-package verb and resource rows; the no-environmentId disabled package's listing 31 failed
discovery readMergedObjects answers undefined (the metadata service read) 3 failed: env_local during-boot list and describe; the no-environmentId disabled package's listing 6 failed
gate readObjectDeclaration always reads the copy 36 failed: the env_local during-boot six verbs, record resource and write-response strip; all 28 disabled-package rows 25 failed
refusal OBJECT_PACKAGE_DISABLED read as an outage again (judged by the copy) 28 failed: every disabled-package verb and resource row on both shapes 13 failed
fix eb3b93d9bd 70 passed 39 passed

The first attempt at the discovery and refusal legs was a no-op reading. Their marker was a bare string expression (void 'ABLATION_…'), which the bundler drops, so the preflight answered exit 1 (marker absent from dist/) although the source mutation landed. They were re-run with a marker the bundler keeps (a globalThis member read): preflight exit 0, and the counts above are those runs (the same counts as the first attempt). The dogfood row now titled "the package's exposed object is queried and written, and create_record does not echo its internal: true field" was titled "CONTROL: …" in those runs; fbb65d51 renamed it because it also pins the strip.

Local verification

On 476460a86c, the final commit, over origin/main 123ff6f4e2 (unmoved since the branch was cut), with the workspace built on 1261513f93 (turbo run build --filter=!@objectstack/docs, 76 tasks, VERDICT command-exit 0). Every heavy run went through the verify lock, and each line cites its VERDICT.

  • @objectstack/mcp unit suite (pnpm --filter @objectstack/mcp test): 41 files, 518 tests passed, 0 failed (VERDICT command-exit 0).
  • pnpm --filter @objectstack/mcp typecheck: exit 0 (tsc --noEmit, and check:test-typecheck OK: 6 files, 53 errors, 8 pinned signatures held in the ledger).
  • pnpm --filter @objectstack/dogfood typecheck with NODE_OPTIONS=--max-old-space-size=8192: exit 0. A first run at the default heap died at exit 134 (out of memory), before any type verdict.
  • pnpm lint (eslint . --no-inline-config): exit 0 on 1261513f93. The first run (on 2c6cb4141b) found one error, a metadata service lookup typed any in the dogfood pin's precondition; 1261513f93 types it IMetadataService. The last commit touches one unit pin file, and eslint --no-inline-config on it answers exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 79 commands on 476460a86c (the pinned ledger brings 9 more families than the earlier tree's 70), each exit code captured before any pipe, all exit 0.
  • --ran reconciliation: ✓ dispatch-gates --ran: 79 derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED (a DERIVED zero — all 79 recorded an exit code and none of them is 3).
  • node scripts/check-changeset-no-major.mjs --base origin/main --event PAYLOAD, with PAYLOAD a pull_request event carrying this body: exit 0. ✓ This diff introduces no major bump. and ✓ LEVEL AXIS: this PR declares clause-② no (narrowing) and metadata-protocol none .... node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0 (in the derived list).
  • node scripts/check-closing-keyword-parity.mjs --body on this body binds #25138 alone.
  • The full dogfood battery, once (pnpm --filter @objectstack/dogfood test, no shard), on 1261513f93: VERDICT command-exit 0; Test Files 288 passed, 3 skipped (291); Tests 2468 passed, 18 skipped (2486); no failure. The last commit changes one @objectstack/mcp unit pin file and the engine-double ledger (scripts/engine-double-contract.pinned.json), neither of which the battery reads (the battery consumes @objectstack/mcp's dist/, and a test file is not in it), so it was not re-run.
  • The first pass of the derived gates, on 2c6cb4141b, found three things, each handled before the pass above: check:engine-double-contract refused the unit pin's engine double, whose findOne / update / delete were looser than ObjectQL's (476460a8, the last commit, opens each with the shared assertEngine* asserts and records the file in the pinned ledger, as the gate's own message prescribes); check-plugin-teardown-shape.mjs --self-test answered 3 because this shallow clone lacked its pinned fixture commit (fetched); check:dual-build-cjs-loads answered 3 before the full build.

Acceptance notes

  • Finding, same defect class, not fixed here: the stdio metadata resources. objectstack://objects and objectstack://objects/NAME (bridgeResources in packages/mcp/src/mcp-server-runtime.ts) read the metadata service. Measured at this PR's head on the env_local during-boot fixture: resources/read of objectstack://objects answers { objects: [], totalCount: 0 }, and of objectstack://objects/qa_pkg_open answers 404 RESOURCE_NOT_FOUND, while list_objects and describe_object now list and describe both. Reach: the stdio resources/read door, measured. It reveals no record and serves no data verb, so it was left for its own card rather than widened in here.
  • Stricter than REST, by design: a disabled package's object as a nested-relation target. The relation-target gate refuses 404 OBJECT_PACKAGE_DISABLED when a condition's target belongs to a disabled package, because the engine would read that target to lower the condition. Code-read, not pinned.
  • The internal-field strip on a package object was a leak, closed in passing. On the env_local during-boot shape the strip read the missing copy, so create_record echoed an internal: true field. Pinned by the exposed-object row, which the gate leg turns red.
  • describe_object reads the whole served listing per call, as the HTTP bridge does since fix(runtime)!: the /mcp bridge reads objects from the protocol's merged view, so a package's objects are listed, described and gated #25136.
  • File surface. The fix reaches plugin.ts (the accessor) and mcp-server-runtime.ts (one export) beside stdio-data-bridge.ts, and the real-kernel pins live in packages/qa/dogfood, where the stdio exposure twin already lives.
  • Branch name. This PR's branch is claude/project-thread-dkk3hd, the name the session was given, not claude/issue-25138-…; the Duplicate Fix Guard may say so.
  • Deviation. The card's dispatch marks (claim comment, labels, assignee) were written through the session's MCP GitHub tools as os-sales, before the fleet-write relay was found available in this container. This PR and its follow-up writes go through the relay.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK

…ct type

The repo's lint refuses a service lookup erased to `any`; the precondition
reads `getObject` off the `metadata` slot, whose contract is IMetadataService.

Claude-Session: https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK
Co-authored-by: Claude <noreply@anthropic.com>
…te contract

The engine-double contract gate refuses a double whose findOne, update or
delete is looser than ObjectQL's. The double opens each with the shared
dispatch asserts, and the pinned ledger records the file.

Claude-Session: https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/mcp, touching 29 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 42b60b91bdee4963a3a64be57f71d729946d649a.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 42b60b91bdee4963a3a64be57f71d729946d649a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 084af0baf71dfbfaf5537ebadfd7c5f77605ce3f — the merge of head 476460a86c89fccb17e4dd9173d5a4165722edd7 into base 42b60b91bdee4963a3a64be57f71d729946d649a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 084af0baf71dfbfaf5537ebadfd7c5f77605ce3f && git checkout 084af0baf71dfbfaf5537ebadfd7c5f77605ce3f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 42b60b91bdee4963a3a64be57f71d729946d649a 476460a86c89fccb17e4dd9173d5a4165722edd7 && git checkout -B drift-repro 42b60b91bdee4963a3a64be57f71d729946d649a && git merge --no-ff 476460a86c89fccb17e4dd9173d5a4165722edd7

node scripts/docs-audit/affected-docs.mjs --json 42b60b91bdee4963a3a64be57f71d729946d649a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 42b60b91bdee4963a3a64be57f71d729946d649a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 476460a86c89fccb17e4dd9173d5a4165722edd7
Local-runs: none
Scope: packages/mcp/src/stdio-data-bridge.ts, packages/mcp/src/plugin.ts, packages/mcp/src/mcp-server-runtime.ts, packages/mcp/src/stdio-data-bridge.exposure.test.ts, packages/mcp/src/plugin.record-resource-exposure.test.ts, packages/mcp/src/stdio-data-bridge.merged-object-reads.test.ts, packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts, scripts/engine-double-contract.pinned.json + packages/metadata-protocol/src/protocol.ts (objectPackageDisabledError, isObjectOfDisabledPackage, isItemOfDisabledPackage, getMetaItems / readFlattenedMetaItems, getMetaItem / getMetaItemForExecution / readMetaItemFace, assertObjectRegistered, metadataStoreUnavailableError), packages/metadata-protocol/src/plugin.ts (the protocol slot's registration), packages/runtime/src/action-execution.ts (readExposureDeclaration, refuseUnexposedDataAction, callData, refuseUnservedRelationTargets), packages/runtime/src/domains/mcp.ts (readMergedItems, listObjectSummaries, listObjectsDiagnosed, describeObject), packages/runtime/src/api-exposure.ts (checkApiExposure), packages/rest/src/rest-server.ts (objectItemReader, enforceApiAccess), packages/rest/src/meta-item-read-gate.ts (loadObjectItem), packages/mcp/src/mcp-http-tools.ts (list_objects, describe_object, validate_expression, isSystemObject, errorResultWithCode), packages/mcp/src/mcp-server-runtime.ts beyond its hunk (bridgeResources, the three objectstack://objects resolvers), packages/mcp/src/index.ts and packages/mcp/package.json (the exports map), packages/mcp/src/metadata-completeness.ts (diagnoseObjectListRead), packages/core/src/security/resolve-authz-context.ts (the stdio principal), .github/workflows/ci.yml (the dogfood job's if:, the Test Core shard exclusion, the dogfood-gate contract), scripts/check-adr-0087-registration.mjs (CATEGORIES); .changeset/25138-mcp-stdio-package-objects.md for ② only

Inputs: card #25138 (body; pointer 6328231985; claim 6341568965; os-dev-report 6343835289, read as claims), PR #25243 (body, nine-file list, the net diff against main at merge base 123ff6f4e2), and the 42 check-runs on the head: 35 success, 7 skipped (the dogfood shard matrix, Dogfood Verify CLI, Build Docs, Temporal Conformance, Console Pin Gate, the 18.x upgrade harness, the packed-tarball smoke), 0 failure; the five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core with its six shards, Build Core, Governed Surface Queue Guard) are success, as are Check Changeset, the two claim guards and the single-writer guard. Prior records read because the card cites them: PR #25136's body and its records 6328012432 (FAIL on d19b65bf1c) and 6329661857 (PASS on c0fb1c715f). No governed path is in the file list. The face is the PR body's and the changeset's Clause-②: no (narrowing). Every line number below is read at the head sha.

① Derived judgments

  1. Discovery reads the protocol's merged listing, through the one unwrap, and list_objects / describe_object cannot disagree — RIGHT. readMergedObjects (packages/mcp/src/stdio-data-bridge.ts:373-377) asks protocol.getMetaItems({ type: 'object' }) and unwraps it with metaItemsArray (packages/mcp/src/mcp-server-runtime.ts:535-539), the function the skill read on this transport already uses (:580). listObjects (stdio-data-bridge.ts:843-844) reads metadataService.listObjects() only when that returns undefined, which happens on structural absence alone (no protocol slot, or one without getMetaItems); a throw has no catch and travels. listObjectsDiagnosed (:863-864) composes over bridge.listObjects() and asks only the verdict of diagnoseObjectListRead(metadataService), so the shared tool's preferred path (packages/mcp/src/mcp-http-tools.ts:704-705) renders the merged listing with the copy's partial verdict — the composition the card asked for. describeObject (:873-876) reads the same listing per call, and validate_expression reads describeObject (mcp-http-tools.ts:793), so every listed object is describable and a name the listing omits is "not found" on all three tools. The listing is the one REST serves: getMetaItems is readFlattenedMetaItems on the served face (packages/metadata-protocol/src/protocol.ts:10007-10019), which omits a disabled package's items by isItemOfDisabledPackage (:10615-10617, predicate :552-555), the same read the HTTP bridge's readMergedItems makes (packages/runtime/src/domains/mcp.ts:454-471, :599-600). The slot the plugin resolves is the full ObjectStackProtocolImplementation that assembleMetadataProtocol registers under 'protocol' (packages/metadata-protocol/src/plugin.ts:250-255), the one seam ObjectQLPlugin's built-in mode and MetadataProtocolPlugin share, so it carries the listing and both by-name faces. sys_ objects are withheld by the shared tool (mcp-http-tools.ts:286-288), narrower than REST, exactly as on HTTP. Noted, not blocking, as on the HTTP record: describe_object reads and decorates the whole served listing where a by-name read would read one object — a cost, not a contract change.

  2. The gate's healthy by-name read is the HTTP twin's and REST's — RIGHT. readObjectDeclaration (stdio-data-bridge.ts:337-355) selects getMetaItemForExecution, else getMetaItem, else the copy (:344), and accepts the item only under its own name (:353): line for line readExposureDeclaration (packages/runtime/src/action-execution.ts:346-371) and RestServer.objectItemReader with loadObjectItem's own-name rule (packages/rest/src/rest-server.ts:2570-2586; packages/rest/src/meta-item-read-gate.ts:491-497). readMetaItemFace (protocol.ts:11422-11428) shares every source and merge between the two faces and differs only in the serving decorations, so the definition this gate judges is the one REST's gate judges. enforceApiExposure then applies the same two codes REST and callData answer: 404 OBJECT_API_DISABLED (:463-469) and 405 OBJECT_API_METHOD_NOT_ALLOWED with the effective set (:477-482), unchanged since The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049 apiEnabled / apiMethods exposure gate that HTTP applies #8083.

  3. OBJECT_PACKAGE_DISABLED is thrown, nowhere swallowed — RIGHT; the defect record 6328012432 found on the HTTP twin is not copied. The protocol raises it from both by-name faces for an object of a disabled package (protocol.ts:11426-11428, constructor :519-528, predicate :536-539). readObjectDeclaration rethrows it before its fallback (stdio-data-bridge.ts:349), enforceApiExposure rethrows it past its fail-open catch (:450), both on the code alone (:295-297), the mechanism callData's 'refuse' arm uses (action-execution.ts:365, :445-447, :471). Every door asks the gate before any engine call: the six verbs (:898, :927, :941, :964, :994, :1010) and the record resource (packages/mcp/src/plugin.ts:768). The engine asks no disabled-package predicate (record 6329661857, judgment 1; unchanged), so this gate is the only refusal on a transport whose every verb is engine-only, and it is now there on all seven doors. Swallow audit of the three remaining catches and reads: enforceRelationTargetExposure's catch (:508-511) swallows a throw from the ADDRESSED object's read, but the gate at :898 / :1010 has already thrown for that object one line earlier, and each target's gate (:514) rethrows; the two write-response strips (:955, :987) read after the gate on the same object and have no catch, so a throw there is an error, never a served response. The tool layer renders code and status off any thrown value (mcp-http-tools.ts:367-370), and the record resource renders { error, code, status } the same way (mcp-server-runtime.ts:1264-1292), which is the envelope both pin files assert. Against REST: loadObjectItem reads the refusal as "no object" (meta-item-read-gate.ts:500) and the data plane refuses next (assertObjectRegistered, protocol.ts:12916); on stdio the tool input schema runs before the bridge and nothing stands between the gate and the engine, so there is no refusal order to keep, and a well-formed request gets REST's answer: 404 OBJECT_PACKAGE_DISABLED, the engine never asked. No refusal's status moves.

  4. An outage of either protocol read never widens exposure — RIGHT. By-name: a protocol without the read judges the copy (:344); any throw but the refusal judges the copy (:350), the source this gate read on every request before the move; a throwing copy read reaches :451 and falls open, the pre-move answer. So on every input the outage answer equals the pre-move answer and cannot allow what the pre-move gate refused; REST fails open at once on the same throw (meta-item-read-gate.ts:501-507), so this door is at most REST's exposure then. Listing: a throw travels to the client with no fallback to the un-merged listing (:373-377, :843, :873), rendered as the protocol's 503 SERVICE_UNAVAILABLE (metadataStoreUnavailableError, protocol.ts:4329-4338) through errorResultWithCode — what GET /api/v1/meta/object answers. Carried from record 6329661857, non-blocking: on the no-copy shape the by-name fallback is a silent fail-open indistinguishable from health; a one-shot warn would make a persistent outage visible on exactly the card's shape.

  5. The accessor resolved per use returns the bridge's own kernel's protocol — RIGHT. protocolReads (plugin.ts:714-721) asks ctx.getService('protocol') at each use and answers undefined on absence or throw; the same ctx resolves metadata (:410) and objectql (:485), so the engine, the copy and the protocol are one kernel's services, and on an environmentId kernel the protocol is the one assembled over that same ql with that environmentId. A slot filled with a partial object is probed per read (:344, :375) and falls to the copy, as before. A replaceService('protocol') after start() is seen by the accessor and not by the captured engine and copy: the direction the dev chose on purpose (the HTTP bridge resolves per request too), and it cannot widen, since an absent or throwing slot reads the copy. StdioProtocolObjectReads (stdio-data-bridge.ts:140-148) is the narrow duck type; the real signatures are assignable.

  6. The relation-target gate and the write-response strip read the judged definition — RIGHT, with one divergence named precisely. enforceRelationTargetExposure (:498-514) finds a package object's condition sites where it has no copy, and refuses each target with the gate's own codes (:514), so an unexposed target is refused as the data door refuses it (security(mcp): a nested-relation filter condition through the MCP stdio data bridge is evaluated without asking the target's exposure (census row 8 of #22661) #22777). The disabled-package arm: a condition whose target belongs to a disabled package is refused 404 OBJECT_PACKAGE_DISABLED here, where the HTTP bridge's refuseUnservedRelationTargets reads the target through resolveWriteResponseSchema and asks checkApiExposure alone (action-execution.ts:227-243) and REST's gate reads the refusal as absence, so neither twin raises it for a target. On that one input stdio refuses what the twins would let the engine read to lower the condition: a narrowing of stdio alone, in the refusing direction, with the code REST gives the same object when addressed; the dev's note says "stricter than REST" and it is stricter than the HTTP bridge too. Code-read, not pinned (the unit pin's target is an apiEnabled: false one). Non-blocking; carried. The strips (:955, :987) now read the definition the gate judged, closing the echo of an internal: true field on the no-copy shape in passing; pinned in both files.

  7. The isSystem bypass — residual, pre-existing, not widened. :443 returns before the read for a system principal, the bypass the HTTP gate shares; the stdio principal comes from resolveStdioExecutionContext (plugin.ts:249-262, resolveAuthzContext over the key row) and plugin.ts sets no isSystem. The unit pin names the bypass. Carried as record 6329661857 carried it.

  8. Public surface — RIGHT, none moves. metaItemsArray becomes an export of mcp-server-runtime.ts (:535), and enforceApiExposure's first parameter and the two new interfaces are exports of stdio-data-bridge.ts; packages/mcp/src/index.ts re-exports none of them (it exports MCPServerPlugin, MCPServerRuntime, the two tool registrars, the skill helpers and the connect UI), and the package's exports map is . alone, resolving to dist/index.*. By the published-surface rule (the exports map decides), nothing published moves. No import cycle: mcp-server-runtime.ts imports nothing from stdio-data-bridge.ts. @objectstack/spec, @objectstack/runtime and @objectstack/rest are untouched; the metadata service gets no second copy, as the card required.

  9. Pins, and what the check-runs measured — read as claims where CI did not execute them. The 39-case unit file ran in Test Core (six shards success; Test Core excludes @objectstack/dogfood, ci.yml:711), and its cases cover each branch of the two reads named in the PR body. The 70-case dogfood file did NOT execute in any check-run on this head: the dogfood job is deny-listed on pull_request and merge_group events (ci.yml:2037, the skills(ci): two-speed CI — fast set on PR and merge-queue runs, full battery hourly on main and before a release, main-red stops the lane, four required contexts plus the guard, timing gates report-only, test budgets not deletions #22998 two-speeds contract), and the Dogfood Regression Gate context concludes success with zero expected attestations by design (ci.yml:109-112). Read at the head, the file's rows match the PR's table (four shapes × 10 rows; a disabled package × 15 rows on two shapes), drive the real MCPServerPlugin stdio session over an in-process JSON-RPC transport, assert the coded envelope on every refusal, and spy the seven engine methods for "never reached". Its execution on this head rests on the dev's report: 70/70 on eb3b93d9bd and the full battery on 1261513f93, the two later commits touching one unit file and the ledger. The push that lands this PR runs the full battery, where a red is the lane's main-red. Not a blocker; named so the seat knows which pin CI has and has not run.

  10. The engine-double ledger — RIGHT. The three scripts/engine-double-contract.pinned.json rows record the new unit double's findOne / update / delete, which open with the shared assertEngine* asserts (unit file :83-94); they are what check-engine-double-contract.mjs --write emits, and the gate ran green inside Lint & Repo Gates.

Verdict on the face: every change narrows or holds; no public surface moves; the one widening the HTTP review found on the twin is absent here, by the same mechanism that closed it there. This head is the narrowing its Clause-② line declares.

② Semver level

'@objectstack/mcp': patch, with the BREAKING banner, the adr-0087: not-required (no-migration-prescription) marker comment (a member of CATEGORIES, scripts/check-adr-0087-registration.mjs:536-543, in the marker form :62-63), and both declaration lines, Clause-②: no (narrowing) and Metadata-protocol: none, line-initial in the changeset body and in the PR body. Right: a (narrowing) is BREAKING and a non-protocol change ships patch with its banner and disposition (AGENTS.md Post-Task Checklist 3); none is right because no authorable key, accepted value or default moves (an enable.apiEnabled / enable.apiMethods the author already wrote is enforced where it was not read, and an operator's package switch is honoured on one more door); the only published package the diff moves is @objectstack/mcp. Check Changeset is success on the head. The marker's own prose matches the diff (nothing exported is removed or narrowed; no stored shape moves).

Sentence by sentence: the title — true, modulo the pre-existing sys_ withholding of the list_objects tool (allowSystemObjects defaults to false), which the PR body's contract item 1 states precisely ("the non-sys_ set"); non-blocking. "Before" — true: discovery read the copy; describe_object and validate_expression both read describeObject; the verbs were served because the gate read the absent copy as "no declaration"; update_record / delete_record reached the engine through findById; the strip read the absent copy and masked nothing. "Now" — true (judgments 1, 2, 6). "Also changed" — true: the listing omits a disabled package's objects (protocol.ts:10615-10617); every verb and the resource refuse 404 OBJECT_PACKAGE_DISABLED (judgment 3); the listing's 503 SERVICE_UNAVAILABLE travels (judgment 4). "Unchanged" — true (judgments 4, 5). "What to do" — prescriptive and consistent with the codes. No model identifier in the changeset, the PR body or the six commits, whose trailer pair is the model-free form.

③ Boundary flags

Report 6343835289: open_questions is empty — nothing to answer.

out_of_scope_findings, each answered or escalated:

  1. The stdio schema resources objectstack://objects and objectstack://objects/NAME read the metadata service — confirmed at the head (mcp-server-runtime.ts:897, :809). On the no-copy shape the two resources now list and describe fewer objects than the tools on the same transport (an under-serving of schema, no record and no data verb behind it), and on the copying shape they still list a disabled package's objects, as they did before this PR. Not a widening; a new tool-versus-resource inconsistency on this transport. ESCALATED: the seat files it from the report's dedupe words; the record resource, which this PR does gate, is not part of it.
  2. Carrier none: (a) the relation-target refusal stricter than REST — judged in ① 6, and stricter than the HTTP bridge as well; carried for parity on the next touch of either side; (b) the internal-field strip leak closed in passing — right, pinned; (c) describe_object reads the whole listing per call — a cost, as on HTTP; noted.

The PR body's Acceptance notes, each judged: the two above; "describe reads the whole listing" — noted; file surface — the claim names stdio-data-bridge.ts, its tests under packages/mcp/src/ and one changeset, with "stop on breach"; the diff also touches plugin.ts (the accessor wiring, without which the real plugin hands the bridge no protocol and the fix is inert), mcp-server-runtime.ts (one in-package export so the two merged reads on this transport share one unwrap), the dogfood pin (where the stdio exposure twin already lives, and the only real-kernel row) and the gate-written ledger. Each is load-bearing for the claimed fix, inside the lane's package or a gate's own output, and none is governed (Queue Guard and the single-writer guard are success). The dev did not stop; it explained in the PR body rather than in the report. Right on the contract face; the process deviation is the seat's to note. Branch name — AGENTS.md (Multi-agent discipline 2) names claude/issue-NNNN-slug; this head is on claude/project-thread-dkk3hd. The claim comment names that branch and "The card this PR closes must claim this branch" is success, so ownership is recorded; the consequences are that the issue-25138 pre-check cannot find this head and that the branch never meets condition ① of the force-with-lease rule. Not a contract matter; non-blocking; the seat records it. Deviation (dispatch marks written through the session's MCP tools as os-sales before the relay was found) — outside the contract face; the seat's.

New from this review, non-blocking, to carry: (1) the dogfood pin is unexecuted by CI on this head by design (① 9) — the landing push's full battery is where it first runs under CI; (2) a one-shot warn on the no-copy outage fall-open (① 4), the same note the HTTP record carried; (3) relation-target parity between the three doors on a disabled package's target (① 6); (4) the isSystem residual (① 7).

What a merge needs beyond this record: the five required contexts are already success; the PR is a draft, so the owning seat readies it and arms the queue per Multi-agent discipline §7.

Implemented-by: claude/project-thread-dkk3hd
Reviewed-by: session_01TgbP2QERdKmh147ohacHbK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

dogfood_battery

dogfood_battery: 476460a86c89fccb17e4dd9173d5a4165722edd7 · os-verify-lock: VERDICT command-exit 0 · held the lock 1095s (18m15s) · waited 0s (shared-box seconds) · Test Files 288 passed / 3 skipped (291) · Tests 2468 passed / 18 skipped (2486) · 0 failed

Owed because the title carries ! and the changeset declares Clause-②: no (narrowing) (.claude/agents/os-dev.md, Definition of done). The Dogfood Regression Gate shards are skipped on pull requests, so this is the only full run. It is the battery on the PR head; the earlier run in the PR body was on 1261513f93, one commit below.

  • Head. Run in the card's checkout at the PR head above, with a clean tree. No ablation marker remains in packages/mcp/dist (0 matches for ABLATION).
  • Closure. pnpm turbo run build --filter='@objectstack/dogfood^...' --concurrency=2 under scripts/pm/os-verify-lock.sh: 66/66 tasks, all cache hits on the same sources. The lock printed VERDICT command-exit 0.
  • Battery. pnpm --filter @objectstack/dogfood test under the lock, with no OS_TEST_SHARD and no path, NODE_OPTIONS=--max-old-space-size=8192. It started 15:48:00Z and ran for 1087.19 s. The new 70-case pin test/mcp-stdio-package-object-discovery.test.ts is in it.
  • Skips. 3 files and 18 tests were skipped by the suite's own environment guards. The diff touches none of them, and no test was skipped or narrowed by hand.
  • Failures. None.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:cli seat 1 (seat post #6024) · os-sales · session session_01CxBKycaiUpA1yGx1XyoGqX · 2026-10-22T16:21Z · head 476460a86c

item reading
Review chain Report 6343835289 refused (its mcp_calls named two deny-list write tools, issue_write and add_issue_comment); ruling 6345555554 on #25138: REWORK round 1, no annotated pass. Cure delivered through the relay: superseding claim 6345620370 (Supersedes: 6341568965, Ruling-ref: 6345555554, same session and branch) and fresh report 6345654418 (mcp_calls: 2 reads, 0 writes). Head unchanged.
Shape 9 files, +1190 / −40 (diff read by the seat). The stdio data bridge reads list_objects / describe_object from the protocol's merged listing and gates every data verb and the record resource on the protocol's by-name read, the HTTP bridge's reading since #25120; a disabled package's object is refused 404 OBJECT_PACKAGE_DISABLED (not copied from the HTTP half's earlier misreading, per pointer 6328231985). The metadata service stays the fallback on a host with no merged read. metaItemsArray is exported within the package, not from index.ts.
Contract review CONTRACT_REVIEW_TIER PASS on this head, 6345244666 (Clause-②: no (narrowing)).
Dogfood battery 6345380964 on this head: lock VERDICT command-exit 0, 288 files / 2468 tests passed, 0 failed (owed: ! title and a narrowing arm).
Gates (check-runs on this head) 42 runs: 35 success, 7 skipped, 0 failed. mergeable_state: clean.
Changeset, read per sentence fix(mcp)! patch with the BREAKING banner; Before / Now / Also changed / Unchanged / What to do each match the bridge and plugin hunks (merged listing, by-name gate, internal-field strip on writes, the package-disabled refusal, the 503 on a metadata-store outage, the metadata-service fallback).
Accepted deviations Branch claude/project-thread-dkk3hd; the file surface grew past the first claim (plugin.ts, mcp-server-runtime.ts, the dogfood pin, the ledger), re-declared in claim 6345620370 and explained in the PR body.
Out of scope The stdio objectstack://objects resources still read the metadata service: filed by this seat as #25245 (p1, Blocked-by: #25138).
Cross-lane touches scripts/engine-double-contract.pinned.json: 3 rows written by the gate's own --write for the new unit double, domain:spec: declaration posted on #6017 in this act. packages/qa/dogfood is this lane's.
Landing Not governed (check-governed-merges.mjs --pr 25243, +1190 / −40, under the threshold). Ready + auto-merge through the relay in this act.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 16:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 8010b64 Oct 22, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-dkk3hd branch October 22, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants