Uh oh!
There was an error while loading. Please reload this page.
feat(contract): a hook refusal can mark its message user-facing — the userMessage producer-side opt-in channel - #9992
Conversation
…ssages — userMessage channel (#9934) The contract half of the ruled objectui#5210 split (maintainer 2026-08-19, option 1: producer-side opt-in). A hook author marks a refusal message user-facing at throw time by setting userMessage on the thrown error; the marking is a text-carrying field (never a flag), status-agnostic, and absent by default — so the console's #3821 generic substitution is preserved by construction for everything unmarked. - spec: ApiErrorSchema.userMessage + EnhancedApiErrorSchema.userMessage - types: declaredUserMessage() one-read + ThrownHttpError.userMessage - rest: mapDataError/resolveErrorResponse ride the marking onto every classified envelope (flat dialect, truncated at the #5423 bound) - runtime: QuickJS side-channel carries userMessage across the sandbox boundary; dispatcher door emits it as a declared sibling (nested dialect) - client: err.userMessage attached from both wire dialects Pins: spec parse pins, types resolver pins, rest door pins (marked/unmarked, status-agnostic, fault terminals unchanged when unmarked), sandbox round-trip pins, dispatcher conformance pin, and a dogfood integration test through the real runtime hook path (handler hook 403 + sandboxed body hook). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw
…r-facing-refusal-marking
📓 Docs Drift CheckThis PR changes 5 package(s): 14 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 — 123 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 d344f2ef93460a9f0e55a937f6fda7c8c4fc3db7 && git checkout d344f2ef93460a9f0e55a937f6fda7c8c4fc3db7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 240ad14da1e23466498d3be19daa9bae48ee9c59 44c8f92b8ccfee620f147481eb857404a547405a && git checkout -B drift-repro 240ad14da1e23466498d3be19daa9bae48ee9c59 && git merge --no-ff 44c8f92b8ccfee620f147481eb857404a547405a
node scripts/docs-audit/affected-docs.mjs --json 240ad14da1e23466498d3be19daa9bae48ee9c59
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9934
Clause-② notice: this PR widens the public error-envelope contract surface — one new optional field (
userMessage) onApiErrorSchemaandEnhancedApiErrorSchema(@objectstack/spec/api), one new exported reader (declaredUserMessage) and one new optional member (ThrownHttpError.userMessage) on@objectstack/types. Additive only; nothing is removed or narrowed. Draft by dispatch contract — the PM reviews and lands it.Ruling implemented
Maintainer ruling of 2026-08-19 on objectui#5210 (accepted verbatim, 「全部接受」): option 1 — producer-side opt-in. An application hook's refusal gets a way to mark its message user-facing, once, at the contract level; consumers render a marked message and keep the #3821 generic substitution for everything unmarked. Constraints from the ruling, each met: producer-side opt-in at throw time; status-agnostic (not a 403 special case); #3821 preserved by construction (the default is unmarked); and none of the rejected alternatives resurface (no consumer-side raw-403 opt-in, no message-copy-only change, no working-as-intended). This PR is the producer half; the console render half is objectui#5210, whose card carries the
Blocked-byback-link and reads these wire bodies.The refusal path, measured before designing
How a hook's throw becomes a wire error today, on
origin/main:defineStack({ hooks })handler fn /engine.registerHook): the throw propagates raw out oftriggerHooks(packages/objectql/src/engine.ts) into the REST door, wheremapDataError's declared-status passthrough (declaredHttpStatus, [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525) answers403/409/...with the message andthrownCodeFields. This is the path of the 11 real hook guards in the objectui#5210 report — their 403 bodies carry the author's message, and the console then discards it (fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821's substitution).__errorInfoside-channel ([17.0.0-rc.0] Runtime dispatcher error paths drop ValidationError.fields[] and downgrade status to 500 (rest-server maps it correctly) #3918/Action-body writes have no not-found gate:ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867) carriescode/fields/statusontoSandboxError, andmapDataError's sandbox-unwrap branch answers 400 withinnerMessageverbatim and no code.HttpDispatcher.errorFromThrown→resolveThrownHttpError(@objectstack/types, the one rule both doors read since The direct-mount REST package door answers 500 INTERNAL_ERROR for coded 4xx errors the dispatcher twin maps correctly #8016/[Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106) → nested{ success: false, error: { code, message, ... } }.@objectstack/clientattachescode/httpStatus/fieldsto the thrownErrorfrom both wire dialects; the console keys its 403 handling off that error.The marking therefore has to exist at four seams to survive to the wire — the throw-site contract, the sandbox side-channel, both HTTP doors — plus the SDK read. All five are in this PR.
The shape chosen: a reserved
userMessagefield, and why not the alternativesuserMessage?: stringon the error envelope; presence is the marking, the value is the marked text. A producer opts in at throw time:Measured against the card's three axes:
code/statusCodedeclarations, on both the handler and the sandboxed-body surface, and it lets the author keep a diagnosticmessage(logs, developers) separate from localized user guidance. Nothing to import, nothing to wrap.userFacing: trueflag marks whatevermessagecurrently holds — any boundary that rewraps, truncates or substitutes the message while the flag survives would promote platform prose into the marked channel. With a text-carrying field, the mark and the marked text are one value and cannot be separated by any rewrap; platform and driver code never set the field, anddeclaredUserMessagerefuses non-string/blank values, so nothing is ever invented. An error-code family (e.g. anAPP_-prefixed range) was rejected because ADR-0112 (as amended by [Decision] The dispatcher'serror.codehas a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106) makescodea closed vocabulary at every door — a marking family would either punch a hole in the closed set or be demoted todeclaredCodeand lose its meaning; and it conflates naming the condition with disclosing the message, two axes the contract keeps separate.Precedent followed:
developerMessageon theDELETE_RESTRICTEDenvelope (#7307) is the identical audience-split, pointed the other way; the connector error-mapping vocabulary already spells this exact conceptuserMessage("Human-readable message to show users"), so the name introduces no second spelling.What changed, per package
ApiErrorSchema.userMessage+EnhancedApiErrorSchema.userMessage, optional, with the full rationale in the contract JSDoc (the one place the semantics are stated; the second field cross-references the first).declaredUserMessage(error)— the ONE "is this marked?" read (non-empty string after trim; the declared value is returned byte-verbatim), andresolveThrownHttpErrorcarries it asThrownHttpError.userMessage, so the two HTTP doors agree by construction rather than by parallel probes (the [rest] Hook refusals carrying an explicitstatusCodeare not mapped by/api/v1/data— they leak as HTTP 500INTERNAL_ERRORwith no located guidance #7525 lesson applied on day one).mapDataErroris now a thin wrapper — classification is untouched (classifyDataError, byte-for-byte the old function) andwithDeclaredUserMessagerides a declared marking onto whatever body classification chose, truncated at the same rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423 bound as the 4xx message.resolveErrorResponse's two passthrough arms apply the same rule. Deliberately branch-agnostic (one rule, no branch table): on the 5xx arms the prose withhold is unchanged — the marked channel is authored user text, never the withheld message — and a genuine crash carries no marking, so the A rawTypeError: not a functionleaks in the 400 envelope for{"title": 12345}— nocode, nofields[]#7543 fault terminals are byte-identical for everything unmarked.userMessageas the fourth allowlisted property (both directions, joiningcode/fields/status— a body that catches and re-throws a marked host refusal keeps the marking, and the value is author-written user-facing text, so it carries no host state into the VM).HttpDispatcher.errorFromThrownemits it as a declared sibling in the nested envelope, exactly likedeclaredCode.err.userMessageattached from both wire dialects' declared spots (flat top-level, wrappederror.userMessage); nothing is promoted frommessageclient-side.#3821 preserved by construction
The default is unmarked: no platform producer sets the field,
declaredUserMessageanswersundefinedfor everything except a deliberate non-empty string, and every layer only ever forwards a declared value — none synthesizes one. An unmarked refusal's wire body is byte-identical to before this PR (pinned as "marked and unmarked bodies differ by exactly one key"). The console keeps substituting its generic string until it sees the field the author wrote.Pins (the card's executable criterion, producer side)
packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts, real kernel + real hook dispatch + real REST door viabootStack): abeforeUpdatehandler-hook refusal WITH the marking answers 403 with the exact text inbody.userMessage; the same throw WITHOUT it answers 403 with nouserMessagekey; a sandboxed L2 body hook's marking survives the QuickJS boundary onto the sandbox-unwrap envelope; both refusals really aborted their writes.packages/rest/src/rest-user-facing-refusal-marking.test.ts): marked/unmarked halves, status-agnostic loop (400–451), one-key body delta, structured-code branch, sandbox unwrap, 5xx withhold + marking, fault terminals byte-identical when unmarked, non-declaration values (blank/non-string), rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423 truncation, and thehandleRouteErrorpassthrough door; plus the wire on the real CRUD PATCH route.error-envelope.conformance.test.ts): a marked refusal's nested body parses against the realApiErrorSchemawitherror.userMessagebesidecode/message; unmarked emits no key.error-passthrough.test.ts): VM→host and host→VM round trip, unmarked and blank negatives.Consumer triage (error-envelope consumers in this repo)
@objectstack/runtimedispatcher + domainsbuildApiError;extraspread already declared for siblings (declaredCode,route,hint)@objectstack/restconformance + envelope suitestoEqualpins involve errors that carry no marking@objectstack/client??-chained likecode@objectstack/mcp/ MCP transportdeps.error/errorFromThrown— same builder, additive sibling@objectstack/specenvelope parsers (BaseResponseSchema,envelopeViolations,makeApiErrorSchema)z.objects; new field declared, not stripped@objectstack/typessendError(extra: Pick<ApiError, ...>)Verification
All suites and gates below ran on the tree that is now head (final head
44c8f92, after mergingorigin/mainand rebuilding; the post-merge re-runs are called out):mainlanded the feat(runtime): carry the flow author's errorMessage and run summary through the /actions door via a typed refusal carrier (#9585) #9950/actionsrefusal-carrier in the same neighborhood), client 23/314, types 13/356, dogfood integration file 1/4.node scripts/pm/dispatch-gates.mjsre-derived on the ACTUAL diff (no hand-fed path list); every named gate run and green, includingcheck:route-envelope,check:error-code-casing,check:error-status-conformance,check:dispatcher-error-vocabulary,check:merge-driver,check:spec-parsed-alias,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputs(+ node twin),check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-dev-prereqs(on the fully built workspace),check-affected-docs, the docs family (check:docs-audit-scope,check:docs-redirects,check:published-readme-links,check:quick-reference-counts,check:role-word,check:doc-anchors),check:slot-lookup,check:objectui-changeset,check:changeset-gate-self-tests,check:doc-formula-expressions, speccheck:empty-state/check:liveness/check:strictness-ledger/check:variant-docs, plus the convention-triggered set on new test files (check:query-options-erasure,check:type-check-coverage,check:engine-double-contract,check:where-matcher, andcheck:type-check-debt --re-measure— every ledger entry exactly at its measurement) andcheck:nul-bytes.check:generated --fixafter build):authorable-surface/api.json(+2 keys:ApiError:userMessage,EnhancedApiError:userMessage) and thecontent/docs/references/api/*.mdxreference pages;check:generatedreports all 13 artifacts up to date at head.check:api-surfacegreen — no export added or narrowed in spec.Changeset / ADR-0087
One changeset:
@objectstack/specminor and@objectstack/typesminor (both widen a public surface, additive — never major), patch for@objectstack/runtime,@objectstack/rest,@objectstack/client. ADR-0087: not required — purely additive optional field and plumbing; nothing authorable is renamed, retired, aliased or tombstoned, so there is no conversion or migration to register (reasoning also recorded in the changeset's marker comment;check-adr-0087-registrationgreen).Downstream reader
objectui#5210 implements the render half on landing: the console form renders
err.userMessagewhen present and keepsform.noPermissionToSaveotherwise. The wire contract it needs is pinned here (both dialects, plus the SDK'serr.userMessage).Out of scope
e.status = 403) is answered 400 on/api/v1/data— the sandbox-unwrap branch outranks the declared-status passthrough, while the actions door honours the declared status (Action-body writes have no not-found gate:ctx.api.object().update()against a nonexistent id answers 400 (or worse) instead of 404, while the protocol and callData paths both gate correctly #7867). Found while measuring the refusal path; the marking is unaffected either way (it rides whichever envelope that branch picks).Generated by Claude Code