docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
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.
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
# ADR-047: Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path
|
||||
|
||||
*Ported from alknet ADR-047 (Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path); re-targeted to alkhttp.*
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Supersedes
|
||||
|
||||
The "direct path mapping" clause of
|
||||
[ADR-036](decisions/036-http-to-call-operation-mapping.md)
|
||||
§Decision ("Direct path mapping is the default HTTP surface") and §HTTP
|
||||
method semantics. ADR-036's other clauses (SSE projection, Bearer auth,
|
||||
`/healthz`, stealth decoy, error mapping, `External`-only dispatch)
|
||||
remain in force — they are independent of the routing decision and are
|
||||
reaffirmed by this ADR (see §"What survives from ADR-036").
|
||||
|
||||
## Context
|
||||
|
||||
ADR-036 defined the HTTP surface as **direct path mapping**:
|
||||
`POST /{service}/{op}` → `call.requested` for every `External`
|
||||
operation. An operation `fs/readFile` was served at `POST /fs/readFile`,
|
||||
one HTTP path per operation — a REST-like surface mirroring the call
|
||||
protocol's `/{service}/{op}` operation paths. This was the original HTTP
|
||||
contract, decided before the simplified-contract / gateway-pattern
|
||||
work landed.
|
||||
|
||||
Since then, three shifts made the direct-call surface a contradiction
|
||||
with the architecture's settled model:
|
||||
|
||||
1. **ADR-042** replaced `to_openapi`'s per-operation-paths projection
|
||||
with the **gateway pattern** — 5 fixed endpoints (`/search`,
|
||||
`/schema`, `/call`, `/batch`, `/subscribe`) where the per-caller
|
||||
operation surface is discovered via `AccessControl`-filtered
|
||||
`/search`, not preloaded into a static doc. The gateway's `/call`
|
||||
endpoint is the invoke path: `POST /call` with
|
||||
`{ operation: "/fs/readFile", input: {...} }`. This is the same
|
||||
RPC-shape pattern MCP uses (`tools/call` with a tool name,
|
||||
[ADR-041](041-mcp-tool-gateway-pattern.md)).
|
||||
|
||||
2. **The simplified contract is the few-fixed-endpoints model**, not a
|
||||
per-operation REST tree. The whole point of the gateway pattern
|
||||
(ADR-042) was to escape the "static full-surface dump" failure mode
|
||||
(the Gitea anti-pattern: every operation gets a path, every caller
|
||||
sees the full surface, per-caller access is an afterthought). The
|
||||
direct-call surface is that anti-pattern at the HTTP level: every
|
||||
`External` operation gets an HTTP path, the path exists regardless
|
||||
of the caller's privilege, and the caller discovers what it can call
|
||||
by trial-and-error `403`s. The gateway's `/search` exists precisely
|
||||
to make the per-caller surface the default; the direct-call surface
|
||||
re-introduces the problem the gateway solved.
|
||||
|
||||
3. **ADR-046** added the custom-routes extension point, so a
|
||||
deployment that genuinely wants a REST-like per-operation HTTP
|
||||
surface (e.g., to match a legacy API shape) builds it as a custom
|
||||
route projection (additive, deployment-owned, not the alkhttp
|
||||
default contract). The direct-call surface is no longer the only
|
||||
way to get per-operation HTTP paths; it's the *default* way, and
|
||||
it's the wrong default.
|
||||
|
||||
The result: the HTTP router currently has **two ways to invoke an
|
||||
operation** — the direct-call surface (`POST /fs/readFile`) and the
|
||||
gateway (`POST /call` with the operation name in the body). That is the
|
||||
contradiction: the simplified contract says "a few core endpoints,"
|
||||
and the direct-call surface is a second, per-operation invoke path that
|
||||
duplicates the gateway's `/call` with a scheme the gateway was built
|
||||
to replace. ADR-042's amendment explicitly preserved the direct-call
|
||||
surface ("unchanged"); that preservation was a leftover from before
|
||||
the simplified contract was fully thought through, not a deliberate
|
||||
endorsement of two invoke paths.
|
||||
|
||||
### The clean-up
|
||||
|
||||
The direct-call surface is residual from early-stage planning, the
|
||||
same way the pre-ADR-042 `to_openapi` per-operation-paths projection
|
||||
was residual. ADR-042 cleaned up `to_openapi`; this ADR cleans up the
|
||||
HTTP handler's routing. The gateway becomes the sole invoke path; the
|
||||
per-operation HTTP paths go away.
|
||||
|
||||
### What about HTTP clients that knew operation names?
|
||||
|
||||
A client that previously called `POST /fs/readFile` now calls
|
||||
`POST /call` with `{ "operation": "/fs/readFile", "input": {...} }`. The
|
||||
operation name is still the call protocol's `/{service}/{op}` form
|
||||
(OQ-13, unchanged) — it moves from the HTTP path to the request body.
|
||||
The gateway's `/call` is the standard invoke endpoint; the direct path
|
||||
was a REST-like affordance that the simplified contract deliberately
|
||||
drops. This is a breaking change for any HTTP client built against the
|
||||
direct-call surface, which is exactly why it needs an ADR — but the
|
||||
direct-call surface has not been implemented or published yet (the
|
||||
alkhttp crate is specced, not shipped), so the "break" is
|
||||
paper-only: no external client depends on it.
|
||||
|
||||
## Decision
|
||||
|
||||
> **Extension note (alkhttp):** the gateway this ADR narrows the HTTP
|
||||
> surface to now has **six** endpoints, not the five listed in the
|
||||
> original decision. alkhttp ADR-068
|
||||
> (`decisions/068-gateway-publish-endpoint.md`) added `/publish` (POST;
|
||||
> invokes Pub operations — producer→consumer streaming; request body
|
||||
> streamed as newline-delimited JSON, each line one published chunk;
|
||||
> final `ResponseEnvelope` returned as the HTTP response) to the
|
||||
> gateway contract after this ADR was decided. "The 5 gateway
|
||||
> endpoints" below is the original decision text, retained as decision
|
||||
> history; the sole-invoke-path decision applies to the 6-endpoint
|
||||
> gateway (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`,
|
||||
> `/publish`).
|
||||
|
||||
### 1. The gateway is the sole invoke path; the direct-call surface is removed
|
||||
|
||||
The `HttpAdapter`'s router serves the **5 fixed gateway endpoints**
|
||||
(`/search`, `/schema`, `/call`, `/batch`, `/subscribe` — ADR-042) as
|
||||
the only way to invoke operations over HTTP. There is no
|
||||
`POST /{service}/{op}` direct-call surface. An HTTP client invokes an
|
||||
operation by `POST /call` with
|
||||
`{ "operation": "/{service}/{op}", "input": {...} }`.
|
||||
|
||||
The router's operation-invoke surface is the gateway's `/call`
|
||||
endpoint, not a per-operation path set. The operation name is in the
|
||||
request body, not the HTTP path — same shape as MCP's `tools/call`
|
||||
([ADR-041](041-mcp-tool-gateway-pattern.md)) and the call protocol's
|
||||
own `call.requested`
|
||||
(`operationId` + `input`).
|
||||
|
||||
### 2. The HTTP method semantics move to the gateway endpoints
|
||||
|
||||
ADR-036's `OperationType` → HTTP method mapping (`Query`→`GET`,
|
||||
`Mutation`→`POST`, `Sub`→`SSE`) no longer applies per-operation
|
||||
at the HTTP path level, because there are no per-operation HTTP paths.
|
||||
The gateway endpoints have fixed methods (ADR-042's table):
|
||||
`/search` `GET`, `/schema` `GET`, `/call` `POST`, `/batch` `POST`,
|
||||
`/subscribe` `POST` (SSE). The `OperationType` of the *called operation*
|
||||
is carried in the request/result, not expressed in the HTTP verb — the
|
||||
client calls `/call` with the operation name; the operation's type is
|
||||
the registry's concern, not the HTTP method's. A `Query` operation and a
|
||||
`Mutation` operation both go through `POST /call`; the distinction is
|
||||
in the operation spec (discovered via `/schema`), not the HTTP surface.
|
||||
|
||||
### 3. What survives from ADR-036
|
||||
|
||||
ADR-036's routing decision is superseded, but its other clauses are
|
||||
independent of routing and remain in force:
|
||||
|
||||
- **SSE projection for subscriptions over `h2`/`http/1.1`** (§Streaming
|
||||
projection). The gateway's `/subscribe` endpoint uses this SSE
|
||||
projection (ADR-042 §2). The framing (`call.responded` → SSE `data:`
|
||||
frame, `call.completed` → stream close, `call.aborted` → error frame)
|
||||
is unchanged; it is now the `/subscribe` endpoint's behavior, not a
|
||||
per-operation SSE stream.
|
||||
- **Bearer auth** (§Auth). `Authorization: Bearer` →
|
||||
`resolve_from_token` on every gateway endpoint. Unchanged.
|
||||
- **`/healthz`** (§`/healthz` and operational endpoints). Raw route, no
|
||||
auth, no call protocol. Unchanged.
|
||||
- **Stealth decoy** (§Stealth mode). Unknown paths get the decoy.
|
||||
Unchanged — and now *all* operation invocations go through the 5
|
||||
gateway paths, so the "unknown path" surface is larger (anything not
|
||||
`/search`, `/schema`, `/call`, `/batch`, `/subscribe`, `/healthz`,
|
||||
`/openapi.json`, the MCP route, or a custom route per ADR-046 is
|
||||
decoy).
|
||||
- **Error mapping** (the call `code` → HTTP status table in
|
||||
http-server.md, per the alkcall crate's ADR-016). The gateway's
|
||||
`/call` endpoint returns the same error mapping. Unchanged in
|
||||
mechanism; the entry point is `/call` instead of `/{service}/{op}`.
|
||||
- **`External`-only dispatch** (Assumption 2). The gateway's `/call`
|
||||
returns `404` (`NOT_FOUND`) for `Internal` operations, same as the
|
||||
direct-call surface did. The `AccessControl` check runs on the called
|
||||
operation regardless of the entry point.
|
||||
- **Abort cascade on HTTP disconnect** (Consequences, citing alkcall
|
||||
ADR-020). An HTTP consumer disconnecting mid-`/subscribe` is detected
|
||||
as a stream close and sends `call.aborted`, cascading to descendants.
|
||||
Unchanged.
|
||||
|
||||
### 4. A deployment that wants per-operation HTTP paths builds them as custom routes (ADR-046)
|
||||
|
||||
A deployment that genuinely needs a REST-like per-operation HTTP
|
||||
surface (to match a legacy API shape, to serve clients that can't
|
||||
adapt to the gateway) builds it as a **custom route projection**
|
||||
([ADR-046](046-assembly-layer-custom-http-routes.md)): the assembly
|
||||
layer injects an `axum::Router` with
|
||||
`POST /{service}/{op}` handlers that dispatch into
|
||||
`OperationRegistry::invoke()`. This is deployment-owned, additive, and
|
||||
explicitly *not* the alkhttp default contract — the same status as an
|
||||
OAI-compatible proxy. The direct-call surface is no longer a built-in
|
||||
default; it's a projection a deployment can build if it needs it, on
|
||||
the same extension point as any other custom HTTP surface.
|
||||
|
||||
This keeps the default surface small (5 gateway endpoints) while
|
||||
preserving the *capability* for REST-like access — it just isn't free
|
||||
by default, which is correct, because the per-operation path surface
|
||||
has real costs (the static-surface problem) that the gateway avoids.
|
||||
|
||||
### 5. `to_openapi` describes the gateway, unchanged
|
||||
|
||||
`to_openapi` (ADR-042,
|
||||
[ADR-045](045-to-openapi-gateway-spec-versioning.md)) already describes
|
||||
the 5 gateway
|
||||
endpoints, not per-operation paths. Removing the direct-call surface
|
||||
does not change what `to_openapi` generates — it already generated the
|
||||
gateway doc. The `info.version` semver (ADR-045) tracks the gateway
|
||||
contract; the direct-call surface was never in that contract. No change
|
||||
to `to_openapi` or its versioning.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- One invoke path over HTTP, not two. The HTTP surface is the 5 gateway
|
||||
endpoints — exactly the "few core endpoints" of the simplified
|
||||
contract. The contradiction with the gateway pattern is resolved.
|
||||
- The per-caller API surface is the default, structurally. An HTTP
|
||||
client cannot stub its toe on `POST /admin/deleteUser` because that
|
||||
path does not exist; it calls `/call` with the operation name, and
|
||||
`/search` tells it what it can call. The Gitea failure mode is
|
||||
structurally impossible at the HTTP level, not just at the discovery
|
||||
level.
|
||||
- The HTTP surface is honest about what the call protocol is: an RPC,
|
||||
not a REST API. The gateway's `/call` with `{ operation, input }` is
|
||||
the call protocol's own shape; the direct path mapping was a REST
|
||||
disguise that didn't fit (the flat JSON input, no path/query/body
|
||||
split — ADR-042 §"The flat→structured problem").
|
||||
- A deployment that wants REST-like per-operation paths still can, via
|
||||
custom routes (ADR-046) — it's an explicit choice with its own costs,
|
||||
not a default that leaks the static-surface problem into every
|
||||
deployment.
|
||||
- No change to `to_openapi` (already described the gateway), to the
|
||||
SSE projection (now on `/subscribe`), to Bearer auth, to `/healthz`,
|
||||
to stealth, or to error mapping. The cleanup is narrow: the routing
|
||||
decision only.
|
||||
|
||||
**Negative:**
|
||||
- An HTTP client that knew an operation name can no longer call it at
|
||||
a predictable HTTP path. It must call `/call` with the operation name
|
||||
in the body. This is one layer of indirection, but it's the same
|
||||
indirection MCP uses and the same shape the call protocol uses
|
||||
natively. The operation name (OQ-13's `/{service}/{op}` form) is
|
||||
unchanged — it moves from the path to the body.
|
||||
- The HTTP surface is RPC-shaped, not REST-shaped. A developer
|
||||
expecting `POST /fs/readFile` sees `POST /call` with a body instead.
|
||||
This is honest (the call protocol is a flat JSON RPC, ADR-042 §3), but
|
||||
it's a departure from the REST conventions ADR-036's direct-call
|
||||
surface offered. A deployment that needs the REST shape builds it as a
|
||||
custom route projection (ADR-046).
|
||||
- The `OperationType` → HTTP method mapping (`Query`→`GET` etc.) no
|
||||
longer applies at the HTTP level. A `Query` operation and a
|
||||
`Mutation` operation both go through `POST /call`. The distinction is
|
||||
in the operation spec (visible via `/schema`), not the HTTP verb. This
|
||||
loses a small amount of HTTP-level signal (a load balancer can't tell
|
||||
a read from a write by method), but the call protocol's
|
||||
`OperationType` was always a registry concern, not an HTTP concern —
|
||||
the direct-call surface borrowed HTTP verbs to express it, and the
|
||||
gateway doesn't.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **No external client depends on the direct-call surface.** The
|
||||
alkhttp crate is specced, not shipped; the direct-call surface
|
||||
has not been published. Removing it is a paper-only break — no
|
||||
deployed client breaks. This is why the cleanup is cheap now and
|
||||
would be expensive after implementation.
|
||||
|
||||
2. **The gateway's `/call` is a sufficient invoke path for HTTP
|
||||
clients.** Any operation callable via `POST /{service}/{op}` is
|
||||
callable via `POST /call` with the operation name in the body. The
|
||||
operation name form (`/{service}/{op}`, OQ-13) is unchanged. The
|
||||
input/output shapes are unchanged. The only difference is where the
|
||||
operation name lives (path vs body).
|
||||
|
||||
3. **A deployment needing REST-like per-operation paths builds them
|
||||
explicitly.** Via ADR-046 custom routes. This is not a common need —
|
||||
the gateway's `/call` covers the standard invoke case, and the
|
||||
OAI-compatible-proxy pattern (ADR-046) covers the "match an external
|
||||
API shape" case. The direct-call surface was a default that served
|
||||
neither case particularly well (it wasn't REST-conventional, per
|
||||
ADR-036 §Negative, and it leaked the static-surface problem).
|
||||
|
||||
4. **The gateway endpoints are stable (ADR-042 Assumption 1).**
|
||||
Removing the direct-call surface does not change the gateway
|
||||
endpoint set; the 5 endpoints are the published contract. This ADR
|
||||
narrows the HTTP surface *to* that contract, it does not modify the
|
||||
contract itself.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-036](decisions/036-http-to-call-operation-mapping.md) — the ADR
|
||||
whose
|
||||
routing decision this supersedes (§Decision, §HTTP method semantics);
|
||||
its other clauses survive (§"What survives from ADR-036")
|
||||
- [ADR-042](decisions/042-openapi-gateway-pattern.md) — the gateway
|
||||
pattern that
|
||||
made the direct-call surface redundant; its amendment to ADR-036
|
||||
preserved the direct-call surface, which this ADR reverses
|
||||
- [ADR-044](044-defer-webtransport-browsers-use-websocket.md) —
|
||||
WebSocket is the browser bidirectional path (the direct-call surface
|
||||
was the `h2`/`http/1.1` one-directional path; removing it does not
|
||||
affect WebSocket, which carries the call protocol natively)
|
||||
- [ADR-046](046-assembly-layer-custom-http-routes.md) — the extension
|
||||
point a deployment uses to build a per-operation HTTP surface if it
|
||||
needs one (the direct-call surface's replacement for the rare case)
|
||||
- [ADR-045](045-to-openapi-gateway-spec-versioning.md) — `to_openapi`
|
||||
versions the gateway contract (unchanged; the direct-call surface
|
||||
was never in the contract)
|
||||
- alkhttp ADR-068 (`decisions/068-gateway-publish-endpoint.md`) — adds
|
||||
`/publish` to the gateway contract (the 6th endpoint; see the
|
||||
extension note above)
|
||||
- OQ-13 (resolved) — operation path format `/{service}/{op}` is
|
||||
unchanged; it moves from the HTTP path to the `/call` request body
|
||||
- `docs/architecture/http-server.md` — this crate's spec whose router
|
||||
surface this ADR narrows to the gateway endpoints
|
||||
|
||||
## Port notes
|
||||
|
||||
- Renames: "alknet-http" → alkhttp throughout (crate name, shipped-vs-
|
||||
specced status, threat-model and default-contract references).
|
||||
- **Gateway endpoint count (extension note, never silently rewritten):**
|
||||
the original decision text says "the 5 fixed gateway endpoints"
|
||||
(`/search`, `/schema`, `/call`, `/batch`, `/subscribe`) throughout.
|
||||
In alkhttp, alkcall ADR-046 (`Pub` operation type) motivated a sixth
|
||||
gateway endpoint, `/publish` (producer→consumer streaming; newline-
|
||||
delimited JSON request body; final `ResponseEnvelope` as the HTTP
|
||||
response), recorded in this crate's ADR-068. A block-quote extension
|
||||
note at the top of §Decision states the 6-endpoint contract and marks
|
||||
the "5 endpoints" phrasing below it as retained decision history;
|
||||
the original text is otherwise verbatim. The stealth-decoy clause §3
|
||||
retains the original 5-path enumeration for the same reason.
|
||||
- **`Subscription` → `Sub`** (§2's method-mapping sentence; alkcall
|
||||
rename of the `OperationType` value).
|
||||
- **Producer/consumer terminology**: §3 abort-cascade bullet "An HTTP
|
||||
client disconnecting mid-`/subscribe`" → "An HTTP consumer
|
||||
disconnecting mid-`/subscribe`" (call-protocol role framing; the
|
||||
abort-cascade citation is alkcall ADR-020). HTTP server/client
|
||||
phrasing elsewhere is HTTP-inherent and untouched (`HttpAdapter`
|
||||
serves; HTTP clients call).
|
||||
- **Cross-refs**: `../../decisions/...` → `decisions/...`. ADR-036 and
|
||||
ADR-042 are ported to this crate under their alknet numbers/slugs and
|
||||
linked (`decisions/036-http-to-call-operation-mapping.md`,
|
||||
`decisions/042-openapi-gateway-pattern.md`); ADR-044/045/046 are
|
||||
ported under the same numbers and linked directly. The source's
|
||||
alknet ADR-023 (error schemas) citation is remapped textually to the
|
||||
alkcall crate's ADR-016 (`decisions/016-operation-error-schemas.md`);
|
||||
the alknet ADR-016 (abort cascade) citation is remapped to alkcall
|
||||
ADR-020. The new-ADR link `decisions/068-gateway-publish-endpoint.md`
|
||||
uses the alkhttp slug for ADR-068 (the alknet ADR-068 is an unrelated
|
||||
hub decision and is not referenced here).
|
||||
- `crates/http/http-server.md` → `docs/architecture/http-server.md`
|
||||
(this crate's docs/architecture/).
|
||||
- No frontmatter in the source; status kept as Proposed.
|
||||
- No decision content changed — the sole-invoke-path decision, the
|
||||
method-semantics move, the "what survives" list, the custom-routes
|
||||
escape hatch, the `to_openapi` unchanged clause, and the
|
||||
consequences/assumptions are verbatim from the alknet ADR modulo the
|
||||
renames and the logged six-endpoint extension note.
|
||||
Reference in New Issue
Block a user