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.mdas 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.gobut 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.