PermDock
Standards

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:

  • 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. 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).
  • 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

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):

SituationStatusWWW-AuthenticateProblem Details type
No token: no Authorization header (Decision reason anonymous)401Bearer, with no error code (RFC 6750 section 3.1).../unauthenticated; no denials
A token that failed verification: on('auth') reason: 'invalid-token', Decision reason anonymous401Bearer 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-delegation403Bearer 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)401Bearer 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-entitled403none.../not-entitled with plans
Every denial is limit429none; Retry-After, RateLimit and RateLimit-Policy instead (RateLimit header fields).../rate-limited
Denials are limit-unavailable, possibly with limit503none.../limit-unavailable
Any denied on a loaded row of a disclosure: 'hide' resource, unless every reason is step-up or limit404none.../not-found, the body a missing row gets
Any other denied403none (the token was fine; authorization was not).../denied
approval-required403none.../approval-required
PermDockValidationError400none.../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

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

approval-required

  1. 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 and every HTTP adapter built on it, and on permdock/terminal. Resume by repeating the call with the PermDock-Approval header.

validation

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

unauthenticated

  1. 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

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

not-entitled

  1. 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.
{
  "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

  1. 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); carries permission and denials like denied.

limit-unavailable

  1. 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

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

method-not-allowed

  1. 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

  1. 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

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

payload-too-large

  1. 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

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

Mapping table

RFC 9457 memberPermDock value
typehttps://permdock.com/problems/denied, .../approval-required, .../validation, .../unauthenticated, .../step-up-required, .../not-entitled, .../rate-limited, .../limit-unavailable, .../not-found
titleFixed per type, for example "Permission denied", "Approval required", "Authentication required", "Plan upgrade required", "Rate limit exceeded"
statusFixed per type: the number in each heading under Problem types
detailModel-readable sentence built from Decision reasons and alternatives
instanceRequest path (adapter-supplied)
Extension permissionpermission.key
Extension denialsDecision.denials (role, reason, portable condition)
Extension alternativesDecision.alternatives as keys
Extension tokenDecision.token (approval-required only)
Extension issuesStandard Schema issues (validation only)
Media typeapplication/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: 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.
  • OWASP Agentic Top 10 mapping for why denials are written to be model-readable.

Last updated on

On this page