Skip to content

fix(types): let sendError's extra carry userMessage - #12477

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-12404-senderror-usermessage
Aug 26, 2026
Merged

fix(types): let sendError's extra carry userMessage#12477
os-litant merged 2 commits into
mainfrom
claude/issue-12404-senderror-usermessage

Conversation

@os-litant

@os-litantos-litant commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12404

ApiErrorSchema declares userMessage — the #9934 producer-side opt-in for "this exact text is addressed to the END USER" (maintainer ruling 2026-08-19 on objectui#5210, option 1), where presence IS the marking. sendError's extra did not admit it, so no route answering the nested envelope could put it on the wire. 'userMessage' joins the Pick; no call site changes, no wire byte moves for any body already emitted, and the accept set is untouched because the schema always declared the field.

packages/spec/** is unmodified — the contract is being honoured, not changed. packages/rest/src/package-routes.ts is untouched: that call-site adoption is #12405, sequenced behind this card.

Note on this body's bytes: type parameters are written with a space after the angle bracket (Pick< … >). GitHub's body sanitizer strips a < followed immediately by a letter as an unknown HTML tag — it silently ate the exact Pick spelling out of the first draft of this body, inside a fenced code block. The space is the workaround, not a typo.

1. Premise re-measured on this branch's base, not recalled

Base origin/main @ bb920ee. packages/types/src/response-envelope.ts:166 read:

extra?: Pick< ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode' >,

A temporary probe compiled through the package's own tsc --noEmit, two call lines in one file, one run:

src/__probe-12404.ts(11,54): error TS2353: Object literal may only specify known properties,
and 'userMessage' does not exist in type 'Pick<…, "declaredCode" | … 3 more … | "requestId">'.

The declaredCode control line on the same instrument compiled clean — one error, not two. Re-run after the widening, the same probe reports userMessage clean and a third line passing an undeclared reason still TS2353, so the door was widened by one field and did not become a Record. The probe file is deleted; the tree at HEAD is byte-identical to the committed content.

Both edited files are inside the package's tsc program (tsc --listFiles: 1 hit each), so "typecheck clean" is a reading over the tests too, not only over the source.

2. Liveness established BEFORE widening

The instrument was proven able to return zero first, on terms that are not substrings of the one under test: declaredUserGuidance → 0, operatorFacingText → 0, zzNoSuchFieldHere → 0, against declaredCode → 138 and declaredUserMessage → 22 over the same corpus. userMessage → 150.

The channel is live at both ends:

  • Producers. A host-side hook throws Object.assign(new Error(…), { statusCode: 403, userMessage: … }); a metadata app's sandboxed hook body writes e.userMessage = …; throw e, and quickjs-runner.ts marshals it across the QuickJS boundary through SANDBOX_ERROR_PASSTHROUGH onto SandboxError.userMessage. Both are driven end-to-end today in packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts, which asserts the exact author text on the wire.
  • Carrier.resolveThrownHttpError already populates ThrownHttpError.userMessage for every caller of the shared resolver.
  • The other two doors already emit it. Flat /data via withDeclaredUserMessage (rest/error-response.ts:554-561); dispatcher via thrown.userMessage (runtime/http-dispatcher.ts:848-851). The nested writer was the only one that could not.
  • A nested-envelope route holds the resolved error at the call.packages/rest/src/package-routes.ts:201 resolves, :206 sends — read only, not touched.

So this admits a channel that has producers, a carrier and consumers; it is not a declared-but-dead surface.

3. Why the Pick stays explicit

Per the card's ruling: widen, do not derive extra from ApiError's optional fields. The two fields hand the caller different obligationsdeclaredCode's presence means demotion, so its caller passes demotedDeclaredCode(thrown); userMessage's presence means only that the producer opted in, which declaredUserMessage already decided, so its caller passes thrown.userMessage straight through. A derivation cannot express that difference, and would admit each future optional the day it lands with nobody asked.

Recorded in the file's docblock because it is the honest cost, and it is a measurement rather than an argument: ApiError has exactly six optional fields (declaredCode, userMessage, category, httpStatus, details, requestId), and with userMessage admitted the Pick now names all six — this gate has to date rejected none. See the report on #12404 for what that does and does not establish about the ruling.

4. Tests — the real pipeline, parsed by the real schema

Five cases in packages/types/src/response-envelope.test.ts, following #12403's shape:

  1. the marked text rides beside the diagnostic message and does not replace it;
  2. the emitted body parses against the real BaseResponseSchema / envelopeViolations, and userMessage is still on it after an ApiErrorSchema.safeParse;
  3. the paired control that makes (2) a reading: an undeclared sibling (reason) in the same body is stripped by the same parse — ApiErrorSchema is a plain z.object, so .success alone would pass against a schema declaring nothing;
  4. a blank marking (' ') stays ABSENT — the writer never invents a marked message, so fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821's generic substitution is preserved by construction;
  5. both open channels on one refusal — declaredCode and userMessage together — so admitting the second did not disturb the first.

Reverse verification (prediction stated before the run: RED). With the widening reverted on disk and everything else held — mutation confirmed by anchored grep -c (widened form 1 → 0, narrow form → 1) and by blob hash 0302d9a0b4887fb2tsc --noEmit turned red with three errors inside the new block: TS2559 at response-envelope.test.ts:287, TS2353 at :308, TS2559 at :352. Restored with git checkout HEAD -- "$ABS_PATH" under an EXIT INT TERM trap; restoration proven by git diff HEAD empty and by the file's hash returning to the HEAD blob 0302d9a0, not by an exit code.

5. Gates

Families re-derived in the worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-supplied paths; 3 changed paths vs merge base bb920ee08) — 19 path-matched plus 6 convention-triggered by the new test code.

Full union re-run at the final commit 899878a5f, exit codes captured before any pipe: 26 green, 0 red. Includes @objectstack/typestest (13 files, 375 tests passed) and typecheck, check:route-envelope (the family that names this exact file), check:type-check-debt --re-measure on the built closure — "32 ledger entr(ies) re-measured … none above its recorded number" — plus check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, check:cross-package-test-inputs, check:nul-bytes and the changeset families. Repo-wide pnpm lint (eslint . --no-inline-config) also ran whole at 899878a5f and exited 0 — no narrowing claimed.


Generated by Claude Code

os-litantand others added 2 commits August 26, 2026 04:44
`ApiErrorSchema` declares `userMessage` — the #9934 producer-side opt-in for
text addressed to the END USER (maintainer ruling 2026-08-19 on objectui#5210,
option 1) — and two of the three doors already emit it: the flat `/data` door
through `withDeclaredUserMessage`, the dispatcher door through
`thrown.userMessage`. `sendError`'s `extra` was typed
`Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode'>`,
so a route answering the NESTED envelope could not put the field on the wire —
a compile error to try, and an author's deliberate, localized refusal text was
dropped on that door alone while a valid body shipped without it.
Additive: `userMessage` joins the `Pick`. No call site changes and no wire byte
moves for any body already emitted; the accept set is untouched, since the
schema always declared the field.
Unlike `declaredCode` this field carries no invariant for the caller to
re-derive — `declaredUserMessage` already decided what counts as marked, so the
caller passes `thrown.userMessage` straight through, exactly as the dispatcher
door does. That difference is why `extra` stays an explicit `Pick` rather than
being derived from `ApiError`'s optional fields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 7 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via httpStatus (literal), requestId (literal))
  • content/docs/api/error-catalog.mdx(via httpStatus (literal), requestId (literal))
  • content/docs/api/error-handling-client.mdx(via httpStatus (literal), requestId (literal))
  • content/docs/api/error-handling-server.mdx(via httpStatus (literal))
  • content/docs/api/index.mdx(via httpStatus (literal))
  • content/docs/api/wire-format.mdx(via httpStatus (literal))
  • content/docs/automation/approvals.mdx(via requestId (literal))
  • content/docs/automation/flows.mdx(via httpStatus (literal))
  • content/docs/deployment/production-readiness.mdx(via requestId (literal))
  • content/docs/kernel/runtime-services/audit-service.mdx(via requestId (literal))
  • content/docs/plugins/development.mdx(via httpStatus (literal))
  • content/docs/protocol/kernel/error-handling.mdx(via requestId (literal))
  • content/docs/protocol/kernel/http-protocol.mdx(via httpStatus (literal))

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

  • content/docs/releases/v17.mdx(via declaredCode (literal), httpStatus (literal), requestId (literal), userMessage (literal))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 1 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 e9147336d1c7a5faea88c54bbd9ecbfca46d8695packageMentionDocs.

Which tree this was computed on

This run read content/docs from 09a5ccd8b6085e941ee2ccdce62ab5accd0f944e — the merge of head 899878a5fe9aaa0fc8e95d1f2c885baa65a152b7 into base e9147336d1c7a5faea88c54bbd9ecbfca46d8695, 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 09a5ccd8b6085e941ee2ccdce62ab5accd0f944e && git checkout 09a5ccd8b6085e941ee2ccdce62ab5accd0f944e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e9147336d1c7a5faea88c54bbd9ecbfca46d8695 899878a5fe9aaa0fc8e95d1f2c885baa65a152b7 && git checkout -B drift-repro e9147336d1c7a5faea88c54bbd9ecbfca46d8695 && git merge --no-ff 899878a5fe9aaa0fc8e95d1f2c885baa65a152b7
node scripts/docs-audit/affected-docs.mjs --json e9147336d1c7a5faea88c54bbd9ecbfca46d8695

⚠️ 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 e9147336d1c7a5faea88c54bbd9ecbfca46d8695 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 05:32
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 0e5fe7fAug 26, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-12404-senderror-usermessage branch August 26, 2026 05:54
os-litant pushed a commit that referenced this pull request Aug 26, 2026
…eset (#12502)
`.changeset/package-door-declared-code.md` closed with "the shared `sendError`'s
`extra` does not admit it yet". That clause was already false when it merged:
PR #12477 (#12404, `0e5fe7fc0`) widened the `extra` Pick to admit `userMessage`
and landed BEFORE `14b114553`. Both changesets are unreleased and compile into
the same CHANGELOG, so this fixes a document before it is published rather than
rewriting history — and it is fixed in place, because a correction sitting
beside the false sentence would ship both to the reader.
One clause. The rest of that changeset's prose is untouched, and no other
changeset is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

types: sendError's extra cannot carry userMessage either — the same Pick bars the second declared open channel from every nested-envelope route

1 participant

@os-litant