Files
alkhttp/docs/architecture/decisions/047-remove-direct-call-http-surface.md
T
glm-5.3-flash 320ea87b08 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.
2026-08-27 14:19:24 +00:00

18 KiB

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 §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).

  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 403s. 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) 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 (QueryGET, MutationPOST, SubSSE) 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: Bearerresolve_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): 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) 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 (QueryGET 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 — the ADR whose routing decision this supersedes (§Decision, §HTTP method semantics); its other clauses survive (§"What survives from ADR-036")
  • ADR-042 — 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 — 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 — 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-045to_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.
  • SubscriptionSub (§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.mddocs/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.