Decisions
ADR 0006 — API v2: versioned, problem+json, cursor streaming
Architecture decision records — why it was built this way.
docs/adr/0006-api-conventions.mdStatus: Accepted Date: 2026-07-26 Wave: W5
Context
v1's API was unversioned, returned ad-hoc error shapes that varied by handler,
and streamed logs over a WebSocket that dropped events for slow clients —
the default: branch of a non-blocking channel send. A user who reloaded the
page or hit a brief network blip silently lost output, with no way to know.
Decision
Adopt uniform conventions under /api/v2:
- Errors are
application/problem+json(RFC 7807) with a stable machinetypeslug (snowops.run.lock_conflict), a humantitle, adetail, and the request ID. Clients branch ontype, never on message text. - Mutations return
202 Acceptedwith a run ID. Progress arrives on the run stream. - Streaming is cursor-based.
GET /api/v2/runs/{id}/stream?after=<seq>over WebSocket, with an SSE fallback for constrained environments. Reconnect replays from the cursor: no gaps, no duplicates. Persistence is never skipped for a slow consumer — only that consumer's delivery is affected. - Collections paginate with opaque cursors; catalog reads return
ETagand honourIf-None-Match. - Every request gets a request ID, echoed in the response header and on every log line it produces.
- OpenAPI is generated from the contract tests, so the document cannot drift from the implementation.
Alternatives considered
| Alternative | Why rejected |
|---|---|
| Keep it unversioned | Guarantees a breaking change with no way to signal it |
Custom {error, message} envelope |
Works, but problem+json is a standard with existing client support and room for typed extensions |
| Long-poll instead of streaming | Higher latency and more load for a live log console |
| Hand-written OpenAPI | Drifts within weeks; the whole point is a contract that is true |
Consequences
- Easier: the UI can implement one error handler and one reconnect strategy and have them work everywhere. Third-party scripting is realistic.
- Harder: every endpoint needs its
typeslug chosen and documented. The slug list lives in one file and is asserted stable by test. - SSE fallback is a second streaming code path to maintain. Justified — WebSockets are still blocked by some corporate proxies.
Implementation status
This ADR is the accepted target for all of Wave 5; it lands incrementally.
- Done (W5-T01): the full handler set is mounted under
/api/v2(with/apikept as an unversioned alias so the current UI keeps working); every request carries a correlation ID — honoured from a sane inboundX-Request-ID, else a minted UUIDv4 — echoed in theX-Request-IDresponse header and attached to a structuredhttp_requestaccess-log line (method, route template, status, duration, size, client, request ID). Seeinternal/httpapi/middleware.go. - Done (W5-T02):
/api/v2errors areapplication/problem+jsonwith a stabletypeslug (https://snowopslabs.dev/problems/<slug>),title,status,detail,instance(request path) and therequestId. The/apialias keeps the legacy{error, code}envelope. The closed slug set lives ininternal/httpapi/problems.goand is asserted against actual usage byTestProblemSlugs_StableSet, so the error contract can't drift silently. - Done (W5-T03): catalog reads carry a content-derived
ETagand honourIf-None-Match(→ 304); under/api/v2list collections return the opaque cursor envelope{items, nextCursor}(?limit,?cursor), while/apikeeps the bare array. Seeinternal/httpapi/httpcache.go+pagination.go. - Done (W5-T04, streaming): the event stream is cursor-based. Every event
carries a monotonic
Seq; the broadcaster keeps a bounded replay ring, andSubscribeFrom(after)returns the missed backlog plus the live channel atomically (gap-free, duplicate-free at the seam). WebSocket honours?after=<seq>; a Server-Sent Events fallback at/streamuses the same cursor (?afteror theLast-Event-IDheader) and writesid:-tagged frames so anEventSourceresumes on its own. A cursor that has fallen off the ring gets aresyncsignal instead of a silent mid-stream start. Seeinternal/executor/broadcast.go+internal/httpapi/stream.go. - Done (W5-T05, mostly): passwords hash with Argon2id (legacy PBKDF2
hashes still verify, so no rewrite is forced); the session cookie gains
Secureautomatically over TLS (r.TLSorX-Forwarded-Proto: https), keepingHttpOnly+SameSite=Strictalways. Deferred: persisting sessions to SQLite so they survive a restart — low value for the local golden path, and it belongs with the team-server work (W8); tracked there. - Done (W5-T06, mostly): login is rate-limited per client (fixed window,
5/min, reset on success) — a credential-stuffing loop gets
429 + Retry-After. Constant-time password comparison was already in place. CSRF tokens deprioritized —SameSite=Strictcookies plus the existing cross-origin POST/DELETE rejection already close the CSRF vector for this tool; explicit double-submit tokens would add client friction for little marginal safety. - Done (W5-T07): the server refuses a non-loopback bind without auth
(
labctl ui --bind 0.0.0.0exits non-zero unlessLABCTL_AUTH=true), the default bind is now127.0.0.1, and TLS is supported via--tls-cert/--tls-key. Seeinternal/httpapi/server.go(CheckBind) andcli/ui.go. - Done (W5-T09): optional Prometheus
/metrics. - Still ahead: audit log (T08), generated OpenAPI from the contract suite
(T10). Mutations still return their current status codes rather than the
202 + run IDshape — a later refinement on top of this stream.