Measured while implementing #13241 (PR #13619). Out of that card's file surface, and filed by the domain:cli PM seat rather than by the dev — see "Why this was escalated before filing" below. Unlabelled for triage.
The gap
#13241 repaired errorResponseBase so the dispatcher's throw-transparent exit carries a producer's userMessage. A PERMISSION_DENIED refusal never gets there.
HttpDispatcher.dispatch's foot catch is not a pure rethrow. It intercepts isPermissionDeniedError — true when any of:
name === 'PermissionDeniedError', or
code === 'PERMISSION_DENIED', or
- the message starts
[Security] Access denied
— and answers it itself, from packages/runtime/src/http-dispatcher.ts, which does not carry userMessage. So a marked authorization refusal loses its author-facing text at a different door than the one #13241 repaired.
Why it matters, stated at its real size
⚠️ This is the refusal class most likely to carry deliberately-authored text. "You do not have access to this report; ask an admin for the Reporting role" is exactly the sentence a producer marks — and #9934 made userMessage status-agnostic precisely so a 403 could carry it. The one door that swallows the mark is the one whose refusals users most need to read.
⛔ Not asserted here: that dropping it is wrong. The denial path answering centrally may well be deliberate. What is measured is only that this is where the field is dropped, and that it is not the exit #13241 fixed.
It is already pinned, so it cannot rot silently
PR #13619 lands a carve-out test (§2) that drives a 403 / PERMISSION_DENIED / marked throw and asserts the mark is absent. That pin is an observation, not a wish:
if a later change makes the denial path throw-transparent, this test fails and tells the author that the carve-out has moved
⇒ whoever takes this card should expect that test to redden and must move it deliberately, not "fix" it green.
⚠️ #7898 — read before dispatching this
The repair lands in packages/runtime/src/http-dispatcher.ts, which is a trigger file of on-hold decision #7898 (isAuthGateAllowlisted's "no path ⇒ exempt" default; ruled defer, Option B, 2026-08-12). Its trigger files are packages/core/src/security/auth-gate.ts · packages/runtime/src/http-dispatcher.ts · packages/adapters/**.
That hold's own wording makes this a check obligation, not a no-fly zone:
Trigger files (any PR touching these must check this hold …)
⇒ Filing is legitimate; dispatching requires first checking #7898's three restart conditions — in particular condition 2, "any synthetic-request caller reaches the auth gate without a populated path" — and reporting if one fires. ⛔ Do not edit through the hold.
Why this was escalated before filing
The #13241 dev measured this, declined to file it, and handed the routing decision up on the ground that filing a card proposing edits to a fenced file could cut across the hold. That was the right instinct and the wrong conclusion, and the PM seat is recording the ruling rather than leaving it implicit:
⭐ A trigger file governs how work is done, not whether a defect is recorded. Declining to file would turn a fence into a memory hole — the measurement would exist only inside one dev's report and one test's docblock. The hold is honoured by the dispatch discipline above, not by silence.
Refs
Measured while implementing #13241 (PR #13619). Out of that card's file surface, and filed by the
domain:cliPM seat rather than by the dev — see "Why this was escalated before filing" below. Unlabelled for triage.The gap
#13241 repaired
errorResponseBaseso the dispatcher's throw-transparent exit carries a producer'suserMessage. APERMISSION_DENIEDrefusal never gets there.HttpDispatcher.dispatch's foot catch is not a pure rethrow. It interceptsisPermissionDeniedError— true when any of:name === 'PermissionDeniedError', orcode === 'PERMISSION_DENIED', or[Security] Access denied— and answers it itself, from
packages/runtime/src/http-dispatcher.ts, which does not carryuserMessage. So a marked authorization refusal loses its author-facing text at a different door than the one #13241 repaired.Why it matters, stated at its real size
userMessagestatus-agnostic precisely so a 403 could carry it. The one door that swallows the mark is the one whose refusals users most need to read.⛔ Not asserted here: that dropping it is wrong. The denial path answering centrally may well be deliberate. What is measured is only that this is where the field is dropped, and that it is not the exit #13241 fixed.
It is already pinned, so it cannot rot silently
PR #13619 lands a carve-out test (
§2) that drives a403/PERMISSION_DENIED/ marked throw and asserts the mark is absent. That pin is an observation, not a wish:⇒ whoever takes this card should expect that test to redden and must move it deliberately, not "fix" it green.
The repair lands in
packages/runtime/src/http-dispatcher.ts, which is a trigger file of on-hold decision #7898 (isAuthGateAllowlisted's "no path ⇒ exempt" default; ruled defer, Option B, 2026-08-12). Its trigger files arepackages/core/src/security/auth-gate.ts·packages/runtime/src/http-dispatcher.ts·packages/adapters/**.That hold's own wording makes this a check obligation, not a no-fly zone:
⇒ Filing is legitimate; dispatching requires first checking #7898's three restart conditions — in particular condition 2, "any synthetic-request caller reaches the auth gate without a populated
path" — and reporting if one fires. ⛔ Do not edit through the hold.Why this was escalated before filing
The #13241 dev measured this, declined to file it, and handed the routing decision up on the ground that filing a card proposing edits to a fenced file could cut across the hold. That was the right instinct and the wrong conclusion, and the PM seat is recording the ruling rather than leaving it implicit:
⭐ A trigger file governs how work is done, not whether a defect is recorded. Declining to file would turn a fence into a memory hole — the measurement would exist only inside one dev's report and one test's docblock. The hold is honoured by the dispatch discipline above, not by silence.
Refs
errorResponseBasenever surfacesuserMessage, so the dispatcher exit has no author-facing text channel — the one the 5xx prose withhold assumes as its compensation #13241 / PR fix(runtime): carry the producer'suserMessageto the wire at the dispatcher's throw-transparent exit #13619 — the throw-transparent exit, repaired; this is the door it does not reach.userMessage, and its status-agnostic ruling.declaredCodechannel in scope for 5xx sanitisation at all — and the answer must be applied to all three doors at once #12509 / runtime: a declared 5xx carrying NOcodekeeps its prose on/analytics/querywhere/datawithholds it unconditionally #12281 — the declared-5xx prose withhold that namesuserMessageas its compensation.