fix(runtime,metadata-protocol): every dispatcher domain gates on handlerReady, not slot occupancy (#4058 step 2) - #4086
Merged
Conversation
…, and give each dev stub its own honesty class (#4058) #4000 made `/analytics` execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability") and left the rest of the class open, because adopting it per-domain needed a decision the issue could not make yet: every plugin-dev stub carried the single `_dev: true` marker, which normalizes to `status: 'stub', handlerReady: false`. One label for "invents answers" AND for "really does the work, in memory" — so no gate could tell the two apart without either 404ing working in-memory implementations or keeping fabricated data on the wire. So the classification comes first, and the gate follows it. Classification — each dev stub declares its own class via the standard `__serviceInfo` marker: - `degraded` (really does the work, reduced capability; `handlerReady` defaults true): `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `realtime`, `workflow`, `metadata`. Their domains keep serving them — which is why `/storage` and `/i18n` do not regress. - `stub` (fabricates): `data`, `auth`, `security.*`, and the shapeless placeholder used for a slot with no factory. - Retired outright, joining `analytics`: `automation`, `notification`, `ai`. Each one's headline method reported success for work that never happened — `execute()` → `{ success: true }` with no flow run, `send()` → a messageId for a message nobody receives, `chat()` → a placeholder answer. An empty slot is one less layer of indirection than a slot that must be ignored, and it is what production already has without the plugin. `_dev: true` stays as the plugin-dev provenance tag (AppPlugin's i18n diagnostic reads it); `__serviceInfo` decides the class. Gate — one predicate, `isServiceServeable` (new `service-serveable.ts`, which absorbed `isAnalyticsServiceServeable`), read by the `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement. A slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's own 404 or 501 — so what is advertised and what is served cannot disagree. `handlerReady`, not `status`, remains the test. `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` with no `route`, which says strictly more than collapsing it to `unavailable` would. `/ai` gets better for the stub case: an occupied-but-unserveable slot used to fall through to the `!routes` 503 and lose the `GET /ai/agents` empty list the console polls for. Both are restored. Deliberately NOT gated: `data` / `auth` / `security.*`. Those stubs back the dev stack's own core loop, so gating their domains would 404 the dev stack itself — a separate decision. They are honestly labelled either way. `search` / `realtime` are unaffected: neither has a dispatcher domain. Verified end to end against a real plugin-dev slate: the four retired slots report `unavailable` with no route and 404, `/ai/agents` answers `{ agents: [] }`, and the `degraded` slots keep their routes and serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
This was referenced Jul 30, 2026
…cile with #4082 (#4058 step 1) #4082 landed the classification half of #4058 while this branch was open, and did it better: `DEV_STUB_SELF_INFO` is one reviewable table, it covers the kernel's OWN fallbacks too (the `_fallback: true` marker no consumer recognized — filed from this branch as #4089, now fixed at the source), it drops the blanket `_dev: true` from plugin-dev rather than keeping it as a provenance tag, and it sets `handlerReady: false` on the surfaceless `degraded` fallbacks (`cache`/`queue`/`job`/`realtime`) — a refinement this branch missed. So this branch keeps only what #4082 explicitly left open: step 2, the gate. Dropped from this branch as superseded: - the per-stub `devDegraded`/`devStub` markers and their tests (main's table wins); - retiring the `automation` / `notification` / `ai` stubs. Step 1 classes them `stub`, so the gate already answers their HTTP surface as an empty slot without deleting anything — verified end to end below. Whether the fabricating class should be registered at all is the wider evaluation in #4093 (A tier), which is where that deletion belongs alongside `data`/`auth`/`security.*`. Kept: `isServiceServeable` + the six domain gates, the discovery `routes`/ `features` alignment, the metadata-protocol route suppression, their tests, and the ADR/docs/changeset text — the ADR annotation now reads as step 2 on top of step 1 instead of re-claiming the classification. The gate is compatible with step 1's table by construction, because it reads `handlerReady` and not `status`: `file-storage` / `i18n` / `metadata` / `workflow` declare `degraded` with `handlerReady` defaulting to true and keep serving, while the surfaceless `degraded` fallbacks have no dispatcher domain to gate. Verified against a real plugin-dev slate after the merge: `automation` / `notification` / `ai` are registered and classed `stub` ⇒ no route advertised, `GET /automation` and `GET /notifications` unhandled, `POST /ai/chat` 404, and `GET /ai/agents` still answers `{ agents: [] }`; `file-storage` / `i18n` / `workflow` / `metadata` keep their routes and serve. runtime 923, plugin-dev 8, objectql 1183, metadata-protocol 99, core 415 all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
Contributor
📓 Docs Drift CheckThis PR changes 2 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:10
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
#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
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…, empty slots stay empty (#4093) (#4103) The evaluation #4093 asked for, recorded as a decision. The founding premise of plugin-dev's fill-every-slot design is refuted by production semantics; the design is retired and plugin-dev converges to a pure assembly plugin. Tiered disposition ratified into two implementation PRs (Tier A+C+guard+auto-wire, then Tier B+#4089+docs), sequenced behind the in-flight #4086. Implementation tracked in #4104. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS
This was referenced Jul 30, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
…ped 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
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 was referenced Jul 30, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…login (#4113) (#4179) `domains/auth.ts` carried a `mockAuthFallback` that answered `POST /auth/sign-up/email`, `/register`, `/sign-in/email`, `/login`, `GET /get-session` and `POST /sign-out` with 200 and a fabricated user plus a 24-hour `mock_token_*` session — for ANY email and ANY password, which was never read. It shipped in `@objectstack/runtime`, not behind a dev-only plugin, and gated on nothing but an empty `auth` slot, so `os serve --preset minimal` and any embedder that mounts the dispatcher without plugin-auth served it. It was never a bypass: no session store backs that token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was wrong in a different way — it told the client the one thing a server must never lie about, that it had authenticated someone, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. Its stated justification ("MSW/browser-only environments") had no consumer in this repo or in objectui, whose auth tests mock at the HTTP client layer. The only things pinning it were nine tests asserting the mock itself. ADR-0115 retired this class of fabricating fallback inside plugin-dev. This was its last surviving member and the only one that shipped to production; the lineage before it (#3891's analytics shim, #4000's dev stub, the three in #4058/#4086, #4126's security trio) was retired the same way: deleted, not put behind a flag, which would keep the code path in every release and become the next thing nobody audits. 501, not 404, following `/i18n` — the nearest precedent in shape: a core capability, a dispatcher-owned domain, an optional plugin behind it, and a route discovery already declines to advertise when the slot is empty. The route IS mounted; what is missing is the implementation behind it, which is what 501 states and 404 would misdescribe. It also keeps faith with the one true observation the mock was built on — its comment said it existed to keep sign-in "from 404ing" — without the lie it answered that concern with. The message names the remedy. A wrong-shaped occupant (a service without the contract's `handleRequest`) takes the same 501, and that is the sharper case: the slot is FILLED, so discovery advertises `routes.auth` while the request used to get a fabricated session. The `randomUUID` helper goes with it: a CSPRNG built solely to mint mock session ids, dead the moment the mock is. `domains/auth.ts` drops 141 → 81 lines. Two things the deletion left behind, both caught after the fact: the route-envelope ledger's `handBuilt: 4` exemption for `auth.ts` drops to 0 — those four better-auth-shaped bodies were the MOCK's, not the bridge's, since the real service returns its own `Response` built nowhere in this file — and `content/docs/permissions/authentication.mdx`, which documented all six fabricating endpoints as a feature, now documents the 501 and points anyone who relied on it at mocking in the HTTP client layer or loading AuthPlugin (which needs no HTTP server). Tests replace the nine that pinned the mock: all six formerly-mocked paths parametrized so a partial re-introduction names itself, no `mock_`/user/session in the body, the remedy named, an identical answer for any credentials (a 501 that varied by password would be an oracle), a registered service still delegated to, and the wrong-shaped-occupant case. Verified: runtime 940, plugin-hono-server 149, plugin-auth 579, rest 512 pass; build 71/71; pnpm lint and all ten check:* scripts clean. Co-Authored-By: Claude Opus 5 <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 #4058. 接 #4082(step 1) 落地 step 2。
#4000 让
/analytics执行了 ADR-0076 D12 结论第 3 条,其余域仍只判槽位占用 —— 自述handlerReady: false的占位者照样被当真实现调用,编造的答案以 200 发出。step 1 把两类实现分开了,这个 PR 是读这个区分的门。门(一个谓词)
新增
packages/runtime/src/service-serveable.ts的isServiceServeable(吸收了 #4000 的isAnalyticsServiceServeable),由以下全部读取:/analytics、/automation、/notifications、/ai、/storage、/i18nroutes/features自述
handlerReady: false的占位者,得到的回答与空槽完全一致 —— 各域原本的 404,或/storage、/i18n用的那个显式 501。判据是
handlerReady而不是status,这正是它能安全推广的原因:真干活的实现声明degraded,handlerReady默认true,继续服务。所以file-storage/i18n/metadata/workflow没有任何回退,只有编造的那类被当空槽。discovery.services.*保持按存在与否判定:已注册的 stub 仍报{ enabled: true, status: 'stub', handlerReady: false }且不带route—— 比塌缩成unavailable说得更多。顺手修好的一处
/ai在 stub 占槽时原本更差:因为 stub 是 truthy,请求会穿到!routes的 503「AI service routes not yet initialized」,同时丢掉 console 每次导航轮询的GET /ai/agents空列表礼遇。视同空槽后两者都回来了。刻意没动的部分
/data、/meta、/auth与安全路径不加门。它们的 dev stub 支撑的是 dev 栈自己的主回路,加门会把 dev 栈本身 404 掉。它们已被 step 1 诚实标成stub。automation/notification/ai的 stub 一起删掉,但 step 1 把它们标成stub之后,门已经让它们的 HTTP 面等于空槽(下面端到端有证),所以"造假类该不该继续占槽"变成了一个可以单独决定的问题 —— 归到 评估:plugin-dev「stub 填满槽位」这个早期设计是否整体作废 —— #4000/#4058 逐个退役到第 4 个之后,该问的是台面本身 #4093 的 A 档,与data/auth/security.*一起评估(其中三个security.*撞在本 ADR 自己写的「fallback 可以降级功能、绝不能降级安全语义」上,那条可能不该等评估)。search(REST 层)、workflow、graphql、queue/job/cache 的路由由注册该服务的插件自己挂,handlerReady说明不了那条路由挂没挂,抑制它是猜而不是诚实 —— 有一条测试专门钉这个边界。验证
pnpm build71/71;eslint 干净三个 stub 仍然注册着,但已经拿不到 route、请求得到空槽的回答 —— 这就是"不删也诚实"的证据,也是把删除留给 #4093 的依据。
新增测试:域级(stub → 空槽答案 且不被调用;degraded → 照常服务)、discovery 级(
routes/features与域一致,services仍报 stub)、metadata-protocol builder 级(含非 dispatcher 自有槽位保持按存在判定的边界)。关联的域外 issue(Prime Directive #10)
/storage/upload用upload(file, { request })调契约里的upload(key, data, options?),Buffer.from({ request })直接 TypeError。装了 service-storage 时被它自己的/api/v1/storage/*遮蔽,所以只在没插件拥有该前缀时可达 —— 那里必崩。与本 PR 无关(改动前后走同一行),端到端验证时撞出来的,上面日志末尾就是它。🤖 Generated with Claude Code
https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B