# RFC 9457 Problem Details

Source: https://permdock.com/docs/standards/problem-details

The application/problem+json body PermDock's HTTP adapters return for denied and approval-required decisions, and why it is written for humans and models alike.

## What it is [#what-it-is]

RFC 9457 (Problem Details for HTTP APIs) defines a JSON media type, `application/problem+json`, for describing errors in HTTP responses. A problem document has five standard members, all optional:

* `type`: a URI identifying the problem type; dereferencing it should yield documentation.
* `title`: a short, human-readable summary that does not change between occurrences.
* `status`: the HTTP status code, duplicated for convenience.
* `detail`: a human-readable explanation specific to this occurrence.
* `instance`: a URI identifying this specific occurrence.

Problem types may define additional members ("extension members"). Consumers ignore members they do not understand.

## Why it matters for PermDock [#why-it-matters-for-permdock]

A denial has to leave the process somehow. Every HTTP adapter (Hono, Express, Fastify, Elysia, Nest, Node, and tRPC and oRPC over HTTP) needs one answer to "what does a 403 look like", and that answer should be one that API clients, gateways and coding agents already know how to parse. RFC 9457 is that answer. It also gives PermDock a place to put the structured parts of a `Decision` (the permission, the denials, the alternatives, the approval token) without inventing an envelope.

Model readability is a design goal: an agent that receives a 403 should be able to read why and what it may do instead, so it self-corrects instead of retrying the same call. The `detail` text is written for that audience, and `alternatives` is the machine-readable version of the same hint. See [decisions](/docs/concepts/decisions) and [errors](/docs/concepts/errors).

## How PermDock uses it [#how-permdock-uses-it]

`assert` runs layered unauthorized handlers and rethrows `PermDockDeniedError` or `PermDockApprovalRequiredError`; the [server kernel](/docs/adapters/server-kernel) converts those errors into `application/problem+json` with the status `protect` sends for the same decision: 401 for no subject or a missing step-up, 404 for a hidden row, 429 for an exhausted limit, 503 for an unavailable limit store, 403 otherwise.

A denial:

```json
{
  "type": "https://permdock.com/problems/denied",
  "title": "Permission denied",
  "status": 403,
  "detail": "Subject u_123 (role member) may not delete post p_42: the grant requires authorId = principal.id. Permitted on this post: post.read, post.update.",
  "instance": "/posts/p_42",
  "permission": "post.delete",
  "denials": [
    {
      "role": "member",
      "reason": {
        "kind": "condition",
        "condition": { "eq": ["authorId", { "subject": "id" }] }
      }
    }
  ],
  "alternatives": ["post.read", "post.update"]
}
```

An approval-required decision:

```json
{
  "type": "https://permdock.com/problems/approval-required",
  "title": "Approval required",
  "status": 403,
  "detail": "Deleting post p_42 requires human approval. Resubmit with the approval token once approved.",
  "instance": "/posts/p_42",
  "permission": "post.delete",
  "token": "sha256:..."
}
```

Rules:

* **`type` URIs are stable** and per outcome: `.../denied`, `.../approval-required`, `.../validation` (for `PermDockValidationError` at a boundary, status 400), plus two 401 types that exist only where a `WWW-Authenticate` challenge is the real answer: `.../unauthenticated` (no token, or a token that failed verification) and `.../step-up-required` (RFC 9470, carrying `acrValues` and `maxAge`). An exhausted `limit` is `.../rate-limited` (429) and a limit store that cannot answer is `.../limit-unavailable` (503); a denied row of a resource with `disclosure: 'hide'` is `.../not-found` (404), and a denial only a plan stands behind is `.../not-entitled` (403, with `plans`). They sit under the fixed base `https://permdock.com/problems`; the host is not configurable, so a self-hosted deployment emits the same identifiers.
* **`permission`** is the permission `key`, never the object; keys are the wire form of references.
* **`denials`** mirrors `Decision.denials` and is always present, in production too, because what it reveals is bounded by the subject's own snapshot: one entry per role that had a matching grant and why it did not apply. Conditions are the portable JSON AST, so a client can show exactly which constraint failed. Closure grants appear as `{ "kind": "closure" }` with no further detail.
* **`alternatives`** lists permission keys on the same resource the subject does hold, so a model can pick a permitted action.
* **`token`** appears only on `approval-required` and is the replay-safe hash described in [approvals](/docs/security/approvals). `approval-required` is a 403 like a denial, so a client that ignores the `type` fails closed; once approved, the client resumes by repeating the request with the `PermDock-Approval` header ([approvals adapter](/docs/adapters/approvals)).
* **Nothing secret** is in the body: no other users' grants, no role definitions beyond the role name, no raw SQL from opaque conditions. What a 403 reveals is bounded by what the subject's own snapshot already reveals.
* HTTP adapters set `Content-Type: application/problem+json` and the status from `status`. tRPC and oRPC adapters embed the same object as the error's `data` because they own their envelopes.

### The `WWW-Authenticate` header next to the body [#the-www-authenticate-header-next-to-the-body]

When the caller is an OAuth client, Problem Details is the body and RFC 6750 (with RFC 9470 for step-up) is the header. The two are emitted together, and the header is derived from the same reason the body carries, so no adapter keeps a second mapping ([subject](/docs/concepts/subject), [JWT adapter](/docs/adapters/jwt)):

| Situation | Status | `WWW-Authenticate` | Problem Details `type` |
| --- | --- | --- | --- |
| No token: no `Authorization` header (Decision reason `anonymous`) | 401 | `Bearer`, with no error code (RFC 6750 section 3.1) | `.../unauthenticated`; no `denials` |
| A token that failed verification: `on('auth')` `reason: 'invalid-token'`, Decision reason `anonymous` | 401 | `Bearer error="invalid_token", error_description="The access token is invalid"`; the `cause` stays on the audit event (RFC 6750 section 3.1 says not to explain to an unauthenticated caller) | `.../unauthenticated` |
| Decision `denied` with reason `not-delegated` or `no-delegation` | 403 | `Bearer error="insufficient_scope", scope="<permission.scope>"` | `.../denied`, with `alternatives` listing what the token would allow |
| Decision `denied` with reason `insufficient-user-authentication` (only `subject.assurance` conditions failed) | 401 | `Bearer error="insufficient_user_authentication", acr_values="<required acr>", max_age="<seconds>"` (RFC 9470 section 3): the union of `acr` and the smallest `maxAge` across the failed assurance grantees, a break-glass `requires.assurance` and a role `activation.assurance` | `.../step-up-required` with `acrValues` and `maxAge` |
| Every denial is `not-entitled` | 403 | none | `.../not-entitled` with `plans` |
| Every denial is `limit` | 429 | none; `Retry-After`, `RateLimit` and `RateLimit-Policy` instead ([RateLimit header fields](/docs/standards/ratelimit-headers)) | `.../rate-limited` |
| Denials are `limit-unavailable`, possibly with `limit` | 503 | none | `.../limit-unavailable` |
| Any `denied` on a loaded row of a `disclosure: 'hide'` resource, unless every reason is step-up or limit | 404 | none | `.../not-found`, the body a missing row gets |
| Any other `denied` | 403 | none (the token was fine; authorization was not) | `.../denied` |
| `approval-required` | 403 | none | `.../approval-required` |
| `PermDockValidationError` | 400 | none | `.../validation` |

Hyphenated PermDock reasons become the underscored wire codes; nothing else is translated.

## Problem types [#problem-types]

Each `type` URI dereferences here: `https://permdock.com/problems/<type>` redirects to the matching section below.

### denied [#denied]

403. A matching deny, no matching grant, or a condition that failed. Carries `permission`, `denials` and `alternatives`.

### approval-required [#approval-required]

403. The grant needs a human approval first. Carries `permission`, `reason`, `token` and, when the adapter knows them, an optional `approval` object (`ApprovalHint`) with `at` (where to approve, a URL) and `hint` (a short instruction), set by the `approval` option on the [server kernel](/docs/adapters/server-kernel) and every HTTP adapter built on it, and on [permdock/terminal](/docs/adapters/terminal). Resume by repeating the call with the `PermDock-Approval` header.

### validation [#validation]

400. Boundary data failed the resource's Standard Schema. Carries `issues` instead of `denials`.

### unauthenticated [#unauthenticated]

401. No token, or a token that failed verification. Sent with a `WWW-Authenticate` challenge and carries only `permission`: no `denials`, no `alternatives` and a generic `detail`, so nothing explains the failure to an unauthenticated caller.

### step-up-required [#step-up-required]

401. Only `subject.assurance` conditions failed (RFC 9470). Carries `acrValues` and `maxAge`.

### not-entitled [#not-entitled]

403. Every denial is `not-entitled`: the subject holds the roles, and one of the plans in `plans` would grant the permission. Carries `permission`, `denials` and `plans` (the plan keys from the denials' `to`, in grant order) so a client can link to the upgrade page. A mix with any other reason stays `.../denied`, because a plan alone would not grant.

```json
{
  "type": "https://permdock.com/problems/not-entitled",
  "title": "Plan upgrade required",
  "status": 403,
  "permission": "analytics.read",
  "denials": [{ "role": "admin", "reason": "not-entitled" }],
  "plans": ["pro"]
}
```

### rate-limited [#rate-limited]

429. Every denial is `limit`: the subject holds the grant but used its count for the window. Sent with `Retry-After`, `RateLimit` and `RateLimit-Policy` ([RateLimit header fields](/docs/standards/ratelimit-headers)); carries `permission` and `denials` like `denied`.

### limit-unavailable [#limit-unavailable]

503. A `limit` grant could not be counted: no `LimitStore`, a store that threw, or one that returned a Promise. Fail-closed, and the server's fault rather than the caller's.

### invalid-signature [#invalid-signature]

403. A request claimed a Web Bot Auth signature that did not verify. It never becomes an anonymous actor.

### method-not-allowed [#method-not-allowed]

405. A route of the [AuthZEN handler](/docs/adapters/authzen) or the [approvals handler](/docs/adapters/approvals) was called with the wrong method or an unknown path. The AuthZEN handler also sets `Allow`.

### not-found [#not-found]

404. The approvals handler has no approval for the token, the AuthZEN handler has no endpoint at the path (including a search endpoint the application did not configure), or `protect`'s loader found no row. A denied row of a resource with `disclosure: 'hide'` answers with the same body and headers, so the two cannot be told apart; the reason stays on the decision event.

### conflict [#conflict]

409. The approvals handler was asked to resolve an approval that is no longer pending or has expired.

### payload-too-large [#payload-too-large]

413. An `evaluations` batch, on the AuthZEN handler or the decision endpoint (`createEvaluationsHandler`, `permdockHandler`), carried more than its `maxEvaluations` (256 by default). The check runs before the subject is resolved, so an oversized batch costs no decision work.

### internal [#internal]

500. The approval store threw. The body says only that the store failed and never carries the store's error.

## Mapping table [#mapping-table]

| RFC 9457 member | PermDock value |
| --- | --- |
| `type` | `https://permdock.com/problems/denied`, `.../approval-required`, `.../validation`, `.../unauthenticated`, `.../step-up-required`, `.../not-entitled`, `.../rate-limited`, `.../limit-unavailable`, `.../not-found` |
| `title` | Fixed per type, for example "Permission denied", "Approval required", "Authentication required", "Plan upgrade required", "Rate limit exceeded" |
| `status` | Fixed per type: the number in each heading under [Problem types](#problem-types) |
| `detail` | Model-readable sentence built from `Decision` reasons and `alternatives` |
| `instance` | Request path (adapter-supplied) |
| Extension `permission` | `permission.key` |
| Extension `denials` | `Decision.denials` (role, reason, portable condition) |
| Extension `alternatives` | `Decision.alternatives` as keys |
| Extension `token` | `Decision.token` (approval-required only) |
| Extension `issues` | Standard Schema `issues` (validation only) |
| Media type | `application/problem+json` |
| Companion `WWW-Authenticate` (RFC 6750, RFC 9470) | `invalid_token` from `on('auth')` `reason`, `insufficient_scope` from `not-delegated` / `no-delegation`, `insufficient_user_authentication` from `insufficient-user-authentication` |

## Why [#why]

The `type` URI is an identifier first: clients and gateways switch on it, so it must be identical across every deployment. A configurable base would let two installs emit different identifiers for the same problem and break any client written against one of them, which is why the kernel has no `problem.base` option. RFC 9457 still asks that the URI dereference to documentation, so `permdock.com/problems/<type>` redirects to the section above for that type rather than living as a separate page tree.

The `approval` member (`at`, `hint`) is shared across the terminal and HTTP adapters rather than terminal-only: it carries no secret, only where and how a human approves, and every surface that can return `approval-required` benefits from the same pointer.

## Sources [#sources]

* RFC 9457, Problem Details for HTTP APIs, referenced by number.
* RFC 6750 section 3 (`WWW-Authenticate: Bearer` and its error codes) and RFC 9470 section 3 (`insufficient_user_authentication`, `acr_values`, `max_age`) for the header emitted next to the body.
* Product plan, HTTP adapter section ("deny → 403 application/problem+json ...") and the [decisions concept](/docs/concepts/decisions).
* [OWASP Agentic Top 10 mapping](/docs/security/owasp-agentic) for why denials are written to be model-readable.
