Skip to content

Commit 3f98c2a

Browse files
claude[bot]claude
andauthored
docs(api): catalog the VALIDATION_FAILED wire code — and the card’s main-red premise is falsified (the failure is PR #18191’s own) (#18196)
Fixes #18193 Clause-②: no Docs-only. One hand-written MDX page under `content/docs/`; no `packages/**` byte changes, no runtime surface widened, no new authorable key. ## ⚠️ The card's premise is falsified: `main` is NOT red — the failure belongs to PR #18191 The card diagnoses a latent red on `main` and rests that on one mechanism claim: that `face.wireCodes` is derived from CODE only, so "no docs edit can move it". **That claim is wrong, and it is the whole diagnosis.** `deriveWireFace` (`scripts/check-error-status-conformance.mjs`) reconciles `StandardErrorCode` with *every other code a scanned page publishes a status for* — and it scans **two** pages: ```js const DOC_HANDLING = 'content/docs/protocol/kernel/error-handling.mdx'; // :219 const DOC_CATALOG = 'content/docs/api/error-catalog.mdx'; // :220 ``` `error-handling.mdx` is exactly the page PR #18191 rewrites. The test file itself never reads it — which is what the card measured — but the derivation it imports does, one frame down. `reconciledVocabulary`'s own docblock calls that second half "the whole point": a ledger code is reconciled **because the docs publish a status for it**. ### Measured, four trees, same script | tree | `wireCodes.length` | `docPublishedBeyondStandard` | wire codes with no catalog heading | |:--|:--:|:--|:--| | `origin/main` @ `076bb975d` (today) | **51** | `INVALID_REQUEST`, `UNIQUE_VIOLATION` | *(none)* | | `main` @ `ba9b4981c` (PR #18191's own base) | **51** | same two | *(none)* | | PR #18191 head `50d799c03` | **52** | + `VALIDATION_FAILED` | `VALIDATION_FAILED` | | this branch | **52** | + `VALIDATION_FAILED` | *(none)* | The provenance line the run prints for the 52nd code on the PR head names the writer outright: ``` claimed VALIDATION_FAILED = [[400, ["content/docs/protocol/kernel/error-handling.mdx:346"]]] ``` That line is in PR #18191's diff. And the test is green on `main`, twice measured: ``` BEFORE (unmodified tree, 076bb97) Test Files 1 passed (1) · Tests 5 passed (5) AFTER (this branch) Test Files 1 passed (1) · Tests 5 passed (5) ``` ### Ablation — the card's two assertions, reproduced byte-for-byte, only with #18191 present Both files swapped under a trapped restore (absolute paths; each mutation proven on disk by a changed `git hash-object`; restore proven by `git checkout HEAD -- PATHS` reproducing the HEAD blob hashes and an empty `git diff HEAD`): ``` MUTATED handling=d174e08b8b93f10891366716e3677458f073944b catalog=6a529b3d11dd502d94c18c6c10dcd6f7f217e95b (handling hash == `git rev-parse probe-18191-head:content/docs/protocol/kernel/error-handling.mdx` — identity, not a grep) catalog claim now: **51 error codes reachable on the wire** catalog VALIDATION_FAILED headings: 0 AssertionError: wire code `VALIDATION_FAILED` has no catalog entry: expected false to be true ❯ src/api/error-catalog-docs.test.ts:95:82 AssertionError: expected 51 to be 52 ❯ src/api/error-catalog-docs.test.ts:125:31 Test Files 1 failed (1) · Tests 2 failed | 3 passed (5) RESTORED both files == HEAD blobs · git diff HEAD empty ``` ⚠️ One honest miss in that probe: the first swap also printed a `grep -c` marker count of **0**. The marker string was mine, invented from the PR's summary rather than read off its diff, so it never existed. The binding evidence is the blob-hash identity above, which fired; the grep proved nothing and is reported rather than quietly dropped. ### So why land this anyway, rather than returning a falsified-premise report with no PR The ruling's *intent* is served and the repair is correct on its own terms — it just is not a `main`-red stanch: 1. **The page already told readers to branch on a code it never catalogued.** `INVALID_REFERENCE`'s **Fix** line says *"branch on `VALIDATION_FAILED` + `fields[].code === 'reference_not_found'`"*, the dangling-reference callout publishes its full JSON body, and the `MISSING_REQUIRED_FIELD` callout names `400 VALIDATION_FAILED`. Seven prose mentions, zero entries. That hole is real with or without #18191. 2. **It is green in both worlds, because the catalog page is itself one of the two scanned inputs.** Adding the entry under `## Validation Errors (400)` is what admits the code to the vocabulary, so the count moves 51 → 52 **on `main` alone** and stays 52 once #18191 lands. Verified on a throwaway merge of this branch with `probe-18191-head`: `wireCodes.length = 52`, zero wire codes missing a heading, and the real test run **5 passed (5)** with #18191's page swapped in. 3. **It unblocks #18191**, which the seat deliberately did not arm for this reason. ⇒ If the intent was specifically "stanch a red on `main`", there is nothing to stanch and this can be closed unmerged at no cost. Everything below stands on its own. ## The entry, traced to its writers ⛔ Not a placeholder row added to satisfy a count. Every claim measured on this tree: | Claim now on the page | Writer it was read off | |---|---| | flat `/data` body is `{ error, code: 'VALIDATION_FAILED', fields: [...] }`, `fields` a **top-level sibling** | `structuredCodeAnswer`'s arm, `packages/rest/src/error-response.ts:1150-1159` | | flat `fields` is **always present**, `[]` when the producer named none | same arm: `fields: Array.isArray(error?.fields) ? error.fields : []` (:1156) | | the nested door carries the list at `error.details.fields` instead | `validationFailureDetails` returns `{ code, fields }` (`packages/types/src/validation-failure.ts:57-63`); `splitSemanticCode` **promotes** `code` to `error.code` and leaves the rest as `details` (`packages/runtime/src/error-envelope.ts:100-126`) | | a route that **builds** its own refusal sends the code with **no `fields` at all** | `sendError(res, 400, 'VALIDATION_FAILED', 'object and recordId are required')`, `packages/plugins/plugin-sharing/src/share-link-routes.ts:156` | | it is the code a **producer names**, unlike `VALIDATION_ERROR` which is **status-derived** | `HttpStatusErrorCodeMap[400]` for the latter; the former is matched on `err.code === 'VALIDATION_FAILED' \|\| err.name === 'ValidationError'` | | status is **400 and only 400** | the derivation's own emitted census: `emitted[VALIDATION_FAILED] = [[400, [...]]]` — `protocol.ts` `MalformedVersionTokenError`, `protocol.ts:2721`, `protocol.ts:13012`, `share-link-routes.ts:156` | | covers a batch row naming no record id | `rowRequiredIdError`, `packages/metadata-protocol/src/protocol.ts:2719-2724` | | `fields[]` entry keys (`field`, `code`, `message`, `label`, `constraint`, `value`, `options`) | `FieldValidationError`, `packages/objectql/src/validation/record-validator.ts:102-133`; `code` from `FieldErrorCode` (ADR-0114) | The count was read off `face.wireCodes.length` **after** the edit (52) and not transcribed from the card, per its acceptance criterion 2. ## 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` in the `:: exit N` form: ``` 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). ``` Exit-code census: **41 × exit 0**, zero non-zero, zero exit 3. `pnpm check:error-status-conformance` — the gate most exposed to this edit, since it is the same derivation — is one of them. Two prerequisite builds ran first (`@objectstack/spec` + `@objectstack/lint`, then the `@objectstack/client-react...` closure) through `OS_VERIFY_LOCK_SLOT=issue-18193 bash scripts/pm/os-verify-lock.sh`; the second printed `VERDICT command-exit 0`. **Lint — full, not narrowed.** `eslint . --no-inline-config --format json` over the whole repo in one foreground pass: **exit 0, 6752 files, 0 errors, 0 warnings**. No narrowing claim is needed. This file is not in eslint's population at all — **0** of the 6752 reported files end in `.mdx`. **Control characters.** `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over the edited file: no match (grep exit 1, zero lines). `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[]` (`["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]`), so the zero is a reading and not an empty walk. Nothing in this diff ships in a tarball. **Tree.** Derived and run on `6f3523462`, branched from `origin/main` `076bb975d`. ## Acceptance notes Observed while measuring, **not filed and not fixed here** — none is a reproducible defect, a declared-contract violation, or a metadata-authoring trap: - **Editing `error-handling.mdx` silently widens the wire face and reds a test about `error-catalog.mdx`.** A docs author on the protocol page has no local signal that publishing a status for a ledger code enlarges the catalog's advertised count. It is loud where it matters (the assertion names the code and the arithmetic) and it is the mechanism `reconciledVocabulary` was deliberately built to have, so this is the design working rather than a defect. **Successor: PR #18191**, which is the PR that hit it and now has the repair landing alongside. - **The count sentence is self-referential.** Adding a catalog entry for a *ledger* code moves `wireCodes.length` by exactly +1 through the same edit, so "add the entry" and "fix the number" are one change, not two. Worth knowing before reading a `51 → 52` diff as two independent claims. Noted, not filed — no rule is violated and the gate is loud. **Successor: none.** - **`VALIDATION_ERROR` and `VALIDATION_FAILED` are adjacent in the catalog and easy to confuse**; the new entry states the distinction (status-derived vs producer-named) rather than leaving it to the reader. This is fixed here, not deferred — noting it only because the same confusion is visible in `packages/cli/src/utils/format.ts:236`'s comment, which is correct and needs nothing. ## 维护者速读(草稿) **改了什么**:错误码目录页 `content/docs/api/error-catalog.mdx` 补上 `VALIDATION_FAILED` 这一条(此前它在页面正文里被提到七次,却没有自己的条目),并把页头「51 error codes reachable on the wire」改成实测的 52。 **为什么改**:两件事,要分开看。① 这一页本来就有洞 —— `INVALID_REFERENCE` 条目明写「请改为 branch on `VALIDATION_FAILED`」,而目录里根本查不到这个码。② 派单说 `main` 红了,**这一条经实测不成立**:`main` 上这个测试是绿的(今天的 `076bb975d` 与 PR #18191 自己的 base `ba9b4981c` 都测过)。真正会变红的是 PR #18191 —— 它重写的 `error-handling.mdx` 恰好是 `deriveWireFace` 扫描的两个页面之一,该页新写的 `VALIDATION_FAILED` 400 把这个码带进了线上词表,目录页却没跟上。卡片认定「文档改动移不动词表」,这一点是错的。 **风险与代价(含回滚)**:纯文档,零运行时影响,`content/docs/**` 不进任何已发布包(70 个非私有包的 `files[]` 实测,带正控)。回滚 = revert 这一个 commit。因为目录页本身就是词表的输入之一,这个改动在「#18191 已合」和「#18191 未合」两种世界里都是绿的 —— 两种都实跑过。 **席位意见**:_(留空,待席位定稿)_ **你要做的**:如果你认同「目录页该有这一条」,直接 review 合并即可,它同时解开 PR #18191 的阻塞。如果你当初批的是「去把 main 的红止住」,那没有红可止 —— 这张 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>
1 parent f0b2db3 commit 3f98c2a

1 file changed

Lines changed: 57 additions & 2 deletions

File tree

‎content/docs/api/error-catalog.mdx‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Error Code Catalog
33
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
44
---
55

6-
ObjectStack uses a structured error system with **9 error categories** and **51 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
6+
ObjectStack uses a structured error system with **9 error categories** and **52 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
77

88
This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
99
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
@@ -74,6 +74,61 @@ entry's cross-reference sentence so the in-process one stays findable.
7474
}
7575
```
7676

77+
### `VALIDATION_FAILED`
78+
**Cause:** Record-level validation refused the write. This is the code a
79+
*producer* names — `ValidationError` from the record and rule validators, and
80+
any error of the same shape (`code: 'VALIDATION_FAILED'` / `name:
81+
'ValidationError'`) — as opposed to `VALIDATION_ERROR` above, which is *derived
82+
from the status* when the producer named no code at all. It covers required,
83+
type, format, length, range and picklist violations, a dangling `lookup` /
84+
`master_detail` reference (`fields[].code === 'reference_not_found'` — see the
85+
callout below), a malformed `expectedVersion` / `If-Match` token, and a batch
86+
row that names no record id.
87+
**Fix:** Branch on this code and read `fields[]`. Each entry carries `field`
88+
(the API name, so a form can focus the right input), `code` from the field-level
89+
catalog ([ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md)),
90+
a `message` rendered in the caller's locale, and — where they apply — `label`,
91+
`constraint`, `value` and `options`. Correct the named values and resend; no
92+
retry of the same payload can succeed.
93+
**Retry:** `no_retry`
94+
95+
<Callout type="warn">
96+
**Where the per-field list rides is decided by the door, not by the code — and
97+
it can be empty.** On the `/data` routes the envelope is flat and `fields` is a
98+
top-level sibling of `code`, always present, `[]` when the producer named none:
99+
100+
```json
101+
{
102+
"error": "Email is not a valid email address",
103+
"code": "VALIDATION_FAILED",
104+
"fields": [
105+
{ "field": "email", "code": "invalid_format", "label": "Email",
106+
"message": "Email is not a valid email address" }
107+
]
108+
}
109+
```
110+
111+
The same throw served through the runtime dispatcher answers the nested
112+
envelope, and the list rides in `details` instead:
113+
114+
```json
115+
{
116+
"success": false,
117+
"error": {
118+
"code": "VALIDATION_FAILED",
119+
"message": "Email is not a valid email address",
120+
"httpStatus": 400,
121+
"details": { "fields": [{ "field": "email", "code": "invalid_format" }] }
122+
}
123+
}
124+
```
125+
126+
Both are `400`. A refusal a route *builds* itself rather than throwing — share
127+
link creation without `object` / `recordId`, for instance — carries the code and
128+
the sentence and **no `fields` at all**. So read the sentence out of `error` /
129+
`message`, and treat `fields` as a refinement that may be absent or empty.
130+
</Callout>
131+
77132
### `INVALID_FIELD`
78133
**Cause:** A field name in the request does not exist on the target object. On a
79134
list read this also covers an unreserved query parameter — `GET /data/:object`
@@ -810,7 +865,7 @@ async function handleApiCall() {
810865

811866
| Status | Category | Common Codes |
812867
|:---:|:---|:---|
813-
| 400 | `validation` | `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |
868+
| 400 | `validation` | `VALIDATION_FAILED`, `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |
814869
| 401 | `authentication` | `UNAUTHENTICATED`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` |
815870
| 403 | `authorization` | `PERMISSION_DENIED`, `FIELD_NOT_ACCESSIBLE`, `LICENSE_REQUIRED` |
816871
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |

0 commit comments

Comments
 (0)