OpenAPI specs are linted with Spectral 6 in Docker (node:24-alpine). Same image locally and in CI.
./scripts/openapi-lint.sh # lint; exit 1 on error (CI)
./scripts/openapi-lint.sh --fix # lint, patch missing example fields, re-lint
CI builds --target lint only, prints the stylish report in the job log, writes a table to the GitHub job summary, and uploads .openapi-lint-report/ as an artifact. Warnings do not fail the gate (--fail-severity error). --fix only auto-patches oas3-valid-media-example missing windowStart/windowEnd.
.spectral.yaml extends spectral:oas — the OpenAPI specification rules — and adds the
conventions this estate has agreed with itself, which spectral:oas has no opinion about.
spectral:oas grades one document in isolation: valid schema, resolvable refs, examples that
match their schemas, no unused components. That is worth having and it is not the same question
as do these ten contracts agree with each other. On 2026-09-18 the estate published two
different error shapes — four APIs on RFC 7807 application/problem+json, three on an Errors
array — and this check reported zero findings at every severity.
The gradera- rules close that gap:
| Rule | What it stops |
|---|---|
gradera-error-response-is-problem-json |
A 4xx/5xx body declared as application/json |
gradera-shared-error-response-is-problem-json |
The same, for reusable components.responses |
gradera-no-error-array-schema |
An Error or Errors schema — the pre-D-37 envelope |
gradera-problem-schema-has-estate-members |
A Problem that does not require code and errorId |
gradera-problem-type-uri |
A type that is not https://gradera.dev/problems/{slug} |
gradera-error-example-carries-a-code |
An error example with no code for a client to branch on |
All of them are error severity, and so are operation-operationId, operation-tags,
info-description, oas3-unused-component and operation-must-have-summary, which were
already enabled but at warn. The lint runs with --fail-severity error, so a rule at warn
cannot fail the build: it is documentation with a linter’s accent.
overrides at the bottom of .spectral.yaml names every contract that is allowed to break one
of these rules, and why. Today that is three — v1-alerts, v1-audit, v1-notifications —
whose services D-37 did not convert, plus one endpoint (POST /flows/{flowSlug}/publish, 422)
that answers with a deliberate domain body rather than the general error envelope. They are
listed so the remaining divergence can be counted; converting a service is what removes an
entry, because editing its contract alone would only make it wrong.
Note when adding one: { and } must be percent-encoded in an override’s JSON pointer. Spelled
literally, the override silently matches nothing and the rule still fires.
Spectral is given --ruleset explicitly rather than left to find the file. Without it, a
ruleset missing from the checkout or the bind mount makes Spectral print No ruleset has been
found, lint nothing, and the report then says “0 errors” — a clean bill of health over a run
that checked nothing. Named, the same situation reads as ENOENT, and the summary says
OpenAPI Lint could not run instead of passed.
sos-admin/, nexus-flow/, tenant-management/ and microapps/. The BFF contracts under
microapps/ were outside the glob until 2026-09-18 and were never linted at all.