RFC 9457 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
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
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 and errors.
How PermDock uses it
assert runs layered unauthorized handlers and rethrows PermDockDeniedError or PermDockApprovalRequiredError; the 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:
{
"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:
{
"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:
typeURIs are stable and per outcome:.../denied,.../approval-required,.../validation(forPermDockValidationErrorat a boundary, status 400), plus two 401 types that exist only where aWWW-Authenticatechallenge is the real answer:.../unauthenticated(no token, or a token that failed verification) and.../step-up-required(RFC 9470, carryingacrValuesandmaxAge). An exhaustedlimitis.../rate-limited(429) and a limit store that cannot answer is.../limit-unavailable(503); a denied row of a resource withdisclosure: 'hide'is.../not-found(404), and a denial only a plan stands behind is.../not-entitled(403, withplans). They sit under the fixed basehttps://permdock.com/problems; the host is not configurable, so a self-hosted deployment emits the same identifiers.permissionis the permissionkey, never the object; keys are the wire form of references.denialsmirrorsDecision.denialsand 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.alternativeslists permission keys on the same resource the subject does hold, so a model can pick a permitted action.tokenappears only onapproval-requiredand is the replay-safe hash described in approvals.approval-requiredis a 403 like a denial, so a client that ignores thetypefails closed; once approved, the client resumes by repeating the request with thePermDock-Approvalheader (approvals adapter).- 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+jsonand the status fromstatus. tRPC and oRPC adapters embed the same object as the error'sdatabecause they own their envelopes.
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, JWT adapter):
| 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) | .../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
Each type URI dereferences here: https://permdock.com/problems/<type> redirects to the matching section below.
denied
- A matching deny, no matching grant, or a condition that failed. Carries
permission,denialsandalternatives.
approval-required
- The grant needs a human approval first. Carries
permission,reason,tokenand, when the adapter knows them, an optionalapprovalobject (ApprovalHint) withat(where to approve, a URL) andhint(a short instruction), set by theapprovaloption on the server kernel and every HTTP adapter built on it, and on permdock/terminal. Resume by repeating the call with thePermDock-Approvalheader.
validation
- Boundary data failed the resource's Standard Schema. Carries
issuesinstead ofdenials.
unauthenticated
- No token, or a token that failed verification. Sent with a
WWW-Authenticatechallenge and carries onlypermission: nodenials, noalternativesand a genericdetail, so nothing explains the failure to an unauthenticated caller.
step-up-required
- Only
subject.assuranceconditions failed (RFC 9470). CarriesacrValuesandmaxAge.
not-entitled
- Every denial is
not-entitled: the subject holds the roles, and one of the plans inplanswould grant the permission. Carriespermission,denialsandplans(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.
{
"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
- Every denial is
limit: the subject holds the grant but used its count for the window. Sent withRetry-After,RateLimitandRateLimit-Policy(RateLimit header fields); carriespermissionanddenialslikedenied.
limit-unavailable
- A
limitgrant could not be counted: noLimitStore, a store that threw, or one that returned a Promise. Fail-closed, and the server's fault rather than the caller's.
invalid-signature
- A request claimed a Web Bot Auth signature that did not verify. It never becomes an anonymous actor.
method-not-allowed
- A route of the AuthZEN handler or the approvals handler was called with the wrong method or an unknown path. The AuthZEN handler also sets
Allow.
not-found
- 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 withdisclosure: 'hide'answers with the same body and headers, so the two cannot be told apart; the reason stays on the decision event.
conflict
- The approvals handler was asked to resolve an approval that is no longer pending or has expired.
payload-too-large
- An
evaluationsbatch, on the AuthZEN handler or the decision endpoint (createEvaluationsHandler,permdockHandler), carried more than itsmaxEvaluations(256 by default). The check runs before the subject is resolved, so an oversized batch costs no decision work.
internal
- The approval store threw. The body says only that the store failed and never carries the store's error.
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 |
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
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
- RFC 9457, Problem Details for HTTP APIs, referenced by number.
- RFC 6750 section 3 (
WWW-Authenticate: Bearerand 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.
- OWASP Agentic Top 10 mapping for why denials are written to be model-readable.
Last updated on
OpenAPI registries
The OpenAPI Initiative registries, the x-permdock- namespace PermDock emits, which registered x-oai-* extensions it reuses, why it does not use x-agent-trust, the JWS typ values and media types PermDock signs with, and the rule for never inventing names in someone else's namespace.
RateLimit header fields
How PermDock's HTTP adapters answer an exhausted limit grant with 429, Retry-After and the IETF RateLimit and RateLimit-Policy header fields, pinned to draft-ietf-httpapi-ratelimit-headers-11.