Skip to content

mcp: list_objects / describe_object cannot see any package-installed object (a marketplace app's whole schema) — the HTTP bridge reads the metadata service, not the SchemaRegistry REST serves #25120

Open

Description

@hotlong

Filing gate ① a reproduced defect. reach: measured live on ObjectOS Cloud production, https://huayan.objectos.ai, framework pin 9d13b5fc83, 2026-10-21. The MCP client used an API key over Streamable HTTP. The maintainer asked for this card in a cloud session: 「查清 MCP 看不到 HotCRM 对象的根因…你直接写issue就可以」.

Reader (who acts): objectstack triage → the runtime / mcp lane (packages/runtime/src/domains/mcp.ts).

What fails

The MCP discovery tools cannot see any object an installed package contributes. That includes a whole marketplace app. huayan has HotCRM 5.1.2 installed (app.objectstack.hotcrm, 18 crm_* objects) and one environment-authored app (app.p2p1, 3 objects). Every call below used the same API key:

Call Answer
REST GET /api/v1/meta/object (browser session) 29 non-sys_ objects: 18 crm_* + 8 ai_* (_provenance: "package") + 3 p2p1_* (no _provenance)
REST GET /api/v1/data/crm_opportunity (key) 200, total: 23
MCP list_objects 3 objects, only the p2p1_* ones, as totalCount: 3 with no partial flag
MCP describe_object crm_opportunity / crm_product / crm_campaign / ai_conversations Object "<name>" not found ×4
MCP describe_object p2p1_expense_item served
MCP query_records crm_opportunity served, records returned

So the data path works and only discovery is blind. An agent follows the tool contract: list, then describe, then query. It concludes HotCRM is not installed, and it never reaches a query that would have worked. The listing claims a complete totalCount: 3, so nothing tells the agent the answer is short.

It is not the objects' validity. crm_opportunity carries _diagnostics.valid: false (a picklist + options clash), but crm_product and crm_campaign are valid and are missing all the same. Every ai_* object, all valid, is missing too. The split runs exactly along _provenance: "package".

Cause

buildMcpBridge (packages/runtime/src/domains/mcp.ts) serves list_objects and describe_object from the per-env metadata service:

const objs = (await meta?.listObjects?.()) ?? [];   // listObjectSummaries
const def  = await meta?.getObject?.(name);         // describeObject

MetadataManager.listObjects() is list('object'), and readListUncached reads only the manager's own registry plus its loaders (packages/metadata/src/metadata-manager.ts). Objects an installed package or a plugin registers live in the ObjectQL SchemaRegistry. REST's getMetaItems reads that registry (this.engine.registry.listItems(...) in readFlattenedMetaItems, packages/metadata-protocol/src/protocol.ts) and merges the overlay on top. MCP never reads it. Environment-authored objects (p2p1_*) reach the manager through its sys_metadata loader, which is why they alone show up.

The repo has already fixed the same shape once for skills. Commit e783e163d moved the MCP skill read off metadataService.list('skill') onto the merged getMetaItems read, with McpMergedMetadataRead = Pick<MetadataProtocol, 'getMetaItems'> declared in this same file. Its comment reads "two surfaces, one skill name, two answers". Objects were left on the lower read.

origin/main has not changed domains/mcp.ts or metadata-manager.ts since 9d13b5fc83.

Possible second consequence (code reading, not walked)

#23689 records that the MCP exposure gate reads metadataService.getObject(object).enable.apiEnabled and treats an absent copy as "no declaration", i.e. allowed. A package object has no metadata-service copy here. So a package object declared apiEnabled: false may be served by query_records and the write verbs. The served query_records on crm_opportunity above is consistent with that path, though that object is not declared off. The fix should close both, and its acceptance should walk an apiEnabled: false package object.

Not a duplicate

Dedupe: REST search/issues, closed included —

  • repo:objectstack-ai/objectstack list_objects package → 49
  • repo:objectstack-ai/objectstack describe_object "not found" mcp → 15
  • repo:objectstack-ai/objectstack mcp listObjects metadata service registry → 17
  • repo:objectstack-ai/objectstack buildMcpBridge → 36

The nearest are #23661 / #23687 and #23689 / #23716 (closed). They give a runtime-authored object (created after boot) a metadata-service copy, on the non-env and the environmentId kernel respectively. This card is the other producer: objects that arrive by package install or plugin registration into the SchemaRegistry. Those never get a copy at all, at boot or later, as the 18 + 8 objects above show on a pin that carries those fixes. #23528 (closed) is the exposure gate's refusal envelope, not discovery.

Suggested direction (for the lane to judge)

Serve list_objects / describe_object, and the exposure gate's declaration read, from the merged getMetaItems({ type: 'object' }) view REST serves, as e783e163d did for skills. Do not mirror package objects into the metadata manager, because a second copy is a second thing to keep in sync. Keep the listDiagnosed('object') verdict for partial.

Acceptance:

  • On a kernel with an installed package, list_objects includes the package's objects and describe_object answers them. Its count equals the non-sys_ count GET /api/v1/meta/object serves to the same principal.
  • A package object declared apiEnabled: false is refused by every MCP data verb.
  • Walked on a cloud environment with a marketplace app installed (e.g. HotCRM), not only on a standalone kernel.

Activity

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    on Oct 21, 2026
  2. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 27 (serial) · domain:cli seat 2 · p1 · claimed under exception 2 (filer-labelled; the lane fits the table, and the body carries the filing gate's three answers) · 2026-10-21T17:38Z
    Session: session_01LmjU2x1faLZ39DVTFyVca6
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-25120-mcp-package-objects
    Worktree: objectstack-issue-25120
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    File surface (read on origin/main 4e2c42f363; line numbers move; stop on a breach and explain it in the report):

  3. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 25120,
      "status": "done",
      "branch": "claude/issue-25120-mcp-package-objects",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25136",
      "session": "session_01LmjU2x1faLZ39DVTFyVca6 — mode:subagent, so this is the parent's session (the commits' Claude-Session line)",
      "premise_still_valid": true,
      "summary": "Premise held on origin/main 4e2c42f363. On an environmentId kernel (the createStandaloneStack shape), a package registered during the boot got no metadata-service copy. The /mcp bridge then listed 0 of its 2 objects while REST listed 2, describe_object answered not found, and query_records on its apiEnabled:false object was served, with create reaching the driver. In packages/runtime/src/domains/mcp.ts, list_objects/listObjectsDiagnosed and describe_object now read getMetaItems({type:'object'}) through the existing McpMergedMetadataRead seam (the e783e163d shape, with a shared readMergedItems helper); the listDiagnosed('object') partial verdict is kept. In packages/runtime/src/action-execution.ts, the shared exposure gate (refuseUnexposedDataAction) reads the object by name through the protocol, the read RestServer's own gate makes: getMetaItemForExecution, else getMetaItem. OBJECT_PACKAGE_DISABLED reads as no declaration, and on a merged-read outage the gate falls back to the metadata-service copy, so exposure cannot widen. The gate fix also closes the dispatcher's /data door, which shares it. No copy is mirrored into the metadata manager. The merged read is per environment on multi-env hosts, measured with two environmentId kernels behind one host.",
      "tests": "New pin packages/runtime/src/domains/mcp-package-object-discovery.test.ts, 41 cases on real kernels (ObjectKernel + MetadataPlugin + ObjectQLPlugin over better-sqlite3 :memory:), each run under the verify lock, ablations by scripts/ablation-replace.mjs with blob-restore proof. Result by tree: HEAD 41 passed. Both source files at base 4e2c42f363 (blobs 5d19bd3b7a / 616639b765 on disk, restored to HEAD blobs 720242c7e7 / c5f2b5499b, git diff HEAD empty) 13 failed / 28 passed, as predicted. Discovery ablation (readMergedObjects answers undefined) 5 failed / 36 passed. Gate ablation (always read the copy) 8 failed / 33 passed, and the same mutation against data-write-request-gate.test.ts 12 failed / 61 passed, which proves the rewired rig reads the protocol. Rig re-judged: data-write-request-gate.test.ts 73/73 green. Full runtime unit layer (--project local, all 433 files in 3 chunks) at d19b65bf1c: 147 + 141 + 145 files passed; 2034 + 2867 + 1934 = 6835 tests passed, 19 skipped, 0 failed. pnpm --filter @objectstack/runtime typecheck exit 0 (check:test-typecheck OK; both test files confirmed in the tsconfig.test.json program via --listFilesOnly). Scratch measurements (uncommitted, deleted): stdio half, dispatcher /data door, list_actions/run_action, disabled package; readings are in the PR body.",
      "gates": "All on d19b65bf1c (final commit; origin/main 1b3b65e643 merged in; full workspace turbo build 76/76 first). dispatch-gates --commands --repo objectstack-ai/objectstack: 68 commands, every exit code captured before any pipe = 0. The 10 Declared WIDE population families = 0: check:init-service-contract, check:live-db-isolation, check:meta-type-normalized, check:optional-error-sink, check:resume-authority-declared, check:runner-env-posture, check:settings-bind-window, check:startup-registry-verdict, check:verify-stand-in, check:wildcard-fallthrough. Round 1 at db6cf6a3e2 had 2 reds, both fixed before the final run. check:slot-lookup was red on 3 any-erased lookups in the new pin; they were typed and it is now green. check:dual-build-cjs-loads said PREREQUISITE NOT MET on a partial build; that is not counted, and it was green after the full build. --ran (exit-coded): '✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).' pnpm lint (eslint . --no-inline-config) exit 0, 156s. node scripts/check-changeset-no-major.mjs --base origin/main --event PAYLOAD (a pull_request event carrying the PR body) exit 0: '✓ This diff introduces no `major` bump.' and '✓ LEVEL AXIS: this PR declares clause-② `no (narrowing)` and metadata-protocol `none`, and it introduces no changeset entry graded `minor` or above'. node scripts/check-adr-0087-registration.mjs --base 1b3b65e643 (merge base) exit 0: '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.' [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). CI check-runs on PR head d19b65bf1c at report time — success: Check Changeset, Check PR Size, Check Documentation Links, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Flag docs affected by code changes, Auto Label, filter, Dogfood Regression Gate (aggregate), and the four claim/closing-keyword checks; in_progress: Build Core, Test Core (1/6 to 6/6), Lint · ESLint, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Type Check · workspace / source gates / consumer gates / debt ledger; skipped by design: the dogfood shards, Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, Build Docs and the two opt-in jobs. NOT MEASURED locally: test:repo (CI's; the diff touches none of its 3 files and the derivation does not name it); dogfood and the temporal live-DB job (main push battery only).",
      "mcp_calls": "0 — no MCP GitHub tool was called",
      "api_writes": "2 relay strokes (fleet-write repository_dispatch, executed as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/25136/assignees [os-justin], read back 14858/14858 bytes identical; (2) comment = POST /repos/objectstack-ai/objectstack/issues/25120/comments (this report). git push is not a REST write. No label written (none named by the dispatch; a changeset is present, so no skip-changeset).",
      "open_questions": [],
      "out_of_scope_findings": [
        "class: a · reach: POST /api/v1/mcp tools list_actions and run_action on an environmentId kernel (os dev / os serve / os start / cloud single-env runtime). For a package registered during the boot, list_actions answers [] and run_action answers 'Action mark_done not found on object qa_pkg_open', while the registry holds the action. · evidence: in-process measurement at d19b65bf1c on the pin's env_local during-boot fixture with an ai.exposed operation:'update' action; control without an environmentId lists it and resolves it (RECORD_NOT_FOUND on the absent record). · Seam: spec:ObjectSchema.actions → runtime:collectActionDeclarations (packages/runtime/src/action-execution.ts) reads metadata.listObjects(), and so does action resolution; producer is the SchemaRegistry. · same family as this card (MCP reads the metadata service, not the merged view); not fixed here because the action resolution path is not a mechanical swap · dedupe words: 'list_actions package object environmentId', 'run_action not found on object', 'collectActionDeclarations listObjects', 'mcp actions metadata service merged'",
        "class: a · reach: the MCP stdio door (MCPServerPlugin stdio session, objectstack mcp) on an environmentId kernel. For a package registered during the boot, list_objects answers {objects: [], totalCount: 0}, describe_object answers 'Object qa_pkg_open not found', and query_records on the package object declared apiEnabled:false is served (total 0). · evidence: in-process stdio JSON-RPC session (the mcp-stdio-runtime-object-exposure dogfood harness with the pin's package), measured at af62db1cb4; control without an environmentId lists, describes and refuses OBJECT_API_DISABLED. · Seam: runtime:createStdioDataBridge listObjects/describeObject and enforceApiExposure (packages/mcp/src/stdio-data-bridge.ts) read metadataService.listObjects/getObject → producer SchemaRegistry · held off by the dispatch (packages/mcp stdio half); the same move this PR makes on the HTTP half would close it · dedupe words: 'stdio list_objects package environmentId', 'enforceApiExposure metadataService getObject package', 'mcp stdio describe_object not found package'",
        "carrier: none · noted, not filed — the GATED_ACTIONS docblock in packages/mcp/src/stdio-data-bridge.ts says the HTTP bridge answers listObjects/describeObject 'straight off the metadata service'; after this PR it reads the protocol's merged listing (the parity claim it makes still holds). Recorded in the PR's Acceptance notes.",
        "carrier: none · NOT MEASURED, noted — card acceptance item 3 (walk on a cloud environment with a marketplace app installed): the cloud host is outside this session. In this repo, marketplace install/rehydrate register after the boot and were already copied, so the red shape here is a package registered during the boot on an environmentId kernel. Whether the cloud runtime registers HotCRM during its boot was not walked."
      ],
      "deviations": [
        "Gate read: the dispatch suggested getMetaItems for the gate too. The gate reads the by-name face of the same merged view (getMetaItemForExecution, else getMetaItem), exactly RestServer's enforceApiAccess read, to avoid a per-data-request whole-list diagnostic pass (the cost #24031/#24535 removed from REST). list_objects/describe_object use getMetaItems as dispatched.",
        "Added beyond the dispatch: a merged-read outage in the gate falls back to the metadata-service copy (the gate's previous source) so an outage cannot widen exposure; and a fourth pin, a disabled package's objects are omitted by the bridge as by GET /meta/object (Contract items 5 and 6).",
        "Fixture re-judged: data-write-request-gate.test.ts's dispatcher door modelled the old gate source (a metadata fake). The registry-backed by-name stub now serves both doors and the metadata fake is removed, so the rig exercises the healthy path; ablation proves it reds.",
        "Merged origin/main (2 commits, 1b3b65e643) before opening the PR per AGENTS §10. Rebuilt the workspace, re-ran the full runtime unit layer and every gate on the merged head.",
        "Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), and the PR body ends with the AGENTS session-URL footer, not the harness reminder's model-named trailer or footer.",
        "The changeset carries the ADR-0087 disposition as the HTML-comment marker its gate reads. That is a repository file, not GitHub text; the PR body and this report carry no HTML comment."
      ],
      "line_budget": "683 changed lines (+623 / -60) over 5 files vs the 3000 human-merge threshold: under. No governed surface touched (no .claude/**, skills/**, docs/adr/**, AGENTS.md, workflows).",
      "files_changed": [
        ".changeset/25120-mcp-package-objects.md (+20 / -0)",
        "packages/runtime/src/action-execution.ts (+74 / -3)",
        "packages/runtime/src/domains/data-write-request-gate.test.ts (+38 / -29)",
        "packages/runtime/src/domains/mcp-package-object-discovery.test.ts (+407 / -0, new)",
        "packages/runtime/src/domains/mcp.ts (+84 / -28)"
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1 (takeover) · domain:cli seat 2 · p1 · it continues claim 6322493221 and its PR #25136 · 2026-10-21T22:20Z
    Release: claim 6322493221 · session session_01LmjU2x1faLZ39DVTFyVca6 · account os-justin · 因: the claimant is unreachable (its session is out of tokens) · 去向: this claim, in this same stroke, on the same branch and PR. Provenance: who — the maintainer; verbatim — 「前任没有 token了,你接手他的所有开发」; where — the chat of this seat's session session_01AFrKwndmrCLtF5V9hNtrw3.
    Session: session_01AFrKwndmrCLtF5V9hNtrw3
    Account: os-bill (the seat's linked user as GET /user answers it; always the card's assignee, swapped from os-justin in this stroke, and on PR #25136 with it)
    Branch: claude/issue-25120-mcp-package-objects
    Worktree: objectstack-issue-25120
    Domain: domain:cli
    Seat: domain:cli#2 (seat post #22648)
    File surface: unchanged from claim 6322493221; PR #25136's five files sit inside it (packages/runtime/src/domains/mcp.ts, packages/runtime/src/action-execution.ts, two pins in packages/runtime/src/domains/, one changeset).
    Container & model: M, mode:subagent, model: default tier (dispatch-gates.mjs --tier at 60b42cd0b: "no path-derived mandate")
    Clause-②: no (narrowing)
    Basis for the Clause-② line: unchanged from 6322493221. The measured exposure-gate fix refuses a data verb that answered before. It owes one CONTRACT_REVIEW_TIER review of record before the queue; this seat commissions it on head d19b65bf1c.
    Responsibility: this lane's packages/runtime/src/domains/mcp.ts. The platform path that already covers the shape is the merged getMetaItems read. It reaches every MCP client of an environment with an installed package.
    Thread-read: 6324844375
    Serial constraints cleared: no other open PR touches packages/runtime/src/domains/mcp*, packages/runtime/src/action-execution.ts or packages/mcp/src/ (all 9 open PRs' file lists read in this act). #25138 and #25139 stay queued behind this PR.
    Resume-from: claude/issue-25120-mcp-package-objects@d19b65bf1c
    Handoff record: the old branch's last pushed sha is d19b65bf1c (PR #25136, draft, 34 check runs success and 7 skipped on that head). Report 6324844375 is in. The CONTRACT_REVIEW_TIER review of record the seat post called "being rendered" was never posted, so it is owed now. No dev is running.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    Contributor

    REWORK → PR #25136 review of record 6328012432 (CONTRACT_REVIEW_TIER, FAIL on d19b65bf1c) · domain:cli seat 2 · os-bill · session session_01AFrKwndmrCLtF5V9hNtrw3 · 2026-10-21T22:45Z. ⛔ Not a new claim.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 25120,
      "status": "done",
      "branch": "claude/issue-25120-mcp-package-objects",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25136",
      "head": "c0fb1c715f485d6f58a6eaf5ec5363db054c97c4",
      "session": "session_01AFrKwndmrCLtF5V9hNtrw3 — this run's harness-stamped id (the commits' Claude-Session line); mode:subagent, so it is the seat's",
      "premise_still_valid": true,
      "summary": "Review of record 6328012432 finding 1 closed. readExposureDeclaration now THROWS the protocol's OBJECT_PACKAGE_DISABLED refusal instead of reading it as 'no declaration', and callData's gate (refuseUnexposedDataAction with disabledPackage 'refuse') throws it past the fail-open catch. Every MCP data verb, aggregate_records included, now refuses a disabled package's object 404 OBJECT_PACKAGE_DISABLED with the store never asked, whatever the object declares. That also closes the pre-existing aggregate hole on the exposed object (record ③ 5). Measured against REST on both kernel shapes: REST answers both objects 404 OBJECT_PACKAGE_DISABLED on every well-formed /data request, but 400 VALIDATION_FAILED on a body that is not a record (enforceApiAccess reads the refusal as 'no declaration', so its request gate answers first). With the refusal thrown at every ask, the dispatcher's /data door then answered those 404, a new divergence. Per the dispatch ('match REST and say so'), the door's early ask keeps REST's reading (default 'defer'), and the door now answers every request what REST answers. Outage fallback left silent (finding 2: REST's warn says 'failing open', which this gate does not do; no logger in ActionExecutionDeps). The changeset's title and 'Also changed' sentence were rewritten; Clause-② no (narrowing), patch, BREAKING and the ADR-0087 marker are kept. The new PR body is in pr_body_replacement for the seat to apply: the definition forbids a dev PATCH of the body.",
      "tests": "Pin packages/runtime/src/domains/mcp-package-object-discovery.test.ts: 70 cases (was 41), on real kernels, every heavy run under the verify lock. New rows, both kernel shapes: the disabled package's six MCP verbs on both objects (apiEnabled:false and exposed) expect {code:'OBJECT_PACKAGE_DISABLED',status:404} with the protocol data methods and engine.aggregate spies uncalled; REST answers both objects 404 OBJECT_PACKAGE_DISABLED on its six well-formed /data requests (a RestServer over the same kernel's protocol); the dispatcher /data door equals REST on nine requests (six well-formed, three bodies that are not a record). HEAD (e471998c5, whose code and pin blobs c0fb1c715 carries): 70/70 green; neighbours data-write-request-gate 73/73, endpoint-executor and action-execution-addressed-exposure-refusal green (5 files, 212 tests, VERDICT command-exit 0). Legs, each count predicted before running and each landed as predicted. Mutations went through scripts/ablation-replace.mjs (anchor 1->0, blob changed; restored blob == HEAD 2277af6c75 / 720242c7e7, git diff HEAD empty). (1) refusal read as 'no declaration' again (the d19b65bf1c reading): 24 failed / 46 passed. The failures are every disabled-package verb row on both shapes; the 4 aggregate_records rows answer 'SERVED', and the other 20 reached the protocol before it refused. (2) gate always reads the copy: 33 failed / 37 passed (was 8/33 of 41): env_local during-boot 6 verbs, 2 two-env gate pins, no-env disabled 12 verb rows + door parity, env_local disabled 12 verb rows. The first attempt was a no-op: the replacement text already occurred twice, so ablation-replace refused and ran nothing, and the leg was re-run with a unique replacement. (3) door twin, the disabledPackage default 'defer' -> 'refuse': 2 failed / 68 passed (door parity on both shapes: 404 OBJECT_PACKAGE_DISABLED vs REST 400 VALIDATION_FAILED). (4) discovery, readMergedObjects answers undefined: 5 failed / 65 passed. (5) main, both source files at base abc25a353 (blobs 616639b765 / 5d19bd3b7a), trap-restored via git checkout HEAD with blob and diff proof: 38 failed / 32 passed. Rig ablation (12/61) not re-run: rig unchanged. Scratch door measurement (uncommitted, deleted): 24 well-formed readings dispatcher == REST == 404 OBJECT_PACKAGE_DISABLED; 12 malformed readings 404 (dispatcher) vs 400 (REST) at e598c1d3b, then 400 == 400 at e471998c5. Full runtime unit layer at c0fb1c715 (vitest run --project local, one run): 433 files passed; 6864 passed, 19 skipped, 0 failed; VERDICT command-exit 0. pnpm --filter @objectstack/runtime typecheck at c0fb1c715: exit 0 (check:test-typecheck OK: 27 files / 190 errors / 68 signatures held). Both test files are in the tsconfig.test.json program (--listFilesOnly). The first run was a false red, TS2307 @objectstack/driver-turso unbuilt (a peer dep outside the runtime^... closure); it was built and re-run.",
      "gates": "On c0fb1c715 after a full workspace build (turbo build !docs, 76 tasks, VERDICT command-exit 0). dispatch-gates --commands --repo objectstack-ai/objectstack: 68 commands, each exit code written to disk before any pipe; 66 exit 0 on the first pass. check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, no full build yet) and check:type-check-debt exit 3 (its closure build queued 540 s on the lock behind another session's dogfood run). Both re-run after the full build: exit 0 ('✓ check:dual-build-cjs-loads — 112 published require entry point(s) across 69 package(s) load'; 'check-type-check-coverage --re-measure: OK'). --ran (exit-coded): '✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).' The 10 declared WIDE families were also run, all exit 0: init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in, wildcard-fallthrough. pnpm lint (eslint . --no-inline-config) at c0fb1c715: exit 0. check-changeset-no-major --base origin/main --event (pull_request payload carrying pr_body_replacement): exit 0, '✓ LEVEL AXIS: this PR declares clause-② `no (narrowing)` and metadata-protocol `none` ...'. check-adr-0087-registration --base origin/main: exit 0, '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition' [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). check-closing-keyword-parity --body on the new body binds #25120 alone. CI check-runs on c0fb1c715: 41 total, 34 success, 7 skipped by design, 0 failure. Required contexts: Lint & Repo Gates success, TypeScript Type Check success, Test Core success (1/6 to 6/6 success), Build Core success, Governed Surface Queue Guard success. Also success: Check Changeset (it read the OLD body, which carries the same Clause-② line), Lint · ESLint, Lint · PM dispatch-gates, Type Check · workspace / source / consumer / debt ledger, Dogfood Regression Gate (aggregate). Skipped by design: dogfood shards, Dogfood Verify CLI, Temporal Conformance, Console Pin Gate, Build Docs, two opt-in jobs. NOT MEASURED locally: test:repo (CI's; the diff touches none of its files and the derivation does not name it); check:dev-prereqs and check:skill-frame-freshness not owed (no packages/spec or frame file touched).",
      "dogfood_battery": "c0fb1c715 · os-verify-lock: VERDICT command-exit 0 · held the lock 1164s · pnpm --filter @objectstack/dogfood test (no OS_TEST_SHARD, no path) after the full workspace build · Test Files 287 passed | 3 skipped (290) · Tests 2395 passed | 18 skipped (2413) · no failure",
      "mcp_calls": "0 — no MCP GitHub tool was called",
      "api_writes": "1 — relay stroke (scripts/pm, executed as objectstack-fleet[bot]): comment = POST /repos/objectstack-ai/objectstack/issues/25120/comments (this report). git push is not a REST write. PR #25136 body NOT written: the dev definition forbids a PATCH of the PR body after the opening stroke, and the dispatch allowed one only 'if your definition's tools allow it'. The replacement is pr_body_replacement below, for the seat (relay issue_patch on 25136, body only). No label, no ready flip, no assignee write.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed — REST's own order on a disabled package's object: a body that is not a record is answered 400 VALIDATION_FAILED before the data plane's 404 OBJECT_PACKAGE_DISABLED, because enforceApiAccess reads that refusal as 'no declaration'. For an apiEnabled:false object the exposure refusal comes first. Nothing is served either way. Measured on both kernel shapes; the dispatcher door now follows REST. Recorded in the PR body's Acceptance notes.",
        "carrier: #25139 · list_actions / run_action read metadata.listObjects() (collectActionDeclarations), the previous dev's measured finding, already queued; unchanged here.",
        "carrier: #25138 · the stdio half (packages/mcp/src/stdio-data-bridge.ts) and the stale GATED_ACTIONS docblock, already queued; held off here. Note for that card: its gate should treat OBJECT_PACKAGE_DISABLED as a refusal on any arm that does not reach the protocol, as callData's gate now does."
      ],
      "deviations": [
        "origin/main had moved one commit past the dispatch's 60b42cd0b (abc25a353, plugin-auth). Merged the newest (merge b7e7f2cf2, no os-regen pending, nothing in packages/runtime). The shared refs/remotes had advanced it.",
        "Fix shape: the named rethrow went in first (dd89b45b5). Measured against REST, the dispatcher's /data door, which asks the same gate before its request gate, then answered three malformed bodies 404 OBJECT_PACKAGE_DISABLED where REST answers 400 VALIDATION_FAILED. Per 'match REST and say so', refuseUnexposedDataAction gained disabledPackage: 'defer' | 'refuse' (default 'defer', REST's enforceApiAccess reading, used by domains/data.ts's early ask, which is untouched and outside the file surface); callData passes 'refuse' (e471998c5). The record warned that an aggregate-only fix would make the gate's answer door-dependent. Here every door answers what REST answers on every measured request; only the order relative to the request gate follows REST, and it is pinned (door-parity row) and ablated (door-twin leg 2/68).",
        "Pins beyond the dispatch's minimum: all six verbs on the disabled package's exposed object (the dispatch named aggregate), both kernel shapes (the record's input was the copy shape), and a REST-answer row and a door-parity row.",
        "PR body: not written by this run (definition rule; see api_writes). The full replacement text is pr_body_replacement, ending in this session's session-URL footer, with zero less-than characters and no code fences. Its Clause-② and Metadata-protocol lines are unchanged.",
        "Outage fallback (record finding 4) left silent, the dispatch's 'your call'. REST logs because it FAILS OPEN on the thrown read; this gate judges the metadata copy instead, so REST's line would be false here, and ActionExecutionDeps carries no logger.",
        "pnpm lint: the definition calls repo-wide scans CI-owned and never owed. The dispatch asked for it, so it was run (exit 0). Running it breaks no rule.",
        "A stray 6-byte file /unit.pid (it holds a PID number) was written at the container's filesystem root by a mis-scoped shell redirect (an && chain backgrounded whole). My rm was refused by a safety check and not retried. It is harmless and left for the user to delete.",
        "Commit trailers: AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named trailer.",
        "The data-write-request-gate rig ablation (12/61 on d19b65bf1c) was not re-run: the rig and its healthy path are unchanged, and it is 73/73 green on the new head."
      ],
      "line_budget": "877 changed lines (+815 / -62) over 5 files vs merge base abc25a353, under the 3000 human-merge threshold. This round's own delta over d19b65bf1c: +228 / -38 over 3 files (action-execution.ts +54/-6, the pin +171/-29, the changeset +3/-3). No governed surface (no .claude/**, skills/**, docs/adr/**, AGENTS.md, CLAUDE.md, workflows).",
      "files_changed": [
        ".changeset/25120-mcp-package-objects.md (+20 / -0 vs base)",
        "packages/runtime/src/action-execution.ts (+124 / -5 vs base)",
        "packages/runtime/src/domains/data-write-request-gate.test.ts (+38 / -29 vs base; untouched this round)",
        "packages/runtime/src/domains/mcp-package-object-discovery.test.ts (+549 / -0 vs base, new)",
        "packages/runtime/src/domains/mcp.ts (+84 / -28 vs base; untouched this round)"
      ],
      "pr_body_replacement": "Fixes #25120\n\nClause-②: no (narrowing)\nMetadata-protocol: none\n\nThe `/mcp` HTTP bridge now reads objects from the protocol's merged view, the read REST serves. `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists to the same caller. The API-exposure gate the data verbs ask refuses a package object declared `apiEnabled: false`, and refuses every object of a disabled package `404 OBJECT_PACKAGE_DISABLED` on every verb, `aggregate_records` included, as REST refuses it. Nothing is copied into the `metadata` service: a second copy would be a second thing to keep in sync.\n\n## This round: the review of record on `d19b65bf1c`\n\nThe contract-tier review of record (comment 6328012432, VERDICT FAIL) found one widening. The gate read the protocol's `OBJECT_PACKAGE_DISABLED` refusal as \"no declaration\", on the premise that the data plane then refuses with that code. Five of `callData`'s six arms reach the protocol, whose registry gate refuses it. The `aggregate` arm does not: it asks the engine, and the engine serves a disabled package's objects. So on `d19b65bf1c`, `aggregate_records` served a disabled package's object declared `apiEnabled: false` wherever the `metadata` service held a copy, where the copy had refused it `404 OBJECT_API_DISABLED`. Contract item 6 and the changeset's \"Also changed\" sentence were false for that verb.\n\nClosed here. `callData`'s gate refuses a disabled package's object itself, with the protocol's own error, so every arm answers `404 OBJECT_PACKAGE_DISABLED`. The same rows also close a hole that predates this PR (review boundary flag ③ 5): `aggregate_records` served a disabled package's *exposed* object on every kernel, before this PR and after it. Both are pinned below.\n\nREST's answer was measured on the same kernels, through a `RestServer` served over each kernel's protocol. It refuses both objects `404 OBJECT_PACKAGE_DISABLED` on every well-formed `/data` record request. It answers a body that is not a record `400 VALIDATION_FAILED` first, because `enforceApiAccess` reads the refusal as \"no declaration\" and its request gate runs next. The dispatcher's `/data` door asks this gate early too, as the twin of `enforceApiAccess`. With the refusal thrown at that early ask, the door answered those three malformed bodies `404 OBJECT_PACKAGE_DISABLED`, a new divergence from REST (measured on both kernel shapes, both objects). So the early ask keeps REST's reading, `callData` refuses, and the door now answers every request what REST answers. That is pinned too.\n\n## The defect, measured on base `4e2c42f363`\n\n`buildMcpBridge` answered `list_objects` and `describe_object` from the `metadata` service (`listObjects()` / `getObject()`). The exposure gate every runtime data door asks (`refuseUnexposedDataAction` in `packages/runtime/src/action-execution.ts`, called by `callData`) read the same service 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`, the full boot bridge is off. That covers `createStandaloneStack` (`os dev` as `env_local`, `os serve`, `os start`) and the cloud single-environment runtime. So an object that a package or a plugin registers in the `SchemaRegistry` during the boot gets no copy.\n\nReal kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin` over better-sqlite3 `:memory:`). The `environmentId` shape uses `createStandaloneStack`'s plugin options verbatim. The fixture package has two objects, one declared `apiEnabled: false`, registered through the kernel's `manifest` service:\n\n| shape, when the package registered | `metadata.getObject` | REST lists | bridge lists | describe | gated `query` | gated `create` |\n| :-- | :-- | :-- | :-- | :-- | :-- | :-- |\n| `env_local`, during the boot | absent | 2 | **0** | **null** | **served** | **reached the driver** |\n| `env_local`, installed after the boot (`source: 'marketplace'`) | copied | 2 | 2 | served | 404 | 404 |\n| no `environmentId`, during the boot | copied | 2 | 2 | served | 404 | 404 |\n| no `environmentId`, installed after the boot | copied | 2 | 2 | served | 404 | 404 |\n\nThe first row is the card's live reading in miniature: a short list presented as a complete `totalCount`, \"not found\" for every package object, and `query_records` served. The other three rows are controls: the boot bridge and the late-manifest bridge already copy, so they were right and must stay right.\n\n**The gate half is real.** The same object declared `apiEnabled: false` was served by `query_records`, and `create_record` got past the gate to the driver. The runtime dispatcher's `/data` door shares the gate, and on that shape it answered `GET /data/qa_pkg_gated` with 200 and `POST` reached the driver. `RestServer` refused the same object, because its gate already reads the protocol's by-name read.\n\n**The multi-tenant (`environmentId` kernel) path.** The merged read is per environment there. The bridge resolves `protocol` through `deps.resolveService(context, 'protocol', envId)`, the same scoped seam `metadata` and the skill read use. Each environment kernel's `getMetaItems` reads its own `ObjectQL` registry (`readFlattenedMetaItems`: \"the SchemaRegistry is owned by THIS kernel's ObjectQL instance\"). Measured: two `environmentId` kernels behind one host each list and gate their own package and none of the other's. That run is red with the fix ablated, because both lists come back empty.\n\n**The stdio half measures the same defect.** That transport is held off by the dispatch, so this is reported and not widened. Same fixture, `env_local`, during the boot, driven through the real `MCPServerPlugin` stdio session: `list_objects` gave `{\"objects\": [], \"totalCount\": 0}`, `describe_object` gave `Object \"qa_pkg_open\" not found`, and `query_records` on the `apiEnabled: false` object was served (`total: 0`). The control without an `environmentId` lists, describes and refuses `OBJECT_API_DISABLED`.\n\n## What changed\n\n- **`packages/runtime/src/domains/mcp.ts`.** `list_objects` / `listObjectsDiagnosed` and `describe_object` read `getMetaItems({ type: 'object' })` through the existing `McpMergedMetadataRead` seam. This is the shape commit e783e163d gave the skill read. The unwrap moved into one helper (`readMergedItems`) shared by the skill read and the object reads, and the skill read's behaviour is unchanged. `describe_object` answers from the same listing, so every listed object can be described. The three outcomes are the skill read's. With no merged read on the host, the code reads the `metadata` service as before. When the merged read answers, it is served. When the merged read throws, the error goes to the client and nothing falls back. `listObjectsDiagnosed` keeps asking `listDiagnosed('object')` for the `partial` verdict.\n- **`packages/runtime/src/action-execution.ts`.** The exposure gate reads the object by name through the protocol, the read `RestServer`'s own gate makes (`enforceApiAccess`). It uses the execution face (`getMetaItemForExecution`) where the protocol has one, and `getMetaItem` otherwise. A host whose protocol has no by-name read keeps reading the `metadata` service. A merged read that throws falls back to the `metadata` service's copy, the source this gate read before: an outage must not widen exposure, and the caller's fail-open `catch` is unchanged.\n  - A refusal `OBJECT_PACKAGE_DISABLED` is a refusal, never a read failure. `readExposureDeclaration` throws it (`isObjectPackageDisabledRefusal`), and `refuseUnexposedDataAction` answers it by call site (`disabledPackage`).\n  - `callData` asks with `'refuse'`. The protocol's own error (`code`, `status` 404, `object`) is thrown past the fail-open `catch`, on every arm, the `aggregate` arm included.\n  - The dispatcher's `/data` door asks early, before its request gate, with the default `'defer'`. That is \"no declaration\", as `enforceApiAccess` reads it, so a malformed body is answered by the request gate first, as on REST. A well-formed request is refused when it reaches `callData`.\n- **`packages/runtime/src/domains/data-write-request-gate.test.ts`, a fixture re-judged.** Its dispatcher door used to model the gate's old source (a `metadata` fake answered from the registry). The rig's protocol cannot read without a metadata store, so with this change that door passed only through the outage fallback. The registry-backed by-name stub now lives in `bootStore` (`stubObjectReads`) and serves both doors, and the dispatcher door is given no `metadata` service. A gate that stopped reading the protocol reds 12 of its rows (measured on `d19b65bf1c`). 73 of 73 rows are green with the fix.\n- **`.changeset/25120-mcp-package-objects.md`.** A `patch` for `@objectstack/runtime`, with the BREAKING banner and the ADR-0087 disposition `not-required (no-migration-prescription)`. Both declaration lines are in the body. Its \"Also changed\" sentence now states the disabled-package refusal on every verb.\n\n## Contract\n\nEvery input whose answer changes, old to new:\n\n1. **`list_objects` (`POST /api/v1/mcp`) on a kernel with an `environmentId`, for an object a package or plugin registered during the boot.** Old: omitted, with `totalCount` short and no `partial`. New: listed. The non-`sys_` set equals what `GET /api/v1/meta/object` serves the same caller.\n2. **`describe_object` and `validate_expression` on such an object.** Old: `Object \"NAME\" not found`. New: described (fields, label, `enableFeatures`).\n3. **`query_records`, `get_record`, `aggregate_records`, `create_record`, `update_record`, `delete_record` on such an object declared `enable.apiEnabled: false`.** Old: served, and the writes reached the store. New: `404 OBJECT_API_DISABLED`, and the store is never asked. With an `apiMethods` whitelist, a verb it leaves out gets `405 OBJECT_API_METHOD_NOT_ALLOWED` with `allowed`.\n4. **The same declaration on every other door that asks this gate** (the runtime dispatcher's `/data` door, the `@objectstack/hono` catch-all; `callData` under business actions and declarative endpoints). Old: served. New: the same refusals as item 3, the codes `RestServer` answers.\n5. **A disabled package's objects, where the `metadata` service still holds a copy.** For example, a kernel without an `environmentId`. Old: `list_objects` listed them and `describe_object` described them, while REST listed neither. New: omitted and not found, as REST answers.\n6. **Each of the six MCP data verbs on an object of a disabled package, whatever the object declares.** New, on every kernel shape: `404 OBJECT_PACKAGE_DISABLED`, the protocol's own error, refused at the gate with the store never asked. That is the code and status `RestServer` answers for the same object on every `/data` record route. Old:\n   - declared `apiEnabled: false`, with a `metadata` copy (a kernel without an `environmentId`; a marketplace install): `404 OBJECT_API_DISABLED` on all six verbs;\n   - declared `apiEnabled: false`, with no copy (an `environmentId` kernel, registered during the boot): `404 OBJECT_PACKAGE_DISABLED` on the five verbs that reach the protocol, and `aggregate_records` **served**;\n   - exposed, on every shape: `404 OBJECT_PACKAGE_DISABLED` on the five, and `aggregate_records` **served**. This hole predates this PR: the `aggregate` arm never asked the protocol.\n\n   No refusal's status moves, and `aggregate_records` stops serving.\n7. **The dispatcher's `/data` door on an object of a disabled package.** A well-formed request: `404 OBJECT_PACKAGE_DISABLED`, as REST answers. Old: the same for an exposed object; `404 OBJECT_API_DISABLED` for one declared `apiEnabled: false` with a copy. A body that is not a record (`POST /data/:object` or `PATCH /data/:object/:id` with an array, `POST /data/:object/query` with `{ \"limit\": \"ten\" }`): `400 VALIDATION_FAILED`, as REST answers. Old: the same for an exposed object; `404 OBJECT_API_DISABLED` for one declared `apiEnabled: false` with a copy. Nothing is served either way. Only which refusal comes first moves, to REST's order.\n8. **An object whose served definition differs from the `metadata` service's copy** (for example, a stored environment row layered over it). Old: MCP discovery and the gate read the copy. New: they read the definition REST serves.\n9. **`list_objects` / `describe_object` while the metadata store cannot be read.** Old: the `metadata` service's listing. New: the protocol's `503 SERVICE_UNAVAILABLE` error envelope, which is what `GET /api/v1/meta/object` answers and what the skill prompt read already answers.\n\nUnchanged: objects that already had a copy are listed and gated as before. That covers every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object. A host with no merged read keeps reading the `metadata` service. The gate's outage branch judges the copy, as before. `packages/mcp` is untouched.\n\n## Pins\n\n`packages/runtime/src/domains/mcp-package-object-discovery.test.ts`, 70 cases. The pins read the bridge the real `/mcp` entry (`HttpDispatcher.handleMcp`) hands the MCP service, so `list_objects` renders `totalCount` from `listObjectsDiagnosed().objects`:\n\n- For each of the four rows above: `list_objects` lists the package's objects, its non-`sys_` set equals `GET /meta/object`'s for the same caller, and it is not `partial`. `describe_object` answers both objects, including `apiEnabled: false`. Each of the six data verbs refuses the gated object with `{ code: 'OBJECT_API_DISABLED', status: 404 }`, and no protocol data method or engine `aggregate` is called. Control: `query_records` serves the exposed object.\n- A disabled package, on both kernel shapes (the copy's existence a precondition on the shape that copies):\n  - its objects are neither listed nor described;\n  - REST answers both objects `404 OBJECT_PACKAGE_DISABLED` on each of its six well-formed `/data` record requests, read off a `RestServer` served over the same kernel's protocol;\n  - the dispatcher's `/data` door answers each of nine requests what REST answers: the six well-formed ones and three whose body is not a record;\n  - each of the six MCP data verbs refuses both objects, the one declared `apiEnabled: false` and the exposed one, with `{ code: 'OBJECT_PACKAGE_DISABLED', status: 404 }`, and no protocol data method or engine `aggregate` is called.\n- Two environments behind one host each list, describe and gate their own package and not the other's.\n\n**Red before, green after,** on `e471998c5`, whose code and pin blobs `c0fb1c715` carries unchanged (it changes only the changeset). Each leg's direction and count was predicted before running, and each landed as predicted. Mutations were landed and restored by `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed; restored blob equals HEAD, `git diff HEAD` empty), under the verify lock. The whole-file leg ran under a script with an `EXIT`/`INT`/`TERM` trap. It restored with `git checkout HEAD` and checked blob equality and an empty `git diff HEAD`. The runtime's own source is read by relative import, so no leg depends on a `dist/` build.\n\n| leg | mutation | result |\n| :-- | :-- | :-- |\n| main | both source files at base `abc25a353` (blobs `616639b765`, `5d19bd3b7a`) | 38 failed, 32 passed. Failed: `env_local` during-boot list, describe and six verbs (8); four two-environment pins; disabled package without an `environmentId`: listing, door parity and twelve verb rows (14); disabled package on `env_local`: twelve verb rows (12) |\n| discovery | `readMergedObjects` answers `undefined` (the `metadata` service read) | 5 failed, 65 passed |\n| gate | `readExposureDeclaration` always reads the copy | 33 failed, 37 passed. Failed: `env_local` during-boot six verbs; two two-environment gate pins; disabled package without an `environmentId`: twelve verb rows and door parity; disabled package on `env_local`: twelve verb rows |\n| refusal | `OBJECT_PACKAGE_DISABLED` read as \"no declaration\" again (the reading on `d19b65bf1c`) | 24 failed, 46 passed. Failed: every disabled-package verb row on both shapes. The four `aggregate_records` rows answer `SERVED`; the other twenty reached the protocol before it refused |\n| door twin | `disabledPackage` defaults to `'refuse'` (the refusal thrown at every ask) | 2 failed, 68 passed. Failed: door parity on both shapes, where a body that is not a record answers `404 OBJECT_PACKAGE_DISABLED` and REST answers `400 VALIDATION_FAILED` |\n| fix | HEAD | 70 passed; rig 73 passed |\n\nThe first attempt at the gate leg was a no-op. Its replacement text already occurred twice in the file, so `ablation-replace.mjs` refused it and ran nothing. It was re-run with a unique replacement, and the row above is that run. The rig leg (12 failed, 61 passed) was measured on `d19b65bf1c` and not re-run: the rig and its healthy path are unchanged.\n\n## Local verification\n\nOn `c0fb1c715`, the final commit, with `origin/main` `abc25a353` merged in and the workspace built (`turbo run build --filter=!@objectstack/docs`, 76 tasks, exit 0). Every heavy run went through the verify lock, and each line cites the lock's `VERDICT` line.\n\n- `@objectstack/runtime` unit layer (`vitest run --project local`), every file in one run: 433 files passed; 6864 tests passed, 19 skipped, 0 failed. That is 29 more than on `d19b65bf1c`, the pin's 41 cases now 70. `test:repo` is CI's: the diff touches none of its files, and the derivation does not name it.\n- `pnpm --filter @objectstack/runtime typecheck`: exit 0 (`tsc --noEmit`, and `check:test-typecheck` OK: 27 files, 190 errors, 68 pinned signatures held in the ledger). Both edited test files are in the `tsconfig.test.json` program (`--listFilesOnly`). A first run, before the peer `@objectstack/driver-turso` was built, reported TS2307 on an untouched file; that was build state, not this change.\n- `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 68 commands, each exit code captured before any pipe, all exit 0. Two first answered 3 (PREREQUISITE NOT MET). `check:dual-build-cjs-loads` had no full build yet; `check:type-check-debt` timed out queueing for the lock behind another session's run. Both were re-run after the full build, exit 0. The 10 families declared WIDE-population were run too, all exit 0.\n- `--ran` reconciliation: `✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3).`\n- `pnpm lint` (`eslint . --no-inline-config`): exit 0.\n- `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, `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.`\n- `node scripts/check-closing-keyword-parity.mjs --body` on this body binds `#25120` alone.\n- The full dogfood battery, once on this head (`pnpm --filter @objectstack/dogfood test`, no shard): `VERDICT command-exit 0`; Test Files 287 passed, 3 skipped (290); Tests 2395 passed, 18 skipped (2413); no failure.\n\nCI on `c0fb1c715`: 41 check runs, 34 `success` and 7 `skipped` by design, 0 failures. The five required contexts are `success`: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core and Governed Surface Queue Guard.\n\n## Acceptance notes\n\n- **Card acceptance item 3 (walked on a cloud environment with a marketplace app installed): NOT MEASURED.** The cloud host lives in `objectstack-ai/cloud`, outside this session. In this repo the shape is the `environmentId` kernel, pinned above with `createStandaloneStack`'s options. On that shape, a package registered during the boot is the one that had no copy. This repo's marketplace install and rehydrate register after the boot and were already copied. Whether the cloud runtime registers HotCRM during the boot was not walked. The card's reading (18 `crm_*` and 8 `ai_*` objects missing) fits that path.\n- **Closed in passing: the `aggregate` arm never enforced `OBJECT_PACKAGE_DISABLED`** (review boundary flag ③ 5). Before this PR, `aggregate_records` served a disabled package's exposed object on every kernel, because `callData`'s `aggregate` arm asks the engine, never the protocol. The gate's refusal closes it. Pinned by the `aggregate_records` rows on the exposed object, which the refusal leg turns to `SERVED`.\n- **REST's order on a malformed body, measured, not changed.** `RestServer` answers a body that is not a record `400 VALIDATION_FAILED` on a disabled package's object, before its data plane's `404 OBJECT_PACKAGE_DISABLED`, because `enforceApiAccess` reads that refusal as \"no declaration\". For an object declared `apiEnabled: false` it is the other way round: the exposure refusal comes first. The dispatcher's door now follows REST. Whether both doors should refuse a disabled package's object before the shape verdict is REST's question, and nothing here changes it.\n- **The outage fallback stays silent** (review judgment 3, non-blocking). `RestServer`'s `loadObjectItem` logs a warn when a thrown by-name read makes it fail open. This gate does not fail open on that throw: it judges the `metadata` service's copy, so REST's line (\"failing open\") would say something false here. `ActionExecutionDeps` also carries no logger. Left silent, as the old `catch` was.\n- **Finding, same defect class, not fixed here: `list_actions` / `run_action`.** `collectActionDeclarations` reads `metadata.listObjects()`, and action resolution reads the same service. Measured on the same `env_local` during-boot fixture: an object action declared `operation: 'update'` with `ai: { exposed: true }` is in the registry, but `list_actions` answers `[]` and `run_action` answers `Action 'mark_done' not found on object 'qa_pkg_open'`. Control without an `environmentId`: listed and resolved. Queued as #25139.\n- **Finding, the stdio half (`packages/mcp/src/stdio-data-bridge.ts`):** the same three answers, measured above. Held off by the dispatch, queued as #25138. Its fix should read `OBJECT_PACKAGE_DISABLED` as this gate now does on the arm that does not reach the protocol.\n- **Note:** the `GATED_ACTIONS` docblock in `packages/mcp/src/stdio-data-bridge.ts` says the HTTP bridge answers `listObjects` / `describeObject` \"straight off the metadata service\". After this change, it reads the protocol's merged listing. The parity claim it makes (those two are not gated through `callData`) still holds. Only the wording is stale. It rides on #25138.\n- **Note, not changed (review note 9):** `describe_object` serves the raw `fields` map and reads the whole served listing per call.\n\n---\n_Generated by [Claude Code](https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3)_\n"
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 22, 2026

    @objectstack-fleet
    Contributor

    ACCEPT → PR #25136 comment (the ## 席位核对 posted in this stroke) · domain:cli seat 2 · session session_01AFrKwndmrCLtF5V9hNtrw3 · 2026-10-22T00:29Z · head c0fb1c715. Review of record 6329661857 (CONTRACT_REVIEW_TIER, PASS).


    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

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:climcppm:dispatchedpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions