ADR-0149: The public API is one contract: a marked break, one list shape, problem+json errors, stated limits
- Status: accepted
- Date: 2026-09-30
- Deciders: Mahdi Amirabdollahi
- Change:
openspec/changes/09-public-api-contract
Context
/api/v1 is used by scripts, CI and the generated clients as much as by the web UI. Its OpenAPI document is committed (web/openapi.json, ADR-0019) and info.version was the literal v1. There was no rule for what counts as a break and nothing that stopped one: a renamed field or a removed endpoint merged like any other change. About 30 list endpoints returned a bare array, five had their own page view and two took only limit. Errors were problem+json (RFC 9457) on most paths, but 401 had an empty body and the framework's own exceptions answered in Boot's default JSON. Limits were signalled on token requests only (ADR-0136). Before stable there is no compatibility promise (36-stable-release sets it), so the need is that a break is visible, not that it is avoided.
Decision
- A break is flagged by the Conventional Commit marker. The
api-compatjob of pull requests runsoasdiff breaking --fail-on ERRbetweenweb/openapi.jsonwhere the PR left main and the PR's (every merge that touchesweb/releases, so main's document is the last release's). It passes an ERR-level change only when a commit inorigin/main..HEADhas!:in its subject or aBREAKING CHANGE:footer, and otherwise fails printing oasdiff's list. The same marker puts the commit under### Breakingin the release notes (ADR-0051), so CI and the release cannot disagree.just api-diffruns the same script. oasdiff is pinned by image digest (ADR-0139). info.versionis the running Studio version, so a client can read what it talks to; the snapshot test pins a placeholder to keep the committed file stable, and the release stamps the real version into the published document.- Versioning and deprecation use Spring Framework 7 API versioning. The version stays a path segment (
/api/v1/...): controllers drop the literal prefix and one/api/{version}path prefix supplies it, the supported version is1, and any other gets 400invalid-api-version. An incompatible endpoint later ships asversion = "2"beside the v1 mapping. Deprecation uses the built-inStandardApiVersionDeprecationHandler(Deprecation, RFC 9745;Sunset, RFC 8594;Linkwithrel="deprecation"andrel="sunset"), fed by one list of declarations,ApiDeprecations, which also marks the matching operationsdeprecatedin the document. There is no hand-written interceptor or annotation. The document keeps concrete/api/v1/...paths. Before stable a removal needs only the break marker; from36-stable-releaseit needs a deprecation announced for a period that change sets. - One list shape.
PagedView<T>is{data, page, pageSize, count, hasNext};countis null only where the total is unknown. Every list takes 1-basedpageandsize(default 50, at most 500; out of range is 400invalid-value). The bespoke page views andlimitparameters are removed, bare lists are wrapped, and a test fails for any/api/v1GET that returns a top-level array. Offset over cursor: resource lists are assembled in memory from a per-node fan-out, so there is no stable cursor. - Every error is problem+json with a stable type under
https://artemis-studio.dev/problems/, including 401, 403, CSRF, framework exceptions and a 500 that hides its cause behind a request id. The document declares4XX/5XXproblem responses, and 429 withRateLimit-*andRetry-After, on every operation; every 429 sendsRetry-After. - Contract tests check every response against the committed document with undocumented properties rejected, so a drift fails the test that produced it.
Consequences
- A break costs one character in a commit subject, and an accidental one fails the PR.
- The list, error and header conventions are enforced by tests rather than review.
- The change is itself breaking (list bodies,
limit, 401/403 bodies), shipped with!commits and a migration note; the web UI is updated with it. - The baseline is main, so only the breaks a PR itself introduces count: a break that is already released does not keep later PRs red.
- The plugin gateway (
/api/v1/p/**) and MCP (ADR-0045) keep their own conventions.
Alternatives considered
- An
ApiContract.VERSIONinteger, likeContract.VERSION. A second version number beside CalVer that says nothing in the release notes. - openapi-diff instead of oasdiff. Less maintained, and no stable exit codes.
- A hand-written
@ApiDeprecationinterceptor. The framework already implements the RFC headers and version routing. - Header or media-type versioning. URLs stay
/api/v1/..., which the clients andschema.d.tsalready use. - Cursor pagination everywhere. No stable cursor exists over the in-memory fan-out; an endpoint that needs one can add it without changing the envelope.
- Baseline from the latest tag or a release download. A released break would keep every later PR red until the next release, and the snapshot is committed, so
git showneeds no network or asset.