Contract Linting & Governance

Part of the API Design Fundamentals & Architecture reference. This page covers how to turn API style rules into executable governance — authoring a shared Spectral ruleset, wiring it into CI as a required gate, choosing severity levels, and rolling linting out across a whole organisation without stalling every team.

Problem framing

Every team ships a slightly different API. One service pluralises collections and another does not. One returns { "items": [...] }, the next returns { "data": [...] }, and a third returns a bare array. Error bodies drift between { "error": "..." }, { "message": "..." }, and a proper Problem+JSON envelope. Paths mix camelCase, snake_case, and kebab-case in the same estate. Some operations carry an operationId, which SDK generators need to name methods, and some do not.

None of this shows up in a single spec review. It emerges across dozens of specs written by dozens of engineers over years. Governance by human review does not scale: reviewers cannot hold the entire style guide in their heads, they apply it inconsistently, and the rules live in a wiki that nobody reads. The only durable fix is to make the style guide executable — a machine-checkable ruleset that runs on every change and fails the build when a contract drifts.

That is what a linter does for OpenAPI. Spectral and Redocly both read your spec, walk it with JSONPath expressions, and report violations at a severity you choose. The interesting work is not running the tool — it is deciding which rules matter, encoding organisation-specific ones (including writing custom Spectral rules in JavaScript), and rolling them out so a 200-endpoint legacy spec and a greenfield service can share the same governance without one blocking the other. Consistent contracts feed everything downstream: predictable pagination, uniform error contracts, and — once you add API versioning and deprecation discipline — SDKs that look the same across every team.

A minimal shared ruleset

The smallest useful ruleset extends Spectral’s built-in OpenAPI rules and adds a few organisation rules on top. Here it enforces kebab-case path segments, requires an operationId on every operation, and requires a Problem+JSON error response on every 4xx and 5xx.

# .spectral.yaml — organisation baseline (Spectral v6+)
extends:
  - "spectral:oas"          # built-in OpenAPI 3.x rules

rules:
  # 1. Path segments must be kebab-case (no camelCase, no snake_case)
  paths-kebab-case:
    description: Path segments must be lower kebab-case.
    message: "{{property}} is not kebab-case"
    severity: error
    given: "$.paths[*]~"                # '~' selects the property KEY (the path)
    then:
      function: pattern
      functionOptions:
        match: "^(/[a-z0-9]+(-[a-z0-9]+)*|/\\{[a-zA-Z0-9_]+\\})+$"

  # 2. Every operation must declare an operationId (SDK generators need it)
  operation-operationId-required:
    description: Every operation needs a stable operationId.
    message: "Operation is missing operationId"
    severity: error
    given: "$.paths[*][get,put,post,delete,patch]"
    then:
      field: operationId
      function: truthy

  # 3. Error responses must offer application/problem+json
  error-responses-use-problem-json:
    description: 4xx and 5xx responses must use application/problem+json.
    message: "Error response must define application/problem+json content"
    severity: error
    given: "$.paths[*][*].responses[?(@property.match(/^(4|5)\\d\\d$/))].content"
    then:
      field: "application/problem+json"
      function: truthy

Run it against a spec with npx @stoplight/spectral-cli lint openapi.yaml. Each rule names a given JSONPath target, a then assertion (truthy, pattern, schema, or a custom function), and a severity. That is the entire model — everything else is composition and rollout.

Governance pipeline

The diagram below shows the path a spec change takes: an author edits the spec, CI resolves the shared ruleset, the lint gate runs, and the change either merges or is rejected back to the author.

API contract governance pipeline Flow diagram: author edits the OpenAPI spec, the shared ruleset is resolved in CI, the lint gate evaluates the spec. On pass the change merges to main. On fail the change is rejected back to the author with rule violations. Author edits openapi.yaml Shared ruleset extends + rules CI lint gate spectral lint Merge to main exit code 0 Rejected error severity pass fail fix violations and re-push

Standard alignment

There is no RFC for API style. Governance rules instead map onto the OpenAPI Specification 3.1 and JSON Schema 2020-12, plus published style guides you choose to adopt. The table below maps common organisation rules to the spec keywords they assert against and the external reference that motivates them.

Governance rule OpenAPI 3.1 / JSON Schema target Reference style guide
Kebab-case path segments paths object keys Zalando RESTful API Guidelines
operationId present and unique operation.operationId OpenAPI Spec — required for tooling and SDKs
Error responses use Problem+JSON responses.4XX/5XX.content media type RFC 9457 (Problem Details), applied via lint
Every schema property has description schema.properties[*].description Google API Improvement Proposals (AIP)
Enums are closed and documented schema.enum + description JSON Schema 2020-12 enum
Pagination params are consistent parameters (limit, cursor) Internal style guide (no external standard)
No additive-only breaking changes required, type diff vs base Complements API versioning and deprecation
Security scheme declared per operation security, components.securitySchemes OWASP API Security guidance

The key point: a linter does not invent standards, it enforces the ones you pick. Write down the style guide, then translate each clause into a rule so the guide becomes executable rather than aspirational.

Implementation — step 1: author a shared ruleset and extend per repo

Governance at scale hinges on one shared ruleset that every repository inherits, plus thin per-repo overlays. Publish the baseline as a versioned artifact — an npm package, a Git URL, or a file in a governance repo — so bumping the shared rules is a dependency update rather than a copy-paste across dozens of specs.

The shared package exposes one ruleset:

# @acme/api-standards/ruleset.yaml — published, versioned governance baseline
extends:
  - "spectral:oas"
rules:
  paths-kebab-case:
    description: Path segments must be lower kebab-case.
    message: "{{property}} is not kebab-case"
    severity: error
    given: "$.paths[*]~"
    then:
      function: pattern
      functionOptions:
        match: "^(/[a-z0-9]+(-[a-z0-9]+)*|/\\{[a-zA-Z0-9_]+\\})+$"
  operation-operationId-required:
    severity: error
    given: "$.paths[*][get,put,post,delete,patch]"
    then:
      field: operationId
      function: truthy
  operation-description-required:
    description: Every operation should describe what it does.
    severity: warn
    given: "$.paths[*][get,put,post,delete,patch]"
    then:
      field: description
      function: truthy

Each repository then ships a three-line .spectral.yaml that extends the shared package and adds only what is local to that service:

# repo: payments-api/.spectral.yaml
extends:
  - "@acme/api-standards/ruleset.yaml"   # resolved from node_modules

rules:
  # Repo-specific: payment amounts must reference the shared Money schema
  payment-amount-uses-money-schema:
    description: Monetary fields must $ref the shared Money component.
    severity: error
    given: "$.paths[*][*]..properties[?(@property == 'amount')]"
    then:
      field: "$ref"
      function: truthy

Because extends composes rulesets, a repo can also raise or lower the severity of an inherited rule without redefining it — set operation-description-required: error to make a warn-level baseline rule blocking for a service that has already cleaned up its descriptions. Inheritance flows one direction: the shared ruleset sets the floor, and repos tighten it.

Implementation — step 2: wire it into CI as a required check and pre-commit

The lint only governs anything if it blocks merges. Run it in CI on every pull request and mark the check required in branch protection. Pair it with a pre-commit hook so authors catch failures locally before they push.

# .github/workflows/api-governance.yml
name: API Governance
on:
  pull_request:
    paths: ["**/openapi.yaml", "**/*.spectral.yaml"]

jobs:
  lint-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - name: Lint OpenAPI against the shared ruleset
        run: |
          npx @stoplight/spectral-cli lint openapi.yaml \
            --ruleset .spectral.yaml \
            --fail-severity=error \
            --format=github-actions   # inline PR annotations

--fail-severity=error makes only error-level rules fail the build; warn and hint still report as annotations without blocking. Once this job is green, add it as a required status check in the repository’s branch protection so a spec change cannot merge past a governance violation.

For local feedback, register the same lint in a pre-commit hook so it runs on staged spec files:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: spectral-lint
        name: Spectral OpenAPI lint
        entry: npx @stoplight/spectral-cli lint --fail-severity=error
        language: system
        files: "openapi\\.ya?ml$"

Some rules cannot be expressed with the built-in functions and need code. A custom Spectral function is a small JavaScript module; here one rejects any enum that is missing a matching x-enum-descriptions extension, so every enum ships documented values.

// functions/enum-documented.js — a custom Spectral function
export default function enumDocumented(targetVal, _opts, context) {
  // targetVal is the schema object at the `given` target
  const values = targetVal?.enum;
  if (!Array.isArray(values)) return;

  const descriptions = targetVal["x-enum-descriptions"];
  if (!descriptions || typeof descriptions !== "object") {
    return [{
      message: "enum is missing x-enum-descriptions",
      path: [...context.path],
    }];
  }
  const undocumented = values.filter((v) => !(v in descriptions));
  return undocumented.map((v) => ({
    message: `enum value '${v}' has no description`,
    path: [...context.path, "enum"],
  }));
}
# .spectral.yaml — register and use the custom function
functions: [enum-documented]
functionsDir: "./functions"
rules:
  enum-values-documented:
    description: Every enum must document each value.
    severity: warn
    given: "$..[?(@.enum)]"
    then:
      function: enum-documented

A function returns an array of { message, path } results — one per violation — or nothing when the target passes. Because it is ordinary JavaScript, you can assert anything the built-in functions cannot: cross-field consistency, naming derived from other properties, or lookups against an external registry.

Edge-case handling

Ruleset inheritance and overrides. When a repo extends a shared ruleset it inherits every rule, but two repos may legitimately need different strictness. Prefer raising severity in the extending ruleset over forking the baseline. To disable an inherited rule entirely, set it to off (operation-description-required: off) rather than deleting it upstream, so the exception is visible and local.

Severity: error vs warn vs hint. Treat severity as an adoption ladder. Land a new rule at hint or warn so it reports without failing CI, watch how many violations it surfaces across the estate, fix them, then promote to error. Only error should ever be a merge blocker; keep advisory rules advisory so the gate stays trustworthy — a gate that fails for stylistic nits gets bypassed.

Gradually adopting linting on a legacy spec. A 200-endpoint spec written before the ruleset existed will light up with hundreds of violations. Do not hold that team hostage. Use a Spectral overrides block to exempt specific files or JSONPath targets, and attach a documentationUrl on rules so violation messages link to the fix guidance:

# .spectral.yaml — scoped exceptions for a legacy spec
overrides:
  - files: ["legacy/orders-v1.yaml#/paths"]
    rules:
      paths-kebab-case: off        # grandfathered snake_case paths
  - files: ["**/*.yaml"]
    rules:
      operation-description-required: warn   # advisory estate-wide for now
rules:
  paths-kebab-case:
    documentationUrl: "https://acme.internal/standards/paths"
    # ...rule body as above

Overrides scope by file glob and by JSONPath, so you can grandfather exactly the legacy endpoints while keeping every new path strict.

Monorepo of many specs. When one repo holds dozens of specs, lint them all in one job by globbing, and keep per-service overlays alongside each spec. Spectral accepts multiple documents, and failing the job on any one violation keeps the whole monorepo honest:

npx @stoplight/spectral-cli lint "services/*/openapi.yaml" \
  --ruleset .spectral.yaml --fail-severity=error

Validation and testing — the lint is the test

Contract linting is itself the test for your specs, but the ruleset is code too, and code that is never tested rots. A subtly broken given path or a typo in a regex can make a rule silently match nothing — it passes every spec, including the bad ones, and nobody notices until a malformed contract ships. Guard against that by testing the ruleset against a known-bad fixture and asserting it still catches the violation.

// tests/ruleset.test.ts — assert the ruleset catches a known-bad spec
import { Spectral } from "@stoplight/spectral-core";
import { bundleAndLoadRuleset } from "@stoplight/spectral-ruleset-bundler/with-loader";
import * as fs from "node:fs";

