feat(spec): ADR-0112 error envelope gains a producer-side refusal declaration so a deliberate 5xx refusal keeps its caller-authored message (#16335) - #17090
Conversation
…eclaration `ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, `refusal: true` — the producer's declaration that the 5xx it named is a deliberate refusal whose `message` is authored for the caller, so the boundary keeps it verbatim (decision batch #58, option C). The TSDoc documents the three cases side by side (undeclared 5xx: heuristic; declared fault: withheld; declared refusal: kept) and reconciles the flag with the recorded reason `userMessage` is a text-carrying field rather than a boolean beside `message`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…efusal` `pnpm --filter @objectstack/spec check:generated` on 9870511 read 14 of 15 artifacts fresh and named `content/docs/references/**` stale; `gen:docs` is the one regeneration. `authorable-surface/api.json` gains the two key rows (`api/ApiError:refusal`, `api/EnhancedApiError:refusal`) the build's `gen:schema` wrote; `api-surface/` and `json-schema.manifest/` are zero-diff by construction (export and schema names only). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
os-regen-merge.sh step 2 took origin/main's side of the generated `content/docs/references/api/contract.mdx` (both sides had moved it — PR #16783 on main, the `refusal` row here); the pre-commit collection point then asked for `gen:schema && gen:docs` on the merged tree, which re-derives the page with both sides' content. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f7657288b75bfd8bc69d1c91a530095a8d7a53bf && git checkout f7657288b75bfd8bc69d1c91a530095a8d7a53bf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f801e7d7fe0722e942f6d07bbcea1e34ab3e9ed0 8d4df8aad5008a31a19c2c119ad592eecbb2bba8 && git checkout -B drift-repro f801e7d7fe0722e942f6d07bbcea1e34ab3e9ed0 && git merge --no-ff 8d4df8aad5008a31a19c2c119ad592eecbb2bba8
node scripts/docs-audit/affected-docs.mjs --json f801e7d7fe0722e942f6d07bbcea1e34ab3e9ed0
|
Contract review at
|
| Surface | Before | After | Direction |
|---|---|---|---|
ApiErrorSchema / EnhancedApiErrorSchema / makeApiErrorSchema(...) / ErrorResponseSchema.error — input without refusal |
accepted | accepted, byte-identical output | unchanged |
same — input refusal: true |
accepted, key stripped (non-strict z.object, no .strict()/.passthrough() in either file) |
accepted, key retained; declared type gains refusal?: true |
widened (published payload gains a key) |
same — input refusal: <non-true> |
accepted (stripped) | refused at ['refusal'] |
narrowed on a spelling no in-repo producer emits (grep packages/**, apps/**: 0 emitters; the only refusal: key is SweepRefusal | null on a CLI plan object, sys-secret-orphan-sweep.ts:186) — same class as userMessage (#9934) and declaredCode (#9106), both shipped minor |
packages/spec/api-surface/api.json |
ApiError (type)/ApiErrorSchema (const) :58-60, :344-346 |
identical (names only) | unchanged |
packages/spec/authorable-surface/api.json |
— | +api/ApiError:refusal :139, +api/EnhancedApiError:refusal :586 |
+2 rows; not in files[] |
json-schema/api/{ApiError,EnhancedApiError}.json (shipped via files[], gitignored .gitignore:63) |
— | refusal: {type: boolean, const: true} per PR body |
NOT MEASURED by the reviewer (needs a build); CI Build Core + Type Check · source gates (runs check:authorable-surface/check:docs, lint.yml:4622-4700) green on this sha |
registered error codes, conversions/registry.ts |
— | — | untouched |
Clause-②: yes at PR body line 3 (line-start); check-widening-tells --declaration no fires T1 at contract.zod.ts:175 and errors.zod.ts:401 (exit 4), so yes is mechanically forced; --declaration yes exit 0; check-clause2-carriers --pair 17090 exit 0. Changeset: @objectstack/spec: minor, private unset → published. Offline gates on --base origin/main --head refs/pr-review/17090: check-changeset-no-major ✓ (level axis N/A offline; CI Check Changeset ✓ carries the PR-payload cross-check), check-adr-0087-registration ✓ "no declared-breaking changeset" (breakingDeclaration at :569-572 signals only on major / **BREAKING / BREAKING CHANGE / ! — none present, so no marker owed), check-empty-changeset ✓. No **BREAKING** banner required.
3. Enforcement / pins (ADR-0078 / ADR-0049)
Reader: none on this head, by ruling — see F1. Schema-level pins: 6 in contract.test.ts (:90-144, :737-755 through makeApiErrorSchema) and 1 in errors.test.ts (:166-191); no .skip/.only/.todo; no test pins the docblock. Structural ablation read: deleting the refusal line turns error.refusal into undefined (unknown key stripped) and safeParse({refusal:false}).success into true, so 5 contract pins + the errors pin go red and only the "absent by default" pin stays green — exactly the dev's recorded 6 failed | 87 passed of 93. The reviewer's own run is NOT MEASURED: node_modules is not installed in the review checkout and it did not install into the shared tree; CI Test Core (1/6–6/6 + rollup) is green on this exact sha. The TSDoc's sweeping claim "no site under packages/** composes a rewrapped error from a caught error's fields" survived wider probes than the PR's Object.assign survey (new XError(<caught>.message, status|statusCode|httpStatus: <caught>.…, {...err} into a throw): no hit outside the sandbox door (quickjs-runner.ts:288, closed list at :1250 verified) and the relay's own body composition (error-response.ts:2118/2249). hostImportError (types/src/node.ts:343) and protocol.ts:3144 compose fresh errors from their own fields.
4. Docs
content/docs/references/api/*.mdx — 11 generated pages, each gains the one row; errors.mdx:153 nested-shape summary also moves. Docblocks: both schemas. content/docs/releases/**: 0 edits. docs/adr/0112 not amended (would make the PR governed; #9934 set the same precedent) — noted by the PR, acceptable. F2 for the hand-written catalog page.
5. CI on head
38 check-runs on ba3d95a4: 33 success, 5 skipped, 0 failure, 0 in_progress. Skipped: Packed-tarball smoke (opt-in) ×2, Auto Label, Check PR Size, Console Pin Gate — opt-in/path filters, each with a successful sibling where one exists. Required floor Lint & Repo Gates ✓, TypeScript Type Check ✓; also Check Changeset ✓, Spec property liveness ✓, Governed Surface Queue Guard ✓, Part-of PR must not also close its card ✓. Head is 11 commits behind origin/main (3045b6fa → a27544b4); none of those commits touch any of the 17 files.
6. PR body accuracy
Verified: 17 files, +306/−1, 4 commits, merge-base 854639b3; 6+1 pins; +2 authorable rows; 11 pages; SANDBOX_ERROR_PASSTHROUGH at quickjs-runner.ts:1250; declaredServerFaultAnswer :574-588, gate :577-578, docblock :565-572; Fixes #16335 correct (all acceptance items land here; no closing keyword adjacent to #16146/#14656). Stale: F3 placeholder only.
Discharged
| Card acceptance item | Status | Evidence |
|---|---|---|
| envelope schema declares the field with a stable TSDoc | ✅ | contract.zod.ts:93-180, errors.zod.ts:390-405; three-case table :105-109; #9934 reconciliation :141-165 |
| api-surface baseline regenerated | ✅ (vacuous, as the pre-reading predicted) | api-surface/api.json byte-identical by construction (names only); the moving baseline authorable-surface/api.json regenerated (+2 rows) |
| changeset present | ✅ | .changeset/adr-0112-envelope-refusal-declaration.md, @objectstack/spec: minor |
| scope: no allow-list / no second table; default stays #5811/#5667 | ✅ | no ledger edits; packages/rest/**, packages/types/** untouched |
dispatch constraint: reconcile with contract.zod.ts:77-83 or report a fork |
✅ | reconciled in TSDoc; fork named, not taken |
{"pr":17090,"head":"ba3d95a4f3514243131a698f12589c23d49e6fcd","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"33 success / 5 skipped (opt-in, path filters) / 0 failure / 0 in_progress on head; required Lint & Repo Gates + TypeScript Type Check green"}维护者速读
这条 PR 给 ADR-0112 错误信封加了一个可选键 refusal: true,让生产者声明「这个 5xx 是刻意拒绝,message 是写给调用方的」,从而在中继半(#16146)落地后不再被替换成 Internal server error;本 PR 只动 packages/spec,rest/types 一行未碰。已发布契约的移动是:两个信封 schema 与 makeApiErrorSchema 派生账本都多了一个键(true 保留、false 或非布尔拒收、缺省不变),authorable-surface 加两行,api-surface 按构造零差;Clause-②: yes 与 @objectstack/spec: minor 在三条 changeset 门禁和 CI Check Changeset 上都对得上,无需 BREAKING 横幅或 ADR-0087 标记。无阻塞项;三条非阻塞发现里最要紧的是 F1:这个键在本 head 上没有读者,.describe() 文案(生成到 11 个参考页和 json-schema)用现在时说「边界会原样保留」,而在 #16146 落地前线上仍会扣住——失效方向是 fail-closed,无安全退化,但发布节奏必须把两半放在同一个 RC 里。CI 在该 sha 上 33 绿 5 跳过 0 红,两个 required job 均绿;head 落后 main 11 个提交但无文件重叠。席位下一步:修 F3 的占位符、决定 F2 走随手一行还是 docs 跟进卡、按 PASS 清双载体标签并入队,合并即刻把 #16146 从 pm:blocked 解开并派发。
Generated by Claude Code
… tighten the survey sentence Contract review on PR #17090 (B1): `@objectstack/rest` withholds a declared 5xx at two arms with byte-identical output — `declaredServerFaultAnswer` (two callers: the `/data` classifier and the analytics door) and `resolveErrorResponse`'s own 5xx passthrough arm, which every route reporting through `handleRouteError` / `sendThrownError` reaches, the `/references` door among them. The TSDoc and the changeset named only the first; both now name both and state that #16146 must move both. Also: the route-local patch is PR #16143 (not #16146, which retires it); the rewrap survey now covers all of `packages/**` — 13 `Object.assign` sites, the three copying off a caught error (`driver-sql`) copy `code`/`cause` and no `status`, and the one in-place `message` rewrite (`domains/actions.ts`) is on a `SandboxError` that cannot carry the flag; both `.describe()` strings carry the relay-half caveat so the generated pages stop advertising a reader that does not exist until #16146 lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
…f caveat) os-regen-merge.sh step 2 took origin/main's side of the generated `content/docs/references/api/protocol.mdx` (both sides moved it); the pre-commit collection point asked for `gen:schema && gen:docs` on the merged tree. The same run re-derives every page that inlines the envelope with the `.describe()` relay-half clause added for the contract review's N1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…n issue id `check:doc-authoring` refuses an issue id in `.describe()` prose — it projects into customer-facing references and the shipped JSON schema, where `#NNNN` resolves to nothing. The caveat stays in both describes, spelled as the fact it states: until the REST withhold arms read the declaration, a declared refusal is still withheld. Generated pages and schemas re-derived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Contract review at
|
| Commit | Hunks (file:line on the new head) | Judgment |
|---|---|---|
6b47277b |
.changeset/adr-0112-envelope-refusal-declaration.md:17 — relay half now "the two withhold arms"; route-local patch attributed to PR #16143 |
prose only; grade unchanged |
contract.zod.ts:125-127 — patch = PR #16143, retired by #16146 |
matches the body the prior verdict read; #16143 itself NOT re-verified | |
contract.zod.ts:132-142 — "Read once, at the withhold arms": declaredServerFaultAnswer and resolveErrorResponse's own 5xx arm; "#16146 must move BOTH" |
verified on origin/main: declaredServerFaultAnswer error-response.ts:574, callers only classifyDataError :1409 and the analytics route rest-server.ts:11190; resolveErrorResponse :1976, guard :2015-2020 keeps a declared 5xx band away from mapDataError, 5xx arm :2116-2124; entered from sendThrownError :1850 and handleRouteError :2439. Beyond the prior findings and material to #16146's scope, not to this PR's accept set |
|
contract.zod.ts:157-160 — "only consumers are the two withhold arms" |
consistent with the above | |
contract.zod.ts:173-180 — survey: 13 Object.assign sites under packages/**; the 3 in driver-sql copy code/cause, no status; the one in-place message rewrite (domains/actions.ts) is on a SandboxError |
verified: 19 grep hits − 5 CHANGELOG − 1 comment = 13; sql-driver.ts sites copy only code/cause (head tree :12352/:15795/:16164); actions.ts:853 gated on innerMessage; SANDBOX_ERROR_PASSTHROUGH quickjs-runner.ts:1250 unchanged. Replaces the prior "no site … composes a rewrapped error" claim with the measured one — strictly more accurate |
|
contract.zod.ts:187-193, errors.zod.ts:401-406 — .describe() gains the relay caveat, first spelled with #16146 |
F1 route B; the #16146 spelling went red (below) |
|
57e6720a |
merge of origin/main 0da638cd (140 main-side files) |
see §merge |
4e9a6c5a |
11 content/docs/references/api/*.mdx regenerated (67/67) |
generated |
e7305600 |
contract.zod.ts:189-191, errors.zod.ts:404-405 — caveat re-spelled without an issue id ("until the REST withhold arms read it, a declared refusal is still withheld"); 11 pages regenerated |
fixes the delta's own CI red; final describe text is true on this head |
Published face vs the prior reading: the accept set did not move. contract.zod.ts with comments stripped is identical to ba3d95a4 except the describe string; errors.zod.ts likewise; contract.test.ts, errors.test.ts, authorable-surface/api.json byte-identical prior→r2. What moved on the published face is only text: the two .describe() strings (→ json-schema/api/{ApiError,EnhancedApiError}.json description, NOT MEASURED without a build; Build Core + Type Check · source gates green), the TSDoc (ships in src/**/*.zod.ts), 66 generated rows across 11 pages (0 stale spellings: git grep "REST withhold arms" hits all 11; relay half: #16146 / so boundaries keep it verbatim 0 hits), the changeset prose. The prior verdict's accept-set table, ledger reading (no liveness/api.json row owed) and api-surface zero-diff stand unchanged.
Merge (57e6720a): PR hunks byte-identical across it for 16/17 files (diff(B,6b47277b) vs diff(M2,merge) per file). protocol.mdx is the one exception: the merge kept the PR side whole (main's dashboards row change not carried — content/docs/references/** is regenerated whole under the os-regen driver), and 4e9a6c5a re-derived it. On the final head protocol.mdx differs from origin/main by the 3 +refusal rows only and its dashboards row equals main's; check:docs (lint.yml:4733, inside Type Check · source gates) green on the head.
Prior findings status
- F1 — addressed. Route B taken and exceeded: the caveat is in both describes (
contract.zod.ts:189-190,errors.zod.ts:404-405) and in all 66 generated rows; the TSDoc names both arms. The review's suggested spelling ("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") is refused bycheck:doc-authoring(issue ids in.describe()prose); the dev's id-less spelling is the correct one. Body 验收备注 hands [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 removal of the clause + regeneration. - F2 — not addressed.
content/docs/api/error-catalog.mdxbyte-identical toorigin/main;interface EnhancedApiError:664-679still lacksrefusal?: true(anduserMessage,declaredCode). No docs-only follow-up card exists (issues created after 08:36Z: none naming the page). The bot's other two pages (api/index.mdx:201,client-sdk.mdx) only name the symbol/route — no F2-class drift there. - F3 — addressed. Body edited 12:49:31Z:
--pair 17090present,PR_NUMBERgone.
Clause-② reading (re-derived; unchanged)
Clause-②: yes at body line 3. check-widening-tells --declaration no on origin/main...r2 fires T1 at contract.zod.ts:187 and errors.zod.ts:401 (exit 4; line moved from 175 by TSDoc growth), --declaration yes exit 0. check-clause2-carriers --pair 17090 exit 0 ("both carriers agree"). @objectstack/spec: minor still correct (text-only delta). Offline on --base origin/main --head refs/pm-review/17090-r2: check-adr-0087-registration 0, check-changeset-no-major 0, check-empty-changeset 0; CI Check Changeset success ×2.
Governed surface / protocol label
Non-governed: GOVERNED_SURFACES (check-governed-merges.mjs:793) = docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md; none in the 17 files (main's merge brought such commits into the branch history, but origin/main...r2 is the 17 files). Governed Surface Queue Guard green. No protocol:* label and none expected: .github/labeler.yml maps protocol:* only to packages/spec/src/{data,ui,system,ai}/**; src/api/** is unmapped. Labels on head: documentation, size/m, tests, tooling, needs:contract-review; card: enhancement, priority:p2, pm:dispatched, domain:spec, needs:contract-review — both carriers still carry the gate.
CI on head
40 check-runs on e7305600 at 12:51Z: 36 success, 4 skipped, 0 failure, 0 in progress. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the 12:49Z edited-event re-runs of Auto Label / Check PR Size (each has a successful push-event sibling). Required floor Lint & Repo Gates ✓ (12:49:52Z), TypeScript Type Check ✓; Test Core rollup + 6/6, Build Core, Type Check · source gates, Spec property liveness, Part-of PR must not also close its card ✓. Intermediate heads: 4e9a6c5a Lint & Repo Gates failed at step 53 "Doc/skill authoring guard" on the #16146 ids at contract.zod.ts:189 / errors.zod.ts:404 — caused by the delta's first spelling, fixed by e7305600 (step 53 ✓ on head). 6b47277b TypeScript Type Check failure is the aggregator refusing four cancelled lanes after the head moved 17 s later — not a delta defect.
New findings
F4 (non-blocking) — no patch-round report on either carrier: zero comments on #17090 or #16335 after the director's 08:36/08:38Z notes, and the seat's adoption is unposted. The body now claims re-runs on e73056007 (93/93, ablation at ba3d95a4 with the schema line unchanged since, check:generated 15/15, 103 derived gates) but the card carries none of it. CI on the head is the reading of record and is fully green, so this does not move the verdict; the seat should post the round report (or point the card at the body) when it adopts and clears.
F5 (non-blocking, cosmetic) — body Reconciliation §2 cites the driver-sql sites at :12354/:15797/:16166; on the head tree the Object.assign lines are :12352/:15795/:16164 (the cited numbers are each site's cause: line). Same three sites; the TSDoc cites file names only, which is the durable spelling.
Acceptance notes
- Blocking set stays empty; prior PASS WITH FINDINGS carries onto
e7305600. Independence pair unchanged (implementer =domain:specseat). - Before ready: choose F2's route (one-line edit here or a filed docs-only card covering
refusal/userMessage/declaredCode); post the F4 round report. Then clear both carriers per the PASS, 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, whose scope is now explicitly both withhold arms plus removing the describe caveat and regenerating 66 rows + 2 shipped schemas. - NOT MEASURED here: the shipped
json-schema/api/*.jsondescription text (no build), the dev's local test/gate runs one73056007(CI covers), PR rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope #16143 as the route-local patch (taken from the body, consistent withrest-server-meta-references-refusal-envelope.test.tson main).
Generated by Claude Code
…r-0112-refusal-declaration
… — the third is `@objectstack/runtime`'s dispatcher exit The published TSDoc, both `.describe()` caveats and the changeset treated the two `@objectstack/rest` arms as the closed set. `errorResponseBase` (`packages/runtime/src/dispatcher-plugin.ts`, gated on `serverFaultProvenance(thrown) === 'declared'`) withholds every declared 5xx too, never consults `declaredServerFaultAnswer`, is mounted by `objectstack serve`, and emits the very envelope this field lands on. The prose now names it, hands the runtime exit to #16146's sub-issue #17153, and: - drops the "13" census count, narrows the copy-off-a-caught-error claim to the `Object.assign` shape, and names the two overlay-delete rewraps in `metadata-protocol/src/protocol.ts` that copy `status` and carry `code`/`userMessage` through `carry*` helpers (⛔ no `carryRefusal`); - notes the analytics dataset door calls arm 1 bare (no `withDeclaredUserMessage`), so the REST arms are not byte-identical when `userMessage` is declared; - replaces the `/references` routing attribution: the throw spells `status`, so it takes `resolveErrorResponse`'s status passthrough into that function's own 5xx arm; a `statusCode` spelling falls to `mapDataError` and arm 1; - qualifies the table's second row as the rule at the three arms, not a platform-wide invariant (the heuristic-only doors read no declaration); - pins the shipped JSON Schema's `const: true` on both envelopes through the generator's own `toJSONSchema` options; - adds the `refusal?: true` line to the hand-written `EnhancedApiError` excerpt in `content/docs/api/error-catalog.mdx`; - regenerates the 66 reference rows for the id-less caveat that no longer says "REST". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…r-0112-refusal-declaration
Fixes #16335
Clause-②: yes
Spec half of #16146, under director decision batch #58 (2026-09-06, option C; maintainer 「同意」): the refusal/fault distinction is a producer-side declaration on the published ADR-0112 envelope — not a status heuristic and not a second allow-list. This PR declares that field; the relay half — moving BOTH withhold arms in
@objectstack/restand retiring the route-local patch from PR #16143 — stays #16146, which ispm:blockedon this card. ⛔packages/rest/**andpackages/types/**are untouched here.What changed
packages/spec/src/api/contract.zod.ts—ApiErrorSchemagains one optional key,refusal: z.literal(true).optional(), with a TSDoc that (a) documents the three cases side by side, (b) states the semantics and names the two withhold arms the relay must move, and (c) reconciles the flag with theuserMessagedesign note at:77-83(see below).packages/spec/src/api/errors.zod.ts—EnhancedApiErrorSchemagains the same key, its TSDoc pointing at the base for the rationale (theuserMessagepattern from 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)..describe()strings carry the relay caveat in present-tense fact form — "until the REST withhold arms read the declaration, a declared refusal is still withheld" — so the 66 generated reference rows and the two shipped JSON schemas do not advertise a reader that does not exist yet.contract.test.ts(6) anderrors.test.ts(1): kept verbatim besidemessage; absent by default;refusal: falserefused at['refusal']withinvalid_value; a non-boolean refused; orthogonal touserMessage; and the docblock's promise that a base-envelope field reaches the downstream ledger throughmakeApiErrorSchema..changeset/adr-0112-envelope-refusal-declaration.md—@objectstack/spec: minor.packages/spec/authorable-surface/api.json(+2 rows) and 11 pages undercontent/docs/references/api/(every page that inlines the envelope gains the one row).⭐ The checked-in baselines are blind to the value rule — the pins are load-bearing
Measured in the contract review and worth stating here: had this shipped
z.boolean().optional()instead ofz.literal(true).optional(), all four checked-in baselines would be byte-identical to this head.authorable-surface/records key names,api-surface/export names,json-schema.manifest/schema names, and the liveness ledger is theapimetadata type's — none of them seesconst: true. The only in-tree evidence of the value rule is the source line and therefusal: false/ non-boolean pins (and, off-tree, the gitignored-but-shippedjson-schema/api/ApiError.json, which does carry"const": true). This is the same blindness this lane measured twice on regex changes; it is why the rejection pins exist and why the ablation below shows them going red.The three cases (now in the envelope's TSDoc)
messageon the wirestatus; the boundary's fallback 500looksLikeInternalErrorLeakdecides per message (#5667 keeps our own bareErrorlegible)status >= 500+code, nothing here — the defaultINTERNAL_ERROR_MESSAGE,codesurvives, full text to the operator log (#5811 / #5437)status >= 500+code+refusal: trueuserMessageis placed as orthogonal, not a fourth row: its audience is the end user, it never replacesmessage, and it already rides a withheld 5xx (withDeclaredUserMessage). A producer may set both.The two withhold arms (contract-review B1, corrected here and in the TSDoc)
@objectstack/restwithholds a declared 5xx's prose at two arms with byte-identical output:declaredServerFaultAnswer(error-response.ts:574-588; its only two callers areclassifyDataErrorat:1409and the analytics door atrest-server.ts:11190), andresolveErrorResponse's own 5xx passthrough arm (:2116-2124), which every route reporting throughhandleRouteError/sendThrownErrorreaches — the/referencesdoor among them — because the guard at:2015-2019(!declaresServerBand) keeps a declared 5xx away frommapDataError. An earlier draft of this PR named only the first arm as "the single relay"; #16146 must move both.Reconciliation with
contract.zod.ts:77-83— the hard constraintThe #9934 note refuses "a boolean beside
message" for the END-USER channel: that channel's consumer is the console renderer across the wire, #3821's generic substitution must hold against every boundary that rewraps or substitutesmessageon the way, and a mark separated from its text could be promoted onto platform prose. Measured on this tree, that argument does not transfer to this field, for three reasons now written into the TSDoc beside the note:codealready qualifiesstatusfordeclaresServerFault(packages/types/src/error-leak.ts:289-293). Its only consumers are the two withhold arms above, each of which readsstatus,codeand the flag off the SAME thrown object in ONE read before composing a body. There is no second channel to promote prose into; the flag only switches the withhold off, and what then reaches the wire is the samemessagea declared 4xx already discloses (error-response.ts:2124-2126, truncate-never-replace).messageinto a new error drops the flag, and the 5xx is withheld as a fault. The one rewrap that could carry it is one that also copiesstatusonto the rewrapped text — and a copied 4xxstatusalready discloses that text today, so the field adds no exposure a declared status does not have. Measured over all ofpackages/**(non-test): the QuickJS door carries a CLOSED list out of the VM —SANDBOX_ERROR_PASSTHROUGH = ['code', 'fields', 'status', 'userMessage'](packages/runtime/src/sandbox/quickjs-runner.ts:1250) — so a sandboxed body's flag never leaves the VM; of the 13Object.assignerror-composition sites, the three that copy anything off a caught error (packages/drivers/driver-sql/src/sql-driver.ts:12354,:15797,:16166—codeandcause) copy nostatus, so the withhold still applies to them; and the one in-place rewrite ofmessageon an error that keeps itsstatus/code(packages/runtime/src/domains/actions.ts:853, installing the sandboxinnerMessage) is on aSandboxError, which cannot carry the flag. The review states it could not construct a boundary that rewrapsmessagewhile carrying the flag — a reading, not an absence. (My first survey missedpackages/drivers/; the review caught it.)trueis therefore the only value: a fault already has its own spelling (status+code, nothing here), sorefusal: falsefails parse rather than becoming a third state every consumer would have to interpret (review probe:false/'yes'/null/1all fail at['refusal']; pre-PR a barez.objectsilently strippedrefusal: false).If a reader judges that (1)–(3) do not survive, the fork is: keep the ruling's producer-side declaration but carry the text (a
refusalMessage-shaped field), which would makemessageon a 501 stayINTERNAL_ERROR_MESSAGEand move the prose to a sibling — the opposite of what the card asks ("keep its caller-authored message"). I did not take that fork; it is named so the choice is visible.Premise re-derivation (dispatch Zone 2)
ApiErrorSchema.userMessage(contract.zod.ts:50-95) is already a producer-side, status-agnostic opt-in ("Presence IS the marking":71, "Status-agnostic":75), andwithDeclaredUserMessage(error-response.ts:646-677) rides it onto the sanitised fault terminals; the flat 5xx arm at:2111-2122carriesuserMessagewhileerrorstaysINTERNAL_ERROR_MESSAGE. So caller-authored prose CAN reach a 5xx body today — inuserMessage, never inmessage(:85-86). The gap this PR declares against ismessagedisclosure, narrower than the card body states.premise_still_valid: truefor that narrower reading.ErrorResponseSchema(errors.zod.ts) nestsEnhancedApiErrorSchema, soerror.refusalis one key at one wire position described by two schemas; declaring it on the base only would leave the wrapped envelope's declaration silent on a key its producers may emit (the ADR-0078 shape, on the declaration side). 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 did the same foruserMessage. This is one field, not a second table.packages/spec/api-surface/api.jsonis byte-identical (check:api-surface ✓; it recordsname (kind)only). What moves:authorable-surface/api.json(+api/ApiError:refusal, +api/EnhancedApiError:refusal— key NAMES only), 11content/docs/references/api/*.mdx, and the gitignored-but-shippedpackages/spec/json-schema/api/ApiError.json+EnhancedApiError.json(.gitignore:63;files[]carriesjson-schema), which is where the GRAMMAR lands:"refusal": {"type": "boolean", "const": true}(positive controluserMessagepresent 1/1).json-schema.manifest/api.jsonis zero-diff (schema names only). The liveness ledgerpackages/spec/liveness/api.jsonis theapimetadata type's (ApiEndpointSchema), 0ApiErrorhits, so no ledger row is owed.scripts/check-adr-0087-registration.mjs(breakingDeclaration): a marker is owed only by a changeset that declares breaking (major,**BREAKING, or a!summary). This changeset declares none — additive,minor— so it carries no marker, on purpose.Changeset grade and the Clause-② line
Clause-②: yes(mechanically forced:check-widening-tells.mjsT1 fires on the addedrefusal: z.literal(true)…lines underpackages/spec/src/**).pr-automation.ymlputs a new accepted key at theminorfloor;check-changeset-no-major.mjsrequires ayesPR to grade at least one moved-src packageminor+ —@objectstack/spec: minorsatisfies both, so the declaration and the grade agree inside this PR.check-clause2-carriers.mjs --pair 17090: exit 4 (C1 carrier split) with the label on the PR only, exit 0 (both carriers agree) after the additive label on the card.Verification
All readings below are on the final head
e73056007(origin/main0da638cdmerged; the second merge of this PR). Every heavy command went throughscripts/pm/os-verify-lock.sh; test/typecheck exits were captured per part inside the hold; every gate exit code was redirected to disk before it was read.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. Tests import./contract.zod/./errors.zodrelatively, so vitest reads source; nodiston the path.ba3d95a4(the schema line is unchanged since — this round moved only TSDoc,.describe()prose and the changeset): leg A 93/93 → mutation deleted therefusal: z.literal(true)…block from both schemas (on-disk proof: marker count 1 1 → 0 0) → leg B 6 failed | 87 passed, exactly the sixrefusalpins (five incontract.test.ts, one inerrors.test.ts; the "absent by default" pin stays green as expected, it cannot fail against this ablation) → restore viagit checkout HEAD -- PATH, proven bygit hash-objectequal to the non-empty HEAD blobs,git diff HEADempty,git status --porcelainempty → leg C 93/93. Direction observed: red.pnpm --filter @objectstack/spec typecheck(tsc, scripts tsconfig,check:test-typecheckover the test files):TYPECHECK_EXIT=0.turbo run build --filter=@objectstack/spec --filter='@objectstack/lint...' --filter='@objectstack/client-react...' --force: 34 tasks successful,VERDICT command-exit 0.check:generatedone73056007:✓ All 15 generated artifacts are up to date(check:authorable-surface,check:api-surface,check:docsamong them).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackate73056007derived 103 commands (one more than the first round:report-test-timings.mjs --self-test, brought by main's movement); all 103 run on this head, recorded byte-for-byte, reconciled:✓ dispatch-gates --ran: 103 derived famil(ies) accounted for — 101 run, 2 NOT-MEASURED.(exit 0). The 2 NOT-MEASURED, each with the gate's ownPREREQUISITE NOT METline and a reason in the run record:check:dual-build-cjs-loads(80+ packages'dist) andcheck:type-check-debt(full workspace re-measure) — whole-workspace builds are CI's run, declared here rather than run.check:doc-authoringon this head:✓ 15182 customer-facing string(s) across 874 spec sources clean — no internal issue-id references(the previous head's describe caveat carried#16146and went red on exactly this gate; corrected ine7305600).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✓,check:lean-entry-closure✓.eslint.config.mjs(files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED; the config states at:328that noparserOptions.projectand no typed rules are enabled, so this diff cannot move any untouched file's verdict);pnpm exec eslint --no-inline-config --format jsonover the 4 TS files this PR edits: 4 files, 0 errors, 0 warnings, exit 0. The repo-widepnpm lintis CI's run.验收备注
userMessage); the ruling is recorded on [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 (comment 5559950291) and ledgered on [PM seat] director(项目总监) — 🟢 os-zhuang(第 15 场)· ⏹️ 第 16–20 场已收班(第 20 场 = 总监契约复审session_01Tep4AYXZvyBA7jsvne5KZV,收班 2026-09-09T07:20Z) #12708. Editingdocs/adr/**would make this PR a governed-surface PR, so it is left out here; if the maintainer wants the batch Query Protocol: Add comprehensive test coverage and documentation for aggregations, joins, and window functions #58 ruling in the ADR file, it is a one-paragraph docs follow-up. 承接者:无.SANDBOX_ERROR_PASSTHROUGH(quickjs-runner.ts:1250) extended only if tenant sandbox bodies are meant to declare refusals — and on that door the caller-addressed text isinnerMessage, notmessage(theSandboxErrordebug wrapper), so the read there must go throughsandboxBusinessMessage. Carried to [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146's author via this note. 承接者:[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()strings and regenerating (66 reference rows + 2 shipped JSON schemas) once the arms read it. 承接者:[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()prose is refused bycheck:doc-authoring(it projects into customer-facing references and the shipped JSON schema); the first spelling of the caveat carried#16146and went red on that gate before this head.origin/mainmerged twice throughos-regen-merge.sh(at854639b3, then at0da638cd); each time one generated reference page had moved on both sides (contract.mdx, thenprotocol.mdx) and was re-derived at the pre-commit collection point.Generated by Claude Code
Generated by Claude Code