Skip to content

A PERMISSION_DENIED refusal never reaches the dispatcher's throw-transparent exit, so it still drops the author's userMessage — a second door, behind #7898's trigger file #13623

Description

@os-steve

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions