Skip to content
sign in

==> read the decisions

ADR-0021: An API is described once, and its OpenAPI document is a build product

Status: Accepted (2026-10-15). Extends ADR-0014 (the repo layout is the contract) and ADR-0009 (a release). Implemented 2026-10-15 (the platform's API, the shared library, the release build).

A route is described in several places today: its handler, the input checks inside it (@cs/input's str() and int()), a comment, and whatever a reader reverse-engineers from the code. There is no machine-readable description of the platform's API or of a user's project API, so neither can be documented, tried, or handed to a program, without writing it again by hand -- and a second copy drifts.

  • A route carries its own description. In a user's service (defineUserService), a route gains optional fields beside its handler: summary, description, input (body, query and path schemas), output, errors. The platform API's Hono routes carry the same, through a small described() helper over the shared module -- not hono-openapi: Zod 4 implements the Standard JSON Schema interface, so one dependency-free module validates and describes for both sides.
  • Schemas are Zod 4, which converts to JSON Schema natively, through the Standard Schema interface. The shared host validates input before the handler runs, so a handler receives typed, checked data; @cs/input becomes a thin helper and then retires.
  • One shared module (@cs/openapi) sets the conventions both sides use: the error shape { error, code? }, the bearer security scheme, the server URLs, and how uploads (an application/octet-stream body with the route's size limit) are written.
  • WebSockets, which OpenAPI cannot describe, carry messages: { in, out } schemas and appear in an x-cs-sockets extension.
  • The document is generated at build time. mkRepo writes projects/<slug>/openapi.json, and a Markdown rendering for language models, into the release beside the UI and migrations; the shared check validates it, so a broken description refuses the push. The platform's build does the same for its own API.
  • A project's overview is an optional projects/<slug>/README.md, part of the layout contract.
  • Where the documents are served: api.<domain>/openapi.json (the platform's, public) and api.<domain>/p/<slug>/openapi.json (a project's, to its owner only), read from the live release by the reconciler like the UI path.
  • The frontend's client is typed from the same definitions: projectApi() checks a call against the route it names, with no code generation.
  • Validation, types, the OpenAPI document, the documentation pages (ADR-0022) and llms.txt come from one definition; nothing about a route is written twice.
  • A project's description is versioned with its release and available when its service is down; the API never runs user code to describe it.
  • Every user repo moves to schema-described routes; the shared library keeps the old form working until hasel and nikita are migrated.
  • Generating the document at runtime from the running service. It would mean asking user code to describe itself on request, and nothing would describe a service that is down.
  • A hand-written OpenAPI file per project. The second copy that drifts.