Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
18 KiB
ADR-042: OpenAPI Gateway Pattern for to_openapi
Ported from alknet ADR-042 (OpenAPI Gateway Pattern for to_openapi); re-targeted to alkhttp.
Status
Proposed
Context
The current to_openapi spec (http-adapters.md in this crate's
docs/architecture/; alknet original: crates/http/http-adapters.md)
describes to_openapi as generating a traditional OpenAPI document with
one path
entry per External operation — POST /fs/readFile,
POST /agent/chat, etc., each with parameters, request body, and
responses built from the operation's input_schema/output_schema/
error_schemas. This is the "inverse of from_openapi" framing: since
from_openapi merges OpenAPI path params / query params / request body
into a single flat JSON input schema, to_openapi should split them
back out.
The flat→structured problem
The inverse is genuinely messy. The call protocol's input is a flat JSON
object (e.g., { path, content, encoding } for fs/writeFile). To
generate a traditional OpenAPI path entry (POST /fs/{path} with path
param path, body content), to_openapi would need to know which
fields are path params, which are query params, and which is the body.
That information isn't in the flat schema — it's metadata the call
protocol doesn't carry because it doesn't care about HTTP parameter
structure. to_openapi would need either:
- HTTP-specific metadata on
OperationSpec(which fields are path params, etc.) — a leaky abstraction that puts HTTP concerns in the protocol-foundation crate (alkcall), or - Heuristics (guess that fields named
idare path params?) — fragile and wrong, or - Manual annotation per operation — boilerplate that defeats the "pure projection" promise.
All three are messy. The flat→structured split is the hard direction,
and it's the one to_openapi has to do.
The per-caller API surface problem
A traditional OpenAPI document is static — it describes the full API
surface regardless of who's reading it. Real APIs have per-caller
authorization: an admin sees admin operations, a regular user sees a
subset. OpenAPI has no standard mechanism for "show me only what I have
access to." The Gitea API is a concrete failure case: its OpenAPI spec
dumps the full API (including admin operations) to every caller,
regardless of privilege. A user reading the spec can't tell which
endpoints they can actually call without trial-and-error 403s.
The call protocol already has the per-caller filtering primitive:
services/list is AccessControl::check(identity)-filtered — the
caller sees only the operations they are authorized to call. A
to_openapi that generates a static full-surface doc loses this
property. A to_openapi that uses the gateway pattern preserves it.
The pattern that works
The same tool-gateway pattern ADR-041 applies to to_mcp applies here:
to_openapi exposes a small fixed set of endpoints that gate access to
the full operation registry. The external client (a code generator, a
human developer, a fetch-based client) calls search to discover
operations, schema to learn an operation's input shape, call to
invoke. The input is always a flat JSON body — no path/query/body split
to reverse-engineer. JSON Schema for the input/output is already in the
OperationSpec — no conversion beyond wrapping it in OpenAPI's schema
format.
The OpenAPI gateway has one endpoint the MCP gateway doesn't:
subscribe (SSE). OpenAPI/SSE supports streaming; MCP tool calls don't.
So the OpenAPI gateway is 5 endpoints; the MCP gateway is 4. (In
alkhttp, the OpenAPI gateway is extended with /publish — ADR-068 —
making it 6 endpoints; see Decision §1.)
Decision
1. to_openapi exposes a fixed gateway endpoint set, not one path per operation
to_openapi generates an OpenAPI document with a small fixed set of
endpoints that gate access to the full operation registry. The external
client discovers and invokes operations through the gateway.
The gateway endpoint set (initial, two-way-door extensible):
| OpenAPI path | Call protocol operation | HTTP method | Purpose |
|---|---|---|---|
/search |
services/list |
GET |
List/search available operations (filtered by the caller's AccessControl). Returns names + descriptions. |
/schema |
services/schema |
GET |
Get an operation's full OperationSpec (input/output JSON Schemas, error schemas). |
/call |
call.requested (Query/Mutation) |
POST |
Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023). |
/batch |
multiple call.requested |
POST |
Invoke multiple operations in one request (correlated request IDs, alknet OQ-14). Returns an array of results. |
/subscribe |
call.requested (Sub) |
POST (SSE) |
Invoke a streaming operation. Body { operation, input } (same shape as /call); response is text/event-stream — each call.responded is an SSE frame, call.completed closes the stream. |
Five endpoints (the 5-endpoint set, extended with /publish in alkhttp
— ADR-068; POST /publish invokes Pub operations — producer→consumer
streaming; the request body is streamed as newline-delimited JSON, each
line one published chunk, and the final ResponseEnvelope is returned
as the HTTP response). The client calls /search to find operations,
/schema to learn the input shape, /call (or /subscribe for
streaming, /publish for Pub streaming) to invoke. The input is always
a flat JSON body ({ operation: "/fs/readFile", input: { ... } }); the
output is the operation's result as JSON. No path/query/body split to
reverse-engineer.
2. subscribe is the OpenAPI gateway's streaming endpoint (SSE)
The OpenAPI gateway includes subscribe (which the MCP gateway excludes
— ADR-041, MCP tool calls are request/response). The subscribe
endpoint maps Sub operations onto SSE: POST /subscribe with
a { operation, input } JSON body (same shape as /call) and
Accept: text/event-stream, each call.responded event is an SSE
data: frame, call.completed closes the stream, call.aborted closes
with an error frame. This is the same SSE projection ADR-036 describes
for h2/http/1.1 clients — the gateway's subscribe endpoint is the
single SSE entry point instead of per-operation SSE streams.
POST (not GET) is used because /subscribe is an invoke endpoint
that carries { operation, input } in the request body, the same flat
JSON body shape the rest of the gateway uses. A GET request has no
body, so it cannot carry the operation name and input. The SSE response
is negotiated via Accept: text/event-stream on the POST, not via the
method. (Browsers using EventSource cannot POST, but browsers use
WebSocket for the bidirectional path — ADR-044; the HTTP gateway's
/subscribe is for non-browser HTTP clients, and fetch +
ReadableStream handles POST-SSE cleanly.)
3. The generated OpenAPI doc is per-caller (AccessControl-filtered)
The /search endpoint's results are filtered by the caller's
AccessControl::check(identity) — the client sees only the operations
it is authorized to call. The /call and /subscribe endpoints run the
same AccessControl check on dispatch. The generated OpenAPI doc
describes the gateway endpoints (5 fixed paths — extended with
/publish in alkhttp, ADR-068); the per-caller
operation surface is discovered through /search, not preloaded into
the doc.
This is the key advantage over a traditional per-operation-paths OpenAPI
doc: the per-caller API surface is the default, not an afterthought. A
client reading the gateway OpenAPI doc learns the gateway's shape (5
endpoints, stable — 6 in alkhttp); a client calling /search learns
what it can call (per-caller, AccessControl-filtered). The Gitea
failure mode (dumping admin ops to every caller) is structurally
impossible — /search doesn't return operations the caller can't call.
4. The gateway OpenAPI doc is a compatibility contract
Once published, the gateway endpoint set (5 endpoints — extended with
/publish in alkhttp, ADR-068) and the
request/response shapes are a compatibility contract (ADR-017
Consequences). Adding endpoints is additive (non-breaking); removing or
renaming is a one-way door. The initial 5-endpoint set is the published
contract. The versioning strategy for the generated doc was tracked as
alknet OQ-39 (now resolved by
ADR-045:
info.version semver tracks the gateway endpoint contract, not the
operation set) — the gateway pattern simplifies versioning to 5 stable
endpoints instead of a per-operation surface.
5. A traditional per-operation-paths projection is additive, not replacement
A deployment that wants a traditional REST OpenAPI doc (per-operation
paths with split parameters) can build it as a separate projection with
the HTTP-specific metadata (which fields are path params, etc.). The
gateway pattern is the default to_openapi projection; the traditional
projection is an additive alternative for deployments that need it. The
gateway does not foreclose the traditional projection — it just doesn't
require it for the common case.
Consequences
Positive:
- No flat→structured split. The gateway's input is always a flat JSON
body (
{ operation, input }); the operation's input/output schemas are already JSON Schemas in theOperationSpec. No reverse- engineering of path/query/body semantics. The messy direction of thefrom_openapiinverse is sidestepped. - Per-caller API surface by default.
/searchisAccessControl-filtered; the client sees only what it can call. The Gitea failure mode (dumping admin ops to every caller) is structurally impossible. This is a property the traditional per-operation-paths OpenAPI doc cannot provide (OpenAPI has no per-caller filtering concept). - Easy to build clients for. Any language's
fetch+ JSON Schema libraries can call the gateway:POST /callwith a JSON body, get a JSON result. No code generator needed for the common case; a code generator produces aCallClient(call/search/schema/batch/ subscribe) instead of typed per-operation methods. - 5 stable endpoints instead of a per-operation surface (6 in alkhttp
with
/publish, ADR-068). The versioning concern (alknet OQ-39) is simpler — a small set of endpoints that rarely change vs. a per-operation surface that changes on every operation addition/modification. subscribemaps cleanly onto SSE — the same projection ADR-036 describes, just as a single gateway entry point instead of per- operation SSE streams.- A deployment that wants the traditional REST surface can build it additively. The gateway doesn't foreclose it.
Negative:
- The generated OpenAPI doc is not a "nice UI" by default. A Swagger UI rendering shows 5 generic endpoints instead of a REST tree. This is the tradeoff for avoiding the flat→structured split and gaining per- caller filtering. A deployment that wants the nice UI builds the traditional projection (additive, with metadata).
- A code generator reading the gateway OpenAPI doc produces a
CallClient(generic call/search/schema methods), not typed per- operation methods. Typed methods require the traditional projection (with metadata) or a client that reads/search+/schemaand generates typed wrappers at build time. The gateway is optimized for thefetch-and-JSON-Schema use case, not the code-generation use case. - The gateway doc is less "traditional" — a developer expecting a REST OpenAPI doc sees a small RPC-style surface instead. This is honest (the call protocol is a flat JSON RPC, not a REST API), but it's a departure from OpenAPI conventions.
Assumptions
-
The gateway endpoint set is stable. Once external clients build against the 5-endpoint gateway, changing the endpoint set (renaming, removing) breaks them. Adding endpoints is additive (non-breaking). The initial 5-endpoint set is the published contract (alkhttp extends it with
/publish— ADR-068 — an additive addition, the pattern this assumption already permits). -
AccessControlfiltering is the right per-caller mechanism. The client sees the operations it's authorized to call. If an operation's existence is itself sensitive,Visibility::Internal(ADR-015) is the mechanism — Internal ops are excluded fromservices/listand therefore from/searchresults. The gateway does not add a separate visibility layer. -
The common case is
fetch+ JSON Schema, not code generation. The gateway is optimized for the developer who callsPOST /callwith a JSON body and parses the result. The code-generation case (typed per-operation methods) is served by the traditional projection (additive) or a client that generates wrappers from/search+/schemaat build time. -
subscribe(SSE) is the streaming projection for the gateway. Overh2/http/1.1, subscriptions are SSE. Over WebSocket (the v1 browser bidirectional path, ADR-044), subscriptions project onto the WS connection directly as binary messages — the gateway's/subscribeis theh2/http/1.1SSE path; the WebSocket path is the native call-protocol session (the alkcall crate'sdocs/architecture/stream model; the gateway shape does not appear on WS per ADR-048). WebTransport (h3, deferred per ADR-044) would project onto WebTransport streams; no deferred WebTransport spec is carried in alkhttp (per ADR-044).
References
- ADR-015 —
External/Internal visibility (Internal ops excluded from
services/list, therefore from/search; the decision record is alkcall ADR-017) - ADR-017 —
to_*adapters are projections; published-spec compatibility contract (the decision record is alkcall ADR-022) - ADR-023 — typed error
detailsmapped to OpenAPI error responses (the decision record is alkcall ADR-016) - ADR-036 — the SSE projection
for subscriptions over
h2/http/1.1(the gateway's/subscribeendpoint uses the same SSE framing) - ADR-044 —
WebSocket is the v1 browser bidirectional path;
h3/WebTransport deferred (the gateway's/subscribeis theh2/http/1.1SSE path; the WS path is the native call-protocol session). ADR-038 is superseded by ADR-044. - ADR-068 — the alkhttp extension of
this ADR's gateway:
POST /publish(Pub operations, newline-delimited JSON chunks) makes the OpenAPI gateway 6 endpoints - ADR-041 (MCP Tool-Gateway Pattern for
to_mcp) — the sibling gateway pattern forto_mcp(4 tools;subscribeexcluded because MCP tool calls are request/response; ported to alkhttp — seedecisions/041-mcp-tool-gateway-pattern.mdif present, otherwise the alknet record) - alknet OQ-39 —
to_openapipublished-spec versioning (simplified by the gateway pattern to 5 stable endpoints; resolved by ADR-045) http-adapters.md(in this crate'sdocs/architecture/) — the spec that implements the gateway (alknet original:crates/http/http-adapters.md)
Port notes
- Renames: "alknet" → alkhttp where the system/node being described is meant; "alknet-call" (protocol-foundation crate) → alkcall.
OperationType::Subscription→Subin the gateway endpoint table, §2, and throughout (alkcall rename). alkcall ADR-046 addedOperationType::Pub(producer→consumer streaming,HandlerKind::Sink); the original 5-endpoint table predatesPuband is preserved verbatim as decision history — the/publishextension is marked as an alkhttp addition (ADR-068), never silently rewritten.- Gateway endpoint count: the alknet record's "5 endpoints" is
preserved everywhere as the original decision; alkhttp's
/publishextension (ADR-068) is noted inline at each site where the count matters (§1, §3, §4, Assumption 1, Context). "Never rewrite history" framing: the published initial contract remains the 5-endpoint set;/publishis an additive extension under the contract's own additivity rule (§4, Assumption 1). - Producer/consumer terminology: "an admin sees admin operations, a
regular user sees a subset" and similar retain their names — no
call-protocol server/client role framing existed in the original
body. The external client roles (code generator, human developer,
fetch-based client, Gitea-style API consumers) are inherent-directionality roles of the HTTP/OpenAPI surface and keep their names. - Cross-reference remappings: ADR-015/017/023 are ported to this crate under the same numbers and linked; their alkcall record numbers are noted alongside (015→alkcall ADR-017, 017→alkcall ADR-022, 023→alkcall ADR-016). ADR-041 is cited textually with its alknet number (the MCP gateway pattern; not part of this port's 3 files — verify the alkhttp port's existence when linking).
- OQ references (OQ-14, OQ-39) are alknet OQ-tracker items, cited textually; OQ-39 is resolved by ADR-045 (ported to this crate under the same number).
- Spec-document links converted per alkhttp conventions:
crates/http/http-adapters.md→http-adapters.mdin this crate'sdocs/architecture/;websocket.md/webtransport.mdwere alknet mono-repo spec-tree documents — the WS path here is the native call-protocol session (ADR-048) and no deferred WebTransport spec is carried (ADR-044). - No decision content changed — the flat→structured problem, per-caller
surface problem, gateway pattern, the 5-endpoint table, the
subscribeSSE semantics,POST-not-GETrationale, compatibility-contract clause, additive traditional projection, consequences, and assumptions are verbatim from the alknet ADR modulo the adaptations above.