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.

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:

  • type URIs are stable and 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. scope and resource.type travel next to it.
  • denials mirrors Decision.denials and 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.
  • 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. 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.
  • An unauthenticated caller gets a generic detail and only permission: no denials and no alternatives, so nothing explains the failure to it.
  • 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 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):

SituationStatusWWW-AuthenticateProblem Details type
No token: no Authorization header (Decision reason anonymous)401Bearer, 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 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)about:blank
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 limit404noneabout:blank, 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.
{
  "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

  1. The grant needs a human approval first. Carries permission, reason, token and, when the adapter knows them, an approval object (ApprovalHint) with at (where to approve, a URL) and hint (a short instruction). The approval option on the server kernel, every HTTP adapter built on it and permdock/terminal sets it. Resume by repeating the call with the PermDock-Approval header.
{
  "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

  1. Boundary data failed the resource's Standard Schema. Carries issues instead of denials.
{
  "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

  1. Only subject.assurance conditions failed (RFC 9470). Carries acrValues and maxAge.
{
  "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

  1. Every denial is not-entitled: the subject holds the roles, and one of the plans in plans would grant the permission. plans holds 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

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

  1. A limit grant could not be counted: no limits feature, 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

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

  1. The approvals handler refused an approver: not in the request's approvers, the request's own principal, or its actor. reason is the ApprovalError code: approver-not-eligible, approver-is-principal or approver-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

  1. 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 pull and rls generate --project print it and exit 1.
{
  "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 memberPermDock value
typeOne of the URIs under Problem types, or about:blank
titleFixed per type; for about:blank, the RFC 9110 reason phrase
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, to, detail)
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.

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