Skip to content

ADR-0003: docs/api-reference.md is the contract; CI fails on drift

Status: Accepted - Date: 2026-05-11 - Issue: #357

Context

docs/api-reference.md documents the gateway REST API and is hand-maintained. Routes are registered in internal/gateway/routes.go as s.mux.HandleFunc("METHOD /path", handler) lines. Today there are 388 registered routes and 146 documented rows — drift is real and large enough to be confusing to anyone reading the doc as a spec.

There is no automated check that the two stay aligned. New handlers ship without doc updates; removed handlers leave orphan rows. The doc is nominally the public REST surface but in practice is a partial snapshot.

Two competing directions were on the table in #357:

  • A. generate-from-code — emit api-reference.md (or a generated fragment of it) from route registrations + comments. Code is the source of truth; docs are downstream.
  • B. validate-code-against-doc — keep api-reference.md as a hand-authored contract; CI parses it, parses the registered routes, and fails when the two diverge.

The choice is load-bearing for the workflow described in #357: the user reads the doc, decides on a shape, then propagates the choice to code. That ordering only works if the doc is allowed to lead.

Decision

Adopt Option B. Treat docs/api-reference.md as the contract. scripts/lint-docs-api-reference.sh parses the route table out of the doc, parses the registered routes out of internal/gateway/routes.go, and fails when:

  • A route is registered in routes.go but absent from the doc (undocumented surface).
  • A route appears in the doc but is not registered in routes.go (orphan documentation).

Routes that are intentionally excluded from the public REST surface (websockets, static asset routes, page templates, the /healthz style liveness endpoints already covered elsewhere) are listed in scripts/lint-docs-api-reference.allowlist with a one-line justification. The allowlist is small and reviewed — it is not a license to drift.

make lint-docs-api-reference runs the check; it joins make verify and the pre-commit hook for Go-file edits.

Alternatives Considered

A. Generate api-reference.md from code

Walk routes.go (or annotate handlers with structured comments) and emit the markdown reference at build time.

  • Pro: zero drift by construction. Adding a new route automatically documents it. No allowlist needed.
  • Con: throws away the doc's hand-organized sections, narrative framing, request/response examples, and authentication notes. The generated artifact would either lose that context or require a significantly more invasive comment system on every handler to carry it. The user's stated workflow — read the doc, decide, propagate to code — inverts when the doc cannot be edited independently of the code.
  • Verdict: Rejected. Generation optimizes for the wrong direction. The doc's value is its editorial structure, not the table rows.

B. validate-code-against-doc (chosen)

Doc is hand-authored; CI checks two-way coverage.

  • Pro: preserves the doc's structure and examples. CI catches drift in both directions. Cheap to implement (~100 lines of shell). Doc remains the place to think first.
  • Con: every new handler needs a doc edit before CI passes. That cost is exactly the point — it is the forcing function that keeps the doc honest.

C. Status quo (do nothing)

Continue with the hand-maintained doc and trust contributors to update it.

  • Pro: zero implementation cost.
  • Con: empirically the doc has drifted to 146 rows against 388 registered routes. Trust has not held.
  • Verdict: Rejected. The drift is the issue we are filing the ADR against.

D. OpenAPI / Swagger generation

Generate an OpenAPI spec from the Go handlers and render the markdown from that spec.

  • Pro: opens the door to client-library generation and schemathesis-style contract testing later.
  • Con: large up-front investment (handler annotations, schema binding for the Go types we serialize, render pipeline) for a benefit — generated clients — we do not need at v1alpha1. The user is the only API consumer today.
  • Verdict: Deferred. Once an external customer or SDK consumer shows up, revisit. Until then, B is sufficient.

Consequences

Positive

  • The doc is allowed to lead. A change to the API surface starts as a doc edit; the code change follows. That matches the user's stated reasoning workflow and is reversible per-route — each row is a small unit.
  • Both directions of drift are caught. Today's known drift (242 rows of undocumented surface, plus any orphan doc rows) becomes a one-time reconciliation rather than an ongoing erosion.
  • The check is opaque-box cheap: a shell script reading two files, no Go dependency, no docs-build dependency.

Negative

  • Every new gateway handler now requires a paired doc edit. This raises the cost of adding routes by one diff. That is intentional but worth naming.
  • The allowlist is a place where drift could re-enter under cover of "internal endpoint." Code review must treat allowlist additions as non-trivial.
  • The initial reconciliation is large — closing the 388 vs 146 gap is the work tracked under #357 once this ADR lands.

Reversibility

Reversing to Option A is straightforward: the parser side of the check becomes a generator, the doc becomes generated, the allowlist becomes annotations. No CR migrations, no API breakage.

Followups

  • Reconcile the existing drift (#357 implementation step): document every registered route or move it to the allowlist with a reason.
  • Once a stable v1 API surface is declared, revisit Alternative D (OpenAPI) for client SDK generation.