fix(runtime,hono,plugin-dev): 退役 dispatcher 的 /storage bridge —— 它从来没按契约调用过 storage (#4087) - #4112
Merged
os-zhuang merged 2 commits intoJul 30, 2026
Conversation
… it never spoke the storage contract (#4087) `POST /api/v1/storage/upload` and `GET /api/v1/storage/file/:id` bridged to the `file-storage` slot against a service shape that does not exist: - upload called `upload(key, data, options?)` as `upload(file, { request })` — the parsed file in the `key` slot, `{ request }` in `data`. A TypeError on every implementation in the repo (S3, local, swappable, plugin-dev's memory one), not a near-miss. - download branched on `{ url | redirect | stream | mimeType }` while `download(key)` resolves a Buffer, so every branch fell through and the route answered a JSON-serialized Buffer. Nothing shadowed them — service-storage mounts `/storage/upload/presigned`, not `/storage/upload`, so both stayed mounted and reachable. What hid the defect is that they had no caller: no SDK method builds those URLs, only the docs pointed at them. The tests that covered them mocked the shape the handler wanted rather than the shape `IStorageService` declares, so the suite was green on two routes that answered 500. `/api/v1/storage` is `@objectstack/service-storage`'s surface — the presigned / chunked / signed-URL protocol `@objectstack/client` actually speaks. Removed: the `/storage` domain, `HttpDispatcher.handleStorage()`, the dispatcher-plugin mounts, the hono adapter's `/storage/*` wildcard (which claimed the whole subtree for the two dead routes), and both route-ledger rows. Without the package the path now has no handler — the same answer every uninstalled capability gives. Two follow-ons keep declared === enforced (Prime Directive #10): - discovery advertises `routes.storage` only when the slot's occupant actually mounts HTTP handlers (`handlerReady`, the predicate #4000 gave analytics); - plugin-dev's in-memory file-storage self-declares `handlerReady: false` — the retired bridge was the only thing that ever routed HTTP to it. It still works for in-process callers, it is simply no longer advertised as an HTTP surface. Docs: `plugin-endpoints` now lists the real protocol with its SDK column, and the #3563 audit records the premise #3584's storage disposition rested on. Closes #4087 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
#4058 step 2 (#4086) landed in between and touched the same ground: it gave every dispatcher domain the shared `isServiceServeable` predicate, `/storage` among them. Resolution: - `domains/storage.ts` — deletion stands. #4086 gated the bridge; it did not fix the call, which is still `upload(file, { request })` against `upload(key, data, options?)`. Gating a handler that cannot serve either answer only changes which failure a caller gets. - `http-dispatcher.ts` discovery — take main's `isServiceServeable` and drop the local `filesServeable` this branch had introduced. Same predicate, better home. The comment records why `file-storage` stays gated even though its surface is no longer a dispatcher domain: `handlerReady` is not standing in for anything there, it IS the fact the advert needs. - The two `/storage` cases #4086 added to the handlerReady block go with the domain. The `degraded store keeps serving` one asserted a 200 off `upload` mocked to resolve `{ key }` — a return value `Promise<void>` does not have, the same imagined contract every other retired storage test encoded. - `metadata-protocol` — `file-storage` stays in `DISPATCHER_GATED_SERVICES`; its comment no longer cites a `/storage` bridge that exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 30, 2026 10:40
This was referenced Jul 30, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
…ails on main
`scripts/check-wildcard-fallthrough.mjs` declared the hono adapter's
`${prefix}/storage/*` mount, which no longer exists:
✗ wildcard fall-through guard (#4116)
packages/adapters/hono/src/index.ts:all `${prefix}/storage/*`
DECLARED but not found by the scan. Moved, renamed or deleted? Update MOUNTS.
#4112 removed that mount outright — the adapter now carries a "deliberately NOT
mounted (#4087)" note in its place — because the wildcard claimed the whole
`/storage` subtree and answered its own 404 for every path `service-storage`
registers under it. That is the #4088 failure actually happening, so removal was
the fix rather than a ratchet.
#4112 and #4122 (which added this scan) were each green on their own base and
landed in that order, leaving the entry declaring a deleted mount. Reproduced on
pristine origin/main, so it is not specific to any branch — and since the guard
runs on every PR, it blocks all of them:
git worktree add /tmp/main-check origin/main
cd /tmp/main-check && node scripts/check-wildcard-fallthrough.mjs # 1 problem(s)
Removes the stale entry, which is what the guard's own message asks for. The
sibling `${prefix}/auth/*` entry stays: that mount is still there
(packages/adapters/hono/src/index.ts:279), so its ratchet is still live. The
comment above them said "Both are the #4088 shape" and is now singular, with the
storage mount's removal recorded so the next reader does not have to reconstruct
why one of a documented pair disappeared.
Verified: `pnpm check:wildcard-fallthrough` self-test 15 cases, then
6 yielding / 1 ratcheted / 5 exempt across 12 namespace-claiming mounts. The two
steps that follow it in the same CI job and never got to run — check:release-notes,
check:node-version — pass too.
Refs #4116, #4117, #4112
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh
This was referenced Jul 30, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…oes not own, and unbreak the #4116 ledger (#4117) (#4133) Two things, and the second is urgent. ## The fix (#4117) `app.all(`${prefix}/auth/*`)` claimed a whole namespace and was TERMINAL: it returned the auth service's response unconditionally, including better-auth's 404 for a path it does not implement, and the legacy `handleAuth` bridge's own `handled: false` 404. That is #4088's shape. #4116's scan found it; the manual greps that preceded that scan had not. A 404 from better-auth, or `handled: false` from the dispatcher, now means "not this mount's path" and the handler yields. The predicate is the dispatcher's OWN `handled` flag wherever one exists — an explicit ownership answer beats inferring one from a status; only the better-auth hand-off lacks such a flag, and there the 404 is the signal, exactly as in #4092. What this buys here is narrower than #4092 and the code says so: it does NOT make a later-registered Hono route reachable, because the `${prefix}/*` dispatcher catch-all below is deliberately terminal (ADR-0076 OQ#9, #3576/#3608) and would swallow one either way. It means an unowned `/auth/*` path now reaches that gated `dispatch()` instead of dead-ending in a 404 built one mount earlier, so a domain handler registered for it becomes reachable — which is this adapter's actual extension mechanism. The first draft of the tests asserted the #4092 outcome and failed, which is how the distinction got established rather than assumed. ## Unbreaking main #4122 shipped the ledger declaring TWO ratcheted mounts here. Between its base (974c6d4) and its merge, #4087/#4112 landed and DELETED the `/storage/*` bridge — so the squash produced a main where the ledger declares a mount that no longer exists, and `check:wildcard-fallthrough` fails on main with "DECLARED but not found". That is my merge race, and this commit removes the entry. Worth recording why the guard behaved well here: the stale-entry arm is what caught it, immediately, on the first run against the new main. And #4112 reached the same verdict about that wildcard independently, from the other direction — "the wildcard was wider than the two routes it served". Two independent reads landing on one defect is the argument for enumerating the shape rather than finding it by eye each time. Also extends `callsContinuation`: handing the continuation to a helper (`yieldUnowned(c, next, …)`) now counts as yielding. Without that the checker called this fix terminal — a false negative that would have pushed the compose subtlety toward being duplicated at each call site instead of written once. Guard: 7 yielding / 0 ratcheted / 5 exempt over 12 mounts. Self-test 17 cases. adapters/hono 73 passed. Verified both defences bite: reverting the fix fails the guard with the TERMINAL diagnostic and fails 2 of the 6 new tests. The 3 `tsc` errors and 2 `eslint` rule-definition errors in this package are pre-existing, measured identical on a clean tree. Claude-Session: https://claude.ai/code/session_013VZLsKypjrGhiLpio2uWKu Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Jul 30, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
…moved NOT part of this branch's work — `main` is red on it and every open PR fails the same check, so this unblocks rather than waits. `origin/main` @ 857a6cf fails `check:wildcard-fallthrough` on its own, verified in a detached worktree with no branch involved: ✗ packages/adapters/hono/src/index.ts:all `${prefix}/storage/*` DECLARED but not found by the scan. Semantic conflict between two commits that both landed, not a bug in the guard. #4112 retired that mount outright — the adapter now carries a comment where `app.all(prefix + '/storage/*')` was, because the handler spoke a storage contract that does not exist and the wildcard claimed the whole `/storage` subtree. #4116's MOUNTS ledger (#4122) was written against a tree that still had it and landed after the removal, so the entry was stale on arrival. Git merged both cleanly because they touch different lines; the guard is what noticed, which is it working on day one. Dropping the entry is the whole fix — there is nothing left to ratchet when the mount it names does not exist. Its `${prefix}/auth/*` sibling is untouched and still correctly ratcheted to #4117. After: 6 yielding / 1 ratcheted / 5 exempt (12 namespace-claiming mounts). Flagged on #4122 as well, so whoever owns it can take it back if they would rather land it themselves. Also enumerated the ESLint job properly this time — it runs ELEVEN checks, not the six I had been running (`doc-authoring`, `authz-resolver`, `release-notes` and `node-version` were the ones I was missing, plus `pnpm lint` itself). All eleven pass, and `pnpm lint` is clean once `.cache/` — the objectui console build artifact an earlier `.objectui-sha` bump left in this worktree — is removed. Those 61 "errors" were entirely that directory; CI checks out fresh and never had them. Full suite: 129/129 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
…0115 wholesale deletion Main's #4112 (retire the /storage dispatcher bridge, #4087) adjusted the file-storage stub's self-description and its test; both live inside the stub table this branch deletes entirely, and the deletion supersedes the adjustment: the in-memory storage stub is gone and the real @objectstack/service-storage — which #4112 points at — is auto-wired. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…lugins, empty slots stay empty (ADR-0115) (#4137) Executes ADR-0115 in full, absorbing the #4112 and #4126 subsets that landed mid-flight (their choices are canonical where they overlap: OS_ALLOW_DEV_PLUGIN, assertNotProduction(), the loud security warn). The entire stub table is deleted across Tiers A+B+C; service-storage and service-realtime are auto-wired as optional child plugins; docs converge on the assembly reality; the breaking changeset carries the full FROM→TO table per retired slot. Closes #4104. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…d envelope (#4053) (#4124) * fix(runtime): the /ai/agents degraded fallback answers in the declared envelope (#4053) The last unenveloped SDK-addressable route. The framework's degraded fallback — what an open-source runtime with no service-ai answers — now returns `{ success: true, data: { agents: [] } }` via `deps.success`, and the guard's last ratchet retires with it: 0 ratcheted on both surfaces. ## Why `data: { agents }` and not `data: []` #3983 set the precedent that `data` carries the payload directly, and following it here would have looked consistent. It would also have been wrong, silently. `AiAgentsResponseSchema` is a DECLARED payload schema; share-links' `{ links }` was an ad-hoc wrapper with none. So this is the #3843 relocation — the declared payload moves under `data` unchanged, as SettingsNamespacePayload did — not a reshape. That decides the blast radius. `unwrapResponse` returns `body.data` when a body has a boolean `success` AND a `data` key, so `data: { agents }` keeps `client.ai.agents.list()` reading `.agents` off it, while `data: [...]` would make `.agents` undefined and the method answer `[]`. An empty list is not a visible failure on this route: `useAiSurfaceEnabled` gates the ENTIRE AI surface on `agents.length > 0`, and an empty catalog is the correct answer for a seat-less user (ADR-0068) or a Community-Edition deployment. Broken and legitimate look identical — no error, no 403, no log. ## Consequence: no lockstep Because the SDK reads both shapes identically, each surface converts on its own schedule. Cloud's service-ai still answers unenveloped and keeps working unchanged; objectui already reads all four shapes (objectui#2992). The "three repos in one batch" framing #4053 opened with does not apply to this variant — which is the finding, not a shortcut around it. Five tests in @objectstack/client pin both shapes, including the road not taken: the flattened body asserts [], so the cost of choosing it is recorded rather than rediscovered. The dispatcher's own fallback test now asserts the envelope and that no `agents` key survives at the top level. runtime 914, client 205, rest 505, spec 6987. All ten typecheck-job gates and the six check scripts pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z * fix(runtime): re-point #4058's new /ai/agents assertion at the enveloped body Semantic conflict, not a textual one. #4058 step 2 (#4086) landed on main while this branch was open and added `http-dispatcher.test.ts`'s "a stub slot 404s per route and keeps the /ai/agents empty list", which asserts the body is exactly `{ agents: [] }` — the shape #4053 enveloped one commit earlier. The two merge cleanly and the test fails, which is why CI caught it and the merge did not. The courtesy that test pins is unchanged: a stub-occupied AI slot still answers an empty list rather than a fault. The list just travels under `data` now, and the assertion says so — plus that no `agents` key survives at the top level, so a revert to the bare body fails here too. Also swept the repo for any other reader of the old shape. Three hits, all correct: the two negative assertions above, and `client/src/index.ts`'s `body?.agents` — which is exactly what the relocation variant keeps working. Merged main in rather than rebasing, per the branch's history so far. Verified against the full suite the way Test Core runs it — `turbo run test --filter='!@objectstack/dogfood'`: 129/129 packages, runtime 924. Two worktree-staleness traps on the way, both reading as code failures: - `plugin-auth` could not resolve `hono` — the merge moved the lockfile and I had not reinstalled. `pnpm install --frozen-lockfile` fixed it. - `runtime` failed under turbo but passed standalone — the gitignored `.objectstack/data/memory-driver.json` accumulating rows again. Neither is CI-visible; CI installs clean and checks out fresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z * fix(tooling): drop the wildcard ledger's entry for the mount #4112 removed NOT part of this branch's work — `main` is red on it and every open PR fails the same check, so this unblocks rather than waits. `origin/main` @ 857a6cf fails `check:wildcard-fallthrough` on its own, verified in a detached worktree with no branch involved: ✗ packages/adapters/hono/src/index.ts:all `${prefix}/storage/*` DECLARED but not found by the scan. Semantic conflict between two commits that both landed, not a bug in the guard. #4112 retired that mount outright — the adapter now carries a comment where `app.all(prefix + '/storage/*')` was, because the handler spoke a storage contract that does not exist and the wildcard claimed the whole `/storage` subtree. #4116's MOUNTS ledger (#4122) was written against a tree that still had it and landed after the removal, so the entry was stale on arrival. Git merged both cleanly because they touch different lines; the guard is what noticed, which is it working on day one. Dropping the entry is the whole fix — there is nothing left to ratchet when the mount it names does not exist. Its `${prefix}/auth/*` sibling is untouched and still correctly ratcheted to #4117. After: 6 yielding / 1 ratcheted / 5 exempt (12 namespace-claiming mounts). Flagged on #4122 as well, so whoever owns it can take it back if they would rather land it themselves. Also enumerated the ESLint job properly this time — it runs ELEVEN checks, not the six I had been running (`doc-authoring`, `authz-resolver`, `release-notes` and `node-version` were the ones I was missing, plus `pnpm lint` itself). All eleven pass, and `pnpm lint` is clean once `.cache/` — the objectui console build artifact an earlier `.objectui-sha` bump left in this worktree — is removed. Those 61 "errors" were entirely that directory; CI checks out fresh and never had them. Full suite: 129/129 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4087。取 issue 里的方案 2(退役这条 bridge),并且核查后发现比 issue 描述的更彻底——下面第 1 节说明为什么另外两个前提也需要更正。
1. 核查结论:两条路由都是假的,而且它们一直是可达的
upload那条 issue 已经说清楚了(upload(key, data, options?)被当成upload(file, { request })调,对S3StorageAdapter/LocalStorageAdapter/SwappableStorageService/ plugin-dev 内存实现都是 TypeError)。核查时另外两点和 issue 的判断不一致:①
GET /storage/file/:id并不是"不受影响"。 契约是download(key: string): Promise<Buffer>,而 dispatcher 分支在result.url && result.redirect/result.stream/result.mimeType上做判断。Buffer 上这些属性全是undefined,所以每个分支都落空,最后deps.success(buffer)把字节数组当 JSON 返回({"type":"Buffer","data":[…]})。多余实参确实被忽略了,但返回值处理面向的是一个仓库里不存在的接口——全仓搜索没有任何file-storage实现返回过{ url | redirect | stream | mimeType }这个形状。而且传进去的是sys_file的 id,不是 storage key:LocalStorageAdapter 会fs.readFile一个不存在的路径 → ENOENT → 500。② 不是被 service-storage "遮蔽"了。 service-storage 挂的是
/storage/upload/presigned、/storage/upload/complete、/storage/upload/chunked/*、/storage/files/:fileId[/url]、/storage/_local/raw/:token——没有/storage/upload,也没有/storage/file/:id。两条路径不冲突,所以装了 service-storage 的完整栈里 bridge 依然挂着、依然可达,只是没有调用方:SDK 不拼这两个 URL,console 也不调,唯一指向它们的是content/docs/api/plugin-endpoints.mdx那张表——照文档调的人拿到的就是 500。这两点让"方案 2"更站得住:#3584 当初把这两条留下的理由是"low-level redirect/stream 兼容面",而这个兼容面从来不存在。
顺带一个域外发现(已在本 PR 一并修):hono adapter 挂的是
app.all('${prefix}/storage/*')通配,为两条死路由占了整个/storage子树——子树下任何其他路径都会拿到 bridge 自己的 404,而不是继续往下匹配。2. 改了什么
删除:
packages/runtime/src/domains/storage.ts(整个域)HttpDispatcher.handleStorage()+ domain registry 注册server.post/get挂载/storage/*通配(storage 现在走通用 catch-all)route-ledger.ts两行(原地留了 tombstone 注释,写清楚为什么不该再长回来)为了守住 declared === enforced(Prime Directive #10):
routes.storage继续用isServiceServeable门控(dispatcher 其余服务域仍只判槽位占用、不读 handlerReady —— #4000 在 analytics 一域落地后剩下的类推面 #4058 step 2 引入的共享谓词)。注释里记了为什么file-storage这一槽在没有 dispatcher domain 之后仍然该被这个谓词管:handlerReady在这里不是任何东西的代理,它就是"这个槽有没有 HTTP 面"这个事实本身。file-storage自述加handlerReady: false。dispatcher 其余服务域仍只判槽位占用、不读 handlerReady —— #4000 在 analytics 一域落地后剩下的类推面 #4058 当时特意让它继续服务,因为那时/storagebridge 还在;bridge 退役后没有任何东西把 HTTP 路由到这个槽,false才是诚实值——和realtime自 ADR-0076 D12 起的位置一样。实现对 in-process 调用照常工作,只是不再被广播成可达的 HTTP 能力。文档:
content/docs/api/plugin-endpoints.mdx:storage 那张表换成 service-storage 的真实协议(带 SDK 列),并按该文件既有的 The SDK's entireainamespace is dead, and the entire real AI surface is unreachable from the SDK — the two sets are disjoint #3718 先例加了一段 callout 说明旧表错在哪。docs/audits/2026-07-dispatcher-client-route-coverage.md:在 §4 Resolution 后追加 dispatcher 的 /storage/upload 用 upload(file, {request}) 调用契约里的 upload(key, data, options?) —— 对任何实现都会 TypeError #4087 修正,记录 Reconcile the four dispatcher↔client shape mismatches (analytics ×2, storage ×2) — #3563 follow-up #3584 那次没有核到的前提。scripts/check-route-envelope.mjs:从DISPATCHER_DOMAINS移除storage.ts(否则该 gate 报"declared but not found")。**测试:**旧的
handleStorage用例全部替换。它们正是这个缺陷能活下来的原因——每个都 mock 了 handler 想要的形状而不是IStorageService声明的形状(upload只断言"被调用过",downloadmock 成{ data, mimeType },而契约是Promise<void>和Promise<Buffer>),所以 500 的路由测出来是绿的。现在钉的是"不存在"本身:没有handleStorage方法、/storage不被 registry 认领(槽位空和被占用两种情况都测)、hono 上/storage/upload/presigned落到 catch-all。3. 与 #4058 step 2(#4086)的关系
#4086 在本 PR 开出后合入 main,改到了同一片地方(给每个 dispatcher domain 上
isServiceServeable,/storage也在内)。合并冲突的处理写在 merge commit 里,摘要:domains/storage.ts保持删除。fix(runtime,metadata-protocol): every dispatcher domain gates on handlerReady, not slot occupancy (#4058 step 2) #4086 给 bridge 上了门控,但没有改那次调用——它仍然是upload(file, { request })打upload(key, data, options?)。给一个两种答案都服务不了的 handler 上门控,只是换了调用方拿到哪种失败。isServiceServeable,删掉本分支自己引入的filesServeable——同一个谓词,main 的位置更好。/storage用例随域一起删。其中「degraded store keeps serving」那个是问题形状的标本:它拿uploadmock 成 resolve{ key }去断言 200,而契约的返回类型是Promise<void>。metadata-protocol的file-storage留在DISPATCHER_GATED_SERVICES里,只是注释不再引用一个已经不存在的/storagebridge。4. 迁移
实际上没有东西需要迁移——两条路由都完不成一次请求。对照关系写在 changeset 里(
.changeset/retire-dispatcher-storage-bridge.md,minor):POST /api/v1/storage/uploadPOST /storage/upload/presigned→ 直传 →POST /storage/upload/complete,或client.storage.upload(file)GET /api/v1/storage/file/:idGET /storage/files/:fileId/url(client.storage.getDownloadUrl),或GET /storage/files/:fileId(浏览器稳定 URL,302)没装
@objectstack/service-storage时/api/v1/storage现在没有 handler——和其他未安装能力的答案一致。5. 验证
pnpm --filter @objectstack/runtime test→ 65 files / 916 tests(合并 main 后)@objectstack/hono67、@objectstack/metadata-protocol110、@objectstack/plugin-dev8、@objectstack/types48 全绿pnpm lint、check:route-envelope、check:doc-authoring、check:nul-bytes、check:role-word、check:org-identifier、check:authz-resolver、check:error-code-casing、check:release-notes、check:node-version全过packages/runtime/src/datasource-autoconnect.test.ts「makes the federated object queryable through the engine with zero app code」。这条在未改动的 main 上同样失败(已git stash后复跑确认:expected [ 'first', 'first', 'first', …(7) ] to deeply equal [ 'first', 'second' ]),与本 PR 无关;分支claude/datasource-autoconnect-federation-test-32356t已在处理。补一个数据点:单文件跑也复现,所以不是跨文件污染。6. 域外发现 → #4127
修完这条之后,我用同一把尺子("调用点 vs
packages/spec/src/contracts/*")扫了其余 dispatcher domain,结果记在 #4127:/notifications的三个方法、/automation的三个方法、/i18n的getFieldLabels都不在各自契约里(方向和 #4087 相反——调用点和实现是对的,契约漏了声明),三处都靠typeof x.foo === 'function'duck-type 绕过。不在本 PR 范围。RouterConfigSchema.mounts.storage = '/storage'保持不变——这个 mount 前缀现在由 service-storage 兑现,依然成立。Generated by Claude Code