Skip to content

docs(protocol): the error-handling page documents BOTH live error envelopes and states the door boundary - #18191

Merged
claude[bot] merged 5 commits into
mainfrom
claude/issue-15632-error-envelope-both-families
Sep 14, 2026
Merged

claude[bot] merged 5 commits into
mainfrom
claude/issue-15632-error-envelope-both-families

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #15632

Clause-②: no
Docs-only. No packages/** byte changes, no runtime surface widened, no new authorable key — the whole diff is one hand-written MDX page under content/docs/.

What the ruling asked for

Decision batch #107 item 3 (comment 5596253788), option A, maintainer reply verbatim: 「其他同意」.

Ruled. content/docs/protocol/kernel/error-handling.mdx stops claiming one universal envelope. The nested section is corrected to the fields the nested writers actually emit (no details / requestId / timestamp unless measured on a writer), a flat ADR-0112 section is added for the /data family, and the page states the boundary: which route families answer nested (/meta/*, the sendError writers in packages/types/src/response-envelope.ts) and which answer flat (packages/rest/src/error-response.ts structuredCodeAnswer / resolveErrorResponse). Every per-code example is placed under the section its route family belongs to. B (narrow the page to /data, flat page-wide) and C (keep nested, fix examples only) refused. Routes are not changed by this card.

Routes are not changed here. Zero bytes outside content/docs/.

Every shape published, traced to its writer

The ruling's execution note requires each envelope claim to be measured against a writer before it is written. Each claim in the new page and the writer it was read off:

Claim now on the page Writer it was measured against
nested body is { success: false, error: { code, message, ...extra } } sendError, packages/types/src/response-envelope.ts:228
nested extra is exactly category · httpStatus · details · requestId · declaredCode · userMessage the Pick<ApiError, …> on sendError's signature, same file :209
nested details is emitted 9 details: extras across the 7 sendError route modules; buildApiError's details split; the rate limiter's { retryAfterSeconds, resetAt }
nested httpStatus is written by the dispatcher writer, not by sendError buildApiError sets it unconditionally (packages/runtime/src/error-envelope.ts:119-126); no sendError call site passes one (measured over the same 7 modules that yielded the 9 details: hits — same grep, same population, so the zero has a firing control)
⛔ nested carries no requestId zero requestId passed into sendError's extra or buildApiError's extra/details at any call site. Firing control: details: fires 167× in the same population
⛔ nested carries no timestamp no timestamp: in error-response.ts, response-envelope.ts or error-envelope.ts
flat body is { error: <string>, code?, …siblings } structuredCodeAnswer arms + the generic passthrough return, packages/rest/src/error-response.ts:2384-2392
flat code is optional thrownCodeFields returns {} when the producer named no code (:493-498)
flat siblings: field, fields, developerMessage, dependentObject, dependentCount, currentVersion, currentRecord, datasource, reason, object, issues, declaredCode, userMessage the seven structuredCodeAnswer arms (:1007-1270) + withDeclaredUserMessage (:708-715)
UNIQUE_VIOLATION flat-only built by the DUPLICATE_RECORD arm, :1229-1245
DELETE_RESTRICTED flat-only, two sentences, dependentCount absent when withheld the arm at :1007-1030 + the throw site packages/objectql/src/engine.ts:13786-13825 (discloseCount gates the key)
INVALID_FIELD flat-only, field/object as siblings the arm at :1255-1269
RATE_LIMIT_EXCEEDED nested-only, with httpStatus + details packages/runtime/src/security/inbound-rate-limit.ts:353-361; code derived via HttpStatusErrorCodeMap[429]
VALIDATION_FAILED is the per-field code, fields a top-level sibling the arm at :1149-1160; entry shape from FieldValidationError (packages/objectql/src/validation/record-validator.ts:102-133); code values from FieldErrorCode (packages/spec/src/api/errors.zod.ts:233-277)
VALIDATION_ERROR is the status-derived 400 code HttpStatusErrorCodeMap (errors.zod.ts:173)
EXPIRED_TOKEN is not emitted by anything its only non-test occurrence repo-wide is the StandardErrorCode registration at errors.zod.ts:71. Firing control: INVALID_TOKEN, same grep, same population, returns two real producers in storage-routes.ts
the flat /data 403 for a shared-record write is FORBIDDEN, not PERMISSION_DENIED packages/plugins/plugin-sharing/src/sharing-plugin.ts:1337-1344
the CODE: prefix is stripped before the body is written withoutDeclaredCodePrefix, error-response.ts:537
5xx prose is withheld unconditionally, not filtered the 5xx arm at :2250-2259 (INTERNAL_ERROR_MESSAGE = 'Internal server error', packages/types/src/error-leak.ts:38)
request id is a response header packages/runtime/src/observability/instrument.ts:103-121 (res.header(requestIdHeader, requestId), default X-Request-Id); inbound echo is validated first — extractRequestId, request-context.ts:40-52

One measurement that corrects the brief

Zone 2's map said /meta/* answers nested. That holds for the runtime dispatcher's /meta door only. The @objectstack/rest /meta registrars report every thrown error through handleRouteErrorresolveErrorResponse, i.e. flat — 18 handleRouteError(res, error) call sites inside registerMetadataEndpointsInner's range, against zero sendError calls there. So the same INVALID_REQUEST spelling refusal (packages/metadata-protocol/src/protocol.ts:274-283, a bare throw with .code + .status) answers nested through the dispatcher — which is what error-catalog.mdx's /meta examples publish, httpStatus and all — and flat through @objectstack/rest.

The page states this as the rule rather than as a path list, which is what Zone 3 asked for: the envelope is decided by the door, never by the code; a refusal a writer builds is envelope-exclusive, a refusal that is thrown takes whichever door caught it. The /meta split is called out explicitly in its own callout.

The page also discloses, without enumerating, that the @objectstack/rest door still carries tracked non-conforming bodies, and names pnpm check:route-envelope as the authority — rather than publishing a ratchet count that goes stale.

Verification

Gates — 41/41 green, derived with no path list (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack), exit codes captured to disk before any pipe, reconciled with --ran:

Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
✓ dispatch-gates --ran: 41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all 41 recorded an exit code and none of them is 3).

The first sweep produced five non-zero results, all of them PREREQUISITE NOT MET, none a finding: check:doc-formula-expressions and check:doc-security-posture exit 3 on an unbuilt @objectstack/lint; check:docs-transcript-drift exit 3 on the same; check:docs exit 1 on a missing packages/spec/json-schema; check:skill-examples exit 1 on packages/spec/dist and then again on packages/client-react/dist. After building those packages the whole sweep was re-run, not just the five. Builds went through OS_VERIFY_LOCK_SLOT=issue-15632 bash scripts/pm/os-verify-lock.sh, all three VERDICT command-exit 0.

Lint — full, not narrowed. eslint . --no-inline-config --format json ran over the whole repo in one foreground pass: exit 0, 6752 files, 0 errors, 0 warnings. No narrowing claim is needed, and the file this PR touches is not in eslint's population at all (.mdx matches none of the config's files: globs — 0 of the 6752 reported files end in .mdx).

Tree. Derived and run on 50d799c03, which already merges origin/main (the derivation's earlier STALE TREE warning is gone, and the derived family list is byte-identical before and after the merge).

Control characters. grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the edited file: no match. pnpm check:nul-bytes exit 0.

skip-changeset. Measured on the publish surface, not asserted: of the 70 non-private workspace packages, zero list anything under content or docs in files[], and apps/docs is "private": true. Positive control: the same reader prints @objectstack/spec's real files[] array, so the zero is a reading. Nothing in this diff ships in a tarball.

Acceptance notes

Observed while measuring, not filed and not fixed here — none is a reproducible defect, a contract violation, or a metadata-authoring trap:

  • rest-server.ts's registerMetadataEndpointsInner mixes four response shapes on one prefix — handleRouteError's flat body, hand-built { error: { code, message } } with no success, { code, message } with no error key at all (respond501), and respondError → the shared nested sendError. This is exactly the drift check:route-envelope's dialectOnly ratchet already tracks against #9559, so it has a successor and needs no new card.
  • The two query-parameter refusal helpers (query-allowlist.ts, query-multiplicity.ts) each write one { error: { code, message } } body with no success above it. Already carried in the same gate as unenveloped: 1 apiece, ratcheted to #9559.
  • The /data door drops a thrown error's details bag (the sharing plugin sets one at sharing-plugin.ts:1343; the generic 4xx passthrough does not relay it). Noted, not filed — the flat envelope has no details slot by design, so this is the design working, not a leak.
  • EXPIRED_TOKEN is a registered StandardErrorCode with no producer — the same declared-but-dead shape QUOTA_EXCEEDED already carries on this page. Noted, not filed: the ADR-0049 enforce-or-remove question for the error-code catalog is a packages/spec decision, not a docs one, and no PR or person is heading for it right now. Successor: none.

维护者速读(草稿)

改了什么:协议文档《错误处理》整页重写。此前开篇写「每个错误都长这样」并给出一个嵌套信封,页面上十几条 per-code 示例全部重复该嵌套形状 —— 而平台实际上有两套活的错误信封。现在页面如实描述两套:新增「信封由哪一道门写决定,不由错误码决定」这条规则、两节分别写明嵌套与扁平各自的真实字段、一张路由族对照表,并把每一条 per-code 示例放回它真正所属的那一族。

为什么改:裁决 A(决策批次 #107 第 3 项,维护者原话「其他同意」)。此前照这页实现的客户端在 /data 一族上整条解包路径都是错的 —— 会去读 body.error.code,而线上给的是 body.code。页面还发布了三个任何写手都不发射的字段(details 在嵌套上是真的,但 requestId / timestamp 两者皆无),以及一个零生产者的错误码 EXPIRED_TOKEN

风险与代价(含回滚):纯文档,零运行时影响,content/docs/** 不进任何已发布包(已在发布面实测)。回滚 = revert 这一个 commit。真正的代价是这页现在更长(955 → 1330 行):两套信封、一条判别规则、逐码归属,写全就是这个体量。若嵌套与扁平将来收敛为一套,这页可大幅收缩 —— 但那是一张协议卡,本次裁决明确没有开(未给 A+)。

席位意见:(留空,待席位定稿)

你要做的:如果同意「两套信封如实公开」这个方向,直接 review 合并即可。如果你希望开一张「收敛为一套信封」的协议卡,那是独立的一张,本 PR 不阻塞它。

🤖 Generated with Claude Code

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk


Generated by Claude Code

…page

Envelope section, auth codes and the validation codes, each measured against
its writer.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
…and security examples

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@claude
claude Bot marked this pull request as ready for review September 14, 2026 14:52
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit d4554d4 Sep 14, 2026
34 checks passed
@claude
claude Bot deleted the claude/issue-15632-error-envelope-both-families branch September 14, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant