# ADR 065: Spectral (OpenAPI Linting & API Governance)

- HTML version: https://robbiepalmer.me/projects/recipe-site/adrs/065-spectral
- Project: Recipe Site (https://robbiepalmer.me/projects/recipe-site.md)
- Status: Accepted
- Date: 2026-08-09
- Ideas: Goodhart's Law (https://robbiepalmer.me/ideas/goodharts-law.md)
- Initiatives: Digital Twins for Everyday Life (https://robbiepalmer.me/initiatives/digital-twins-for-everyday-life.md)

> **Status: Accepted.** The adopting change migrates the governed routes to
> `@hono/zod-openapi` `createRoute` declarations, commits the generated OpenAPI
> contract, blocks contract lint and breaking changes in CI, fixes the audited
> route drift, and removes the route-convention violation baseline.

# Context

The monorepo's quality stack watches almost every axis of the codebase except
the one that outlives any single file: the **HTTP contract** of the recipe API.
SonarQube scores health within files
([ADR 048](/projects/personal-knowledge-graph/adrs/048-sonarqube)), Knip finds unreachable
code ([ADR 052](/projects/personal-knowledge-graph/adrs/052-knip)), Gitleaks blocks secrets
([ADR 054](/projects/personal-knowledge-graph/adrs/054-gitleaks)), all of which cover the
recipe-api Worker's code. None of them can see that `POST /recipes/import-url`
puts a verb in a path, or that a `GET` and a `POST` on the same URL mean two
unrelated things.

That is the recipe site's blind spot specifically: it is the only part of the
monorepo with a public HTTP contract. The API is the \~40-route Hono app in
`workers/recipe-api`, reached through the Cloudflare Pages Function proxies in
`functions/api/**`. An audit found the drift a growing surface accumulates
without a gate:

* **Verbs in paths.** `POST /recipes/import-url` and `POST /recipes/import-file`
  are RPC-shaped actions sitting beside the properly-modelled async job resource
  `POST /recipe-imports` (+ `GET /recipe-imports/:jobId`), one concept, two
  shapes.
* **Method/resource mismatch.** `GET /api/profile/cooking-insights` returns a
  read model, but `POST` to the *same* path creates a `cookingSession`, a
  different resource entirely.
* **Action paths that are really methods.** `PUT /pantry/restore` is an
  idempotent additive merge into the pantry singleton, i.e. `PATCH /pantry`,
  wearing a verb in its URL.
* **Inconsistent error shapes.** Handlers return JSON `{ error }` bodies in most
  places, while `c.notFound()` previously fell back to Hono's plain-text 404,
  with no documented envelope a client could rely on.

Two facts make the timing favourable. **One atomic consumer:** the only client
of the recipe API today is the UI in this same repository, behind the proxy, so
renaming `import-url`/`import-file` or folding `pantry/restore` into
`PATCH /pantry` can change on both sides in the same PR without a versioned migration,
and that window closes the moment the API is public. **The proxy already
normalises the prefix:** callers only ever see `/api/*` (the internal split
where profile and auth keep `/api` and everything else drops it never reaches a
client), so the generated spec describes one clean external surface. Nothing
stops the next agent-written route from adding more drift; this is the cheapest a
contract cleanup will ever be.

# Decision

Adopt **Spectral** as the recipe API's governance linter, driven by an
**OpenAPI** document generated from the routes' shared Zod schemas via
**`@hono/zod-openapi`**, its `createRoute` pattern makes the route definition,
its request schemas, its shared response envelopes, and the spec one declaration,
so the contract cannot drift from the code. This is chosen over the lower-churn
`hono-openapi` (which bolts metadata onto the existing handlers) deliberately:
the single source of truth is worth rewriting the \~40 routes to `createRoute`,
and the Zod schemas most handlers already validate through (`parseJsonBody`)
port directly into those definitions. The generated `openapi.json` is committed
and linted on every change.

Rulesets, pinned by version:

* **`spectral:oas`**, the built-in OpenAPI correctness baseline.
* **`@stoplight/spectral-owasp-ruleset`**, the OWASP API Security rules:
  bounded arrays (`maxItems`) and strings (`maxLength`) against payload-DoS, a
  `429` documented on rate-limited operations (URL/photo import and
  recommendations already rate-limit), no credentials in query strings, a
  declared security scheme per operation.
* **A small custom project ruleset**, plural collection nouns, no verbs in
  paths (with an explicit allowlist for legitimate state-transition actions such
  as `accept` / `decline` / `leave` / `read-all`), method-per-intent, and a
  single shared error envelope.

Enforcement:

* **`mise run //:lint:api`**, wired into the root `lint` aggregate.
* A **blocking** CI job on any change under `workers/recipe-api`, justified the
  same way Knip's and zizmor's were
  ([ADR 049](/projects/personal-knowledge-graph/adrs/049-zizmor)): the baseline is greened
  first. The inconsistencies above are fixed in the adopting change,
  `import-url`/`import-file` renamed to reflect that they return drafts,
  `cooking-insights` `POST` split to `cooking-sessions`, `pantry/restore` folded
  into `PATCH /pantry`, and the error envelope unified, verified by the worker's
  typecheck, unit tests, and a full build. No external consumers exist to
  break.
* **`oasdiff`** diffs the committed `openapi.json` against the PR and fails on
  breaking changes (a removed endpoint, a dropped enum value, a newly-required
  request field). This is the breaking-change gate the spec unlocks at near-zero
  extra cost.

**Versioning** stays deferred: a single unversioned `/api` for now, because the
one consumer ships atomically with the API. The strategy is recorded here so the
choice is made once, **URL-based `/api/v1`** when the first independent client
(mobile app, third-party integration) appears, and the `oasdiff` gate ensures
the first breaking change is a deliberate version bump rather than an accident.

# Rules (Concrete Set)

The ruleset lives at `workers/recipe-api/.spectral.yaml` with two custom
functions in `workers/recipe-api/spectral-functions/`. Severities are chosen to
green quickly rather than flood the first PR.

| Rule                                    | Source                       | Severity | Example                                                                                |
| --------------------------------------- | ---------------------------- | -------- | -------------------------------------------------------------------------------------- |
| `path-segments-kebab-case`              | custom                       | error    | ✗ `/recipeImports` ✓ `/recipe-imports`                                                 |
| `paths-no-verbs`                        | custom (`noVerbPaths`)       | error    | ✗ `/recipes/import-url`, `/pantry/restore` ✓ `/households/{id}/leave` (allowlisted)    |
| `collections-are-plural`                | custom (`pluralCollections`) | warn     | ✗ `/household/{id}` ✓ `/households/{id}`; singletons (`pantry`, `profile`) allowlisted |
| `path-keys-no-trailing-slash`           | `spectral:oas`               | error    | ✗ `/recipes/`                                                                          |
| `errors-use-shared-schema`              | custom                       | error    | ✗ ad-hoc `{ message: "failed" }` ✓ `$ref` a shared `Error`                             |
| `owasp:api4:2023-array-limit`           | OWASP                        | error    | list responses need `maxItems`                                                         |
| `owasp:api4:2023-string-limit`          | OWASP                        | error    | `title`/`source` need `maxLength`                                                      |
| `rate-limited-operations-document-429`  | custom (OWASP-scoped)        | error    | import + recommendation ops document `429` and `Retry-After`                           |
| `owasp:api2:2023-no-credentials-in-url` | OWASP                        | error    | no `?token=` in query, stays green as a guardrail                                      |

**Boundary.** Spectral lints the contract's *shape*, not its *intent*. It flags
the import verbs and `pantry/restore` structurally, but it cannot see that
`POST /api/profile/cooking-insights` creates a `cookingSession`, that is a
right-method / wrong-resource mismatch the spec considers valid. Semantic
findings like this are fixed by hand in the greening pass and guarded going
forward by a lightweight route-introspection test
(`workers/recipe-api/tests/route-conventions.test.ts`) that asserts the
path/method conventions directly against Hono's `app.routes` and verifies every
governed route appears in the generated spec. The adopting change removes the
now-empty violation baseline.

# Why It Adds Something New

The axis is the **externally-observable HTTP contract**, orthogonal to
everything already running: file-local health (SonarQube), cross-module
reachability (Knip), secrets (Gitleaks). On the determinism axis of
[ADR 048](/projects/personal-knowledge-graph/adrs/048-sonarqube) it lands squarely in the
deterministic column, an AI reviewer *might* notice a singular/plural slip
inside one diff's blast radius; Spectral fails the same rule on the same path
every time. Biome, the monorepo's formatter/linter, has no equivalent rules for
REST conventions, so this is genuinely uncovered ground rather than a duplicate
gate.

# Alternatives Considered

| Tool                                          | Role                        | Pros                                                                                                                                                       | Cons                                                                                                        | Verdict                                                                              |
| --------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| **Spectral + generated OpenAPI** *(accepted)* | Contract linting            | Sees request/response schemas, not just paths; composable rulesets (OWASP, custom); the spec doubles as docs and a typed-client source; unlocks `oasdiff`. | Requires generating and committing a spec; OWASP ruleset churns and needs pinning.                          | **Accepted.**                                                                        |
| **Route-introspection Vitest test**           | Path/method assertions      | No spec, trivial to write against Hono's `app.routes`; sees routes that bypass the spec.                                                                   | Blind to request/response bodies, can't do OWASP payload limits, error-envelope, or breaking-change checks. | Rejected as *sole* solution; kept as the permanent parity guard against spec bypass. |
| **`@hono/zod-openapi`** *(accepted bridge)*   | Spec generation             | Tightest coupling; route, schema, and spec are one declaration, so no drift.                                                                               | Rewrites \~40 routes to the `createRoute` pattern.                                                          | **Accepted:** the churn buys a genuine single source of truth.                       |
| **`hono-openapi`**                            | Spec generation             | Annotates existing handlers; far less churn.                                                                                                               | Spec is a parallel annotation that can drift from the handler it describes.                                 | Rejected: lower cost, weaker guarantee than the accepted bridge.                     |
| **Optic**                                     | Spec-from-traffic + diffing | Captures the real contract from tests/traffic; strong breaking-change UX.                                                                                  | Heavier; leans SaaS; overlaps `oasdiff` on the part we need.                                                | Rejected: more than the job needs today.                                             |
| **Biome / ESLint plugin**                     | Lint rules                  | Biome already in the stack.                                                                                                                                | No REST-convention rules exist for Biome; we don't run ESLint.                                              | Not available.                                                                       |
| **Status quo / hand review**                  | n/a                         | Nothing new.                                                                                                                                               | The four drift classes above say it doesn't hold as the API grows.                                          | Rejected.                                                                            |

# Where It Sits On The Adoption Curve

**Late majority** for the core: Spectral is the de-facto OpenAPI linter with a
deep LLM corpus, `oasdiff` is stable, and OpenAPI itself is a decade-old
standard. The newer piece is the **Zod-to-OpenAPI bridge**
(`@hono/zod-openapi`), which sits in the
[Goldilocks zone](/projects?tab=philosophy#the-goldilocks-zone), young enough
that its corpus is thinner, accepted because the alternatives either rot (a
hand-maintained spec) or drift (annotation-based `hono-openapi`). The OWASP
ruleset is actively maintained and version-pinned so a rule addition can't
silently redden an unrelated PR.

# Relation To Building Philosophy

* [Short Feedback Loops](/projects?tab=philosophy#short-feedback-loops). A PR
  that adds a verb-in-path route or an unbounded array hears about it on that
  PR, not from a client that breaks after launch.
* [LLM-Optimized](/projects?tab=philosophy#llm-optimized). Agents write most new
  routes; a deterministic contract gate is exactly the guardrail that keeps
  agentic velocity from becoming agentic drift, and the generated `openapi.json`
  is machine-readable context an agent can read before adding the next endpoint,
  the same instinct as the site's agent-friendly Markdown twins.
* [Less Is More](/projects?tab=philosophy#less-is-more). One generated artifact,
  three pinned rulesets, one CI job, and it *removes* surface by forcing the
  duplicate import shapes and mismatched methods to converge.
* [Respect Goodhart's Law](/projects?tab=philosophy#respect-goodharts-and-conways-laws).
  Legitimate action endpoints are allowlisted openly rather than suppressed with
  inline ignores; the gate covers only rule classes greened in the adopting
  change.

# Gaps & Risks

* **Routes could bypass the spec without the parity test.** `createRoute`
  removes drift inside a governed route, while the route-introspection test now
  compares every registered recipe route with the generated document. Better
  Auth's pinned wildcard adapter is the one explicit boundary: it owns a dynamic
  third-party route family rather than a finite recipe-domain contract.
* **Success responses start broad.** Request bodies and the shared `Error`
  envelope are concrete Zod schemas; successful JSON responses currently use a
  shared open object while endpoint-specific read models stabilise. `oasdiff`
  therefore blocks removed operations and request-side breaks immediately, but
  response-field compatibility becomes stronger as those read models are
  tightened.
* **Allowlist creep.** The "no verbs in paths" rule needs an escape hatch for
  genuine state transitions; that allowlist must stay small and reviewed, or it
  becomes a place to hide new RPC endpoints.
* **OWASP false positives.** Bounded strings remain blocking, while the semantic
  format/pattern rule is disabled in free-form human text. The upstream
  rate-limit-header rule is replaced with a scoped blocking rule for the five
  operations that actually rate-limit, and the CORS-header rule is disabled
  because this is a same-origin API behind Pages Functions.
* **`oasdiff` needs a truthful baseline.** The committed spec must be regenerated
  with each contract change or the diff compares against stale fiction;
  regeneration belongs in the same `mise` task.

# Consequences

## Positive

* A deterministic, blocking gate on the recipe API's contract, naming, methods,
  security limits, and breaking changes, on every `workers/recipe-api` change.
* The four audited drift classes are fixed while the API still has one atomic
  consumer, before any of them calcifies into a breaking change.
* The committed `openapi.json` becomes documentation, a typed-client source, and
  machine-readable context for agents, at no cost once generated.

## Negative

* The heaviest linter adoption so far: routes must be wired to emit schemas, a
  refactor rather than a config file.
* Two more moving parts to keep truthful, the generated spec and its `oasdiff`
  baseline, plus an OWASP ruleset whose upgrades need review.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
