Skip to content

Commit d4554d4

Browse files
claude[bot]claude
andauthored
docs(protocol): the error-handling page documents BOTH live error envelopes and states the door boundary (#18191)
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 `handleRouteError` → `resolveErrorResponse`, 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.com/claude-code) https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 99edfd0 commit d4554d4

1 file changed

Lines changed: 638 additions & 263 deletions

File tree

0 commit comments

Comments
 (0)