0004 — RFC 7807 Problem Details + SASO-DOMAIN-NNNN error codes¶
- Status: accepted
- Date: 2026-04-26
- Deciders: @kackey621, Willen Federation contributors
Context and Problem Statement¶
The legacy stack returns errors as ad-hoc JSON shapes (common/FailJsonView) for the few endpoints that speak JSON, and as HTML pages for everything else. Failure messages are user-facing Japanese strings with no machine-readable identifier; debugging an operator report typically requires correlating the timestamp in the operator's screenshot to a server log, then guessing which code path produced it.
The M3 REST surface (ADR 0002) and the Pluggable IdP work (ADR 0003) introduce many more failure modes that need to be triaged without a screenshot — OIDC discovery failures, claim-mapping mismatches, JWT signature errors, rate limits, validation rejections. We need an error format that:
- is the same shape across every endpoint;
- carries a stable, greppable identifier so operators and support staff can map a failure to a runbook entry;
- is translatable, because the system is bilingual (en + ja) by requirement;
- ties each response back to a server-side log entry without leaking internals; and
- survives at least one major refactor without breaking clients.
Decision Drivers¶
- Standards alignment — clients and proxies should be able to introspect errors without bespoke parsing.
- Greppable IDs —
SASO-AUTH-1003is searchable across logs, code, docs, and translation files. - Translatable — the human-readable
titleanddetailfields must come from the same i18n catalogue as the rest of the UI. - Correlation without leakage — every response must carry a
traceIdthat maps to a full stack trace in the server log, but the response must never carry the stack itself. - Backward-incompatible by intention — the legacy
common/FailJsonViewshape is replaced for/api/v1/*and for any new endpoints. Old endpoints retain their shape until they are migrated, at which point they switch in one PR.
Considered Options¶
Option A — Custom envelope ({ "ok": false, "error": { "code": "...", "message": "..." } })¶
A bespoke shape designed for this codebase.
- (+) Trivial to implement; matches what the existing FailJsonView does today.
- (−) No standards alignment. Every consumer learns a SASO-specific shape.
- (−) No
Content-Typesignalling — clients have to inspect the body to know it is an error. - (−) HTTP intermediaries (proxies, dashboards) cannot recognise the shape without configuration.
Option B — RFC 9457 Problem Details with vendor extensions¶
The successor to RFC 7807. Adopts the same shape but allows additional fields.
- (+) Latest revision of the standard.
- (−) Tooling, validators, and most of the documentation in the wild still target RFC 7807. The two specs are wire-compatible; choosing 9457 buys nothing for clients today.
Option C — RFC 7807 Problem Details with vendor extensions¶
A application/problem+json body with the standard fields (type, title, status, detail, instance) plus our extensions (code, traceId).
- (+) Mature standard, widely supported, recognised by Swagger UI, Postman, k6, and HTTP libraries.
- (+) The vendor-extension mechanism is exactly what we need for
codeandtraceId. - (+) Wire-compatible with RFC 9457 if we ever want to upgrade.
Decision Outcome¶
Chosen option: C — RFC 7807 Problem Details with code + traceId extensions.
Wire format¶
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://docs.willen-federation.org/error-codes#SASO-ITEM-2014",
"title": "Item barcode is already in use",
"status": 422,
"detail": "Barcode 4901234567890 is already assigned to item #4711.",
"instance": "/api/v1/items",
"code": "SASO-ITEM-2014",
"traceId": "1f9b3c8a-9e15-4d6a-8c2c-3d8f4f1f7a12"
}
type— URL pointing at the canonical entry indocs/error-codes.md.title— short summary, locale-resolved fromtranslations/<lang>.yaml.status— HTTP status, mirrors the response status line.detail— request-specific message, locale-resolved.instance— request URI (no query string, no trailing IDs that would amount to PII unless they are non-sensitive).code— vendor extension, the stableSASO-DOMAIN-NNNNidentifier. This is the field clients should branch on.titleanddetailare for humans.traceId— UUIDv4 generated per request. The same id appears in the Monolog log entry for the failure. Operators paste this into the support form.
Code namespace¶
SASO-AUTH-1xxx authentication and session
SASO-ITEM-2xxx item domain
SASO-LABEL-3xxx label / PDF
SASO-SHELF-4xxx shelf / location
SASO-INSTALL-5xxx installer and migrations
SASO-CONFIG-6xxx system_setting and provider configuration
SASO-FLAG-7xxx feature flag
SASO-INFRA-9xxx infrastructure (DB, filesystem, third-party APIs)
- The four-digit number is allocated sequentially within a domain. Codes are append-only: a code that goes out of use is marked
(deprecated)indocs/error-codes.mdbut never reassigned. docs/error-codes.mdis the canonical catalogue. Adding a code requires editing this file, the translation YAMLs, and the throwing site in the same PR.
Server side¶
src/Domain/Shared/ErrorCode/ErrorCode.php—enumof every defined code, withdomain(),httpStatus(), andtranslationKey()methods.src/Domain/Shared/DomainException.php— base exception carrying anErrorCodeand a context array.src/Presentation/Http/Problem/ProblemDetails.php— value object that materialises the wire shape.src/Presentation/Http/Problem/Renderer.php— turns aProblemDetailsinto aResponsewithContent-Type: application/problem+json.src/Presentation/Http/Problem/ExceptionHandler.php— global handler. CatchesDomainExceptionand renders Problem Details; catchesThrowableand rendersSASO-INFRA-9000with a freshtraceId, while logging the full stack aterrorlevel.src/Infrastructure/Logging/MonologFactory.php— Monolog with rotating-file handler,traceIdinjected via processor.
Translations¶
- Translation keys are
error.<code>.titleanderror.<code>.detail. They live intranslations/en.yamlandtranslations/ja.yaml. - Missing translations fall back to English; missing English entries fall back to the code itself with a CI warning, not a 500.
Coexistence with legacy¶
/api/v1/*and any new endpoint use this format from day one.- Existing endpoints (the Web UI behind
framework/Router.php) continue to use HTML responses andcommon/FailJsonViewuntil each feature migrates. When a feature is migrated to the new layout, its error responses switch in the same PR and the legacy code path is removed.
Consequences¶
- Every API failure carries a stable
code, a translatabletitle/detail, and atraceIdthat anchors it to a server log. Support workflows can be scripted around these fields. docs/error-codes.mdbecomes a contract: every code documented there must have a translation and at least one production code path that throws it. CI enforces this in M3-B.- Clients can rely on
application/problem+jsonfor content-negotiation. They can branch oncode, ignoretitle/detail(which may change wording), and present the localised string the server already produced. - Adding a new error costs three edits (catalogue, two translations) plus a thrown site. We accept this overhead in exchange for greppable, translatable failures.
- Internal exceptions never leak. Anything not derived from
DomainExceptionbecomesSASO-INFRA-9000with the trace stored server-side. Operators get a reference; attackers get a UUID. - This ADR is wire-compatible with RFC 9457; if we adopt the newer revision later, we change the spec citation, not the response shape.