The projected error-code catalogue is open¶
The projection described Error.code as a bare {"type": "string"}, so the 53 stable
codes core raises — each with a fixed status, a fixed default title and a known source
member — reached a generated client as an opaque string to match on by hand. We now emit
one named components.schemas.<Code>Error per code (allOf: [$ref Error, {code: const,
status: const, source: required}], with core's default title as the schema's title
annotation) and offer them from ErrorDocument.errors.items as an anyOf whose first
branch is the open generic Error.
The open branch is the whole decision. A closed oneOf would read as an exhaustive list,
and it would be wrong the first time an application threw an error of its own: the server
would emit a document that fails its own published schema. Keeping the generic branch
means the anyOf constrains nothing — every error object already satisfies it — so the
catalogue is discoverable without being authoritative. A generator reads the named
variants to emit typed exception subclasses and falls back to a status-based exception,
with the raw code intact, for a code it does not recognise. We accept that the validation
value is nil and the document grows by roughly 50 schema components; discovery is the
point, and validation of code was never possible anyway.
The membership is open too. A catalogue that only ever holds core's 53 codes describes
half of any real server: applications and framework integrations raise codes of their own,
and a generated client should be able to type those as well. So ErrorCatalog is a
composite over ErrorCatalogSourceInterface — each source yields described-error classes,
core's is one of them, and the projector assembles a fresh catalogue per server from
core's source plus whatever the server's metadata contributes through
ContributesErrorCodes. Per server, not static: an application's codes belong in the
documents of the servers that registered them and nowhere else, which is the
registration-awareness ADR 0131 already
applies everywhere else in the projection. Two different classes claiming one code
throws, naming both — a code is what a client dispatches on, so resolving the clash
silently would publish one class's status and title under the other's code. Core's source
reads the classes filed beside it, scoped to that one directory, at projection time and at
most once per process; a projected document is a warmed or exported artefact, so a
directory read costs nothing where it happens. (scandir, not glob: glob() does not
traverse stream wrappers and returns an empty array under phar://, which would empty the
catalogue without a word.) ErrorFeature stays a closed enum of core's own capabilities —
a contributed error names none and is published wherever it is registered.
Two supporting decisions. context is not projected at all. Error::$context is the
interpolation input core fills into the title / detail templates (ADR
0128) and never reaches the
wire, so typing it as a member would describe something no server sends. Publishing its
shape as an x-error-context extension describes the same unsendable thing one keyword
over, which is why that extension is gone too. Its audience was whoever writes a
replacement template, and they write PHP: ErrorDescriptor::$context already names the
tokens and their types, where they are already reading. The document stays a description of
what a client receives. The catalogue is registration-aware. Each descriptor names the
ErrorFeature it depends on (atomic operations, cursor pagination, a pagination menu,
the Countable profile, client-generated ids, or any write at all) and the projector drops
codes the server cannot raise, the same gating ADR
0131 applies to ?withCount and the
write components.
The catalogue is also offered per status
(ADR 0139): each error
response points at an ErrorDocument<status> listing only the codes pinned to that status,
so a 415 no longer advertises 53 codes when one can occur. Every one of those keeps the
generic branch first, and for a second reason on top of the one above — a document of mixed
statuses takes the class they round down to
(ADR 0018), so a 400 body
may carry an error object whose own status reads "422". The named set per status is
deliberately not exhaustive.
Considered options¶
- A closed
oneOf. Rejected above: it makes an application's own error codes invalid against the server's own schema. - A
discriminatoroncode. OpenAPI'sdiscriminatorneeds a closed mapping to be useful and is only advisory in 3.1; it buys a generator nothing theconstdoes not, and reintroduces the closed-list problem in the mapping. - Serializing
contextinto the error'smeta. It would make the typed-context story work end to end, but it changes every error document for every existing server, and ADR 0128 deliberately kept context internal. Out of scope for a projection change. - A hand-written roster of core's exception classes, policed by a test that scans
src/Exceptionand fails when one lands outside it. It is greppable and it diffs, but it is a second copy of the membership and it is closed by construction: an application cannot add a code to it at all. Discovering the same directory the test scanned gives the identical guarantee with nothing to drift, and the roster's absence is what made room for the source contract. - Reading the catalogue by reflecting over
src/Exceptionand constructing each exception with placeholder arguments. Fragile and dishonest —getErrors()builds its errors from constructor state, so the arguments would have to be invented and the resultingdetailwould be fiction. Instead each exception gained a staticdescribe(): ErrorDescriptor(a new opt-inDescribedErrorInterface, not a widening ofJsonApiExceptionInterface, so an application's own exceptions keep working undescribed) and now renders itsErrorthrough that descriptor, so the published description and the rendered error cannot disagree.