Skip to content

Two error-code vocabularies are both live: StandardErrorCode is lowercase snake_case, the servers emit SCREAMING_SNAKE #3841

Description

@os-zhuang

Noted as "Related, not the same" in #3689 and carried out of it when #3837 landed, so it is filed here rather than left buried in a closed issue. This one is a spec decision, not a bug fix, and it should be settled before #3689's other sibling (error.code occupied by the HTTP status) is touched — otherwise that fix has no vocabulary to migrate to and would have to be redone.

The drift

packages/spec/src/api/errors.zod.ts declares StandardErrorCode as a closed enum of lowercase snake_case codes:

export const StandardErrorCode = z.enum([
  'validation_error', 'invalid_field', 'missing_required_field', …
  'unauthenticated', 'invalid_credentials', 'expired_token', …
  'permission_denied', 'insufficient_privileges', …
]);

It is the declared type of FieldErrorSchema.code and EnhancedApiErrorSchema.code, and content/docs/api/error-catalog.mdx documents it as the catalog.

Meanwhile the wire is majority SCREAMING_SNAKE. Counting distinct literals in non-test source under packages/:

Vocabulary Distinct codes Examples
SCREAMING_SNAKE 139 AUTH_REQUIRED, FILE_NOT_FOUND, INVALID_REQUEST, PERMISSION_DENIED, ATTACHMENT_DOWNLOAD_DENIED, UPLOAD_SESSION_NOT_FOUND
lowercase snake_case ~100 validation_error, forbidden, bad_request, driver_missing, checksum_drift

(The lowercase count includes a handful of false positives — code: 'custom', code: 'finance' — but the shape of the split is not in doubt.)

The split runs through a single request path. http-dispatcher.ts:1379 parks { code: 'PERMISSION_DENIED' } — SCREAMING — in details, while the enum that names the same condition calls it permission_denied. Neither ApiErrorSchema.code nor the routes reference StandardErrorCode at all: ApiErrorSchema declares a bare z.string(), so nothing is validated and both dialects pass.

Why it matters now, not before

#3687 (for #3675) and #3837 (for #3689) moved the storage and i18n services into the declared envelope without reconciling the vocabulary — deliberately, because the envelope was mechanical and this is not. With the envelope settled on both paths, the code field is the remaining unenforced part of ApiErrorSchema, and it is the part consumers actually branch on: the console's attachment panel maps ATTACHMENT_DOWNLOAD_DENIED / AUTH_REQUIRED to user-facing copy, and the dogfood suite asserts on them.

What needs deciding

  1. Adopt SCREAMING_SNAKE — rename the enum's members, rewrite error-catalog.mdx. Matches what 139 codes already emit and what every consumer already reads, so no runtime migration. But StandardErrorCode is an exported spec type, so this is a breaking rename for anyone importing it, and the ~100 lowercase emitters still need a sweep.
  2. Adopt lowercase snake_case — keep the enum, migrate the 139 emitters. Consistent with the documented catalog and with the repo's snake_case-for-data-values convention (Prime Directive Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3), but it changes strings the console and the dogfood suite branch on, so it needs the consumers in hand.
  3. Declare them different things — StandardErrorCode stays the field-level validation vocabulary (FieldErrorSchema), and top-level error.code gets its own declared enum in the other dialect. Honest about current usage; costs one more concept.

Recommendation: option 1, on the grounds that the wire is the harder thing to move and 139 > 100. Whichever wins, ApiErrorSchema.code should stop being z.string() and start referencing the chosen enum — otherwise this reopens the first time someone types a new code, and the conformance suites added in #3687/#3837 have nothing to assert the value against, only the shape.

Guard note

error-envelope.conformance.test.ts and success-envelope.conformance.test.ts (service-storage, service-i18n) already drive every branch and parse against the imported spec schemas. Once code is a real enum they get the value check for free — the schema tightens, the suites do not change.

Activity

  1. self-assigned this
    on Jul 29, 2026
  2. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    给 batch-1 实现者的交接核对点(来自 batch 3 / #3971,已先行合并)

    ADR-0112 的 batch 3(dispatcher 位置收敛)在 batch 1 之前落地了。#3971 原样搬运既有 code、未定任何拼法,但有四处请在 batch-1 PR 里核对:

    1. 两处 fix(runtime,spec)!: the dispatcher's error.code is the semantic string; the HTTP status moves to httpStatus (#3842) #3971 引入的小写:packages/spec/src/api/errors.zod.ts 里 HttpStatusErrorCodeMap 的 13 个派生值,和新增成员 method_not_allowed / precondition_required。D2 重命名 enum 时 TypeScript 会自动点名这一个文件——确认一并扫到即可。
    2. D7 生成 error-catalog.mdx 时别丢手写内容:fix(runtime,spec)!: the dispatcher's error.code is the semantic string; the HTTP status moves to httpStatus (#3842) #3971 手写了 "Request Errors (405/428)" 小节(405/428 的 dispatcher 语义、Allow header 行为)。若生成器只从 enum + ledger 出发,这段没有结构化来源,会被覆盖蒸发——请给它一个去处(ledger 的描述字段,或生成器模板)。
    3. content/docs/releases/v17.mdx 含 403 → permission_denied 字样,重命名后即过时;对 packages/ 的 harvest 大概率不扫 releases 文档。
    4. DispatcherErrorCode(runtime)可就势收敛:其四个成员中 METHOD_NOT_ALLOWED / NOT_IMPLEMENTED / SERVICE_UNAVAILABLE 三个在重命名后与标准目录拼写完全重合,可考虑并入;只剩 ROUTE_NOT_FOUND 需判归属(独立注册 vs 与 ENDPOINT_NOT_FOUND 合一)。

    另,D5 的 client 三位置 probe 请勿在 batch 1 顺手删除:#3971 有意保留了它(版本偏斜——新 SDK 连老 server 仍需在旧位置找到 code),与 ADR「batch 3 后即删」存在一处已上报的分歧,待维护者裁决后再动。


    Generated by Claude Code

  3. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    Status after #3988 (batch 1, merged): the decision is settled and enforced — option 1 (SCREAMING_SNAKE) per ADR-0112, StandardErrorCode renamed in place, ApiErrorSchema.code tightened from z.string() to the closed set ErrorCode = StandardErrorCode ∪ ERROR_CODE_LEDGER, catalog docs rewritten and drift-locked by test. The guard note played out as predicted: the #3687/#3837 conformance suites picked up the value check with zero test changes.

    Remaining work is tracked in follow-ups, so this issue stays closed:


    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

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions