Uh oh!
There was an error while loading. Please reload this page.
fix(types): let sendError's extra carry userMessage - #12477
Conversation
`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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 1 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 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
|
Uh oh!
There was an error while loading. Please reload this page.
…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
Fixes#12404
ApiErrorSchemadeclaresuserMessage— 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'sextradid not admit it, so no route answering the nested envelope could put it on the wire.'userMessage'joins thePick; 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.tsis untouched: that call-site adoption is #12405, sequenced behind this card.1. Premise re-measured on this branch's base, not recalled
Base
origin/main@bb920ee.packages/types/src/response-envelope.ts:166read:A temporary probe compiled through the package's own
tsc --noEmit, two call lines in one file, one run:The
declaredCodecontrol line on the same instrument compiled clean — one error, not two. Re-run after the widening, the same probe reportsuserMessageclean and a third line passing an undeclaredreasonstillTS2353, so the door was widened by one field and did not become aRecord. 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, againstdeclaredCode→ 138 anddeclaredUserMessage→ 22 over the same corpus.userMessage→ 150.The channel is live at both ends:
Object.assign(new Error(…), { statusCode: 403, userMessage: … }); a metadata app's sandboxed hook body writese.userMessage = …; throw e, andquickjs-runner.tsmarshals it across the QuickJS boundary throughSANDBOX_ERROR_PASSTHROUGHontoSandboxError.userMessage. Both are driven end-to-end today inpackages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts, which asserts the exact author text on the wire.resolveThrownHttpErroralready populatesThrownHttpError.userMessagefor every caller of the shared resolver./dataviawithDeclaredUserMessage(rest/error-response.ts:554-561); dispatcher viathrown.userMessage(runtime/http-dispatcher.ts:848-851). The nested writer was the only one that could not.packages/rest/src/package-routes.ts:201resolves,:206sends — 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
Pickstays explicitPer the card's ruling: widen, do not derive
extrafromApiError's optional fields. The two fields hand the caller different obligations —declaredCode's presence means demotion, so its caller passesdemotedDeclaredCode(thrown);userMessage's presence means only that the producer opted in, whichdeclaredUserMessagealready decided, so its caller passesthrown.userMessagestraight 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:
ApiErrorhas exactly six optional fields (declaredCode,userMessage,category,httpStatus,details,requestId), and withuserMessageadmitted thePicknow 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:messageand does not replace it;BaseResponseSchema/envelopeViolations, anduserMessageis still on it after anApiErrorSchema.safeParse;reason) in the same body is stripped by the same parse —ApiErrorSchemais a plainz.object, so.successalone would pass against a schema declaring nothing;' ') stays ABSENT — the writer never invents a marked message, so fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821's generic substitution is preserved by construction;declaredCodeanduserMessagetogether — 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 hash0302d9a0→b4887fb2—tsc --noEmitturned red with three errors inside the new block:TS2559atresponse-envelope.test.ts:287,TS2353at:308,TS2559at:352. Restored withgit checkout HEAD -- "$ABS_PATH"under anEXIT INT TERMtrap; restoration proven bygit diff HEADempty and by the file's hash returning to the HEAD blob0302d9a0, 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 basebb920ee08) — 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) andtypecheck,check:route-envelope(the family that names this exact file),check:type-check-debt --re-measureon the built closure — "32 ledger entr(ies) re-measured … none above its recorded number" — pluscheck:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:cross-package-test-inputs,check:nul-bytesand the changeset families. Repo-widepnpm lint(eslint . --no-inline-config) also ran whole at899878a5fand exited 0 — no narrowing claimed.Generated by Claude Code