ADR-0063: The OpenAPI documents describe refusals¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
Schemathesis found that artifactr's router answers statuses and bodies its OpenAPI document doesn't describe (lattice #66), so the Acceptance job ran with status_code_conformance and response_schema_conformance off (lattice ADR-0015):
- Refusals were undocumented. Every route can answer a rejection with
{"detail": {"type": ..., "message": ...}}, the resolver's 401 or its ownHTTPExceptionwith a stringdetail, or FastAPI's 422 with a list of field errors. The document listed 200 and FastAPI's 422 only, whose body differs from a rejection's. - Tags weren't required. A union member's tag (
typeorkind) has a default, so Pydantic's schema calls it optional, though the discriminator needs it. A command without one matched the document and was refused. An event in the log matched more than one member of its union, since an unknown event'stypewas any string. - An empty id in a path (
.../threads/) reached the list route instead, by FastAPI's slash redirect, and answered a list where the document said one item.
Decision¶
- Every route documents its refusals. The router is built with
responses=REFUSALS: 400, 401, 403, 404, 409 and 422, each answeringRefused, whosedetailis aRejectionDetail(type,messageand the rejection's typed details), a string, or a list ofFieldErrors.POST .../commandsanswers aCommandResultor aRefusedat each rejection's status. Each is described in words of its own, since FastAPI's default description, Python's phrase for the status, changed in 3.13 (Unprocessable Content). The models are shared verbatim with reflexr's router. - Every protocol model's tags are required in its JSON Schema.
tag_required, inartifactr.core.taggedand shared verbatim with reflexr, marks a model's fields fixed to one value required. Every frame, command, outcome, event, envelope, actor, target and union member names it in its config, so the committed protocol schemas and the OpenAPI document say what every frame on the wire carries, and portalr's client no longer adds it (lattice #82). A test holds the OpenAPI document and every*.v1.jsonschema to it. The document asks a REST body for itstypetoo, as the WebSocket does; the server still fills it in when it is left out. - An unknown event's
typeis none of the known ones in its JSON Schema, so no event matches two members of the union. - A path's ids are never empty (
minLength: 1), and neither is an id a command gives what it creates: an artifact, a proposal, a thread or a message. One created with an empty id could only be reached through the list route's redirect. - Schemathesis checks conformance. The root's
schemathesis.tomlturnsstatus_code_conformanceandresponse_schema_conformanceon.positive_data_acceptancestays off for the commands, which can be well formed and still not apply, and for the log's reads, whoselimitandlastexclude each other.
Options considered¶
| Option | Against |
|---|---|
One refusal model, a union of what detail can be (chosen) |
A client tells the three forms apart by shape |
| A model per status | Most statuses answer more than one form: a 403 is a rejection, or a resolver's own refusal |
Leave FastAPI's HTTPValidationError at 422 |
A rejection's 422 doesn't match it, so the check stays off |
| Mark tags required by rewriting the document | The application's own /openapi.json would still say they are optional |
Consequences¶
- Easier: a client generated from the document knows every refusal's body, and Schemathesis checks every status and body.
- Harder: a new protocol model names
tag_requiredin its config, or the documents' test fails. - Harder: the refusal statuses are one shared set, so the document says artifactr may answer 400, which it never does: no rejection of artifactr's is 400 over REST unless a resolver raises it.