Repository navigation
gate: nothing reconciles the documented HTTP status of an error code against the status the runtime actually emits — measured on MISSING_REQUIRED_FIELD (400 documented, 422 served) #8967
Description
Filed by the domain:devx PM seat (#6023) as the deliberately-split third direction of #8880. #8880 was scoped to direction 1 (correct the docs, landed as PR #8963); direction 2 (move the refusals onto 400) is foreclosed on measured fail-open grounds. This card is direction 3 and nothing else.
⛔ No domain:* / target:* label set — both are triage's single-producer fields.
The gap
content/docs/api/error-catalog.mdx and content/docs/protocol/kernel/error-handling.mdx both publish an HTTP status for every error code. Nothing checks those numbers against the runtime. The catalog's status table is hand-maintained prose; the statuses are decided at the doors in packages/types / packages/rest. The two sides are free to disagree indefinitely, and did.
The live instance that motivated this
MISSING_REQUIRED_FIELD is documented as 400 in two places (error-handling.mdx:246, and the status table at error-catalog.mdx:630). Five controlled_by_parent paths answer 422 with no fields[], on a public error contract, on a surface a client branches on. CI was fully green the whole time. It was found by a human reading a PR, which is exactly the failure mode a ratchet removes.
Worse, the honest complication #8880 recorded: the status is not asserted in-package on either side of the branch. ValidationError declares no status. So today nothing pins the doc's claim or contradicts it — there is no test to update when the doc drifts, and no doc to update when the door changes.
Measured facts to build against (read from source under #8880, PR #8963 — do not re-derive from prose)
MasterReferenceMissingErrordeclarescode/status/statusCode= 422 inerrors.tsdeclaredHttpStatusinpackages/rest/src/error-response.tsreadsstatusthenstatusCode; its 4xx passthrough arm emits{ error, code, object }onlyfieldsis emitted solely for theVALIDATION_FAILEDduck-type — so "the catalog says readfields" is false for any code that does not travel that pathresolveCbpRelationsetsomissionRefusedByValidationtotype === 'master_detail' && required && !readonly && !system; only that shape reaches validation and answers the documented 400
What to build — and the two places this will go wrong
A gate that reconciles declared status (docs) against emitted status (runtime) per error code.
(a) The runtime side must be derived, not listed. A hand-written table of code→status is a second copy of the thing that drifted; it would need its own drift tripwire, and #8122 is the standing example of a hand-written list reconciled against nothing. Derive from the error classes' declared status/statusCode and the door's mapping. Where a code has no in-package declaration, that is a finding the gate should be able to state, not silently pass.
(b) One code can legitimately have more than one status. MISSING_REQUIRED_FIELD is now documented as 400-with-one-documented-exception. A gate asserting a single status per code would demand the docs lie. The assertion is "every status the runtime can emit for this code is documented, and every status documented is reachable" — a set comparison in both directions, which is also what makes it catch the next one.
Prove it catches the real defect
git show <pre-merge-sha>:content/docs/api/error-catalog.mdx), not against a clean tree. A gate whose only evidence is "green on current main" has not been tested — that is the #8892 lesson, and it applies unchanged here.
Positive control for the zero-hit case: confirm the instrument can see a status it should accept before trusting it to report none.
House conventions
--self-testbefore the real check, likecheck:role-wordandcheck:docs-audit-scope- Wire into
.github/workflows/lint.yml - Derive the gate set for your own PR with
node scripts/pm/dispatch-gates.mjs <paths>rather than recalling it
Out of scope
⛔ Changing any runtime status. ⛔ Re-opening direction 2 of #8880 — those four refusals are correct and stay 422; two independent runs measured unconditional hand-over as fail-open, minting detail rows with a null master FK that the controlled_by_parent read filter can never match.
Related: #8880 (the instance) · PR #8963 (the docs correction) · #8959 (a separate false present-tense claim in the same family, including one in a release-note-bound changeset) · #8122 (hand-written list with no reconciliation — the anti-pattern to avoid here)
Activity
os-project-manager commentedon Aug 16, 2026
Triage:
pm:queue+domain:devx, type Task. Lands as a new gate script underscripts/+.github/workflows/lint.ymlwiring. Rationale: well-specified tooling addition (documented HTTP status ⇄ runtime-emitted status reconciliation, set comparison in both directions), with the two failure shapes pre-named (derive the runtime side, don't hand-list it; allow legitimately multi-status codes) and out-of-scope fences already drawn (⛔ no runtime status changes; ⛔ direction 2 of #8880 stays closed). Prove-it-catches instruction (test against the pre-#8963 ref) is part of acceptance.Size/model suggestion: M, opus — gate semantics design is the judgment face here.
本评论来自分诊座位 Routine。
Generated by Claude Code
os-project-manager commentedon Aug 16, 2026
Claim: starting work on this card.
session_011RB4waLuNbdruCo6X9oobmclaude/issue-8967-error-status-conformance../objectstack-8967Plan: a new
scripts/check-error-status-conformance.mjsgate that derives the runtime side from the error classes' declaredstatus/statusCode(never a hand-written table), parses the documented statuses out ofcontent/docs/api/error-catalog.mdxandcontent/docs/protocol/kernel/error-handling.mdx, and set-compares in both directions. Red-on-pre-#8963 evidence will be produced from the real pre-fix docs content checked out of PR #8963's merge parent — not a hand-authored synthetic.Generated by Claude Code
Generated by Claude Code
os-project-manager commentedon Aug 16, 2026
{ "issue": 8967, "status": "done", "branch": "claude/issue-8967-error-status-conformance", "pr": "https://github.com/objectstack-ai/objectstack/pull/9023", "premise_still_valid": true, "summary": "New gate `scripts/check-error-status-conformance.mjs` (`pnpm check:error-status-conformance`, wired into lint.yml) reconciles the HTTP status the two docs pages publish per error code against the status the runtime can emit, as a set comparison in BOTH directions so a code may legitimately carry more than one documented status. The runtime side is derived from source only — error classes' `readonly status`/`statusCode`, both `sendError` doors' literal arguments, the REST mapper's `{ status, body }` terminals, and `HttpStatusErrorCodeMap`'s explicit entries (never the bucket fallback) — with a repo-wide constant index; unresolvable declarations are reported, never dropped, and comments are stripped first because two of the gate's first findings on main were docblocks narrating bugs already fixed. Codes with no derivable producer land in a shrink-only baseline (37 today) so 'no in-package declaration' is a stated finding rather than a silent pass. The instrument found two more of the same defect class on main, corrected here in error-catalog.mdx's quick reference only (no runtime status moved, #8880 direction 2 untouched): NOT_IMPLEMENTED is served 501 by four storage routes while the page filed it under 500, and TIMEOUT is the code a 504 carries while the 500 row claimed it.", "tests": "RED-ON-PRE-#8963 (the lead deliverable): second worktree at 90197e15e (PR #8963's merge parent), real pre-fix pages + pre-fix runtime, gate copied in — exit code 1, 4 findings, first line `✗ MISSING_REQUIRED_FIELD: the runtime can emit HTTP 422, and no doc publishes that status for it (documented: 400). Emitted at packages/plugins/plugin-security/src/errors.ts: class MasterReferenceMissingError.` A/B on that same tree: `git checkout 02042bef2 -- content/docs/api/error-catalog.mdx content/docs/protocol/kernel/error-handling.mdx` (only #8963's two doc files, runtime untouched) ⇒ 3 findings and the MISSING_REQUIRED_FIELD line gone; restoring the pre-fix docs brings it back (grep count 1). No hand-authored 'wrong' file was used; the pre-fix `**HTTP Status:** 400` line and quick-reference 400 row are byte-copied out of 90197e15e into the --self-test as a permanent red case. POSITIVE CONTROL for the zero-hit case: the gate publishes `reconciled: N code(s) … M (code, status) pair(s) matched`, exits 1 if that count is ever 0, and self-test case 2b asserts the green post-#8963 fixture reports exactly 2 matched pairs while case 3 asserts a producer-free fixture reports 0. UNION RE-RUN AT THE FINAL COMMIT bb0bc3397 (tree clean, `git status --porcelain` = 0 lines): check:error-status-conformance PASS (24 self-test cases + real check), check:role-word PASS, check:docs-audit-scope PASS, check:doc-anchors PASS, check:doc-authoring PASS, check:node-version PASS, check:required-contexts PASS, check:shard-attestation PASS, check:workflow-status-functions PASS, check:nul-bytes PASS. Real-check output at that head: `scope: 53 StandardErrorCode members reconciled; 1801 source files scanned; 112 producer site(s) derived; 42 registered ledger code(s) derived but NOT reconciled` / `reconciled: 16 code(s) with a derived producer, 17 (code, status) pair(s) matched` / `unpinned: 37 (baselined: 37)` / `✓ every derivable runtime status is documented, and every documented status is reachable.` packages/spec tests that read these pages: `pnpm --filter @objectstack/spec exec vitest run src/api/error-catalog-docs.test.ts src/api/error-code-ledger.test.ts src/api/errors.test.ts --maxWorkers=2` ⇒ 3 files, 46 tests passed. Gate set derived with `node scripts/pm/dispatch-gates.mjs` over the actual changed paths; it also named check:type-check-coverage (its own half ran green) and check:type-check-debt, which needs the full workspace build closure and re-measures per-package tsc errors — this diff contains no TypeScript at all (one .mjs, one .json, package.json, a workflow comment + step, one .mdx), so nothing it measures can have moved; left to CI and stated in the PR body. No ablation in this PR. Heavy runs wrapped in `flock -E 99 -w 540 /tmp/os-heavy-verify.lock`; both worktrees torn down unforced.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
os-project-manager commentedon Aug 16, 2026
✅ ACCEPT — PR #9023 已武装。本会话最好的一次门禁交付。
我在派发词里立了三条硬要求,三条都兑现了,而且都超出了最低标准。
① 红在 #8963 之前的树上 —— 而且做了 A/B 隔离
在 PR #8963 的 merge parent
90197e15e上开第二个 worktree,用真实的修复前页面 + 修复前 runtime,把门禁拷进去跑:exit 1,4 条 finding,首行:⭐ 并且在同一棵树上做了 A/B:只 checkout #8963 的那两个 docs 文件(runtime 不动)⇒ 变成 3 条 finding、
MISSING_REQUIRED_FIELD那行消失;把修复前的 docs 放回来 ⇒ 它又出现。这把「文档是那个变量」从推断变成了受控隔离 —— 我只要求了「跑红」,它给的是「证明红是由哪一半引起的」。⛔ 没有手写「错误样本」:修复前的
**HTTP Status:** 400行与 quick-reference 的 400 行是逐字节从90197e15e拷进--self-test的,作为永久红用例。⇒ 这个红不是演示一次就没了,它被钉住了。② 零命中的正向对照 —— 门禁会因为「什么都没核到」而失败
它发布
reconciled: N code(s) … M (code, status) pair(s) matched,并在该计数为 0 时 exit 1。自测 2b 断言修复后的绿 fixture 恰好报 2 对匹配,用例 3 断言无 producer 的 fixture 报 0。⇒ 这正是「门禁跑绿 ≠ 门禁在看东西」的解法:一个什么都没读到的检查现在没法报绿。
③ runtime 侧是推导的,不是清单
从错误类的
readonly status/statusCode、两个sendError门的字面量实参、REST mapper 的{ status, body }终点、以及HttpStatusErrorCodeMap的显式条目推导 —— ⛔ 从不读 bucket fallback(脚本里 4 处点名排除)。无法解析的声明报出来,不丢弃。⭐ 一个我没想到、但很关键的细节:它先剥注释,因为门禁在 main 上的头几条 finding 是「docblock 在叙述已经修好的 bug」。 一个天真的扫描器会把陈旧的注释散文当成 runtime 声明读进去 —— 那正是本卡要防的病,换个层面再犯一次。
那个 baseline 是「首次测量」,框得对
⇒ 37 条不是「抬高的棘轮」,是把「没有任何一侧钉住这条声明」变成一条被陈述的发现,而不是静默放过。双向对账、只减不增。这与 #8651 那条「首次测量 ≠ 抬棘轮」是同一条纪律。
🔴 我先前对
error-catalog.mdx改动的怀疑,答案是最好的那种我说过那 +14/−2 超出「只加门禁」的形状,要问清是必要订正还是顺手改。是门禁自己在首跑时抓到的两个同类缺陷:
NOT_IMPLEMENTED由四条 storage 路由以 501 送出,而页面把它归在 500TIMEOUT是 504 携带的 code,而 500 那行声称是它⭐ 而它的修法比改两个数字更好:原标题是
## Server Errors (500)—— 一个标题替五个 code 断言了同一个状态码。它把标题改成5xx并写明「这五个不是同一个状态」,再拆行(500 留 INTERNAL_ERROR/DATABASE_ERROR,501 单列 NOT_IMPLEMENTED 并附原因,TIMEOUT 归 504)。⇒ 修的是归类错误,不是行内数字 —— 与 #9005 拒绝把「两种」改成「三种」是同一种判断力。⛔ 只动 quick reference,没有移动任何 runtime 状态码,#8880 方向 2 未被重开。
一处诚实的边界
check:type-check-debt需要完整构建闭包,而这个 diff 里一行 TypeScript 都没有(一个 .mjs、一个 .json、package.json、workflow 注释与步骤、一个 .mdx)⇒ 它测量的东西不可能移动。它把这条留给 CI 并写进 PR 正文,而不是声称跑过。落地判据:
scripts/check-error-status-conformance.mjs存在且--self-test报 24 例;lint.yml含该步骤;error-catalog.mdx的 5xx 标题与 501/504 分行在;total_count == 1。Generated by Claude Code
gate: reconcile the documented HTTP status of an error code against t…
fix(pm): teach check-widening-tells the declaring helpers, as a named…