Skip to content

feat(contract): a hook refusal can mark its message user-facing — the userMessage producer-side opt-in channel - #9992

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-9934-user-facing-refusal-marking
Aug 19, 2026
Merged

feat(contract): a hook refusal can mark its message user-facing — the userMessage producer-side opt-in channel#9992
os-elon merged 2 commits into
mainfrom
claude/issue-9934-user-facing-refusal-marking

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#9934

Clause-② notice: this PR widens the public error-envelope contract surface — one new optional field (userMessage) on ApiErrorSchema and EnhancedApiErrorSchema (@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-by back-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:

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 userMessage field, and why not the alternatives

userMessage?: string on the error envelope; presence is the marking, the value is the marked text. A producer opts in at throw time:

throwObject.assign(newError('close-period guard refused the write'),{statusCode: 403,userMessage: '该记录已进入结账期,暂不能修改;请联系财务主管解锁。',});

Measured against the card's three axes:

  • Authorability: one property on the error the author already throws — the same authoring move as the existing code/statusCode declarations, on both the handler and the sandboxed-body surface, and it lets the author keep a diagnostic message (logs, developers) separate from localized user guidance. Nothing to import, nothing to wrap.
  • Wire compatibility: strictly additive — an optional field on two non-strict envelope schemas, a top-level sibling in the flat dialect. Unmarked errors produce byte-identical bodies (pinned); no existing parser or pin moves.
  • Console trust (fix(sharing): 共享规则新建页 — 自定义 widget 未国际化,且「接收方」永远无可选项 #3821: platform diagnostics must never be markable by accident): this is where the field beats a boolean flag structurally. A userFacing: true flag marks whatever message currently 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, and declaredUserMessage refuses non-string/blank values, so nothing is ever invented. An error-code family (e.g. an APP_-prefixed range) was rejected because ADR-0112 (as amended by [Decision] The dispatcher's error.code has a limb authored by TENANTS at runtime — registration cannot close it, and ADR-0112 does not say what should happen there #9106) makes code a closed vocabulary at every door — a marking family would either punch a hole in the closed set or be demoted to declaredCode and lose its meaning; and it conflates naming the condition with disclosing the message, two axes the contract keeps separate.

Precedent followed: developerMessage on the DELETE_RESTRICTED envelope (#7307) is the identical audience-split, pointed the other way; the connector error-mapping vocabulary already spells this exact concept userMessage ("Human-readable message to show users"), so the name introduces no second spelling.

What changed, per package

#3821 preserved by construction

The default is unmarked: no platform producer sets the field, declaredUserMessage answers undefined for 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)

  • Integration through the real runtime hook path (packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts, real kernel + real hook dispatch + real REST door via bootStack): a beforeUpdate handler-hook refusal WITH the marking answers 403 with the exact text in body.userMessage; the same throw WITHOUT it answers 403 with no userMessage key; a sandboxed L2 body hook's marking survives the QuickJS boundary onto the sandbox-unwrap envelope; both refusals really aborted their writes.
  • REST door (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 the handleRouteError passthrough door; plus the wire on the real CRUD PATCH route.
  • Dispatcher door (error-envelope.conformance.test.ts): a marked refusal's nested body parses against the real ApiErrorSchema with error.userMessage beside code/message; unmarked emits no key.
  • Sandbox (error-passthrough.test.ts): VM→host and host→VM round trip, unmarked and blank negatives.
  • Spec / types / client unit pins for the declaration rule at each seam.

Consumer triage (error-envelope consumers in this repo)

ConsumerReadVerdict
@objectstack/runtime dispatcher + domainsbuilds envelopes via buildApiError; extra spread already declared for siblings (declaredCode, route, hint)additive, full suite green (176 files / 2633 tests)
@objectstack/rest conformance + envelope suitesflat-dialect pins never assert key absence for foreign keys; toEqual pins involve errors that carry no markingfull suite green (130 / 2122)
@objectstack/clienterror attachment reads both dialects; new read is ??-chained like codefull suite green (23 / 314)
@objectstack/mcp / MCP transportroutes through deps.error / errorFromThrown — same builder, additive siblingcovered by runtime suite + dispatcher conformance
@objectstack/spec envelope parsers (BaseResponseSchema, envelopeViolations, makeApiErrorSchema)plain z.objects; new field declared, not strippedfull suite green (414 / 11023)
@objectstack/typessendError (extra: Pick<ApiError, ...>)Pick list unchanged — route modules cannot emit the field accidentallytypes suite green (13 / 356)

Verification

All suites and gates below ran on the tree that is now head (final head 44c8f92, after merging origin/main and rebuilding; the post-merge re-runs are called out):

  • Suites, all green: spec 414 files / 11023 tests (full), rest 130/2122 (full; re-run post-merge), runtime 176 files/2633 tests pre-merge and 176/2640 re-run post-merge (full — main landed the feat(runtime): carry the flow author's errorMessage and run summary through the /actions door via a typed refusal carrier (#9585) #9950/actions refusal-carrier in the same neighborhood), client 23/314, types 13/356, dogfood integration file 1/4.
  • Typecheck green: spec, types, rest, runtime, client, dogfood.
  • node scripts/pm/dispatch-gates.mjs re-derived on the ACTUAL diff (no hand-fed path list); every named gate run and green, including check: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, spec check: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, and check:type-check-debt --re-measure — every ledger entry exactly at its measurement) and check:nul-bytes.
  • Spec generated artifacts regenerated via the repo tooling (check:generated --fix after build): authorable-surface/api.json (+2 keys: ApiError:userMessage, EnhancedApiError:userMessage) and the content/docs/references/api/*.mdx reference pages; check:generated reports all 13 artifacts up to date at head. check:api-surface green — 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-registration green).

Downstream reader

objectui#5210 implements the render half on landing: the console form renders err.userMessage when present and keeps form.noPermissionToSave otherwise. The wire contract it needs is pinned here (both dialects, plus the SDK's err.userMessage).

Out of scope


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/client, @objectstack/rest, @objectstack/runtime, @objectstack/spec, @objectstack/types, touching 20 documentable anchor(s).

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

  • content/docs/ai/agents.mdx(via /:object/query (route))
  • content/docs/api/client-sdk.mdx(via data.query (sdk))
  • content/docs/api/data-api.mdx(via /:object/query (route))
  • content/docs/api/error-catalog.mdx(via ApiErrorSchema (symbol))
  • content/docs/api/index.mdx(via ApiErrorSchema (symbol))
  • content/docs/api/wire-format.mdx(via /:object/query (route))
  • content/docs/data-modeling/analytics.mdx(via /:object/query (route))
  • content/docs/data-modeling/queries.mdx(via /:object/query (route))
  • content/docs/getting-started/quick-reference.mdx(via /:object/query (route))
  • content/docs/kernel/runtime-services/data-service.mdx(via data.query (sdk), /:object/query (route))
  • content/docs/kernel/services-checklist.mdx(via /:object/query (route))
  • content/docs/protocol/objectql/query-syntax.mdx(via /:object/query (route))
  • content/docs/ui/forms.mdx(via mapDataError (symbol), /forms/:slug/submit (route))
  • content/docs/ui/public-data-collection.mdx(via /forms/:slug/submit (route))

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

  • content/docs/releases/v17.mdx(via ApiErrorSchema (symbol), /:object/query (route))

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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 123 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 240ad14da1e23466498d3be19daa9bae48ee9c59packageMentionDocs.

Which tree this was computed on

This run read content/docs from d344f2ef93460a9f0e55a937f6fda7c8c4fc3db7 — the merge of head 44c8f92b8ccfee620f147481eb857404a547405a into base 240ad14da1e23466498d3be19daa9bae48ee9c59, 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 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

⚠️ 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 240ad14da1e23466498d3be19daa9bae48ee9c59 → 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 19, 2026
@os-elon
os-elon marked this pull request as ready for review August 19, 2026 12:28
@os-elon
os-elon enabled auto-merge August 19, 2026 12:28
@os-elon
os-elon added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 79c46daAug 19, 2026
27 checks passed
@os-elon
os-elon deleted the claude/issue-9934-user-facing-refusal-marking branch August 19, 2026 13:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@os-elon@claude