ADR-0028: The HTTP API surface, the error vocabulary, and the event stream¶
- Status: Accepted
- Date: 2026-07-27
- Related: Sections 5 (principal), 13 (error taxonomy), 16 (API contract), 17 (CLI), 19 (observability), 21 (Milestone 5), 22 (security baseline), 27 (run, turn, session), ADR-0004 (the run queue), ADR-0006 (no private reasoning storage), ADR-0009 (run, turn, and session model), ADR-0010 (live event transport), ADR-0017 (layered approval), ADR-0023 (the run loop), ADR-0024 (composition root)
- Detailed design:
docs/plan/http-api-and-streaming.md
Context¶
The readiness review's verdict on Milestone 5 was that coding can reach it and should not enter it, and that an API specification was the single most valuable document not yet written. That verdict is narrower than "the API is undocumented", which is not true.
Section 16 is a real design. It fixes the method and path of nine
endpoints, the request body where there is one, the 202 on
submission, the SSE frame format, the Last-Event-ID replay rule, the
shape of the error envelope, the authentication posture, and the rule
that tracebacks are never exposed. Three further routes are named
elsewhere — the two approval reads in the policy spec, and
POST /v1/runs/{id}/input in ADR-0009 — and the health probe is two
routes rather than one. Thirteen routes.
What the corpus does not contain is a response body for twelve of the
thirteen. Only the session created by POST /v1/sessions has one
written down. An implementer holding Section 16 knows
GET /v1/runs/{run_id} exists and cannot write it, because nothing
states which of the run row's columns are public, what
WAITING_FOR_APPROVAL tells a client to do, or whether the failure
that ended a run is visible at all.
Six more gaps sit inside Section 16 rather than outside it, and the
readiness review names each: the error envelope has one worked example
and no code list; request identifiers are an implement bullet with no
semantics; Idempotency-Key is named twice and specified nowhere; the
SSE consumer side is one sentence; authentication is designed only at
its refusal, so nothing says what a successful authentication produces;
and nothing turns an HTTP cancel into an observation by a worker in a
different process.
Two further problems became visible only while writing the design.
SessionStatus is referenced in Section 5 and declared nowhere, and
Section 16's one sample shows it lowercase while every RunStatus
value in the corpus is uppercase. And the word "idempotency key" names
two unrelated mechanisms — the HTTP header on submission, and
tool_invocations.idempotency_key, which the milestone map schedules
as a Milestone 1 port under ToolInvocationRepository.
Decision¶
- The wire error vocabulary is the existing error taxonomy under
one rule, not a new vocabulary. Section 16's single worked
example,
tool_validation_error, isToolValidationErrorsnake-cased. That is the convention; applying it mechanically to Section 13's twenty-three classes and the runtime loop's eight produces the code list. Four API-specific codes are added for conditions that have no domain class —malformed_request,unsupported_media_type,payload_too_large,rate_limited. - Four classes deliberately never cross the boundary.
WorkerFencedis not a run failure,EmptyModelTurnis retried internally, and two remaining classes have no client-actionable meaning. An unmapped class resolves tointernal_errorand 500. - Authentication produces a
Principaland nothing downstream re-reads the credential. Section 5's four-fieldPrincipalis the only authorization input. No handler takes atenant_idfrom a path, query, body, or header. - Scopes are exact-match strings over a closed dotted
vocabulary. No wildcard, no prefix rule, no hierarchy in which
run.writeimpliesrun.read. Roles are bundles resolved at authentication; the API never checks a role. - A resource in another tenant is 404, never 403, generalizing the rule the policy spec already fixes for approvals. Scope is checked before tenancy, so a principal with no scope cannot probe for existence by watching 403 become 404.
SessionStatusis declared here, uppercase, with two values. Section 16's lowercase sample is read as illustrative and Section 16 is not edited. Recorded as an open question.- The HTTP idempotency key and the tool idempotency key are two
mechanisms with one unfortunate name. Different scope, different
table, different milestone. A repeat with a matching
request_hashreturns the original run with 200; a repeat with a different hash is 409, which makes a client bug loud rather than returning an unrelated run. - A second message to a session with a non-terminal run is 409,
except where that run is
WAITING_FOR_USER, where it is routed to input delivery and returns 202. This is Section 27.3's deterministic routing rule with "route" as the configured default. - The cancel endpoint writes
runs.cancel_requested_atand returns 202 for aRUNNINGrun; forQUEUEDand bothWAITING_*states it transitions directly toCANCELLEDand returns 200. The direct transitions are safe only because those states hold no lease. Cancelling a terminal run is 200 and does nothing. - Transient SSE frames carry no
idfield. The EventSource specification advances a client's last-event-ID only on a frame carryingid, so a synthetic id on a token delta silently corrupts every subsequent reconnect. - Replay subscribes before it reads.
LISTENfirst, buffer arrivals, read the persisted prefix, note the high-water mark, drain the buffer discarding at or below it, then go live. Subscribe-before-read is what makes it gapless; discard-by- sequence is what makes it duplicate-free. - Overflow closes the stream with a resumable marker rather than buffering without bound or dropping silently. The client reconnects against the durable log.
- Artifact content is always served
Content-Disposition: attachment, for every media type. An artifact served inline from the API origin is stored cross-site scripting. - Health is the only unauthenticated surface, and its bodies carry no version, host, dependency, or count, so that being unauthenticated is safe.
- The four application services get their method signatures
here, with
Principalas the first argument of every method and view types rather than rows as returns. Section 17 makes the CLI a second caller, which makes these a shared contract rather than an API detail. - 413 and 429 have response shapes now and mechanisms later. Section 22 owns per-tenant rate limiting; fixing the shape means a client written against 0.1 already handles the day it arrives.
- One route is added:
GET /v1/sessions/{session_id}. A client reconnecting with only a session identifier otherwise cannot learn the session's status or find its active run. It exposes no capability a client with its own records lacked.
Consequences¶
- Milestone 5 becomes implementable. Every route now has a request shape, a response shape, a status code, a required scope, and an error mapping, and the readiness review's six gaps plus the missing response bodies are closed.
- Ten hard gates are added, all at Milestone 5, taking that milestone from one gate to eleven. It was the largest under-gated milestone in the corpus and the one with the most externally visible surface.
- The gate registry gains an eleventh area,
api. Registry entries go from ninety-four to one hundred and four. The milestone map's table and census and the harness's gate table are updated; ADR-0027 is not, because its arithmetic is a record of what was true when it was decided and this ADR is where the new total is stated. SessionStatusacquires a declaration, which removes one of the two types the readiness review found referenced and undeclared.ArtifactMetadataandArtifactRefremain undeclared and are now the only ones.- The
idempotency_keystable stays at Milestone 2 and nothing reads it until Milestone 5. This is stated explicitly so that an implementer moves neither the DDL forward nor the endpoint back. - A client library can be written from this document alone. That was the test applied while writing it: every question a client author would have to ask a server author is answered, including the four things a client must not infer from the stream.
- Section 16 is unedited. Where the two disagree — the session status case — the disagreement is recorded as an open question rather than resolved by changing the plan, per the conversion rules.
Alternatives considered¶
- Inventing a fresh, small error vocabulary designed for clients: rejected. It would require a mapping from the internal taxonomy that somebody maintains by hand, and Section 16 had already chosen the convention in its only example. A second vocabulary is a second thing that drifts.
- Returning 403 for a resource in another tenant: rejected. It is the more informative answer and that is exactly the problem — it confirms existence, which makes identifier enumeration a working attack.
- A scope hierarchy where
run.writeimpliesrun.read: rejected. It is more convenient and it needs a grammar, and a grammar has an evaluation order that can be subtly wrong in the direction of granting access. Exact match cannot be subtly wrong. - Lowercase
SessionStatusto match Section 16's sample: rejected in favour of consistency withRunStatusand the DDL's guarded updates, and recorded as an open question rather than decided silently. Two cases on one wire is what a client library encodes as two enums and a comment. - Treating the HTTP and tool idempotency keys as one mechanism:
rejected on evidence. The milestone map schedules the tool key as a
Milestone 1 port on
ToolInvocationRepository, a tool-call concern, while theidempotency_keystable is Milestone 2 and carriesrequest_hashfor an HTTP body. Unifying them would put a table at a milestone whose DDL does not exist. - Queueing a second message rather than rejecting it: rejected, and not by this document — ADR-0004's partial unique index makes queueing impossible at the database level as specified, which Section 27.5's resolution already records.
- Stamping a synthetic
idon transient frames for uniformity: rejected, and it is the most tempting wrong decision in the document. Uniform framing costs correct reconnects. - Reading the persisted prefix before subscribing: rejected. It is the obvious order and it drops every event committed between the read and the subscribe. The window is small and it is not empty.
- Persisting token deltas so replay could include them: rejected
by ADR-0010 already, for write volume against data that is
reconstructible from
assistant.message.completed. This document adds the client-side consequence: a UI that renders deltas must reconcile against the completed message. - A per-run SSE id counter: rejected. ADR-0010 fixed the id as the session sequence, and a per-run counter needs a second allocation column and a second uniqueness constraint to protect it, duplicating machinery the event log spec built once.
- Serving artifacts inline when the media type is safe: rejected. The safe list is a thing that grows by argument, and the argument happens in a pull request rather than a threat model.
- Offset pagination: rejected. Over a table still being written it both skips and repeats rows, and an approvals list is such a table.
- Deferring the application service signatures to the implementation: rejected. Section 17 makes the CLI a second caller, and two callers discovering a signature independently is how the CLI ends up importing a web framework.
- Specifying rate limiting now: rejected. Its numbers should come from operational data that does not exist, and Section 22 already owns it. Fixing only the response shape gets the client-side benefit without inventing a mechanism blind.