test("kebab-case rule flags a camelCase path", async () => {
  const spectral = new Spectral();
  spectral.setRuleset(
    await bundleAndLoadRuleset(`${__dirname}/../.spectral.yaml`, { fs }),
  );

  const badSpec = `
openapi: 3.1.0
info: { title: Bad, version: 1.0.0 }
paths:
  /userAccounts:            # camelCase — must be rejected
    get:
      responses: { "200": { description: ok } }
`;
  const results = await spectral.run(badSpec);
  const codes = results.map((r) => r.code);
  expect(codes).toContain("paths-kebab-case");
  expect(codes).toContain("operation-operationId-required");
});
# tests/test_ruleset.py — same idea, driving the Spectral CLI from pytest
import json
import subprocess
import textwrap

def test_ruleset_flags_bad_spec(tmp_path):
    bad = tmp_path / "bad.yaml"
    bad.write_text(textwrap.dedent("""\
        openapi: 3.1.0
        info: { title: Bad, version: 1.0.0 }
        paths:
          /userAccounts:
            get:
              responses: { "200": { description: ok } }
    """))
    proc = subprocess.run(
        ["npx", "@stoplight/spectral-cli", "lint", str(bad),
         "--ruleset", ".spectral.yaml", "--format", "json"],
        capture_output=True, text=True,
    )
    codes = {r["code"] for r in json.loads(proc.stdout)}
    assert "paths-kebab-case" in codes
    assert "operation-operationId-required" in codes

Run these ruleset tests in the same CI job that publishes the shared package, so a change that weakens a rule fails before it reaches the repos that depend on it. This is the governance equivalent of a regression test: the fixture proves the rule still bites.

SDK generation impact

Consistent specs produce consistent SDKs. When every operation carries an operationId, the generator names client methods predictably — client.orders.list() rather than client.getOrdersGet() — across every service. When error responses uniformly use Problem+JSON, one generated error type covers the whole estate and clients written for one API handle failures from any other. When pagination parameters are named identically everywhere, the same helper that walks a cursor works against every endpoint.

Without governance, each spec’s quirks leak into its generated client: one SDK throws ApiError, the next returns a nullable result, a third exposes a method named after an auto-generated operationId that changes whenever the path changes. Consumers then learn a different client shape per service — exactly the friction SDKs are supposed to remove. Linting the contract is upstream of every generator, so fixing the spec fixes all of them at once. This is why operationId presence and error-shape uniformity are worth enforcing at error severity: they are load-bearing for SDK version pinning and predictable client ergonomics, not cosmetic.

Anti-patterns quick-reference

Anti-pattern Correct approach
Style guide lives only in a wiki nobody reads Encode each clause as a lint rule so the guide is executable
New rules land at error and break every open PR Land at warn/hint, fix violations, then promote to error
Copy-pasting the ruleset into every repo Publish one versioned shared ruleset; repos extends it
Lint runs but is not a required check Mark the CI lint as required in branch protection
Grandfathering legacy by lowering rules globally Use scoped overrides per file/path; keep new specs strict
Ruleset itself is never tested Assert it flags a known-bad fixture in CI
Custom logic crammed into brittle regex Write a custom JS function returning { message, path }
Disabling a rule by deleting it upstream Set it to off in the extending ruleset so the exception is visible
Failing the gate on advisory stylistic nits Keep only error-severity rules blocking; the rest advise

FAQ

Should we standardise on Spectral or Redocly for contract linting?

Use Spectral when you want an open-source, JSONPath-based ruleset you can extend with custom JavaScript functions and run anywhere. Use Redocly when you also want bundling, docs, and a hosted registry in one toolchain. Many organisations run Spectral as the governance gate and Redocly for docs; the two are not mutually exclusive. See the Spectral vs Redocly linting rule coverage comparison for a rule-by-rule breakdown.

How do we adopt linting on a large legacy spec without blocking every merge?

Start every new rule at severity warn or hint so it reports without failing CI, then promote rules to error one at a time as each violation class is fixed. For known legacy exceptions, use a Spectral overrides block scoped to specific files and JSONPath targets so the rest of the estate stays strict.

What is the difference between severity error, warn, and hint in Spectral?

error fails the run with a non-zero exit code and should block merges. warn reports a problem but keeps the exit code zero unless you pass a failure threshold. hint and info are advisory and never fail. Use the ladder to migrate rules from advisory to blocking as adoption matures.