fix(runtime,spec)!: the dispatcher's error.code is the semantic string; the HTTP status moves to httpStatus (#3842) - #3971
Merged
Conversation
…es to httpStatus (#3842) `HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it into the field `ApiErrorSchema` reserves for a semantic string, so `error.code` came back as 400/403/503 — a number duplicating the response status and occupying the one slot a caller branches on. The real code then had to go elsewhere, and did, three elsewheres: `details.code`, `details.type` and `error.type`. Four sites, three parking spots, because the declared one was full. `code` is now the semantic string, `httpStatus` carries the number, and `details` is genuine context only. Every code already on the wire moves verbatim — PERMISSION_DENIED, ROUTE_NOT_FOUND, PASSWORD_EXPIRED, PROJECT_MEMBERSHIP_REQUIRED, VALIDATION_FAILED, unauthenticated. This moves a field; #3841 still owns renaming any of them. A branch with no code of its own gets one derived from the status via one declared map in the spec (`HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`), so the value is a catalogued `StandardErrorCode` rather than an invented string, and #3841's sweep is one file. Spec: - `ApiErrorSchema` gains optional `httpStatus` (precedent: `EnhancedApiErrorSchema.httpStatus`). Additive. - `StandardErrorCode` gains `method_not_allowed` / `precondition_required` — the two statuses the runtime returns that the enum could not name. Additive. - BREAKING `DispatcherErrorCode`: `'404'|'405'|'501'|'503'` becomes the four semantic spellings the removed `error.type` declared, moved verbatim. - BREAKING `DispatcherErrorResponseSchema`: `code` is a string, `type` is gone, `httpStatus`/`details` are declared. This schema declared the opposite of `ApiErrorSchema` for the same field, which is what legitimised the deviation. Also aligned, being the same wire surface: `dispatcher-plugin`'s `errorResponseBase` (which used to discard a thrown error's `.code` outright, having nowhere to put it) and its inline 404, plus the MCP 405. All bodies now come from one builder, guarded both ways by `error-envelope.conformance.test.ts` — every branch driven and parsed against the schema imported from the spec, plus a source scan so a new branch cannot reintroduce a numeric `code`. Deletes the #3687 pin, which asked to be deleted rather than updated once the dispatcher was fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 115 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…nges `content/docs/references/` is generated from `packages/spec` by `scripts/build-docs.ts` and is gated by `check:docs`. The spec edits in the parent commit — `ApiError.httpStatus`, two new `StandardErrorCode` members, and `DispatcherErrorCode`/`DispatcherErrorResponseSchema` — left 12 reference pages stale. Regenerated with `gen:schema && gen:docs`; no hand edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj
…pshot (#3842) Two more artifacts generated from `packages/spec` and gated in CI, missed alongside `content/docs/references/`: - `skills/objectstack-api/references/_index.md` (`check:skill-refs`) - `packages/spec/api-surface.json` (`check:api-surface`) — records the two intentional additions, `HttpStatusErrorCodeMap` and `standardErrorCodeForHttpStatus`. 0 breaking, 2 added. Ran every generate/check pair in the package this time rather than one CI round at a time: docs, skill-refs, skill-docs, api-surface, spec-changes, upgrade-guide, react-blocks, authorable-surface, liveness, empty-state, react-conformance, skill-examples — all twelve pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj
os-zhuang
marked this pull request as ready for review
July 30, 2026 00:35
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
Integrates two main-side changes that landed mid-flight, both touching the same contract this branch changes: - ADR-0110 (#3958): action identity is `name`, undeclared executables refuse. Its new tests asserted the pre-#3962 double envelope; updated to the single wrap, and its handler-rejection case ("NOT a 404 routing miss") now asserts the 400 — the distinction it exists to pin is 400-vs-404, unchanged. - #3971: the dispatcher's `error.code` is the semantic string and `details.code` is PROMOTED into it. Assertions on `error.details.code` moved to `error.code`; `fields` stay in `details`. Conflicts resolved in ui/actions.mdx (kept main's name-vs-target paragraph + this branch's "failures speak HTTP" contract) and the type-dispatch test (kept main's ADR-0110 undeclared/valve/degraded cases, adjusted the valve run's envelope to the single wrap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
… single wrap (#3962) (#3969) Fixes #3962 — the platform decision classifying the 200-on-failure wire as a bug, not a contract. The 200-with-inner-envelope shape was never designed: no ADR or doc specified it, it originated as the catch block reusing deps.success(), and /actions was the only route of 12 that double-wrapped. Five defects traced back to that one extra layer. Contract now, identical to /data: - ran, returned → 200 {success:true, data: <handler return value>} (single wrap) - ran, rejected → 400, semantic code on error.code (VALIDATION_FAILED with fields[] in details; FLOW_FAILED for a rejected flow) - never dispatched → 404 / 403 / 400 / 503 (unchanged, #3930/#3951) - crashed → 500 (unchanged, #3951; name-based discriminator now selects 400 vs 500) actions-validation-envelope.test.ts pinned the 200 "so flipping it later is a conscious, documented break"; #3962 is that decision and the flipped test cites it. Integrates ADR-0110 (#3958/#3987 — name identity, undeclared refusal with no opt-out) and #3971 (semantic error.code, details.code promotion) from main. client.actions.invoke/invokeGlobal still never throw: every failure status folds into {success:false, error}, success reads the single wrap, and a narrow legacy heuristic (boolean success, no foreign keys) keeps a current SDK correct against pre-#3962 servers. Migration (raw-HTTP callers): branch on the status; on 200, data is the handler's return value directly. SDK callers need no change.
This was referenced Jul 30, 2026
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 #3842. Deletes the #3687 pin, which asked to be deleted rather than updated once the dispatcher was fixed.
为什么现在可以做,而不必等 #3841
Issue 里写着 “Blocked on #3841 —— 先修这个就等于随手选了一种命名方言”。这一点值得先说清楚,因为本 PR 没有等 #3841 落地。
关键区分是:#3841 管的是「重命名」,本 PR 做的是「换字段」。
PERMISSION_DENIED、ROUTE_NOT_FOUND、PASSWORD_EXPIRED、PROJECT_MEMBERSHIP_REQUIRED、VALIDATION_FAILED、unauthenticated。一个字符都没改,只是从details.code/details.type/error.type搬进了声明好的error.code。ApiErrorSchema.code是必填的,所以必须填点什么。这些走 spec 里唯一一张声明表(HttpStatusErrorCodeMap/standardErrorCodeForHttpStatus),取值全部来自 spec 已经声明、error-catalog.mdx已经文档化的StandardErrorCode。这是「照着已声明的目录取值」,不是「另立一套方言」。结果是:#3841 要扫的面没有变大,反而变小了 —— 原来是四个站点、三个停车位,现在是一张表加一个 enum,改名时改一个文件。
顺带说明:dispatcher 今天本来就同时在发两种方言(
ANONYMOUS_DENY_CODE是小写的unauthenticated,其余是 SCREAMING),本 PR 如实保留了这个现状,没有粉饰,也没有替 #3841 做决定。漂移本身
HttpDispatcher.error()把 HTTP 状态码当作code参数,直接写进了ApiErrorSchema留给语义字符串的字段:于是
error.code返回400/403/503—— 一个数字,和 response status 重复,并且占住了调用方本该用来分支的那个槽位。真正的 code 只能另找地方,而且找了三个地方:details.codedetails.typerouteNotFound()error.type—— 第三种拼法,和数字code并列改成什么样
error.details.code/error.details.type/error.typeerror.codeerror.codeerror.httpStatus(或 response status)error.details(code 混在里面)error.details(只剩真上下文,空则不出现)顺带对齐的:同一张网络面上的另外两个出口
Issue 的 scope note 说 “dispatcher only”。但同一个 wire surface 上其实有三个构造点,只修一个会让这张面自相矛盾,所以三个一起改了:
HttpDispatcher.error()—— returned 错误HttpDispatcher.routeNotFound()—— 404(以及dispatcher-plugin里那份重复的内联 404)dispatcher-plugin的errorResponseBase()—— thrown 错误第三个还顺手补了一个真 bug:它根本没读过
err.code(errorFromThrown至少还把它塞进details),因为那个字段被状态码占着,没地方放。所以同一个 SDK 方法,由哪个出口作答会决定调用方能不能拿到 code。现在两个出口一致了 —— 这正是 #3636 → #3675 → #3689 一层层在收的那个不对称。另外
domains/ai.ts有两处手搓 envelope、domains/mcp.ts的 405 有一处(它需要自己的Allowheader),也一并接进同一个 builder。Spec 改动
ApiErrorSchema新增可选httpStatus。先例是EnhancedApiErrorSchema.httpStatus。加法,不破坏。StandardErrorCode新增method_not_allowed/precondition_required—— runtime 实际会返回、而 enum 叫不出名字的两个状态(405 / 428)。没有它们,405 会掉进 4xx 兜底桶被报成校验失败。加法,不破坏。DispatcherErrorCode:成员从'404' | '405' | '501' | '503'(HTTP 状态码的字符串写法,当初就是为了 match 数字code)改为被移除的error.type所声明的那四个语义拼法,原样搬运。FROMDispatcherErrorCode.parse('404')→ TODispatcherErrorCode.parse('ROUTE_NOT_FOUND');要匹配状态码请读error.httpStatus。TypeScript 会报出每一处调用点。DispatcherErrorResponseSchema:error.code变成z.string()(原z.number().int()),error.type移除(并入code),httpStatus/details补声明。这张 schema 是漂移的根源 —— 它对同一个字段声明了和ApiErrorSchema相反的东西,等于给偏离发了许可证。FROM{ code: 404, type: 'ROUTE_NOT_FOUND' }→ TO{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }。消费方梳理(逐个查过,不是假设)
ObjectStackClient早就为这个 bug 挂了 shim(asSemanticCode(errorBody?.error?.details?.code) ?? asSemanticCode(errorBody?.error?.code)),注释里还专门写了 “error.codein the WRAPPED form is the HTTP status”。生产端修好后这句注释不再成立,已改写。shim 本身保留:SDK 构建和它连的 server 是独立发版的,新 SDK 连老 server 时仍要能在老位置找到 code —— 和 fix(service-storage,service-i18n): emit the declared error envelope, not a bare{ error }(#3675) #3687 里 console 读双方言的理由一样,这是版本边界上的容忍,不是债。error.code的读取方(marketplaceApi、suggestedBindingsApi、CloudConnectionPanel、SettingsView)全都期望语义字符串(payload?.error?.code ?? \HTTP_${res.status}`、=== 'SETTINGS_LOCKED'),今天从 dispatcher 拿到的是数字;修完才是它们一直以为自己在读的东西。details.code只有两处读取(error-message.ts、metadata-client.ts),两处都有落到e.code的兜底,现在兜到语义字符串。error.type**零读取方**。没有任何一处把error.code` 和数字比较。error.code全部来自 service-storage 路由(AUTH_REQUIRED/ATTACHMENT_DOWNLOAD_DENIED),那是 fix(service-storage,service-i18n): emit the declared error envelope, not a bare{ error }(#3675) #3687 已经修好的另一个生产者。plugin-auth、cloud-connection、rest-server等:各自本来就发语义 code,不受影响 —— 反过来也说明 dispatcher 才是那个异类。守卫
新增
packages/runtime/src/error-envelope.conformance.test.ts,双向,和 #3687 给 storage/i18n 写的那对套件同一形状:details.code提升 / 从DispatcherErrorCode拼写 / 从抛出的错误上取)各驱动一遍,外加被本 PR 收编的每一个停车位各一例,body 全部拿从packages/specimport 的BaseResponseSchema/ApiErrorSchema去 parse —— 不是本地抄一份、会跟着漂的字面量。http-dispatcher.ts、dispatcher-plugin.ts、domain-handler-registry.ts、domains/ai.ts、domains/mcp.ts),新分支无法悄悄写回数字code、无法复活type-当-code 的兄弟字段,且 envelope 只能在一个地方构造。没有第 2 条,这套件只能覆盖写它那天存在的分支 —— 而四个站点漂成三个停车位,正是这么来的。两个方向都做了 mutation check:把
domains/ai.ts的一处改回手搓数字 envelope,扫描立刻红两条(never writes a numeric code+builds every error body through the one builder)。Tests
pnpm test(turbo 全量)@objectstack/runtime@objectstack/spec@objectstack/client@objectstack/rest@objectstack/dogfood(真实 stack)另外跑过
check:authorable-surface(api/ApiError:httpStatus已入 ratchet 并提交)、check-doc-authoring、check-nul-bytes、eslint。文档
wire-format.mdx§7 新增 “Dispatcher routes use it too” —— 之前只描述了 service-mounted 路由的信封,dispatcher 那份从来没写过。api/index.mdx:那段 “wherecodeis the numeric HTTP status” 的示例已更新。client-sdk.mdx/error-catalog.mdx:两处描述旧形状的段落已更新;catalog 的 code 计数 51 → 53,并新增 “Request Errors (405/428)” 小节。releases/v17.mdx:新增 breaking + migration 条目。docs/audits/2026-07-dispatcher-client-route-coverage.md§12:dispatcher 那一行从「pinned」改为「moved」,并记下这枚 pin 到底买到了什么 —— 它在修复落地的那一刻就红了,而且精确点名了唯一那个字段。留在原地的
StandardErrorCodeis lowercase snake_case, the servers emit SCREAMING_SNAKE #3841) 未动,理由见开头。rest-server.ts的扁平信封未动 ——wire-format.mdx§7 和error-catalog.mdx都把它记录为/api/v1/data/*的既定形状,是有意为之而非事故。{ error: '<string>' }#3843 里那四个模块(settings-routes.ts、admin-routes.ts、external-datasource-routes.ts、package-routes.ts)未动,那是它自己的 issue。🤖 Generated with Claude Code
https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj
Generated by Claude Code