Skip to content

feat(spec): ADR-0112 error envelope gains a producer-side refusal declaration so a deliberate 5xx refusal keeps its caller-authored message (#16335) - #17090

Draft
os-bill wants to merge 11 commits into
mainfrom
claude/issue-16335-adr-0112-refusal-declaration
Draft

feat(spec): ADR-0112 error envelope gains a producer-side refusal declaration so a deliberate 5xx refusal keeps its caller-authored message (#16335)#17090
os-bill wants to merge 11 commits into
mainfrom
claude/issue-16335-adr-0112-refusal-declaration

Conversation

@os-bill

@os-bill os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

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/rest and retiring the route-local patch from PR #16143 — stays #16146, which is pm:blocked on this card. ⛔ packages/rest/** and packages/types/** are untouched here.

What changed

  • packages/spec/src/api/contract.zod.tsApiErrorSchema gains 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 the userMessage design note at :77-83 (see below).
  • packages/spec/src/api/errors.zod.tsEnhancedApiErrorSchema gains the same key, its TSDoc pointing at the base for the rationale (the userMessage pattern 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).
  • Both .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. ⚠️ [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 owes the removal of that clause (and the regeneration) when it lands.
  • Pins in contract.test.ts (6) and errors.test.ts (1): kept verbatim beside message; absent by default; refusal: false refused at ['refusal'] with invalid_value; a non-boolean refused; orthogonal to userMessage; and the docblock's promise that a base-envelope field reaches the downstream ledger through makeApiErrorSchema.
  • .changeset/adr-0112-envelope-refusal-declaration.md@objectstack/spec: minor.
  • Regenerated: packages/spec/authorable-surface/api.json (+2 rows) and 11 pages under content/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 of z.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 the api metadata type's — none of them sees const: true. The only in-tree evidence of the value rule is the source line and the refusal: false / non-boolean pins (and, off-tree, the gitignored-but-shipped json-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)

the throw declares message on the wire
undeclared 5xx — no status; the boundary's fallback 500 HEURISTIC: looksLikeInternalErrorLeak decides per message (#5667 keeps our own bare Error legible)
declared faultstatus >= 500 + code, nothing here — the default WITHHELD: INTERNAL_ERROR_MESSAGE, code survives, full text to the operator log (#5811 / #5437)
declared refusalstatus >= 500 + code + refusal: true KEPT verbatim, bounded as a 4xx is (#5423); not logged as an unhandled fault (#16146's logging half, the #14656 question)

userMessage is placed as orthogonal, not a fourth row: its audience is the end user, it never replaces message, 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/rest withholds a declared 5xx's prose at two arms with byte-identical output: declaredServerFaultAnswer (error-response.ts:574-588; its only two callers are classifyDataError at :1409 and the analytics door at rest-server.ts:11190), and resolveErrorResponse's own 5xx passthrough arm (:2116-2124), which every route reporting through handleRouteError / sendThrownError reaches — the /references door among them — because the guard at :2015-2019 (!declaresServerBand) keeps a declared 5xx away from mapDataError. 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 constraint

The #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 substitutes message on 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:

  1. It qualifies the STATUS declaration, not a text. The flag says "the 5xx I declared is a refusal", the way code already qualifies status for declaresServerFault (packages/types/src/error-leak.ts:289-293). Its only consumers are the two withhold arms above, each of which reads status, code and 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 same message a declared 4xx already discloses (error-response.ts:2124-2126, truncate-never-replace).
  2. A rewrap is fail-closed. A boundary that rewraps message into 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 copies status onto the rewrapped text — and a copied 4xx status already discloses that text today, so the field adds no exposure a declared status does not have. Measured over all of packages/** (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 13 Object.assign error-composition sites, the three that copy anything off a caught error (packages/drivers/driver-sql/src/sql-driver.ts:12354, :15797, :16166code and cause) copy no status, so the withhold still applies to them; and the one in-place rewrite of message on an error that keeps its status/code (packages/runtime/src/domains/actions.ts:853, installing the sandbox innerMessage) is on a SandboxError, which cannot carry the flag. The review states it could not construct a boundary that rewraps message while carrying the flag — a reading, not an absence. (My first survey missed packages/drivers/; the review caught it.)
  3. Shape follows the ruling's own words. The recorded ruling 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) says the relay "keeps the message verbatim only when it is present and withholds it otherwise" — presence is the declaration. true is therefore the only value: a fault already has its own spelling (status + code, nothing here), so refusal: false fails parse rather than becoming a third state every consumer would have to interpret (review probe: false / 'yes' / null / 1 all fail at ['refusal']; pre-PR a bare z.object silently stripped refusal: 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 make message on a 501 stay INTERNAL_ERROR_MESSAGE and 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)

  • Qualified, as the pre-reading said. ApiErrorSchema.userMessage (contract.zod.ts:50-95) is already a producer-side, status-agnostic opt-in ("Presence IS the marking" :71, "Status-agnostic" :75), and withDeclaredUserMessage (error-response.ts:646-677) rides it onto the sanitised fault terminals; the flat 5xx arm at :2111-2122 carries userMessage while error stays INTERNAL_ERROR_MESSAGE. So caller-authored prose CAN reach a 5xx body today — in userMessage, never in message (:85-86). The gap this PR declares against is message disclosure, narrower than the card body states. premise_still_valid: true for that narrower reading.
  • Which envelope: both. ErrorResponseSchema (errors.zod.ts) nests EnhancedApiErrorSchema, so error.refusal is 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 for userMessage. This is one field, not a second table.
  • Zero-diff step confirmed, and which artifacts actually move (measured): packages/spec/api-surface/api.json is byte-identical (check:api-surface ✓; it records name (kind) only). What moves: authorable-surface/api.json (+api/ApiError:refusal, +api/EnhancedApiError:refusal — key NAMES only), 11 content/docs/references/api/*.mdx, and the gitignored-but-shipped packages/spec/json-schema/api/ApiError.json + EnhancedApiError.json (.gitignore:63; files[] carries json-schema), which is where the GRAMMAR lands: "refusal": {"type": "boolean", "const": true} (positive control userMessage present 1/1). json-schema.manifest/api.json is zero-diff (schema names only). The liveness ledger packages/spec/liveness/api.json is the api metadata type's (ApiEndpointSchema), 0 ApiError hits, so no ledger row is owed.
  • ADR-0087 marker: measured in 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.mjs T1 fires on the added refusal: z.literal(true)… lines under packages/spec/src/**). pr-automation.yml puts a new accepted key at the minor floor; check-changeset-no-major.mjs requires a yes PR to grade at least one moved-src package minor+ — @objectstack/spec: minor satisfies 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/main 0da638cd merged; the second merge of this PR). Every heavy command went through scripts/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.

  • Testspnpm --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.zod relatively, so vitest reads source; no dist on the path.
  • Reverse verification (ablation), one lock hold on the committed fix at ba3d95a4 (the schema line is unchanged since — this round moved only TSDoc, .describe() prose and the changeset): leg A 93/93 → mutation deleted the refusal: z.literal(true)… block from both schemas (on-disk proof: marker count 1 1 → 0 0) → leg B 6 failed | 87 passed, exactly the six refusal pins (five in contract.test.ts, one in errors.test.ts; the "absent by default" pin stays green as expected, it cannot fail against this ablation) → restore via git checkout HEAD -- PATH, proven by git hash-object equal to the non-empty HEAD blobs, git diff HEAD empty, git status --porcelain empty → leg C 93/93. Direction observed: red.
  • Typecheckpnpm --filter @objectstack/spec typecheck (tsc, scripts tsconfig, check:test-typecheck over the test files): TYPECHECK_EXIT=0.
  • Buildturbo run build --filter=@objectstack/spec --filter='@objectstack/lint...' --filter='@objectstack/client-react...' --force: 34 tasks successful, VERDICT command-exit 0. check:generated on e73056007: ✓ All 15 generated artifacts are up to date (check:authorable-surface, check:api-surface, check:docs among them).
  • Derived gatesnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at e73056007 derived 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 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) — whole-workspace builds are CI's run, declared here rather than run. check:doc-authoring on this head: ✓ 15182 customer-facing string(s) across 874 spec sources clean — no internal issue-id references (the previous head's describe caveat carried #16146 and went red on exactly this gate; corrected in e7305600).
  • Families the dispatch named: 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 ✓.
  • Lint, narrowed with proof — population read from eslint.config.mjs (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED; the config states at :328 that no parserOptions.project and no typed rules are enabled, so this diff cannot move any untouched file's verdict); pnpm exec eslint --no-inline-config --format json over the 4 TS files this PR edits: 4 files, 0 errors, 0 warnings, exit 0. The repo-wide pnpm lint is CI's run.

验收备注


Generated by Claude Code


Generated by Claude Code

…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
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
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getReferences (sdk, the bare tail of client method meta.getReferences, bound to GET /api/v1/meta/:type/:name/references), meta.getReferences (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/references, selected by route anchor /meta/:type/:name/references))
  • content/docs/api/error-catalog.mdx (via ApiErrorSchema (symbol, a top-level const))
  • content/docs/api/index.mdx (via ApiErrorSchema (symbol, a top-level const))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ApiErrorSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via ApiErrorSchema (symbol, a top-level const), EnhancedApiErrorSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f801e7d7fe0722e942f6d07bbcea1e34ab3e9ed0packageMentionDocs.

Which tree this was computed on

This run read content/docs from f7657288b75bfd8bc69d1c91a530095a8d7a53bf — the merge of head 8d4df8aad5008a31a19c2c119ad592eecbb2bba8 into base f801e7d7fe0722e942f6d07bbcea1e34ab3e9ed0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f801e7d7fe0722e942f6d07bbcea1e34ab3e9ed0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS (audit reading; director seat, summon #18 segment 3, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T08:5xZ)

PR #17090 · head ba3d95a4f3514243131a698f12589c23d49e6fcd (re-read at posting; unchanged) · reviewed-at 2026-09-09T08:34:33Z.

Findings

F1 (non-blocking)packages/spec/src/api/contract.zod.ts:175-180 and errors.zod.ts:401-405 — the .describe() strings assert present-tense wire behaviour ("so boundaries keep it verbatim") for a key that has no reader on this head: packages/rest/** is untouched, and declaredServerFaultAnswer (origin/main error-response.ts:574-588) still withholds every declared 5xx. The TSDoc paragraph (contract.zod.ts:133-137) and the changeset (.changeset/adr-0112-envelope-refusal-declaration.md:17) do disclose "until it lands, a declared refusal is still withheld at the wire" — but the .describe() text is what lands in the 11 generated reference pages (content/docs/references/api/contract.mdx:34 etc.) and in the shipped json-schema/api/*.json description, and a release cut between this merge and #16146 ships that sentence false. Not blocking: the split is director-ruled (#16146 comment 5559950291 sequences spec → relay), the inert state is fail-closed (the withhold stays on; no ADR-0049 regression), and the schema-level behaviour this PR does introduce is pinned. Fix: seat action — flip #16146 from pm:blocked the moment this merges and do not let an RC cut sit between them; if that cannot be guaranteed, append one clause to both .describe() strings ("relay: #16146") and regenerate the 11 pages (pnpm --filter @objectstack/spec gen:docs).

F2 (non-blocking)content/docs/api/error-catalog.mdx:663-679 — the hand-written interface EnhancedApiError excerpt (lint.yml:2310 confirms the page is hand-written) does not gain refusal?: true; the docs-drift bot named this page. Pre-existing drift: the same block already omits userMessage and declaredCode, so this PR did not create the gap. Fix: add refusal?: true; // producer-declared refusal; keeps message on a 5xx (#16335) after the message line, or file one docs-only follow-up covering all three omissions.

F3 (non-blocking) — PR body, Verification section, last bullet — literal placeholder check-clause2-carriers.mjs --pair PR_NUMBER. The real reading is on the card (report 5598012933: exit 4 → 0 after the card label) and the reviewer re-measured --pair 17090 → exit 0 ("both carriers agree"). Fix: replace PR_NUMBER with 17090.

1. Card ruling vs head

Ruling chain verified: #16146 comment 5559950291 (option C, batch #58, maintainer 「同意」) → "one optional field … keeps the message verbatim only when it is present". Head delivers presence-as-declaration via z.literal(true).optional() (contract.zod.ts:175, errors.zod.ts:401); refusal: false fails parse at ['refusal'] with invalid_value (zod ^4.4.3, packages/spec/package.json:324). Three-case table present at contract.zod.ts:105-109; userMessage placed orthogonal at :161-165. The dispatch's hard constraint (reconcile with the #9934 note at contract.zod.ts:77-83 or report a fork) is met head-on at :141-165; the fork is named in the PR body rather than taken. Fence: packages/rest/**, packages/types/**, content/docs/releases/** untouched (17-file list verified via git diff --name-status). The card's premise-narrowing (userMessage already carries caller prose on a 5xx; the gap is message disclosure only) is stated in the PR body and not walked past.

2. Accept-set movement (before → after)

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 (3045b6faa27544b4); 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 清双载体标签并入队,合并即刻把 #16146pm: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
…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

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS, carries to the current head (delta re-review; audit reading; director seat, summon #18 segment 5, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T12:5xZ)

PR #17090 · head e730560072f5eac052aae60d382546d94277d9bb (re-read at posting 12:54:43Z; unchanged since 12:26Z push, body edited 12:49Z) · reviewed 12:34Z–12:51Z · verdict of record: 5598904803 (PASS WITH FINDINGS on ba3d95a4f3).

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (136 harness model stamps, all claude-fable-5-1, zero residue; positive control 114 assistant / 70 user role tokens), adopted verbatim below.
  • Implemented-by: the domain:spec seat session_01MkQhmuuJAVDjmeWNixwDDH (os-bill) itself, branch of 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. Distinct from this session ⇒ not a self-review by this seat; the implementer being the dispatching seat is the pair the prior verdict already recorded.
  • Reading for the seat: the accept set did not move — the delta is TSDoc, two .describe() strings, 66 regenerated reference rows, changeset prose, and one clean merge of main; F1 addressed (route B, id-less spelling after check:doc-authoring refused the first), F3 addressed, F2 (error-catalog.mdx still lacks refusal/userMessage/declaredCode) still open — one-line edit here or a docs-only card; F4 the patch-round report is on neither carrier. CI 36 green / 0 red on the head. Release stays the seat's act. ⛔ This seat cleared no carrier.

Head reviewed: e730560072f5eac052aae60d382546d94277d9bb (re-read 12:51Z; refs/pm-review/17090-r2 resolves to it). Prior verdict head: ba3d95a4f3514243131a698f12589c23d49e6fcd. Still draft; 8 commits; 17 files, +321/−1 vs merge-base 0da638cd (= base.sha). PR body edited 12:49:31Z with no push.

Delta summary (ba3d95a4..e7305600, PR-owned hunks only)

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

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


Generated by Claude Code

… — 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants