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.
PermDock defines a type URI only where about:blank says too little: the nine types under Problem types that the package emits, plus policy-not-published from PermDock Cloud. Every other answer is about:blank, with the RFC 9110 reason phrase as title (RFC 9457 section 4.2.1). That covers 401 without a step-up, 404 for an unknown path, a missing row or a hidden one, 405 (sent with Allow), 409, 412, 413 for an oversized evaluations batch or a request body over its byte cap, and 500 when an approval store throws:
{
"type": "about:blank",
"title": "Method Not Allowed",
"status": 405,
"detail": "use GET, POST"
}Rules:
typeURIs are stable and 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.scopeandresource.typetravel next to it.denialsmirrorsDecision.denialsand is always present on a decision problem, 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.alternativeslists permission keys on the same resource the subject does hold, so a model can pick a permitted action.tokenappears only onapproval-required.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.
- An unauthenticated caller gets a generic
detailand onlypermission: nodenialsand noalternatives, so nothing explains the failure to it. - 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 examples below are PROBLEM_EXAMPLES from permdock/testing, one body per type for a post.delete request by subject u_123. A client can test its error handling against them; tests/standards/problem-details.test.ts rebuilds each one from the code that emits it and checks this page shows the same JSON.
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) | about:blank; 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) | about:blank |
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 | about:blank, 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.
{
"type": "https://permdock.com/problems/denied",
"title": "Permission denied",
"status": 403,
"detail": "post.delete denied for subject u_123: member (condition). Alternatives: post.read, post.update.",
"instance": "/posts/p_42",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post" },
"denials": [{ "role": "member", "reason": "condition" }],
"alternatives": ["post.read", "post.update"]
}approval-required
- The grant needs a human approval first. Carries
permission,reason,tokenand, when the adapter knows them, anapprovalobject (ApprovalHint) withat(where to approve, a URL) andhint(a short instruction). Theapprovaloption on the server kernel, every HTTP adapter built on it and permdock/terminal sets it. Resume by repeating the call with thePermDock-Approvalheader.
{
"type": "https://permdock.com/problems/approval-required",
"title": "Approval required",
"status": 403,
"detail": "post.delete requires human approval (human). Token: pd1.eyJhbGciOiJFZERTQSJ9.",
"instance": "/posts/p_42",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post" },
"reason": "human",
"token": "pd1.eyJhbGciOiJFZERTQSJ9",
"approval": { "at": "https://app.example/approvals" }
}validation
- Boundary data failed the resource's Standard Schema. Carries
issuesinstead ofdenials.
{
"type": "https://permdock.com/problems/validation",
"title": "Invalid resource data",
"status": 400,
"detail": "invalid post",
"permission": "post.delete",
"issues": [{ "message": "title is required", "path": ["title"] }]
}step-up-required
- Only
subject.assuranceconditions failed (RFC 9470). CarriesacrValuesandmaxAge.
{
"type": "https://permdock.com/problems/step-up-required",
"title": "Step-up required",
"status": 401,
"detail": "post.delete denied for subject u_123: member (insufficient-user-authentication). Alternatives: post.read, post.update.",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post" },
"denials": [
{
"role": "member",
"reason": "insufficient-user-authentication",
"to": { "kind": "assurance", "acr": ["urn:mfa"], "maxAge": 600 }
}
],
"alternatives": ["post.read", "post.update"],
"acrValues": ["urn:mfa"],
"maxAge": 600
}not-entitled
- Every denial is
not-entitled: the subject holds the roles, and one of the plans inplanswould grant the permission.plansholds 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,
"detail": "post.delete denied for subject u_123: admin (not-entitled). Alternatives: post.read, post.update.",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post" },
"denials": [
{
"role": "admin",
"reason": "not-entitled",
"to": { "kind": "plan", "plan": "pro" }
}
],
"alternatives": ["post.read", "post.update"],
"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).
{
"type": "https://permdock.com/problems/rate-limited",
"title": "Rate limit exceeded",
"status": 429,
"detail": "post.delete denied for subject u_123: member (limit). Alternatives: post.read, post.update.",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post" },
"denials": [
{
"role": "member",
"reason": "limit",
"detail": {
"count": 10,
"per": 60,
"resetsAt": "2030-03-17T17:46:40.000Z"
}
}
],
"alternatives": ["post.read", "post.update"]
}limit-unavailable
- A
limitgrant could not be counted: nolimitsfeature, a store that threw, or one that returned a Promise. Fail-closed, and the server's fault rather than the caller's.
{
"type": "https://permdock.com/problems/limit-unavailable",
"title": "Rate limit unavailable",
"status": 503,
"detail": "post.delete denied for subject u_123: member (limit-unavailable). Alternatives: post.read, post.update.",
"permission": "post.delete",
"scope": "post:delete",
"resource": { "type": "post" },
"denials": [{ "role": "member", "reason": "limit-unavailable" }],
"alternatives": ["post.read", "post.update"]
}invalid-signature
- A request claimed a Web Bot Auth signature that did not verify or did not follow the profile (Web Bot Auth). It never becomes an anonymous actor.
{
"type": "https://permdock.com/problems/invalid-signature",
"title": "Invalid signature",
"status": 403,
"detail": "HTTP Message Signature did not verify"
}approver-not-eligible
- The approvals handler refused an approver: not in the request's
approvers, the request's own principal, or its actor.reasonis theApprovalErrorcode:approver-not-eligible,approver-is-principalorapprover-is-actor.
{
"type": "https://permdock.com/problems/approver-not-eligible",
"title": "Approver not eligible",
"status": 403,
"detail": "approver is the principal of this request",
"reason": "approver-is-principal"
}policy-not-published
- The projects read API of PermDock Cloud answers it for a project that exists but has no pushed catalog yet; the package never emits it.
permdock cloud pullandrls generate --projectprint it and exit1.
{
"type": "https://permdock.com/problems/policy-not-published",
"title": "Policy not published",
"status": 404,
"detail": "project prj_123 has no pushed catalog; run permdock cloud push"
}Mapping table
| RFC 9457 member | PermDock value |
|---|---|
type | One of the URIs under Problem types, or about:blank |
title | Fixed per type; for about:blank, the RFC 9110 reason phrase |
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, to, detail) |
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.
A status whose meaning HTTP already defines (an unknown path, a wrong method, a conflict, a server fault) answers about:blank, as RFC 9457 section 4.2.1 recommends: a PermDock URI there would add an identifier clients must learn without telling them anything the status does not.
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.
CloudEvents 1.0.2
The CNCF CloudEvents envelope PermDock wraps every sink event in, the closed com.permdock type list, the JSON batch format webhooks carry, and the distributed tracing extension.