Repository navigation
spec: ADR-0112 error envelope gains an explicit producer-side refusal declaration so a deliberate 5xx refusal can keep its caller-authored message (spec half of #16146) #16335
Description
Activity
- addedenhancementNew feature or requestNew feature or requestpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 Pre-dispatch contract reading, taken at
CONTRACT_REVIEW_TIER— the card's central premise is qualified, not cleandomain:specseat,session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-09T04:3xZ. Produced by a read-only subagent dispatched with an explicitmodel, tier verified by fuse rather than by self-report: 110 harness-stamped"model":"claude-fable-5-1"in its transcript, zero other values. ⛔ Nothing was written to the tree or to this card's state by that run. Measured againstorigin/mainc0e4bc74.⭐⭐ The finding that must reach whoever takes this card
The card's Why section says, verbatim: "Nothing on the wire today lets a producer say 'this 5xx is a refusal whose prose is for the caller'." That is not clean.
ApiErrorSchema.userMessage(packages/spec/src/api/contract.zod.ts:50-95) is already a producer-side, status-agnostic opt-in — its own TSDoc says "Presence IS the marking" (:71) and "Status-agnostic" (:75).packages/rest/src/error-response.ts:646-677(withDeclaredUserMessage) rides it onto "even the sanitised fault terminals", and the flat 5xx arm at:2111-2122carriesuserMessagethrough whileerrorstaysINTERNAL_ERROR_MESSAGE.
⇒ A producer can already put caller-authored prose on a 5xx body today — in
userMessage, never inmessage(:85-86: "never replacesmessage"). The gap this card closes is narrower than the body states: it is aboutmessagedisclosure, not about whether any caller-authored prose can reach a 5xx.⭐⭐ And the card's example spelling was explicitly rejected once already, in the same file
contract.zod.ts:77-83is the #9934 design note recording why "a boolean besidemessage" was refused. The card's own examples aredisclose: true/refusal: true— that shape.⛔ This does not reopen the ruling: director batch #58 (2026-09-06, option C, maintainer 「同意」) settled that the distinction is a producer-side declaration on the published envelope, and it is not in question. But the PR must reconcile with
:77-83in its TSDoc, not walk past it. The reconciliation that appears available from the tree: the flag marks the producer's own authoredmessageat throw time,declaredServerFaultAnswerreads it before any rewrap, and withhold stays the default — so a boundary that rewrapsmessagecannot promote platform prose into the marked channel.⚠️ If that reconciliation does not survive contact with the code, stop and report a fork — do not ship a field whose own file argues against its shape.Clause-②:
yes, and it is mechanically forcedThe new key lands on
ApiErrorSchema(a non-strictz.object), so the parse accept set does not move — the direction rests on the published-surface limb ofSKILL.md:514, pluscontract-review.md's floor 「已发布载荷上的新键恒yes」. Mechanically:scripts/pm/check-widening-tells.mjs:629fires T1 on any addedkey: z.…line underpackages/spec/src/**(verdict:768), so anoclaim could not passcheck-clause2-carriers --pairregardless.Premise check on
c0e4bc74— holds, with line driftcard / triage citation current declaredServerFaultAnswerpackages/rest/src/error-response.ts:574-588; gatedeclaredHttpStatus(error) >= 500at:577-578; docblock:565-572states the gate is notdeclaresServerFaultdeclaresServerFaultpackages/types/src/error-leak.ts:289-293; the card's paraphrasestatus >= 500 && codeholdsthe live caller now error-response.ts:583— triage's 2026-09-06 citation of:577has drifted +3Which schema is "the" envelope: ADR-0112
:34namescontract.zod.ts#ApiErrorSchemaas the enforced one;EnhancedApiErrorSchema(errors.zod.ts:372) also carriesuserMessage(:385) — #9934 amended both, so the dev decides one or two and says why.⚠️ One acceptance item on this card is a zero-diff steppackages/spec/api-surface/api.jsonrecords exports asname (kind)only, so adding a member moves nothing there —ApiError (type)/ApiErrorParsed/ApiErrorSchema (const)at:58-60stay byte-identical. The ratchet that actually moves isauthorable-surface/api.json(a new row besideapi/ApiError:userMessageat:140), which is not in the package'sfiles[]. ⇒ ticking "api-surface baseline regenerated" green proves nothing here. Also:check:api-surfacereads builtdist/*.d.ts, so a staledistyields a false "removed" report.NOT MEASURED, stated rather than glossed: the emitted
packages/spec/json-schema/api/ApiError.json(gitignored, but shipped viafiles[]) — that needs a build; and which ADR-0087 changeset disposition marker an additive key takes.Serial: clean
All 17 open PRs enumerated at ~04:20Z: none touches
contract.zod.ts,errors.zod.ts,error-leak.tsorerror-response.ts. Only adjacency is PR #16783 regeneratingcontent/docs/references/api/contract.mdx— a generated file, soscripts/pm/os-regen-merge.shapplies at merge. #16146 (the consumer half) ispm:blockedwaiting on this card.Size read: S–M, one optional key plus TSDoc, pins in
contract.test.ts/errors.test.ts,@objectstack/specminor. No per-family split.
Generated by Claude Code
Claim: PM loop round 1 — first card taken under the maintainer's standing instruction 「后续优先派 fable卡」
Session:session_01MkQhmuuJAVDjmeWNixwDDH
Branch:claude/issue-16335-adr-0112-refusal-declaration
Worktree:objectstack-issue-16335
Domain:domain:spec
File surface:packages/spec/src/api/contract.zod.ts(ApiErrorSchema) and, if the dev's own reading says both envelopes move,packages/spec/src/api/errors.zod.ts(EnhancedApiErrorSchema); their tests; the regeneratedpackages/spec/authorable-surface/api.jsonandcontent/docs/references/api/contract.mdx;.changeset/*.md. ⛔ Notpackages/rest/**and ⛔ notpackages/types/**— the consumer half is #16146 (stop on breach; explain in the report)
Container & model:S–M,mode:subagent,model: claude-fable-5-1(CONTRACT_REVIEW_TIER) — taken at the ceiling by per-card judgment, not as a new default: this card settles the shape and name of a new field on a published error envelope, whichSKILL.md:508puts in the ceiling tier's class, and the maintainer's standing instruction prioritises fable cards.--tierate4fd55d9: no path-derived mandate, "Clause ② SUSPECT surface" printed for bothcontract.zod.tsanderrors.zod.ts.⚠️ That run also printedSTALE TREE — at least 19 commit(s) behind origin/main; the tier answer is path-glob-derived and unaffected, but the gate-family list from this shared checkout is not, so the dev derives its own in its own worktree
Clause-②: yes
Thread-read: 5595839147
Serial constraints cleared: 15 open PRs had their file lists read at 05:31Z. None touchesapi/contract.zod.ts,api/errors.zod.ts,types/src/error-leak.ts,rest/src/error-response.tsorauthorable-surface/api.json. One adjacency, and the probe fired on it so the zero is a reading: PR #16783 regeneratescontent/docs/references/api/contract.mdx— a generated file, sobash scripts/pm/os-regen-merge.shapplies at merge rather than a hand-resolved conflict. #16146, the consumer half, ispm:blockedwaiting on this card, so nothing downstream can race it.⚠️ The at-tier pre-dispatch reading on this card (comment5595839147) found the card body's central premise qualified rather than clean, and the dispatch order carries that as a hard constraint:ApiErrorSchema.userMessageis already a producer-side status-agnostic opt-in, andcontract.zod.ts:77-83records why "a boolean besidemessage" was refused once — which is the shape this card's own examples propose. ⛔ The ruling (batch #58, option C) is not reopened; the PR must reconcile with that design note rather than walk past it, and report a fork if it cannot.
Generated by Claude Code
Dispatch held on lock depth — the claim above stands, ⛔ the slot is not released.
domain:specseat,session_01MkQhmuuJAVDjmeWNixwDDH.bash scripts/pm/os-verify-lock.sh --statusreadings, both this minute:time holder queue N:linesarrival depth 05:30Z @objectstack/spec test, held 23s0 1 — below threshold, so the atomic claim pair was written 05:31Z same 1 2 — at LOCK_DEPTH_HOLD, so the dev is not dispatched⇒ The window closed between the claim and the dispatch. Waiting rather than dispatching into depth 2 (the depth table puts exit-99 at 23% there). ⛔ Waiting does not fill a slot and does not release this claim; the actual dispatch time is recorded here when a reading comes back below the threshold.
⚠️ Context for whoever reads this later: four sibling devs share this container's single heavy-verify lock, and the lock ledger over the previous 1h14m measured a median 294s acquisition wait, p90 540s, and fourqueue-timeoutoutcomes — runs the ledger itself marks NOT MEASURED because no gate was decided by them. The dev slot for this card is free; what is not free is the verification capacity to run it honestly.
Generated by Claude Code
Hold released — dev dispatched 2026-09-09T05:41Z at
claude-fable-5-1.domain:specseat,session_01MkQhmuuJAVDjmeWNixwDDH, claim5596331879unchanged.Lock re-read immediately before the dispatch:
queue: empty⇒ arrival depth 1, belowLOCK_DEPTH_HOLD. The hold recorded at 05:31Z lasted ten minutes across two windows that closed before the dispatch could take them; the third held.The dispatch order carries the at-tier pre-reading (
5595839147) as its measurement zone, so the dev does not re-derive it from scratch — in particular the two findings that change how this card must be built:- the card's premise that nothing today lets a producer put caller-authored prose on a 5xx is qualified —
userMessagealready does, in a different channel, so the real gap ismessagedisclosure; contract.zod.ts:77-83records that "a boolean besidemessage" was refused once, which is the shape this card's examples propose — the PR reconciles with that note or reports a fork; ⛔ it does not walk past it.
⚠️ One carry-forward from a sibling PR that went red this hour: the dispatch requires a line-startClause-②: yesin the PR body and a changeset at theminorfloor, becauseCheck Changesetcross-checks the declaration against the graded level and fails when they disagree inside one PR. That failure cost PR #17060 two red runs today, and the cause was a stale conservative declaration rather than anything in its diff.
Generated by Claude Code
- the card's premise that nothing today lets a producer put caller-authored prose on a 5xx is qualified —
os-dev-report
{ "issue": 16335, "status": "done", "branch": "claude/issue-16335-adr-0112-refusal-declaration", "pr": "https://github.com/objectstack-ai/objectstack/pull/17090", "premise_still_valid": true, "summary": "Added one optional key, `refusal: z.literal(true).optional()`, to `ApiErrorSchema` (contract.zod.ts) and to `EnhancedApiErrorSchema` (errors.zod.ts, TSDoc pointing at the base, the #9934 `userMessage` pattern), meaning: the producer declares the 5xx it named is a deliberate refusal whose `message` is authored for the caller, so the boundary keeps it verbatim; absent (the default) keeps the #5811/#5667 withhold; `true` is the only value because the ruling on #16146 (comment 5559950291) makes PRESENCE the declaration and a fault already has its own spelling (status + code). The TSDoc documents the three cases side by side (undeclared 5xx: heuristic; declared fault: withheld; declared refusal: kept) with `userMessage` placed as orthogonal, and reconciles the flag with contract.zod.ts:77-83 on three measured grounds: it qualifies the STATUS declaration (as `code` qualifies `status` for `declaresServerFault`), its only consumer is the withhold itself reading status, code and flag off one thrown object in one read, and a rewrap is fail-closed (a new Error drops the flag; the QuickJS door's SANDBOX_ERROR_PASSTHROUGH list at quickjs-runner.ts:1250 is closed and excludes it; a survey of Object.assign error sites under packages/**/src finds 10, none copying a caught error's fields onto a rewrapped message). Premise re-derived as QUALIFIED, as the pre-reading said: `userMessage` already carries caller-authored prose onto a 5xx body, so the gap declared against is `message` disclosure only. No allow-list, no second table; packages/rest and packages/types untouched; the relay half stays #16146 and until it lands a declared refusal is still withheld at the wire (stated in the TSDoc and the changeset). Both envelopes rather than one because `ErrorResponseSchema` nests `EnhancedApiErrorSchema`, so `error.refusal` is one key at one wire position described by two schemas. Which artifacts move, measured: `authorable-surface/api.json` +2 key-name rows; 11 `content/docs/references/api/*.mdx`; the gitignored-but-shipped `json-schema/api/ApiError.json` and `EnhancedApiError.json` carry the grammar (`refusal: {type: boolean, const: true}`, positive control `userMessage` 1/1); `api-surface/api.json` and `json-schema.manifest/api.json` are byte-identical by construction (names only); the liveness ledger is the `api` metadata type's and owes no row. ADR-0087: measured in check-adr-0087-registration.mjs `breakingDeclaration`, a marker is owed only by a declared-breaking changeset (major, **BREAKING, or a `!` summary) so this additive `minor` changeset carries none. origin/main merged once via os-regen-merge.sh (contract.mdx was moved by both sides; re-derived at the collection point, main's #16783 content present).", "tests": "Final head ba3d95a4 (origin/main 854639b3 merged). Tests: `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/api/contract.test.ts src/api/errors.test.ts` = `Test Files 2 passed (2) | Tests 93 passed (93)`, wrapper line `VERDICT command-exit 0` (tests import ./contract.zod and ./errors.zod relatively, so vitest reads source; no dist on the path). Ablation, one lock hold on the committed fix: leg A 93/93; mutation deleted the `refusal: z.literal(true)` block from both schemas, on-disk proof marker count 1 1 to 0 0; leg B `6 failed | 87 passed` = exactly the six refusal pins (contract.test.ts x5, errors.test.ts x1; the absent-by-default pin stays green as predicted); restore `git checkout HEAD -- PATH` proven by git hash-object equal to non-empty HEAD blobs e232f949 and b07422ee, `git diff HEAD` empty, `git status --porcelain` empty; leg C 93/93; direction observed: red. Typecheck: `pnpm --filter @objectstack/spec typecheck` exit 0 (tsc + scripts tsconfig + check:test-typecheck over the test files). Build on the merged head: `VERDICT command-exit 0` (held 180s); `check:generated`: `All 15 generated artifacts are up to date`. Gates: `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at ba3d95a4 derived 102 commands (identical to the pre-merge derivation); all 102 run, exits redirected to disk before reading, recorded byte-for-byte; reconcile `dispatch-gates --ran: 102 derived famil(ies) accounted for, 99 run, 3 NOT-MEASURED` exit 0. 95 green on the spec build; 4 refused on unbuilt sibling closures and re-ran green after `turbo run build --filter='@objectstack/lint...' --filter='@objectstack/client-react...'` (34 tasks, VERDICT 0): check:doc-formula-expressions (`9 @example(s) judged clean across 1314 packages/spec/src files`), check:doc-security-posture (`27 ObjectSchema.create example(s) ... validate-clean`), check:skill-examples (`258 prose examples type-check across 3 surface(s)`), check:docs-transcript-drift (`4 declared transcript value(s) ... equal what the registry derives`). NOT MEASURED, each with the gate's own PREREQUISITE NOT MET line and a stated reason: check:dual-build-cjs-loads (80+ packages' dist), check:lean-entry-closure (built lean entries; diff moves no entry point, api-surface/export-origins/declaration-map all green), check:type-check-debt (full workspace re-measure) -- whole-workspace builds declared to CI. Named families green: check:nul-bytes, check:error-code-casing, check:error-status-conformance, check:dispatcher-error-vocabulary, check:pm-widening-tells, check:merge-driver, check:changeset-gate-self-tests, check-adr-0087-registration --base origin/main, check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main. Lint narrowed with proof: population from eslint.config.mjs (`files: **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus NEVER_LINTED; config line 328 states no parserOptions.project and no typed rules, so the diff moves no untouched file's verdict); `eslint --no-inline-config --format json` over the 4 edited TS files: 4 files, 0 errors, 0 warnings, exit 0; repo-wide `pnpm lint` is CI's. Clause-② carrier: `check-clause2-carriers.mjs --pair 17090` first read exit 4 (C1: label on the PR, not on the card), after the additive label on the card exit 0 (`both carriers agree`). Lock: three queue-timeouts are NOT in these readings; one exit-99 (waited 540s) was retried under the kept slot and measured on the retry.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (issue/comments/pulls/labels probed 200 first) and git; zero MCP GitHub calls", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: ADR-0112 carries no amendment paragraph for the `refusal` field (as #9934 added none for `userMessage`); the ruling lives on #16146 comment 5559950291 and the #12708 ledger; adding one would make the PR governed-surface, so it is a one-paragraph docs follow-up if the maintainer wants it in the ADR file. 承接者:无", "noted, not filed: the relay half (#16146) must extend SANDBOX_ERROR_PASSTHROUGH (quickjs-runner.ts:1250) only if sandboxed tenant bodies are meant to declare refusals, and on that door the caller-addressed text is innerMessage, not message, so the read must go through sandboxBusinessMessage. 承接者:#16146" ], "gates": { "derived": 102, "run": 99, "green": 99, "red": 0, "not_measured": 3, "not_measured_families": [ "pnpm check:dual-build-cjs-loads", "pnpm check:lean-entry-closure", "pnpm check:type-check-debt" ], "reconcile": "dispatch-gates --ran: 102 derived famil(ies) accounted for — 99 run, 3 NOT-MEASURED (exit 0)", "spec_check_generated": "All 15 generated artifacts are up to date (ba3d95a4)", "clause2_pair_17090": { "before_card_label": 4, "after_card_label": 0 }, "ci": "in_progress — draft PR opened 07:3xZ; CI convergence is the PM's read" }, "line_budget": "n/a — no file under skills/** touched (0 SKILL.md lines moved)", "deviations": [ "The dispatch said to run each derived gate through os-verify-lock.sh; .claude/agents/os-dev.md says check:* gates do not take the lock (only build/test do). Followed os-dev.md: builds, vitest runs and the ablation went through the lock; the 102 check:* commands ran unlocked in two foreground chunks.", "Three whole-workspace families declared NOT MEASURED rather than built (dual-build-cjs-loads, lean-entry-closure, type-check-debt), each with the gate's own PREREQUISITE NOT MET line as the reason; declared narrowing, CI runs them.", "Applied `needs:contract-review` additively on card #16335 as well as on PR #17090, because `--pair 17090` read C1 (a carrier split) with the PR-only label and the checker's header records the maintainer's ruling that the label sits on both carriers; card assignee and pm:* labels untouched.", "Commit trailer spelled `Co-Authored-By: Claude Fable 5.1` per the harness attribution guidance (AGENTS.md names a harness-written trailer as the reporting exemption).", "The resume message stated the spec test run had not landed before the 429; the transcript shows it had (05:56Z, VERDICT 0, 93 passed); it was re-measured anyway on the merged head inside the ablation legs.", "`pnpm lint` narrowed to the 4 edited TS files with the three-piece proof; the repo-wide run is CI's." ], "files_changed": [ ".changeset/adr-0112-envelope-refusal-declaration.md", "packages/spec/src/api/contract.zod.ts", "packages/spec/src/api/errors.zod.ts", "packages/spec/src/api/contract.test.ts", "packages/spec/src/api/errors.test.ts", "packages/spec/authorable-surface/api.json", "content/docs/references/api/analytics.mdx", "content/docs/references/api/auth.mdx", "content/docs/references/api/automation-api.mdx", "content/docs/references/api/batch.mdx", "content/docs/references/api/contract.mdx", "content/docs/references/api/errors.mdx", "content/docs/references/api/export.mdx", "content/docs/references/api/metadata.mdx", "content/docs/references/api/package-api.mdx", "content/docs/references/api/protocol.mdx", "content/docs/references/api/storage.mdx" ] }
Generated by Claude Code
席位复核 PR #17090 —— ⭐ 那条「同一个文件曾否决过这个形状」的硬约束,被正面回应而不是绕过
domain:spec席,session_01MkQhmuuJAVDjmeWNixwDDH,2026-09-09T07:3xZ。对 GitHub 与树核验,⛔ 不对报告自述核验。我下的硬约束是什么,以及它有没有被满足
派发令要求:
contract.zod.ts:77-83记着 #9934 否决过「在message旁边加一个布尔」,而本卡举的例子正是那个形状 ⇒ 必须在 TSDoc 里正面回应它,或者停手报分叉;⛔ 不许发一个自己所在文件反对其形状的字段。本席在 PR head 上逐字读了新字段自己的 TSDoc。它有一节标题就是:
## Why a flag besidemessageis the right shape HERE, when
##userMessageabove refused exactly that shape⇒ 不是绕过,是点名那条否决并逐条拆它。三条理由,每条都可检验:
- 它限定的是 STATUS 声明,不是一段文本。 它说「我声明的这个 5xx 是一次拒绝」,方式与
code已经在为declaresServerFault限定status完全相同。它唯一的消费者就是那个 withhold,而后者在同一个抛出对象上一次读完status、code与这个 flag ⇒ 标记与它释放的那段消息从不分离,而分离正是原否决的全部理由。 - 重包裹是 fail-closed 的。 把
message重包进一个新 error 会连同 flag 一起丢掉,于是那个 5xx 照旧被扣住。实测支撑:QuickJS 门带一张闭集字段直通表(SANDBOX_ERROR_PASSTHROUGH),本字段不在其上 ⇒ 沙箱体的 flag 永远出不了 VM;且packages/**下没有任何站点用一个被捕获 error 的字段去组装重包裹后的 error。 userMessage是正交的,不是第四行。 它的受众是终端用户,从不替换message,而且它本来就已经骑在被扣住的 5xx 上。
⭐ 原否决怕的是「标记被提升到平台散文上」;这份交付测出的是「重包裹会把标记丢掉」。 同一个机制,方向相反 —— 这才叫回应,不叫援引。
形状与围栏,本席逐条实测
项 读数 声明形状 refusal: z.literal(true).optional()⇒ 可选、无默认值;⭐ 且refusal: false解析失败而不是变成第三种状态围栏 packages/rest/**未触 围栏 packages/types/**未触 content/docs/releases/**未触 路径肢 packages/spec/src/**触及 ⇒ 开火双载体 --pair 17090exit 0,「both carriers agree」前提如预读所言是被限定的,而 PR 照此收窄了自己的主张
达档预读测出卡面那句「今天没有任何机制让生产者把面向调用方的文案放上 5xx」不干净 ——
userMessage已经在做。这份交付接受了这个更正,并把要补的缺口明确写成只是message的披露。⇒ ⛔ 没有沿用卡面那句更大的说法。它还自己指出一件继任者必须知道的事:中继那一半是 #16146,在它落地之前,一个已声明的拒绝在线上仍然被扣住 —— 这个键是它将要落地时所对的契约。这句写进了 TSDoc 与 changeset,⛔ 没有留在报告里。
状态与排期
席位清单结论:ACCEPT,待达档复核与 CI 收敛。 无阻塞发现。
⚠️ 达档复核尚未派出,这是刻意的节流:当刻已有三份复核在跑(PR #17056 / #17068 / #17085),而每份复核的量级与一个 dev 相当。本班 05:5xZ 撞过一次账号用量墙,起因正是五个 agent 同时在飞。⇒ 本卡的复核在其中一份返回后立刻派,⛔ 不并排开第四份。dev 槽位空着两个也不派新卡:当刻的瓶颈是复核,不是编写,而开新卡不会让落地队列变短。
Generated by Claude Code
- 它限定的是 STATUS 声明,不是一段文本。 它说「我声明的这个 5xx 是一次拒绝」,方式与
Contract review — PASS WITH FINDINGS recorded on the PR by the director seat (summon #18 segment 3, audit at
CONTRACT_REVIEW_TIER): #17090 (comment) — 2026-09-09T08:5xZ, headba3d95a4f3.- Reviewed-by: isolated
claude-fable-5-1subagent, transcript-verified (79 stamps, no other model value), adopted verbatim bysession_017Js5kTpTtxieBjPyScgxJ3(huangyiirene). Implemented-by:os-bill/session_01MkQhmuuJAVDjmeWNixwDDH— the seat that owns this card, which is why the review was taken independently. - Outcome: nothing blocking. F1: the new key has no reader on this head and the generated
.describe()text reads present-tense — keep [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 (the relay half) in the same RC, or add a one-clause "relay: [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146" to both describes and regenerate. F2: hand-writtenerror-catalog.mdxexcerpt lacksrefusal?: true(pre-existing drift for two other keys too). F3:PR_NUMBERplaceholder in the PR body. - Next act is the
domain:specseat's: adopt or discard, fix F3 (body) and choose F2's route, clear both carriers, ready → auto-merge; on merge, un-block [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 immediately. ⛔ This seat cleared no carrier and touched no PR state.
Generated by Claude Code
- Reviewed-by: isolated
Pointer correction: the review comment is #17090 (comment) (the id in the pointer above was written before the comment existed and is wrong). Everything else in that pointer stands.
Generated by Claude Code
os-dev-report
{ "issue": 16335, "status": "done", "branch": "claude/issue-16335-adr-0112-refusal-declaration", "pr": "https://github.com/objectstack-ai/objectstack/pull/17090", "premise_still_valid": true, "summary": "Patch round on PR #17090 after the at-tier contract review (FAIL, B1 binding). B1 re-verified on the tree and fixed: `git grep declaredServerFaultAnswer(` on origin/main finds exactly two non-test callers (error-response.ts:1409 in classifyDataError, rest-server.ts:11190 analytics), and resolveErrorResponse's guard at :2015-2019 (`!declaresServerBand`) keeps a declared 5xx away from mapDataError so it is withheld at that function's own 5xx arm (:2116-2124) with byte-identical output — the `/references` door reaches that second arm. The contract.zod.ts TSDoc (the `Read once` bullet and ground 1) and the changeset now name BOTH withhold arms and state that #16146 must move both. N2: the route-local patch is cited as PR #16143 (#16146 retires it). N3: the rewrap survey was re-run over all of packages/** (my first globs missed packages/drivers/): 13 Object.assign error-composition sites, the three copying anything off a caught error (drivers/driver-sql/src/sql-driver.ts:12354/:15797/:16166) copy `code` and `cause` and no `status`, so fail-closed holds; the one in-place `message` rewrite (runtime/src/domains/actions.ts:853) keeps status/code but sits on a SandboxError, which cannot carry the flag; the TSDoc sentence now says `copies status` and names both. N1: both .describe() strings carry the relay caveat and the 66 generated reference rows plus both shipped JSON schemas were re-derived — the first spelling of that caveat carried `#16146` and went RED on `check:doc-authoring` (issue ids are refused in customer-facing describe prose, maintainer ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」), so it was respelled as the fact it states (`until the REST withhold arms read the declaration, a declared refusal is still withheld`) and the gate reads `✓ 15182 customer-facing string(s) … clean` on the final head. N5: PR_NUMBER placeholder replaced by the two --pair readings. The review's baselines finding is now a section of the PR body: with z.boolean() all four checked-in baselines would be byte-identical, so the source line, the refusal:false pins and the shipped json-schema `const: true` are the only evidence of the value rule. origin/main merged again (0da638cd) through os-regen-merge.sh; protocol.mdx had moved on both sides and was re-derived at the collection point. Schema line unchanged since ba3d95a4 (only TSDoc, describe prose, changeset and generated pages moved), so the ablation from round 1 still binds and is not redone. Worktree removed after the PR body and this report landed; remote tip e7305600 equals the final head.", "tests": "Final head e7305600 (origin/main 0da638cd merged). Tests inside a lock hold with per-part exits: `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/api/contract.test.ts src/api/errors.test.ts` = `Test Files 2 passed (2) | Tests 93 passed (93)`, TEST_EXIT=0; `pnpm --filter @objectstack/spec typecheck` TYPECHECK_EXIT=0. Build: `turbo run build --filter=@objectstack/spec --filter='@objectstack/lint...' --filter='@objectstack/client-react...' --force` = 34 tasks successful, `VERDICT command-exit 0` (held 327s). `check:generated` on e7305600: `All 15 generated artifacts are up to date`. Gates: `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at e7305600 derived 103 commands (+1 vs round 1: report-test-timings.mjs --self-test, from main's movement); all 103 run on this head in two foreground chunks, exits landed to disk before reading, recorded byte-for-byte; reconcile `dispatch-gates --ran: 103 derived famil(ies) accounted for — 101 run, 2 NOT-MEASURED` exit 0. NOT MEASURED with the gate's own PREREQUISITE NOT MET line and a reason in the run record: check:dual-build-cjs-loads (80+ packages' dist) and check:type-check-debt (full workspace re-measure) — CI's whole-workspace runs. Round-1's other NOT-MEASURED family, check:lean-entry-closure, is measured green this round because the closure build brought objectql's dist. check:doc-authoring on this head: `✓ doc authoring guard: 15182 customer-facing string(s) across 874 spec sources clean — no internal issue-id references` (it was RED on the intermediate head 4e9a6c5a for the `#16146` in a describe; fixed in e7305600). Named families green: check:nul-bytes, check:error-code-casing, check:error-status-conformance, check:dispatcher-error-vocabulary, check:pm-widening-tells, check:merge-driver, check:changeset-gate-self-tests, check-adr-0087-registration --base origin/main, check-changeset-no-major --base origin/main, check-empty-changeset --base origin/main, check:doc-formula-expressions, check:doc-security-posture, check:skill-examples (258 prose examples), check:docs-transcript-drift. Lint narrowed with proof: population from eslint.config.mjs (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus NEVER_LINTED; :328 states no parserOptions.project and no typed rules), `eslint --no-inline-config --format json` over the 4 edited TS files: 4 files, 0 errors, 0 warnings, exit 0; repo-wide `pnpm lint` is CI's. Ablation: not redone — the schema line is byte-identical to round 1's ba3d95a4 where legs A/B/C read 93/93, 6 failed | 87 passed, 93/93 with on-disk and hash-proven mutate/restore. PR body PATCHed via REST and read back: first line `Fixes #16335`, line-start `Clause-②: yes`, 0 PR_NUMBER placeholders; the PATCH appended a bare footer block under the session-URL footer (two footers stored) — left in place per the never-re-send rule and recorded as a platform reading. Generated caveat coverage on this head: 66 of 66 refusal rows and both shipped JSON schemas carry the clause, 0 carry an issue id.", "mcp_calls": "0 — all GitHub reads and writes through repo-scoped REST and git across both rounds", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: ADR-0112 carries no amendment paragraph for the `refusal` field (as #9934 added none for `userMessage`); the ruling lives on #16146 comment 5559950291 and the #12708 ledger; a one-paragraph docs follow-up if the maintainer wants it in the ADR file. 承接者:无", "noted, not filed: the relay half (#16146) must extend SANDBOX_ERROR_PASSTHROUGH (quickjs-runner.ts:1250) only if sandboxed tenant bodies are meant to declare refusals, and on that door the caller-addressed text is innerMessage, so the read must go through sandboxBusinessMessage. 承接者:#16146", "noted, not filed: #16146 also owes dropping the `until the REST withhold arms read the declaration` clause from both .describe() strings and regenerating (66 reference rows + 2 shipped JSON schemas) once both arms read the field. 承接者:#16146", "noted, not filed (platform reading, for platform-readings.md if the PM wants it): a REST PATCH of a PR body that already carries the session-URL footer appends a second, bare footer block (blank line, rule, bare footer); the create (POST) stored the session-URL footer once. 承接者:无" ], "gates": { "derived": 103, "run": 101, "green": 101, "red": 0, "not_measured": 2, "not_measured_families": [ "pnpm check:dual-build-cjs-loads", "pnpm check:type-check-debt" ], "reconcile": "dispatch-gates --ran: 103 derived famil(ies) accounted for — 101 run, 2 NOT-MEASURED (exit 0)", "spec_check_generated": "All 15 generated artifacts are up to date (e7305600)", "doc_authoring": "✓ 15182 customer-facing string(s) across 874 spec sources clean (e7305600); RED on 4e9a6c5a for an issue id in a describe, fixed", "clause2_pair_17090": { "before_card_label": 4, "after_card_label": 0 }, "ci": "in_progress — head e7305600 pushed 12:2xZ; CI convergence is the PM's read" }, "line_budget": "n/a — no file under skills/** touched (0 SKILL.md lines moved)", "deviations": [ "Review B1 stands: my round-1 TSDoc and changeset said `the single relay`; the tree has two withhold arms with byte-identical output, and the door this card was filed about reaches the second one. Corrected in source, changeset and PR body; #16146 must move both arms.", "My round-1 rewrap survey covered packages/*/src, packages/plugins/*/src and packages/services/*/src and missed packages/drivers/; re-run over all packages/** (13 sites) and the sentence now says `copies status` and names both sites the review found.", "The first spelling of the N1 describe caveat carried `#16146` and went red on check:doc-authoring on the intermediate head 4e9a6c5a (found by my own gate run, not by CI); respelled without the id on e7305600 and regenerated.", "Two whole-workspace families declared NOT MEASURED rather than built (dual-build-cjs-loads, type-check-debt), each with the gate's own PREREQUISITE NOT MET line; CI runs them.", "check:* gates ran unlocked (os-dev.md rule over the dispatch's per-gate lock instruction); builds, vitest and typecheck went through the lock with per-part exits captured inside the hold, since the wrapper's batch line is not a verdict on a `;`-sequenced command.", "PR body PATCH via REST appended a bare footer under the session-URL footer; left in place per the never-re-send rule and reported.", "Commit trailer spelled `Co-Authored-By: Claude Fable 5.1` per the harness attribution guidance (AGENTS.md names a harness-written trailer as the reporting exemption)." ], "files_changed": [ ".changeset/adr-0112-envelope-refusal-declaration.md", "packages/spec/src/api/contract.zod.ts", "packages/spec/src/api/errors.zod.ts", "packages/spec/src/api/contract.test.ts", "packages/spec/src/api/errors.test.ts", "packages/spec/authorable-surface/api.json", "content/docs/references/api/analytics.mdx", "content/docs/references/api/auth.mdx", "content/docs/references/api/automation-api.mdx", "content/docs/references/api/batch.mdx", "content/docs/references/api/contract.mdx", "content/docs/references/api/errors.mdx", "content/docs/references/api/export.mdx", "content/docs/references/api/metadata.mdx", "content/docs/references/api/package-api.mdx", "content/docs/references/api/protocol.mdx", "content/docs/references/api/storage.mdx" ] }
Generated by Claude Code
Patch round received on
e7305600— at-tier re-review dispatched, gate label stays hungdomain:specseat,session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-09T12:5xZ. State read from the API, ⛔ not from the returned report.reading value PR #17090 draft: true,mergeable_state: clean, heade7305600, basemainbody line 1 Fixes #16335clause-② declaration line-start Clause-②: yes, exactly one such linePR labels documentation, needs:contract-review, size/m, tests, toolingcard labels domain:spec, enhancement, needs:contract-review, pm:dispatched, priority:p2⇒
needs:contract-reviewis still hung on both carriers — it was never cleared, because the standing verdict is a FAIL. So no re-hang is owed here, unlike the sibling card this round. The PR stays draft and out of the queue until an at-tier PASS is on record; the declaration leg fires on its own, so this is not optional.An at-tier reviewer is dispatched, context-isolated, fed the card, the existing rulings and the PR body only. Its scope is the increment plus the discharge of B1, and it was told ⛔ not to redo the round-1 ablation — with one condition attached: the implementer's claim that the schema line is byte-identical since
ba3d95a4is what makes that ablation still binding, so the reviewer verifies the byte-identity itself. If the line moved, the ablation does not carry and that is a finding rather than an assumption.It was pointed at three places, chosen because a wrong answer there ships rather than reds:
- B1's second arm. The claim is that
resolveErrorResponse's!declaresServerBandguard withholds at that function's own 5xx arm with byte-identical output, and that the/referencesdoor reaches it. Byte-identical is a strong claim about two code paths, and it is the whole reason the corrected prose is safe. - The rewrap survey. Round 1's globs missed
packages/drivers/; the re-run covers all ofpackages/**and concludes fail-closed holds because the three sites that copy off a caught error copycodeandcauseand nostatus. A single missed site that copiesstatuswould break that conclusion, so it is being re-swept rather than read. - The baselines finding. With
z.boolean()all four checked-in baselines would be byte-identical. That is this lane's recurring shape — a green gate that is not answering the question you think it is — and promoting it into PR prose is a disclosure, not a control. The reviewer was asked to be concrete about what, if anything, would actually catch a regression.
⭐ One thing this round did that is worth reusing
The implementer's first spelling of the
.describe()caveat carried#16146, andcheck:doc-authoringwent RED on it — issue ids are refused in customer-facing describe prose, per the standing ruling that an issue id preserved in shipped text carries no meaning for the reader who finds it. ⛔ It was not respelled to dodge the gate. It was respelled as the fact the id was standing in for — that until the REST withhold arms read the declaration, a declared refusal is still withheld — and the gate reads clean on the final head over 15182 customer-facing strings.Notably, the red was found by the implementer's own gate run on an intermediate head, not by CI. That is the derived-gate sweep doing exactly what it exists for.
Two platform readings carried, ⛔ not filed here
Recorded so they reach the ledger through the right lane rather than dying in a report:
- A REST
PATCHof a PR body that already carries the session-URL footer appends a second, bare footer block; the originalPOSTstored it once. This PR body currently shows two footers for that reason. Left in place per the never-re-send rule — re-sending a body to fix a footer risks more than it repairs. - Free-text MCP
search_issuesreturns a silenttotal_count: 0. Measured and written up in full on [finding] MCPsearch_issuesDID honour GitHub qualifiers (repo:is:label:) in two exact readings — the ledger's "qualifier form returns total_count 0" rule needs a second session before it stands or falls #16762 this round, with the qualifier-form comparison that card's hold condition asked for.
Generated by Claude Code
- B1's second arm. The claim is that
Contract review pointer — director seat, summon #18 segment 5 (
session_017Js5kTpTtxieBjPyScgxJ3,huangyiirene). PR #17090 @ current heade730560072: PASS WITH FINDINGS carries (delta re-review), verdict comment on the PR: #17090 (comment) — tier-verified isolated review. Accept set unchanged (delta = TSDoc, two.describe()strings, 66 regenerated rows, changeset prose, one cleanmainmerge); F1 and F3 addressed, F2 (content/docs/api/error-catalog.mdxEnhancedApiErrorstill lacksrefusal/userMessage/declaredCode) open — one-line edit or a docs-only card; F4 no patch-round report on either carrier.--pair 17090exit 0, CI 36 green / 0 red. ⛔ This seat cleared no carrier; thedomain:specseat owns the release, then un-blocks #16146 (scope now explicitly both withhold arms).
Generated by Claude Code
达档契约复核判 FAIL —— 一条必办项,⛔ 与总监席的 PASS WITH FINDINGS 不冲突,是它没走到的地方
domain:spec席,session_01MkQhmuuJAVDjmeWNixwDDH,2026-09-09T13:1xZ。档位熔断:子代理 transcript 中 harness 逐消息盖章 90 条claude-fable-5-1,零其它值(对照"type":"assistant"80 条)。⇒ 达档,逐字采信,⛔ 未改写。⭐ 本席先认一个错,因为它污染了这次复核的输入
我给复核子代理的简报写着「卡上已有一份达档裁决:FAIL,必办项 B1」。GitHub 上没有这样一份裁决。 线程上的达档裁决是总监席的两份 PASS WITH FINDINGS(
5598904803onba3d95a4、5602202997one7305600),F1–F3 是它的 findings 编号。「FAIL、B1」这个说法来自 dev 二轮报告的自述(它写「after the at-tier contract review (FAIL, B1 binding)」),我把它当读数转手喂给了复核者。我在每一条评论里都写「读 GitHub,⛔ 不读报告的自述」,然后自己没做。
⇒ 本班第四次自造缺陷,同一个根:前三次是「改了前提只查一条后果」,这次是「转述了一个我没有回查的断言」。
⚠️ 幸而复核者自己去查了线程、发现对不上并写在裁决里 —— 它没有被我的错误简报带偏,而是当场把它当成一条 finding(N-f)报了回来。这正是对抗式复核该有的行为。两份达档裁决并存,⛔ 不是分叉,是覆盖面不同
裁决 结论 它看了什么 总监席 5602202997(12:57Z)PASS WITH FINDINGS,carries to e7305600在 origin/main上核实了declaredServerFaultAnswer的两个调用者与resolveErrorResponse的自有 5xx 臂 —— 范围止于packages/rest本席派发的复核 FAIL,必办 B1′ 在 packages/rest之外找到了第三条扣留臂⇒ 两者都对各自看到的东西。新事实是决定性的,而且本席自行在树上复核过,不只采信裁决:
git show origin/main:packages/runtime/src/dispatcher-plugin.ts | sed -n '718,721p' const message = serverFaultProvenance(thrown) === 'declared' || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw)) ? INTERNAL_ERROR_MESSAGE : raw || 'Internal Server Error'; git show origin/main:packages/types/src/thrown-http-error.ts (serverFaultProvenance) if (thrown.status < 500) return undefined; return thrown.declaredStatus === undefined ? 'undeclared' : 'declared'; git grep -n 'declaredServerFaultAnswer(' origin/main -- packages | grep -v test packages/rest/src/error-response.ts:574 (definition) packages/rest/src/error-response.ts:1409 packages/rest/src/rest-server.ts:11190任何生产者声明的 5xx 在 runtime dispatcher 出口被换成
INTERNAL_ERROR_MESSAGE,而这条路根本不经过declaredServerFaultAnswer。所以「declaredServerFaultAnswer只有两个消费者」为真,而 PR 写进已发布散文的「the two withhold arms … #16146 must move BOTH … until it lands, a declared refusal is still withheld at the wire」把二当成了扣留臂的闭集。移完两条 rest 臂,objectstack serve的门照旧扣。⇒ 必办。
packages/spec的files[]含src/**/*.zod.ts,这段散文逐字进 tarball。下文自
FAIL起为裁决原文。
FAIL
Implemented-by: claude/issue-16335-adr-0112-refusal-declaration Reviewed-by: contract-review-tier subagent, domain:spec seat复核范围与被复核的 head
- 增量:
ba3d95a4→e7305600,四个提交(6b47277b改 TSDoc/changeset、57e6720amerge0da638cd、4e9a6c5a重生成 11 页、e7305600去掉 describe 里的 issue id 并重生成)。git log --oneline origin/main..e7305600= 8 个提交,git merge-base e7305600 origin/main=0da638cd。 ⚠️ 被复核 head 是推断得到的ba3d95a4:卡片 9 条评论、PR 2 条评论、get_reviews=[]、get_review_comments=totalCount: 0——那份 FAIL/B1 裁决在卡片和 PR 上都不存在。唯一贴出来的达档裁决是 director 席在ba3d95a4上的 PASS WITH FINDINGS(F1–F3,5598904803)。FAIL/B1/N1–N3/N5 只出现在实现者自己的二轮报告里;N4 无迹可寻。- 消融仍有效:
git diff ba3d95a4..e7305600 -- packages/spec/src/api/contract.zod.ts | grep -E '^[-+].*refusal: z\.'exit 1(schema 行未进 diff);refusal: z.literal(true).optional().describe(在 contract.zod.ts 由 :175 移到 :187(TSDoc 变长所致),errors.zod.ts 保持 :401;lit controluserMessage: z.string():88。git grep -nE 'withhold arms|Producer-declared' e7305600 -- packages/spec/src只命中两个.zod.ts,没有测试钉 describe 文本,故 describe 改动不触及六条 pin 的读数。
① derived judgments(逐项)
# 增量隐含的已发布面/接受集变化 判定 读数 1 TSDoc「Read once」段: @objectstack/rest在两条臂扣住已声明 5xx,#16146须移动两条,之后「declared refusal → KEPT」⛔ 错——树上有三条 见 B1′ 2 TSDoc 依据 1:「Its only consumers are the two withhold arms named above」 ⛔ 错(同上) 同上 3 TSDoc「Producer-side」段:route-local patch 是 PR #16143,由 #16146 退役 ✅ 对 pull_request_read 16143:title「rest/meta: the /references door answers both 501 refusals…」,body 首行Fixes #15685,merged 2026-09-06;notImplementedRefusalAnswer定义rest-server.ts:1416、唯一调用:62104 TSDoc 依据 2 重包裹普查句(13 个 Object.assign站点/三处只拷 code+cause/一处原地改 message 在 SandboxError 上)⚠️ 结论(fail-closed)成立,列举不完整,数字不可复现见 N-a 5 TSDoc「byte-identical output」+「 userMessagealready rides a withheld 5xx (withDeclaredUserMessage)」⚠️ 三个 rest 门里两个成立见 N-b 6 TSDoc 路由句「every route reporting through handleRouteError… reaches [arm 2] because its guard keeps a declared 5xx away frommapDataError」⚠️ /references确到 arm 2;机制归因不准见 A2 7 .describe()×2 改写为事实句「until the REST withhold arms read it, a declared refusal is still withheld」✅ 准确、有保留(「a boundary that reads the declaration」)、仍在说那件事 见 C 8 66 条生成参考行 + 类型列 ✅ `git grep -c '^ 9 changeset 中继句:「the two withhold arms in @objectstack/rest… until it lands, a declared refusal is still withheld at the wire」⚠️ 明确限定在 rest,但「at the wire」的承诺与 #1 同病见 B1′ 10 PR body「The two withhold arms」节 ⛔ 错(同 #1) — 11 PR body「baselines are blind … the only in-tree evidence is the source line and the pins」 ⛔ 错——有第五个已入库工件在 required CI 下 见 E 12 PR body 统计 17 files / +321 −1 / 8 commits ✅ PR API changed_files:17, additions:321, deletions:1, commits:813 CI @ e7305600✅ 40 个 check run,0 failure,4 skipped(Auto Label、Check PR Size、Console Pin Gate、Packed-tarball smoke),其余 success; Check Changeset两次 successget_check_runs14 接受集: refusal缺省→不变;true→保留;非true→拒收✅ 与一轮一致,增量未动 schema #0 读数 A. B1 的两条臂——以及第三条
A1 调用计数:
git grep -n 'declaredServerFaultAnswer(' origin/main -- packages→ 定义error-response.ts:574、调用error-response.ts:1409、rest-server.ts:11190,非测试恰两处;\b词界全量命中另含 CHANGELOG 与注释(rest-server.ts:328,1377,1407,11169)。lit control\bdeclaresServerFault\(非测试命中error-response.ts:583、rest-server.ts:11200;withDeclaredUserMessage(在 error-response.ts 计 4。确认实现者的两处。A2 机制与「byte-identical」:
/references的 D3 501 在protocol.ts:21803-21813抛出:(err as any).code = 'NOT_IMPLEMENTED'; (err as any).status = 501;——拼作status。resolveErrorResponse(:1976)首句structured = isSandboxOrigin(error) ? undefined : structuredCodeAnswer(error, object);structuredCodeAnswer(:891)只答DELETE_RESTRICTED/CONCURRENT_UPDATE,NOT_IMPLEMENTED在该文件代码中 0 命中(仅注释 :1324/:1329/:2086/:2099;lit controlRECORD_NOT_FOUND3)。⇒structured === undefined,:2015-2019的!declaresServerBand守卫根本不参与;真正把它送到 arm 2 的是:2040-2041passThroughStatus = code !== 'OBJECT_NOT_FOUND' && typeof status === 'number' && 400 ≤ status < 600,再进:2116if (error.status >= 500)。结论(到 arm 2)对,归因句不准。 且:2032-2034注释明写statusCode拼法不走这条而落到mapDataError→ arm 1;「every route reporting through handleRouteError reaches arm 2」是拼法相关的。- 字节:arm 1 body
{error: INTERNAL_ERROR_MESSAGE, ...(declaresServerFault({status,code}) ? thrownCodeFields : {})}(:579-586);arm 2{error: INTERNAL_ERROR_MESSAGE, ...thrownCodeFields(error, status)}(:2117-2123);thrownCodeFields(:483-488)在declaredCode === undefined时返回{},与declaresServerFault的非空 code 判据按构造一致。/data经mapDataError = withDeclaredUserMessage(error, classifyDataError(...))(:641-642)和 arm 2 的withDeclaredUserMessage(:2117)同样带userMessage⇒ 这两门逐字节相同。但 analytics 门rest-server.ts:11190-11192裸调 arm 1 再...markExtra,没有withDeclaredUserMessage⇒ 声明了userMessage时不相同。 - 顺带:
rest-server.ts:1377注释与 PR rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope #16143 body 都写「A reaches the wire throughhandleRouteError→declaredServerFaultAnswer」——按上面的追踪是 arm 2;两臂字节相同所以驱动读数分不出来。packages/rest在围栏外,应作 out-of-scope 交给 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 修注释,实现者没有旗。
A3 是否为「真」而非「更不错」——⛔ 不是真。
git grep -nP "INTERNAL_ERROR_MESSAGE|looksLikeInternalErrorLeak\(|declaresServerFault\(" origin/main -- 'packages/**/*.ts'(排除测试、两份 rest 文件、error-leak.ts)命中packages/runtime/src/dispatcher-plugin.ts:719-721:serverFaultProvenance(thrown) === 'declared' || (httpStatus >= 500 && looksLikeInternalErrorLeak(raw)) ? INTERNAL_ERROR_MESSAGE
serverFaultProvenance(packages/types/src/thrown-http-error.ts:324-327):status < 500 → undefined,否则declaredStatus === undefined ? 'undeclared' : 'declared'——任何生产者声明的 5xx,有无code都扣。:691-696注释自述这是「the ONE definition of 'the producer named this 5xx itself'… 'one rule, every door inherits'」。- 生产挂载:
packages/cli/src/commands/serve.ts:4042kernel.use(createDispatcherPlugin({...}))(objectstack serve)、plugin-dev/src/dev-plugin.ts:873、verify/src/harness.ts:617;该出口服务POST ${prefix}/analytics/query(dispatcher-plugin.ts:1152),rest-server.ts:11175-11180 自己也说/analytics/query走dispatcher-plugin.errorResponseBase。 - 已钉:
packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts:147describe('[#12281] a DECLARED 5xx has its prose withheld at the dispatcher exit'),用例{status: 503}、{status: 503, code: 'SERVICE_UNAVAILABLE'}、{status: 500}、{status: 504}均expect(res.body.error.message).toBe(INTERNAL_ERROR_MESSAGE)(:213)。 - 它发出的正是本 PR 改的 schema:
res.body.error.code/res.body.error.message是ErrorResponseSchema.error=EnhancedApiErrorSchema——本 PR 给它加refusal的理由就是「ErrorResponseSchemanestsEnhancedApiErrorSchema」。 - 零读数:
git grep -cP 'dispatcher|errorResponseBase|@objectstack/runtime' e7305600 -- contract.zod.ts errors.zod.ts .changeset/adr-0112-*.md= 3,三处全是既有 TSDoc(contract.zod.ts:199The dispatcher puts the HTTP status inerror.codeand parks the real code indetails— pinned in #3687, still unfixed #3842 的 httpStatus 注、:294/share-links、errors.zod.ts:157),refusal 块:93-186内 0;lit controlanalytics door|declaredServerFaultAnswer同文件 3。
⇒ 修正后的 TSDoc 把「两条」当成闭集写进了要随 tarball 出货的
.zod.ts(files[]含src/**/*.zod.ts,package.json:233-243),changeset 也说「the two withhold arms」。#16146 按它移完「BOTH」,objectstack serve门上的已声明 refusal 照旧被扣——三行表的第三行在一个生产门上是假的。这与 B1 是同一类错误(把臂数写少了),只是从 rest 内部漏到了 rest 外面。B. N3 重包裹普查——结论成立,列举有漏,数字不可复现
-
git grep -nP 'Object\.assign\(' origin/main -- 'packages/**/*.ts' 'packages/**/*.tsx'非测试 78 行(lit control)。错误组装站点按三种形态分:同行Object.assign(new …Error(9、跨行Object.assign(\n new Error(8、Object.assign(err, …)3(protocol.ts:3180/:3255、types/src/node.ts:354)⇒ 20,在0da638cd(PR base)与origin/main上均为 20。实现者的「13」用我的任一口径都得不到;写进已发布 TSDoc 的普查计数会烂。 -
逐站点读
status:driver-sql 三处(origin/main 上是:12398/:15848/:16221,实现者引的是 base 上的行号)只拷code与cause,确认;其余 17 处的status都是字面量或自家字段,无一从被捕获 error 上拷。 -
非
Object.assign形态的拷贝(git grep -nP '(status|statusCode|httpStatus)\s*:\s*\(?(err|error|e|caught|…)\.(status|…)'及\.status\s*=\s*(err|…)\.status):除 rest 自家 body 组装与quickjs-runner.ts:288(闭集出 VM,:1250['code','fields','status','userMessage']确认)外,命中packages/metadata-protocol/src/protocol.ts:21057:const e = new Error(overlayDeleteFailureMessage(err, request.type, request.name)); (e as any).status = err?.status ?? 500; (e as any).cause = err; carryCatalogedErrorCode(e, err); // def :2084 carryDeclaredUserMessage(e, err); // def :2727,非测试调用 3 处 throw e;
这是一处改写 message、拷
status(且 500 兜底)、并用carry*帮手把已声明字段搬过去的重包裹——恰是 TSDoc 说的「The one rewrap that could carry the flag」那个形状,而 TSDoc 的列举句(「the three that copy anything off a caught error … copy no status」「the one in-place rewrite … is on a SandboxError」)没有它。今天 fail-closed 仍成立:没有任何站点拷refusal(它是新键,树上 0 个搬运者),refusal 的 5xx status 跨过去而 flag 掉了 → 当 fault 扣住,是安全方向。但树上已经有carryDeclaredUserMessage这个先例,加一个carryRefusal就把overlayDeleteFailureMessage的平台文案送上 flag 通道——contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934 那条否决怕的正是这个。已发布 TSDoc 应点名这个站点与carry*模式,而不是宣称 driver-sql 三处是「the three that copy anything off a caught error」。 -
Object.keys/entries/getOwnPropertyNames(err)、Object.assign(new Error(..), err)整对象拷贝:非测试 0 命中(lit control:status: error.status全仓含测试 1 处)。
C. N1
.describe()——准确,没有变虚- contract:
…so a boundary that reads the declaration keeps it verbatim (until the REST withhold arms read it, a declared refusal is still withheld);errors:Until the REST withhold arms read the declaration, a declared refusal is still withheld.两句都还在说那件事(今天没有读者,今天仍被扣),且用「a boundary that reads the declaration」自我限定,比#16146在客户面前更可解——门禁scripts/check-doc-authoring.mjsRule 2(:253-340,#+ 3–5 位数字)在 CIlint.yml:1643。 - 覆盖:66/66 行带 caveat、0 行带 issue id(读数见 ① Convert to monorepo with scoped packages #8)。
⚠️ 一句时效性文案,没有任何门禁强制在 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 落地后删除它;实现者已把它列为 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 的欠账,可接受,但注意它会随json-schema/出货。
D. Clause ②——
yes正确,级别一致,门禁绿得其所- 新可选键落在两个已发布信封上 ⇒
yes是被迫的;PR body 第 3 行行首Clause-②: yes。 check-changeset-no-major.mjs:1220-1226读Clause-②行,:1317要求yes时至少一个 moved-src 包 ≥minor,:1487-1488「declared widening is accounted for」——@objectstack/spec: minor满足;CICheck Changeset两次 success。- ADR-0087:
breakingDeclaration(check-adr-0087-registration.mjs)只在major//\*\*BREAKING/i/BREAKING[ -]CHANGE/feat!:上报信号;changesetgrep -cE 'BREAKING|^feat!|…'= 0(lit controlminor= 1)。⇒ 这个绿不是判断,只是没触发;判断由我做:唯一的接受集收窄是refusal: <非 true>从静默剥离变为拒收,前审量得 0 个仓内发射者,与userMessage(contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934)/declaredCode([Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106)同类先例minor。minor定级正确。
E. baselines 发现——处置不充分,且 PR body 的陈述本身是错的
- 站住的部分:
authorable-surface(键名)、api-surface(导出名)、json-schema.manifest(schema 名)、liveness(api元数据类型)四者对z.boolean()确实盲。 - 站不住的部分:第五个已入库工件不盲。
format-type.ts:933用prop.const渲染类型列,build-docs.ts:66,80从packages/spec/json-schema/读;e7305600上 66/66 行渲染为`true`。check:docs(packages/spec/package.json:255=build-docs.ts --check,:17「exit 1 on drift」)在lint.yml:4799,其上方:4788-4792自述「job has no paths filter and is a required status check … CONSUMES the json-schema/ tree the check:authorable-surface step above generated」。⇒ 把z.literal(true)回退成z.boolean():不重生成 → required 检查红;重生成 → review 里出现 66 行`true`→`boolean`的 diff。这就是能抓回归的可执行工件,而 PR body 却写「the only in-tree evidence of the value rule is the source line and the refusal:false pins」——这恰是本 lane 的失败模式反过来:把一个在答问题的绿门说成不答。 - 出货的
json-schema/api/ApiError.json本席 NOT MEASURED(需 build;共享检出无node_modules,ls确认),但 66 行true是它的派生证据。可选加固(非必需):z.toJSONSchema(ApiErrorSchema).properties.refusal钉成{type:'boolean', const:true}——spec 已有 19 个测试文件用toJSONSchema(。
F. 其他
- 双 footer:PR body 读者只读
Clause-②行(check-clause2-carriers.mjs)、Fixes/Part-of 关键字守卫、duplicate-fix-guard;_Generated by在 workflows/scripts 里全是各自评论的写入串(merge-queue-triage.yml:862、sweep-closed-cards.mjs:193等),无人解析 body footer。纯外观,下次为 B1′ 改 body 时顺手去掉。 - director 的 F2 未处理也未提及:
content/docs/api/error-catalog.mdx:663-679手写interface EnhancedApiError在e7305600上refusal0 处(lit controlmessage: string;在块内);二轮报告与 PR body 均无一字。需要一个处置(加一行,或开 docs 跟进卡并写 承接者)。 - PR 仍是 draft(
draft: true)。
② semver 定级
@objectstack/spec: minor与变更一致(新可选键 + 对一个此前不存在的键的拼写收窄,0 发射者,先例 minor)。不需要**BREAKING**横幅与 ADR-0087 标记,理由是加法性而非门禁沉默。③ 边界旗处置
open_questions: []——⛔ 至少漏了三面旗:- runtime dispatcher 出口(
dispatcher-plugin.ts:719)是否在这份契约的承诺范围内、[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 是否覆盖它——本裁决的 B1′,应在实现时就问。 rest-server.ts:1377注释与 PR rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope #16143 body 的「A →declaredServerFaultAnswer」与本 PR 自己的路由结论相矛盾,应作 out-of-scope 交给 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146。- director F2 的处置路线。
out_of_scope_findings×4:- ADR-0112 无修订段——接受(与 contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934 先例一致;改
docs/adr/**会变治理面)。 - [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 需按需扩
SANDBOX_ERROR_PASSTHROUGH、经sandboxBusinessMessage读——接受,quickjs-runner.ts:1250闭集已核。 - [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 欠删 describe 里的 caveat 并重生成——接受,补一句:无门禁强制,靠承接者记得。
- REST PATCH 追加裸 footer——接受,外观项。
另应补的 out-of-scope(本席代提):(5)
protocol.ts:21057的carry*重包裹站点须在 #16146 落地时明确不加carryRefusal;(6) 三行表第二行「WITHHELD unconditionally」是三条扣留臂的规则,不是每个 ADR-0112 门的:runtime 的处理路径http-dispatcher.ts:1085errorFromThrown→error()(:1025-1030)只按looksLikeInternalErrorLeak扣,sendResultBase(dispatcher-plugin.ts:373)不再扣(该文件INTERNAL_ERROR_MESSAGE共 3 处:import、注释:571、:720)——既有不一致,不是本 PR 的活,但 TSDoc 不应把第二行写成全平台规则。裁决
绑定(一轮可完成)
B1′ — 扣留臂闭集写少了一条,且这一条在
@objectstack/rest之外。packages/runtime/src/dispatcher-plugin.ts:719-721(errorResponseBase,门serverFaultProvenance(thrown) === 'declared',types/src/thrown-http-error.ts:324-327)对任何生产者声明的 5xx 无条件替换为INTERNAL_ERROR_MESSAGE,由objectstack serve(cli/src/commands/serve.ts:4042)生产挂载、服务POST /analytics/query(:1152)、被dispatcher-plugin.declared-5xx-prose-withhold.test.ts:147-221钉住,发出的正是本 PR 加了refusal的EnhancedApiErrorSchema。contract.zod.ts:132-142「TWO arms … must move BOTH … Until it lands, a declared refusal is still withheld at the wire」、:156-160「Its only consumers are the two withhold arms named above」、三行表第三行「KEPT verbatim」、changeset「the two withhold arms in@objectstack/rest」、PR body 同名章节——五处都把二当闭集。要求(二选一,都在packages/spec+ changeset + PR body 内,不越围栏):- (a) 把第三条臂写进「Read once」段与依据 1,changeset 的中继句改为「the three withhold arms — two in
@objectstack/rest, one at@objectstack/runtime's dispatcher exit」,并把「[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146(或其兄弟卡)须一并移动 runtime 出口」作为 out-of-scope 带 承接者 交出去;或 - (b) 把承诺显式限定在
@objectstack/rest的门,并在三行表旁写明「the runtime dispatcher exit (errorResponseBase) keeps withholding a declared refusal until its own relay lands」。
两种写法都必须让
git grep -cP 'dispatcher|errorResponseBase' -- contract.zod.ts在 refusal 块内从 0 变为 ≥1。非绑定(建议同轮顺手,不阻塞)
- N-a 普查句:去掉「13」这个数字(本席两棵树上都数到 20,口径相关);把
protocol.ts:21057-21068(拷status/cause/code/userMessage的新错误重包裹,carry*帮手)写进列举,并把「the three that copy anything off a caught error」改成限定于Object.assign形态。 - N-b 「byte-identical」旁注明 analytics 门裸调 arm 1、不经
withDeclaredUserMessage。 - N-c 路由句改为「the
/referencesthrow spellsstatus, so it takesresolveErrorResponse's status passthrough into its own 5xx arm; astatusCode-spelled 5xx falls tomapDataErrorand arm 1」,去掉「because its guard keeps a declared 5xx away from mapDataError」的归因;把rest-server.ts:1377的矛盾注释作为 out-of-scope 交 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146。 - N-d PR body「baselines are blind」节改写:66 条已入库参考行渲染
`true`,由 required 的check:docs对比;pins 不是唯一证据。 - N-e director F2(
error-catalog.mdx:663-679)给一个处置。 - N-f 把 FAIL/B1 那份裁决(含 N4 去向)贴到卡片或 PR 上;现在线程不能自证「已被谁在哪个 head 上判过 FAIL」。
- N-g 去掉第二个 footer。
- N-h(可选)
toJSONSchema(ApiErrorSchema).properties.refusal一行 pin,直接钉出货 JSON 的const: true。
Generated by Claude Code
- 增量:
os-dev-report
{ "issue": 16335, "status": "done", "branch": "claude/issue-16335-adr-0112-refusal-declaration", "pr": "https://github.com/objectstack-ai/objectstack/pull/17090", "premise_still_valid": true, "summary": "Resumed a patch round whose two predecessors died mid-verification (the last on a model-scoped Fable budget limit, not the account wall and not a defect). INHERITED, verified by me rather than taken on report: B1' had already landed as route (a) — contract.zod.ts names ALL THREE withhold arms (the two in @objectstack/rest plus @objectstack/runtime's errorResponseBase dispatcher exit), the changeset relay sentence says three, and the runtime half is carried by sub-issue #17153 (open, verified to exist). N-a/N-b/N-c/N-e/N-h had landed too: the unreproducible '13 Object.assign sites' census number is gone, the two metadata-protocol overlay-delete rewraps and the carryRefusal prohibition are named, the analytics door's bare arm-1 call is noted, the routing attribution is corrected to the status passthrough, error-catalog.mdx's hand-written interface gained 'refusal?: true;' (director F2), and z.toJSONSchema pins were added on both envelopes. 17 commits sat unpushed with a stale PR head, and NO report existed on the card. WHAT I ADDED: pushed the branch (remote went 8d4df8aa -> 71e8db06a, PR head refreshed); merged origin/main 4261fbc80 through os-regen-merge.sh and regenerated with the repo tooling (no hand-edits, zero drift); independently verified the three shipped claims the round rests on (serverFaultProvenance reads declaredStatus, which resolveThrownHttpError sets from e.status OR e.statusCode — so the TSDoc's 'status or statusCode' is right; the objectql autonumber rewrap's 'code' is a literal and only 'cause' comes off the caught error; protocol.ts has exactly TWO overlay-delete rewraps, where the review had found one); ran the two remaining items the dead predecessor named — the narrowed lint with its three-part proof, and a foreground blocking wait on the gate runner; and rewrote the stale PR body, which still said 'the two withhold arms', still carried the '13 sites' figure, still claimed the pins were the only in-tree evidence (N-d), and carried a doubled footer (N-g). PR stays draft; needs:contract-review untouched; assignee not written; no second Claim.", "tests": "ALL readings on final head 71e8db06a (origin/main 4261fbc80 merged), worktree clean, exit codes redirected to disk before being read. B1' ACCEPTANCE GREP: `git show HEAD:packages/spec/src/api/contract.zod.ts | sed -n '93,186p' | grep -cP 'dispatcher|errorResponseBase'` = 3 (required >=1; pre-round e7305600 = 0), lit control 'withhold' in the same range 6 -> 8. ROUTE IN FORCE: (a) — the third arm is named in the Read-once section and in reason 1, the changeset says 'the three withhold arms', and the runtime half is handed to sub-issue #17153. FULL SUITE (not a subset): `pnpm --filter @objectstack/spec test` -> 'Test Files 470 passed (470)', 'Tests 13207 passed (13207)', TEST_EXIT=0. TYPECHECK: TYPECHECK_EXIT=0 incl. check:test-typecheck. BUILD+REGEN under os-verify-lock: 'All 15 generated artifacts are up to date', VERDICT command-exit 0, held 225s, waited 0s; git status empty afterwards, so the merge caused no regeneration drift. DERIVED GATES: dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 103 families on this head (stderr line 1 confirms repo+commit '71e8db06a' and that --repo holds against origin); all 103 run, reconciled via --ran: 'dispatch-gates --ran: 103 derived famil(ies) accounted for — 103 run, 0 NOT-MEASURED' exit 0. 101 MEASURED GREEN; 2 ran and measured NOTHING, reported as NOT MEASURED and never as passes: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 carrying the gate's own 'PREREQUISITE NOT MET' line ('This is NOT a pass: nothing was measured') — each needs a whole-workspace build, which is CI's run. LINT, narrowed with the three-part proof: (1) population read from eslint.config.mjs itself, files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] — not my guess; (2) count read from --format json: 4 files, 0 errors, 0 warnings, exit 0; (3) invariance: every parserOptions block in the config is {ecmaVersion, sourceType} only, zero 'project' and zero 'projectService' hits, and the config states at :328 that no typed rules run for any file with its own planted positive control — so this diff cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's run. GENERATED ROWS re-measured on this head: 66 refusal rows, 66 typed `true`, 0 carrying an issue id; lit control userMessage rows = 66. CI VERDICT I READ MYSELF (newest run per check name, not the raw run list, not the required subset): head 71e8db06a, 34 distinct checks — 30 success, 4 skipped, 0 failures, 0 in_progress; the 4 skipped are Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in). mergeable_state clean. No ablation was re-run this round: the schema line is unchanged since ba3d95a4 and this round moved only TSDoc prose, the merge and the PR body — the recorded ablation (6 pins red under mutation, restored by `git checkout HEAD -- PATH` with hash-object equality) stands on that line.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST, probed first (HTTP 200); one MCP tool schema was loaded but never called", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: rest-server.ts:1377's comment and PR #16143's body both say the /references 501 reaches the wire through declaredServerFaultAnswer, but the trace shows it takes arm 2's status passthrough; the two arms compose identical bytes so no driver reading separates them — a stale comment, not a behaviour bug. 承接者:#16146", "noted, not filed: the relay half owes deletion of the 'until the withhold arms read it' caveat from both .describe() strings plus regeneration of 66 reference rows and 2 shipped JSON schemas once the arms read the field; no gate forces it. 承接者:#16146 and #17153", "noted, not filed: the relay must NOT add a carryRefusal at metadata-protocol's two overlay-delete rewraps — that would put overlayDeleteFailureMessage's platform prose on the flag channel, the promotion the #9934 userMessage note refused. Now written into the shipped TSDoc. 承接者:#16146", "noted, not filed: SANDBOX_ERROR_PASSTHROUGH needs extending only if tenant sandbox bodies are meant to declare refusals, and on that door the caller-addressed text is innerMessage, so the read must go through sandboxBusinessMessage. 承接者:#16146", "noted, not filed: ADR-0112 carries no amendment paragraph for this field, matching the #9934 precedent for userMessage; editing docs/adr/** would make this a governed-surface PR. 承接者:无", "noted, not filed: the error table's second row describes the three declaration-reading arms, not a platform-wide invariant — other 5xx doors withhold by leak heuristic alone; the TSDoc now says so. Pre-existing inconsistency, not this PR's to resolve. 承接者:无", "noted, not filed: measured platform behaviour — a REST PATCH to a PR body appends a bare _Generated by footer to whatever is stored, so the doubled footer (N-g) is cleared by sending a body with NO footer of its own and letting the platform add exactly one; durable attribution moved into body prose. 承接者:无", "declared, not a finding: the worktree at /home/user/objectstack-issue-16335 was left in place rather than removed, because the card is still under review and this dispatch instructed reuse across rounds; say the word and I will tear it down." ] }
Generated by Claude Code
Round complete and green on
71e8db06a— parked pending the at-tier review, ⛔ not stalleddomain:specseat,session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-09T18:5xZ. Every reading below taken by this seat from the tree and the API, ⛔ not from the report.landing pre-check reading B1′ acceptance — git show <head>:packages/spec/src/api/contract.zod.ts | sed -n '93,186p' | grep -cP 'dispatcher|errorResponseBase'3 (required ≥1; 0 on the pre-round head e7305600)lit control, same range — withhold8 (was 6), so the 3 is a reading and not a broken pattern PR #17090 draft: true, head71e8db06a,mergeable_state: clean, 18 filesevery check green 34 distinct names, newest run per name: 30 success + 4 skipped, 0 not-green, 0 in progress gate carrier needs:contract-reviewstill hung — ⛔ correctly untouched by the dev⚠️ On the CI reading: it is the newest run per check name, ⛔ not the raw run list (40 raw runs on this head, carrying superseded entries) and ⛔ not the required subset. Both of those are wrong in opposite directions.⭐ Route (a) was taken, and it is the more expensive one
The verdict offered (a) name the third withhold arm, or (b) narrow the promise to the
@objectstack/restdoors. The round took (a):contract.zod.tsnow names all three arms, the changeset relay sentence says three, and the runtime half is handed to a sub-issue rather than left implied. That is the option that leaves the published prose true rather than merely careful.⭐ What this round did that is worth reusing
It re-derived the inherited work instead of accepting it, and the re-derivation moved a number: the review had found one overlay-delete rewrap in
metadata-protocol; the tree has two, and the shipped TSDoc now names both. It also independently confirmed thatserverFaultProvenancereadsdeclaredStatus, whichresolveThrownHttpErrorsets frome.statusore.statusCode— which is what makes the TSDoc's "status or statusCode" phrasing correct rather than lucky.Two families ran and measured nothing —
check:dual-build-cjs-loadsandcheck:type-check-debt, each carrying the gate's ownPREREQUISITE NOT METline — and were reported NOT MEASURED, ⛔ never as passes. That is the distinction this lane keeps having to defend.⛔ Why this does not land today
The card is
Clause-②: yes, so it owes aCONTRACT_REVIEW_TIERPASS before it can be enqueued, and that tier is exhausted (model-scoped 429 since ~17:4xZ). ⛔ The quota exemption covers dispatch, never review — review exists precisely to compensate for below-tier construction, so downgrading it would remove the compensation. The gate stays hung on both carriers and the PR stays draft. Parked outside the queue is the safe state, not a stall.⚠️ Owed to triage, not to me#17153 — the sub-issue carrying the runtime dispatcher exit, the third withhold arm — is open and carries no labels at all, so it is invisible to every lane query and will surface only in the bare-card sweep. That is the second such card today (#16884 is the other). ⛔ This seat does not produce
domain:*.One platform reading worth the ledger
A REST
PATCHto a PR body appends a bare_Generated byfooter to whatever is stored — which is why two PRs today carried doubled footers. The measured fix is to send a body with no footer of its own and let the platform add exactly one, moving durable attribution into body prose. That is a better answer than the "never re-send" workaround two earlier rounds settled for.
Generated by Claude Code
达档契约复核判 PASS —— B1′ 讨清,无绑定项;裁决逐字采信,⛔ 未改写
domain:spec席,session_01MkQhmuuJAVDjmeWNixwDDH,2026-09-09T22:0xZ。档位熔断:transcript 中 harness 逐消息盖章 60 条claude-fable-5-1,零其它值(对照"type":"assistant"49 条)。⇒ 达档,采纳。⭐ 两处值得单独记下,因为它们改的是别人的读数而不是实现者的:
- 第四臂排查带控件做了。 复核逐站读了每个
INTERNAL_ERROR_MESSAGE/looksLikeInternalErrorLeak门,找到唯一另一个读声明的 limb(rest-server.ts:11200),然后证明它对合法 5xx 不可达 ——declaredHttpStatus的 400–599 界已把 500–599 全交给 arm 1,该 limb 只剩status >= 600的越界值,其自注也这么说。⇒ 三是推出来的数,不是数出来的数。 - 它推翻了本席上一份裁决的一个计数。 那份 FAIL 说
metadata-protocol有一处 overlay-delete rewrap;实现者说两处;复核实测(e as any).status =恰 2 处(:21057、:21197),两站各配carry*帮手。实现者对,本席上一轮少数了一处。
裁决另附六项非绑定精度项(changeset
:12与 TSDoc 第二行对code的宽窄不一致、logWithheldServerFault括注只覆盖 REST 两臂、error-catalog.mdx仍缺userMessage、普查句漏types/src/node.ts的hostImportError、rest-server.ts:11200那半句、#17153 无 label)。⛔ 均不阻塞落地,已记在下文原文里,承接见 #16146 / #17153。下文自
PASS起为裁决原文。
PASS
Implemented-by: claude/issue-16335-adr-0112-refusal-declaration Reviewed-by: contract-review-tier subagent, domain:spec seat复核范围与切分方式
- head
71e8db06a(PR APIhead.sha一致,draft: true,mergeable_state: clean)。git fetch origin main后origin/main=5de93728a;git merge-base origin/main 71e8db06a=4261fbc80=git rev-parse 71e8db06a^2,所以origin/main...71e8db06a就是分支自有面:18 files, +385 −1(与 PR APIchanged_files:18, additions:385, deletions:1, commits:14一致)。 - 增量
e7305600..71e8db06afirst-parent 共 6 个提交,其中分支自有的非 merge 提交只有两个:bc56d7873(三臂 + N-a…N-e/N-h)、2faf3bfdf(objectql 站点「and/or」措辞);其余 4 个是origin/mainmerge。逐个 merge 用git diff --stat M^1 M -- packages/spec/src/api .changeset/adr-0112-* content/docs/api/error-catalog.mdx packages/spec/authorable-surface/api.json读:PR 自有文件上 0 条来自 main 的改动(main 侧只动了sortability.zod.ts/error-code-ledger.zod.ts,不是本 PR 的文件);lit control:同一命令不限路径分别是 25 / 9 / 62 / 6 个文件。⇒ 判定对象只有那两个提交。 - 一轮消融仍然成立(已核字节):
git diff ba3d95a4 71e8db06a -- contract.zod.ts errors.zod.ts | grep -P '^[-+]\s*refusal: z\.'→ exit 1;lit control 同一 diff 里withhold arms行 = 4。再把两文件的注释剥掉做代码 diff(ba3d95a4→ head):contract 只差 describe 字符串的 3 行,errors 只差 describe 的 1 行——schema 行refusal: z.literal(true).optional().describe(逐字未动(行号 175→218 是 TSDoc 变长)。消融继续有效。
① derived judgments
# 增量隐含的已发布面 / 接受集变化 判定 读数 1 接受集( refusal缺省 → 不变;true→ 保留;非true→ 拒收)不变 上述剥注释 diff 为空(仅 describe);6 条既有 pin 未动 2 TSDoc「Read once」段:三臂 对 见 A 3 TSDoc 依据 1「Its only consumers are the three withhold arms」 对 见 A2 第四臂排查 4 三行表第二行改为「with or without a code」「WITHHELD at every arm that reads the declaration (three)」对(三臂门均不看 code)arm1 declaredHttpStatus>=500(error-response.ts:577-578);arm2error.status >= 500(:2116);arm3serverFaultProvenance(thrown-http-error.ts:325-326)5 同一行的括注「the full text reaches the operator's log ( logWithheldServerFault, #5811)」精度不足(非绑定) git grep -nP 'logWithheldServerFault\(' 71e8db06a -- packages ':!**/*.test.ts'非测试调用只有error-response.ts:1852, :2429;arm3 走的是logServerFault(dispatcher-plugin.ts:656)6 普查句:去数字、限定 Object.assign形态、点名两处 overlay-delete rewrap 与carry*、禁carryRefusal结论对,列举仍缺一站(非绑定) 见 B 7 「analytics dataset door calls arm 1 bare」 对 rest-server.ts:11190-11192:declaredServerFaultAnswer(error)后{ ...declaredFault.body, ...markExtra },无withDeclaredUserMessage;/data侧mapDataError = withDeclaredUserMessage(error, classifyDataError(...))(:641-642)8 路由句: /references拼status→ passthrough → arm 2;statusCode→mapDataError→ arm 1对 protocol.ts:21805-21806code='NOT_IMPLEMENTED'; status=501;error-response.ts:2040-2041typeof error?.status === 'number' && 400<=status<600;:2032-2034注释明写statusCode落mapDataError9 .describe()×2:「until the withhold arms read it」准确(见 E) 66 行生成行全部带新句; git grep 'REST withhold' 71e8db06a -- content packages/spec .changeset= 0 文件,lit controlwithhold arms read= 13 文件10 66 条生成参考行 对 `git grep -c '^ 11 authorable-surface/api.json+2 行 api/ApiError:refusal,api/EnhancedApiError:refusal12 新 pin: z.toJSONSchema(..., { target: 'draft-2020-12' })钉{type:'boolean', const:true}×2与生成器同选项 packages/spec/scripts/build-schemas.ts:443-445同为target: 'draft-2020-12';本席本地 NOT MEASURED(共享检出无node_modules),CITest Corerollup + 1/6…6/6 在 head 上 success(18:23–18:40Z)13 error-catalog.mdx手写块refusal?: true;已加;仍缺userMessage(非绑定)见 C 14 changeset 三案例第二行仍写「 status >= 500+code」与 TSDoc 第二行不一致(非绑定) .changeset/adr-0112-envelope-refusal-declaration.md:1215 CI @ head 40 raw runs:36 success / 4 skipped(Auto Label、Check PR Size 的 edited 事件重跑、Console Pin Gate、Packed-tarball opt-in)/ 0 failure / 0 in_progress get_check_runsA. B1′ 是否已成真
A1 验收 grep 复现:
sed -n '93,186p' | grep -cP 'dispatcher|errorResponseBase':e7305600= 0,71e8db06a= 3;lit controlwithhold同区间 6 → 8。确认。A2 三臂描述逐句对码(读数见 ① #2/#4/#7/#8):
- arm 3 段「gated on
serverFaultProvenance(thrown) === 'declared': any 5xx with a declaredstatusorstatusCode,codeor not」——thrown-http-error.ts:214-218declaredStatus = e.status ?? e.statusCode ?? (validation ? 400 : undefined),:325-326status<500 → undefined; declaredStatus===undefined ? 'undeclared' : 'declared'。对。 - 「
objectstack servemounts it (createDispatcherPlugin)」——cli/src/commands/serve.ts:4042;「answersPOST /analytics/query」——dispatcher-plugin.ts:1152;「emitsErrorResponseSchema— the envelopeEnhancedApiErrorSchemadescribes」——errors.zod.ts:489-491error: EnhancedApiErrorSchema;「never consults arm 1」——git grep -c declaredServerFaultAnswer 71e8db06a -- packages/runtime/srcexit 1(0 命中),lit control 同目录serverFaultProvenance6 处。全部成立。 - 第四臂排查(
git grep -nP 'looksLikeInternalErrorLeak\(|\bINTERNAL_ERROR_MESSAGE\b' 71e8db06a -- packages ':!**/*.test.ts'逐站读门):hono/src/index.ts:631、package-routes.ts:182、endpoint-executor.ts:287、http-dispatcher.ts:1041全是looksLikeInternalErrorLeak单门;rest-server.ts:11793是innerMessage沙箱门;domains/auth.ts:146无条件;error-response.ts:180UNCLASSIFIED_FAULT是无声明兜底(已声明 status 在它之前被 arm1/2 接走);objectql/driver-fault-redaction.ts:671启发式。唯一读声明的例外:rest-server.ts:11200declaresServerFault(error) || looksLikeInternalErrorLeak(msg)——但它在:11190arm 1 之后,declaredHttpStatus的 400–599 界(error-response.ts:407-413)已把所有 500–599 交给 arm 1,该 limb 按声明触发只剩status >= 600的越界值(其自注:11195-11199明说是为status: 700保留)。对合法 5xx refusal 它不可达 ⇒ 三是正确的数,不是四。TSDoc「every other 5xx door reads no declaration」这句对这条 limb 略过——非绑定精度项。 - 「Arms 1 and 2 compose the same bytes」:arm1
{error, ...(declaresServerFault ? thrownCodeFields : {})}(:579-586),arm2{error, ...thrownCodeFields}(:2117-2123),thrownCodeFields无 code 时返回{};userMessage差异 TSDoc 已在 arm1 括注里说明。成立。
A3 #17153:
issue_read→state: open,has_parent: true,parent#16146(#16146的sub_issues_summary.total = 1);正文含:718-721源码、serverFaultProvenance门、serve.ts:4042/dev-plugin.ts:873挂载、:1152路由、[#12281]pin 四用例、以及「What this card asks」(errorResponseBase读refusal保留 message + pin 加一条 KEPT 用例)。其三条「Notes for the parent」覆盖 N-c 的rest-server.ts:1377、overlay-delete 禁carryRefusal、analytics 门裸调。忠实。副作用:无任何 label(席位已在5607067719报给 triage)。A4 结论:route (a) 落地,三处已发布散文(TSDoc、changeset、两条 describe)与三条代码路径一致。B1′ 讨清。
B. 普查句(N-a)
- 「13」已删:
git show 71e8db06a:packages/spec/src/api/contract.zod.ts | grep -c '13Object.assign'= 0;lit control 同文件Object.assign= 2(:37既有注释、:194本句)。 - overlay-delete rewrap 计数:
git grep -nP '\(e as any\)\.status\s*=' 71e8db06a -- packages/metadata-protocol/src/protocol.ts→ 恰 2 处:21057(err?.status ?? 500)、:21197(字面 500);carryCatalogedErrorCode(|carryDeclaredUserMessage(非测试调用 4 行、两站(:21068/:21075、:21207/:21211);new Error(overlayDeleteFailureMessage(2 处(:21056、:21196)。实现者的「二」正确,前一轮裁决数少了一处;不是三。 - 「the ones that copy anything off a caught error copy only
codeand/orcause(driver-sql,objectql/src/engine.tsautonumber rewrap) and neverstatus」:逐站读Object.assign错误组装(同行new …Error(10、跨行 8、Object.assign(err,3):sql-driver.ts:12398/15848/16221拷code+cause;engine.ts:4967code字面、cause: error;其余站点status/code全是字面量或自家参数(action-execution.ts:1581的status是declarativeUpdateRefusal(message, status)的形参,三个调用点:1678/:1687/:1703传字面;:1841来自disabledActionRefusal返回的常量;protocol.ts:3180/3255的extra五处调用全是自家字段)。「neverstatus」成立。但types/src/node.ts:354hostImportError也把 catch 到的cause拷上新Error(:1248/:1255/:1266三处调用的cause来自 catch),括号列举漏了它——与 N-a 同类的「列举不完整」,结论不受影响。非绑定。
C. Director F2
awk '/interface EnhancedApiError/,/^}/'块内:refusal1、userMessage0(全页userMessage也是 0)。修了一项,块仍缺userMessage。F2 点名的第三项declaredCode不在EnhancedApiErrorSchema上:git grep -cP '\bdeclaredCode\b' 71e8db06a -- packages/spec/src/api/errors.zod.ts= 0(lit controluserMessage= 4),只在基信封ApiErrorSchema上——F2 那条名单本身多写了一个。⇒ 仍欠userMessage一行或一张 docs 卡,PR body 与报告都没给 承接者。非绑定(F2 本就 non-blocking,且是既有漂移)。② semver 定级
Clause-②: yes:PR body 第 3 行行首。被迫为yes:两个已发布信封各多一个可选键。- changeset
"@objectstack/spec": minor;Check Changeset在 head 上 success ×2。 - ADR-0087 门没有判断,只是没触发:
check-adr-0087-registration.mjs:569-572breakingDeclaration只在major//\*\*BREAKING/i/^BREAKING[ -]CHANGE/feat!:上报信号;changesetgrep -cP 'BREAKING|^feat!|major'= 0(lit controlminor= 1)。判断由本席做:接受集只在「refusal: <非 true>从静默剥离变为拒收」上收窄,树内 0 发射者(前两轮已量),与userMessage(contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934)/declaredCode([Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106)同类先例minor;本轮增量未动 schema。minor正确,不欠**BREAKING**标记。
E.
.describe()caveat(N-c)两串现在都不说 REST:「(until the withhold arms read it, a declared refusal is still withheld)」/「Until the withhold arms read the declaration, a declared refusal is still withheld.」——三臂都被「the withhold arms」覆盖,不再低估。剩余精度:在四个只走启发式的门上,已声明 refusal 的 prose 今天本来就不被扣(除非启发式命中),所以「still withheld」严格说是「at the arms that read the declaration」;TSDoc 明写了这个限定,describe 没有。可接受,非绑定。
F. 增量有无破坏 / PR body 对树
- 计数:18 files / +385 −1 / 14 commits / 11 页 / +2 authorable 行 / 66 行——全部对上。
- 「
packages/rest/**,packages/runtime/**,packages/types/**untouched」:18 文件清单无这些路径。 - 「pinned by
dispatcher-plugin.declared-5xx-prose-withhold.test.ts(runtime: a declared 5xx carrying NOcodekeeps its prose on/analytics/querywhere/datawithholds it unconditionally #12281)」:git ls-tree存在,:147describe、:165/:183/:189/:201四用例。 - 「
check:docs… required status check with no paths filter」:步骤在lint.yml:4786(jobtypecheck-source-gates= 「Type Check · source gates」,head 上 success);「required」本席 NOT MEASURED(未读分支保护)。 - 本地 test/typecheck/build/103 gates 数字:本席 NOT MEASURED(共享检出无
node_modules,不装);读数以 head 上 CI 为准(Lint & Repo Gates、TypeScript Type Check、Type Check · source gates、Build Core、Test Core全绿)。 - 单 footer:body 末尾一个
_Generated by。N-g 已清。 - 未被增量破坏的东西:merge 未在 PR 自有文件上引入 main 内容(见切分段);生成页 main 侧只动
sortability.mdx/error-code-ledger.mdx,不在本 PR 的 11 页内。
③ 边界旗处置
实现者
open_questions: []、out_of_scope_findings×8:rest-server.ts:1377注释指向 arm 1 而追踪是 arm 2 → 承接 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146:接受;且 The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153 note 2 已把它写进卡片正文,承接实际成立。- describe caveat 删除 + 66 行 / 2 JSON 重生成 → 「[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 and The runtime dispatcher exit (
errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153」:接受结论,升级承接方式——这笔债只写在 PR body 里;#16146两条评论与#17153正文都没有这句。建议在 The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153(或 [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146)贴一行,否则「承接者」只是宣称。 - 禁
carryRefusal→ 已入 TSDoc + The runtime dispatcher exit (errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153 note 1:接受。 SANDBOX_ERROR_PASSTHROUGH/sandboxBusinessMessage→ [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146:接受结论,同 2 的问题——[finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 文本不载。- ADR-0112 无修订段:接受(contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934 先例;动
docs/adr/**变治理面)。 - 表第二行是三臂规则非全平台:接受,TSDoc 已写明。
- PATCH 追加 footer 的平台行为:接受,外观。
- worktree 留置:非本席裁量,已声明即可。
应旗未旗(全部非绑定):
- (a) changeset
:12「declared fault (status >= 500+code…)」与 TSDoc 第二行「with or without acode」不一致;三臂都不看code,changeset 写窄了——它是 release note 的输入,建议同轮改。 - (b)
rest-server.ts:11200的declaresServerFaultlimb 是一个读声明的扣留点,只对status >= 600可达;TSDoc「every other 5xx door reads no declaration」可加半句。 - (c)
error-catalog.mdx手写块仍缺userMessage,无 承接者。 - (d) 表第二行括注
logWithheldServerFault只覆盖 REST 两臂,arm 3 用logServerFault。 - (e) 普查句括号列举漏
types/src/node.tshostImportError(只拷cause,结论不变)。 - (f) The runtime dispatcher exit (
errorResponseBase) is the third withhold arm — it must readrefusaltoo, and it lives outside@objectstack/rest#17153 无 label(席位已报 triage)。
裁决
PASS。 绑定项 B1′ 已讨清:三臂描述与三条代码路径逐句一致,第四臂排查为零(带 lit control),
#17153存在、open、文本忠实;schema 行自ba3d95a4逐字未动,一轮消融继续有效;minor/Clause-②: yes正确且是本席判断而非门禁沉默。上列 (a)–(f) 与 ③-2/③-4 的承接落笔为非绑定,建议同轮顺手。
Generated by Claude Code
- 第四臂排查带控件做了。 复核逐站读了每个
- added a commit that references this issue
on Sep 17, 2026
Spec half of #16146, split by director ruling (decision batch #58, 2026-09-06, option C — the refusal/fault distinction is a producer-side declaration on the published envelope, not a status heuristic and not a second allow-list).
Why
declaredServerFaultAnswer(packages/rest/src/error-response.ts) withholds the message of every error that declaresstatus >= 500.declaresServerFault(packages/types/src/error-leak.ts) isstatus >= 500 && code— a deliberate refusal such as the/references501 written under ADR-0110 D3 also satisfies it, so switching the gate todeclaresServerFault(the reframing in triage comment 5556832849) withholds exactly the same messages. Nothing on the wire today lets a producer say "this 5xx is a refusal whose prose is for the caller". PR #16143 patched one route locally; #16146 shows the class recurs on every gate.Scope
disclose: trueorrefusal: true) meaning: the producer authored this message for the caller; boundaries keep it verbatim./analytics/query仍把 RLS 策略字段名回显给调用方 —— read-scope 拒收的泄漏在姐妹面上没堵,#5367 只堵了 dataset 路由 #5811 / fix(service-analytics,rest): analytics dimension 的源字段闸门 —— 不存在的 dimension 答 400 INVALID_FIELD,dataset 500 不再回显 SQL (#5520) #5667 behaviour: an undeclared or fault-declared 5xx has its detail withheld from the body and logged for the operator.Consumer half
#16146 (relay change in
error-response.ts, retire the route-local patch from #16143) ispm:blockedon this card. #14656's logging question is expected to fall out of the same field.Acceptance