Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): carry the producer's userMessage to the wire at the dispatcher's throw-transparent exit - #13619
Conversation
…throw-transparent exit
`errorResponseBase` resolved every throw through `resolveThrownHttpError` —
whose result already carries `userMessage` — and never read the field, making
it the only ADR-0112 boundary that dropped the author-facing text channel.
Both declared siblings now share ONE `extra` object: two conditional
`{ extra: … }` spreads do not merge, so a throw carrying both a demoted
`declaredCode` and a mark would have shipped only one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk…spatcher-usermessage-channel
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 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 707d35adf48162df29799f09f0c6c6fa4d0044ca && git checkout 707d35adf48162df29799f09f0c6c6fa4d0044ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 5d93e24278a6127ef7dbb17f4ed3ba12ebfb5df4
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038 |
…spatcher-usermessage-channel
…:error-code-casing The two `code: 'acme_quota_exceeded'` literals are the fixture for the #9106 demotion path: `demotedDeclaredCode` yields a `declaredCode` only for a spelling the closed vocabulary rejected, so a registered SCREAMING code would make §4 stop being the both-fields case and assert nothing. Declared with `adr0112-ok:` and a reason rather than hidden behind a constant — the gate counts an applied suppression into its verdict, so the deliberate non-conformance stays visible instead of becoming invisible by construction. ⛔ Not KNOWN_LOWERCASE_CODES (closed to new lines) and ⛔ not EXEMPT_FILES (these ARE in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
Uh oh!
There was an error while loading. Please reload this page.
…RMISSION_DENIED door `HttpDispatcher.dispatch`'s foot catch recognises `isPermissionDeniedError` and answers the refusal itself, so a marked denial never reaches the throw-transparent exit #13241 repaired and lost its `userMessage` at this second door instead. The read is `declaredUserMessage` (`@objectstack/types`) — the one rule every boundary applies — and the mark rides as a declared top-level sibling via the `extra` bag, exactly as the sibling `errorFromThrown` carries it. #7450's withhold is untouched: `details` still carries only the ROUTE-derived object, and the gate's positions/permissionSets/cascade child stay server-side. The PR #13619 §2 carve-out pin is MOVED, not fixed green — its docblock asked for exactly that when the denial path stopped dropping the mark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
Fixes#13241
errorResponseBase(packages/runtime/src/dispatcher-plugin.ts) resolved every throw throughresolveThrownHttpError— whose result already carriesuserMessage— and then did not read the field. It was the only ADR-0112 error boundary that dropped the author-facing text channel.Verified on this branch's merge base, all four of triage's readings re-measured rather than taken on trust, with one correction noted below.
Clause-②: yesThe PM claim declares clause ② on this card, deliberately and conservatively: two careful seats disagree on whether populating an already-declared field constitutes widening, and a below-tier seat picking the reading that lets it proceed is an escape, not a judgement. This PR is complete-but-parked and an at-tier reviewer clears it.
⛔ This seat has not self-cleared any contract-review gate, has not armed auto-merge, and has not flipped the PR ready. ⛔ It also did not pick the narrower of the two implementation options to look safer under that gate — see the census below, which chose the route on architectural grounds before the gate was ever considered.
⛔ No inference is drawn here from PR #13240 having merged. A merge says that PR cleared; it says nothing about this door's clause-② question, or how it would be answered.
The binding measurement — the
buildApiErrorcaller censusTriage's instruction was to census the caller set first, then choose between (1) plumbing at this exit only and (2) making the shared
buildApiErrorcarry it so no door can forget again. Six call sites, all inpackages/runtime:userMessageavailable?error-envelope.ts:132—apiErrorResponsedispatcher-plugin.ts:468— inlineROUTE_NOT_FOUND404dispatcher-plugin.ts:662—errorResponseBasedomains/mcp.ts:176— 405 withAllowdomains/meta.ts:666— 405 withAllowsecurity/inbound-rate-limit.ts:356— 429Exactly one of six has a throw. The other five author their own refusal text; there is no producer mark for them to carry.
⇒ Route (2), as stated, is not implementable at
buildApiError. That function takes an already-sanitisedApiErrorInputand never sees the throw. AddinguserMessage?: stringtoApiErrorInputwould create a typed slot every caller must still populate explicitly — the "cannot forget" property is not obtained, the forgetting merely moves from the body oferrorResponseBaseto its argument list — while widening a shared input type consumed byendpoint-executor.ts,api-mapping.ts,endpoint-policy.ts,api-endpoint-step.tsandhttp-dispatcher.tsfor zero behavioural gain at any of them.⭐ The "one rule, every door inherits" property route (2) was reaching for already exists — one layer up.
declaredUserMessage(@objectstack/types) is the single read every boundary applies; its own docblock says so, naming the REST door, the dispatcher door and the sandbox side-channel.resolveThrownHttpErroralready resolves the field ontoThrownHttpError.userMessage. This exit already called that resolver and simply did not read the answer. So the defect is one door not reading a shared rule, not an absent shared rule — and route (1) is the repair that matches the diagnosis.The pre-registered conditional escalation does NOT fire. Route (2) could not make a door that today deliberately withholds
userMessagestart emitting it, because none of the other five call sites has auserMessagein scope to withhold. There is no such door. Measured, not assumed.What the change is
Both declared siblings now share one
extraobject, mirroringerrorFromThrown's expression:{ extra: … }spreads on one object literal do not merge — the later replaces the earlier — so a throw carrying both a demoteddeclaredCodeand a mark would have silently shipped only one. That is pinned (§4) and ablated below.Wider than the card's framing:
userMessageis status-agnostic by ruling (#9934), so the gap was never confined to the declared-5xx band that motivated it. A marked 4xx refusal reaching this exit lost the field too, with no withhold anywhere in the picture. The plumbing is deliberately not gated on the withhold limb, and §2 drives 400/403/409/503 so a later edit cannot quietly re-gate it.The open question triage asked to be answered on the way past
Does any producer on the throw-transparent routes set author-facing text today? Measured answer: ZERO. The only two in-repo
userMessageproducers are:packages/runtime/src/sandbox/quickjs-runner.ts), reached via/actions— butdomains/actions.tscatches and answers througherrorFromThrown, which already carries the field;metadata-protocol'smarkedApplicationRefusalError, reached via the REST/metaand/datadoors.Neither reaches this exit. A package-level sweep finds
userMessagein only six packages (runtime,rest,metadata-protocol,spec,types,client); noplugin-*orservice-*package sets it. ⇒ This confirms the p2 and confirms the change is a no-op on today's tree. It does not close the card's underlying concern, because the contract stays unenforced at one door either way — which is exactly why every test drives the real mounted route rather than asserting a predicate.1. A measured carve-out, recorded rather than closed.
HttpDispatcher.dispatch's foot catch is not a pure rethrow: it interceptsisPermissionDeniedError(name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or a message starting[Security] Access denied) and answers it itself frompackages/runtime/src/http-dispatcher.ts. Such a throw never reacheserrorResponseBase, so its mark is still dropped, by a different door. That file is a trigger file of on-hold decision #7898 and is out of this change's file surface, so this is pinned as an observation, not repaired here. My own test initially assumed aPERMISSION_DENIED403 was throw-transparent; the instrument caught it, and the corrected row now pins the carve-out so a future reader learns if it moves.2. A build-graph mechanism, contributed to the existing card #13513 rather than filed anew.
pnpm --filter '@objectstack/runtime^...' build— the closure-build recipe AGENTS.md prescribes — cannot be used forruntime: a devDependency cycle (runtime→driver-turso→(devDep)verify→runtime) pullsverifyand runtime itself into runtime's own dependency closure, so there is no topological order andverify's DTS build races runtime's, dying onCannot find module '@objectstack/runtime'. Pre-existing and unrelated to this diff; worked around here with--filter '!@objectstack/verify', which is a local unblock and ⛔ not a proposed repair. #13513 already tracks the symptom across two earlier observations and states that what it needs is one confirming build owning the cause; that measurement is now a comment there. ⛔ A duplicate was deliberately not opened.Patch round —
check:error-code-casingwas RED on the first push, now greenThe gate flagged the two
code: 'acme_quota_exceeded'literals in §4 as lowercase error codes in a code position (ADR-0112 D1). They are a deliberately unregistered producer spelling, and they have to be:demotedDeclaredCodeyields adeclaredCodeonly when the throw spelled something the closed vocabulary rejected, so a registered SCREAMING code there would produce nodeclaredCodeat all and §4 would stop being the both-fields case and assert nothing.Declared with
adr0112-ok:plus a reason, on the line directly above each hit — the only two placements the gate accepts (it matches the marker on the hit line or the one above, and requires a non-empty reason; both pinned in its--self-test).KNOWN_LOWERCASE_CODES: the gate refuses new lines there in terms, and the list is shrink-only and currently empty by design.EXEMPT_FILES: that is for literals which are not error codes (D6/D6b/D6c). These genuinely are in a code position, so exempting the file would be a false declaration and would blind the gate to every future literal in it.The gate's own verdict line, after the fix:
Suppression count moved 13 → 15, which is the positive control that my two were applied rather than silently ignored.
check:error-code-casingis not in the path-derived union —dispatch-gates.mjsclassifies it as content-judged and says so itself: "adds or edits a file carrying an ADR-0112 error or notice CODE (judged from CONTENT — no path derivation can name this gate)". So this was not a skipped gate; it was one no path derivation can name, which is exactly the residue CI exists to cover.Verification
All readings below were re-taken at
5d93e2427after the patch round described just above — that is this branch's head, so the gate union, the lint run and the test runs are all on the same tree as the final commit. (Authored in sessionhttps://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk, recorded here in prose because a body edit rewrites the footer below to its bare form.)Tests. 21 new cases in
packages/runtime/src/dispatcher-plugin.declared-user-message.test.ts, driving the real mountedPOST /api/v1/analytics/queryroute.codekeeps its prose on/analytics/querywhere/datawithholds it unconditionally #12281's withhold is not re-opened@objectstack/runtime: 202 files, 2992 tests, all passingpnpm --filter @objectstack/runtime run typecheck: exit 0Positive controls, not merely green. Absence is asserted twice everywhere it is asserted at all — on
Object.keys(error)/hasOwnPropertyand on the serialized bytes — becauseJSON.stringifysilently drops a key whose value isundefined, so a byte assertion alone cannot distinguish "omitted" from "emitted asundefined". §3 additionally drives the guard itself against a present-but-undefinedenvelope, demonstrating the byte limb blind on that shape while the key limb still catches it.Ablations — direction predicted before running, both matched exactly.
userMessagelimb from the mergedextraextra(the pre-merge bug shape)Both legs were mutated with an anchored replacement asserting a hit count of exactly 1 (a zero-hit edit exits 0 and would have read as a successful ablation), confirmed on disk by blob-hash inequality against the
HEADblob, and restored viagit checkout HEAD -- ABSOLUTE_PATHwith the restore proven by hash equality plus an emptygit diff HEAD— not by an exit code. No rebuild was needed or claimed: the suite imports./dispatcher-plugin.js, which vitest resolves to same-package source, and every@objectstack/*it touches is source-aliased inpackages/runtime/vitest.config.ts.Gates. Union derived at
5d93e2427withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed; stderr read and confirms the change set and the repo assertion). Harvested with--commandsto avoid the 15-of-30 single-spelling trap. Exit codes captured before any pipe.check:route-envelope,check:test-source-alias,check:cross-package-test-inputs,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:comment-mask-adoption,check:undeclared-dep-imports, and the changeset family.pnpm lint(whole repo,eslint . --no-inline-config): exit 0, run in full — no narrowing claimed and none needed.node scripts/check-nul-bytes.mjs: OK, 7541 files, no raw control bytes.3 refusals, reported separately and ⛔ NOT folded into the passes — each is NOT MEASURED, in the gate's own words:
node scripts/check-test-completeness.mjs— exit 3: "the local reading for this gate is NOT MEASURED. ⛔ It is not a red, and there is nothing here to fix." It needs a savedturbo run testlog that only CI produces.pnpm check:dual-build-cjs-loads— exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass: nothing was measured."pnpm check:type-check-debt— exit 1. Its structural half printedOK — 66/78 workspace packages type-checked, but the--re-measureratchet refused: "cannot run: 23 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk", because a number measured without the closure "would silently measure a DIFFERENT WORLD".One NOT MEASURED I am flagging on my own diff, not on a gate.
@objectstack/runtime's tsconfig excludes**/*.test.ts, so its greentypechecksays nothing about the new test file. Measured rather than assumed withtsc --noEmit --listFiles:src/dispatcher-plugin.ts→ 1 hit (the implementation is covered), the new test file → 0 hits.check:type-check-coveragepasses, so the existing TEST_DEBT entry already accounts for that layer.Triage's four readings, re-measured
Three hold exactly. One has drifted and is corrected here:
5d93e2427userMessageabsent fromdispatcher-plugin.tsuserMessage"), not code. The substantive claim holds: zero code references. Triage measured 0 ata81aa9dd, before that comment landed.buildApiErrorin the same filehttp-dispatcher.ts:850— confirmed verbatimcontract.zod.ts:87— confirmedCard body sanitizer self-check
Clean. The body is structurally complete —
## The gap→## Why it matters more after #12281→## Not established here→## Region→## Refs, closing on its three-ref list, with no tag-shaped fragment anywhere in it and nothing cut mid-sentence.