Files
alkhttp/docs/architecture/decisions/036-http-to-call-operation-mapping.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

303 lines
16 KiB
Markdown

# ADR-036: HTTP-to-Call Operation Mapping
*Ported from alknet ADR-036 (HTTP-to-Call Operation Mapping); re-targeted to alkhttp.*
## Status
Proposed — **routing decision superseded by
[ADR-047](047-remove-direct-call-http-surface.md)** (the direct-call
surface `POST /{service}/{op}` is removed; the gateway `/call` is the
sole invoke path). ADR-036's other clauses — SSE projection, Bearer
auth, `/healthz`, stealth decoy, error mapping, `External`-only
dispatch — remain in force (see ADR-047 §"What survives from
ADR-036"). The `to_openapi` clause was already superseded by ADR-042.
## Context
`alkhttp` implements `ProtocolHandler` for the standard HTTP ALPNs (`h2`,
`http/1.1`; `h3`/WebTransport is deferred per
[ADR-044](044-defer-webtransport-browsers-use-websocket.md)). An inbound
HTTP request that targets an alkhttp operation
must become a call-protocol `call.requested` dispatch — the HTTP handler is a
*projection* of the call protocol, not a parallel routing layer. The
question is how an HTTP request maps to an operation invocation.
Three options were considered in the alkhttp Phase 0 research
(alknet mono-repo: `docs/research/alknet-http/phase-0-findings.md`,
decision point DH-3):
- **(a) Direct path mapping.** `POST /{service}/{op}``call.requested` for
`/{service}/{op}`. The HTTP handler parses the request body as the
operation input, sends `call.requested`, and returns the response as JSON.
The HTTP surface is a thin projection of the call protocol's
`/{service}/{op}` operation path format (resolved by alknet OQ-13).
- **(b) OpenAPI-defined routes.** The HTTP surface is defined by the
`to_openapi` projection — routes, methods, schemas are generated from the
registry's `External` operations, and the HTTP handler dispatches based on
the generated OpenAPI spec's path mapping.
- **(c) Explicit route registration.** The assembly layer registers HTTP
routes explicitly, mapping URL paths to operations. Most flexible, most
boilerplate.
This is a load-bearing architectural choice. Once the HTTP surface's routing
contract is published and external clients build against it, changing the
mapping (e.g., from "the HTTP path IS the operation path" to "the HTTP path
is a generated alias") is a one-way door: every client breaks. It needs an
ADR before implementation.
The call protocol's operation path format is `/{service}/{op}` (alknet
OQ-13, resolved). The HTTP handler serves these operations over HTTP. The
mapping must be a *projection* of that single operation surface, not a second
routing table that has to be kept in sync with the registry.
## Decision
> **Routing decision superseded by
> [ADR-047](047-remove-direct-call-http-surface.md).** The direct-call
> surface defined below (`POST /{service}/{op}` → `call.requested`) is
> removed — the gateway's `/call` endpoint ([ADR-042](042-openapi-gateway-pattern.md))
> is the sole invoke
> path over HTTP. This section is retained as the historical record of
> the original decision; ADR-047 records the reversal and what survives.
> The `to_openapi` clause below was already superseded by ADR-042 (see
> the amendment in this section).
**Direct path mapping is the default HTTP surface; `to_openapi` is the
discovery/projection layer, not a parallel router.**
The `HttpAdapter` receives an HTTP request whose path is `/{service}/{op}`
(e.g., `POST /fs/readFile`, `POST /agent/chat`), constructs a
`call.requested` dispatch with `operationId: /{service}/{op}` and `input:
<parsed body>`, and returns the operation's response as JSON. The HTTP path
IS the operation path — one routing surface, the call protocol's.
`to_openapi` generates the OpenAPI spec that *describes* this surface for
external consumers (route paths, methods, request/response schemas, error
schemas per [ADR-023](023-operation-error-schemas.md)). It does not define
separate routes — the generated
spec's `paths` mirror the `/{service}/{op}` operation paths. An external
client reading the OpenAPI doc learns the same routes the HTTP handler
serves; there is no second mapping.
> **Amendment (superseded by [ADR-042](042-openapi-gateway-pattern.md) on
> the `to_openapi` clause):** The paragraph above described the original
> "per-operation-paths projection" — `to_openapi` generating one OpenAPI
> path entry per `External` operation, mirroring `/{service}/{op}`. ADR-042
> replaces this with the **gateway pattern**: `to_openapi` generates 5
> fixed gateway endpoints (`/search`, `/schema`, `/call`, `/batch`,
> `/subscribe`) instead of one path per operation (the 5-endpoint set,
> extended with `/publish` in alkhttp — ADR-068). The "no second routing
> table" property is preserved (the gateway endpoints are fixed; the
> per-caller operation surface is discovered via `/search`, not preloaded
> into a generated path set). The direct-call surface (`POST
> /{service}/{op}`) that this ADR defines was **unchanged at the time**
> — ADR-042 only changed what `to_openapi` *describes*, not what the
> HTTP handler *serves*. **The direct-call surface was later removed by
> [ADR-047](047-remove-direct-call-http-surface.md)** (the gateway
> `/call` is the sole invoke path; the simplified contract is a few
> fixed endpoints, not a per-operation REST tree). A traditional
> per-operation-paths OpenAPI projection remains available as an
> additive alternative (ADR-042 §5), and a deployment that wants the
> former direct-call HTTP surface builds it as a custom route
> projection ([ADR-046](046-assembly-layer-custom-http-routes.md)).
### HTTP method semantics
The call protocol's `OperationType` (`Query`, `Mutation`, `Sub`,
per the alkcall crate's `docs/architecture/operation-registry.md`) maps to
HTTP methods on the default surface:
| `OperationType` | Default HTTP method | Notes |
|-----------------|----------------------|-------|
| `Query` | `GET` | Read-only, idempotent. Input from query parameters + optional body. |
| `Mutation` | `POST` (or `PUT`/`PATCH`/`DELETE` if the operation declares it) | Default `POST`; the op may declare a specific mutation method in its spec metadata. |
| `Sub` | `GET` with `Accept: text/event-stream` | Streaming — the HTTP handler projects the subscription's `call.responded` stream as SSE chunks. |
The default method for an `External` operation with no explicit HTTP method
declared is `POST` for `Mutation`, `GET` for `Query`. This is the
least-surprise default; an operation that wants a specific HTTP verb
declares it. The method-to-`OperationType` mapping is a two-way-door
default (changing it later is additive — a new method is added, existing
methods keep working).
### Streaming projection (SSE)
A `Sub` operation served over HTTP/1.1 or HTTP/2 projects its
`call.responded` stream as Server-Sent Events. Each `call.responded` event
becomes an SSE `data:` frame; `call.completed` closes the SSE stream;
`call.aborted` closes the stream with an SSE error event. This is the
HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket (the v1 browser
bidirectional path, ADR-044), the subscription projects directly onto the
WS connection — `call.responded` events as binary WS messages, no SSE
framing. WebTransport (`h3`) would project onto WebTransport bidirectional
streams but is deferred per ADR-044.
### Auth
Inbound HTTP auth is `Authorization: Bearer <token>`, resolved via
`IdentityProvider::resolve_from_token()` (the alknet mono-repo `auth.md`
handler table — `HttpAdapter`, Bearer header, `resolve_from_token`). This
is settled by [ADR-004](004-auth-as-shared-core.md) and alknet OQ-11; this
ADR does not change it. Bearer-only is the auth
mechanism; other HTTP auth schemes (Basic, API key in query param) are not
implemented. An unauthenticated request to an operation with
`AccessControl` restrictions returns `401`/`403` (mapped from the call
protocol's `FORBIDDEN` protocol code).
### Stealth mode
The HTTP handler on `h2`/`http/1.1` serves a decoy (configurable: fake
404, a static site, a redirect) for paths that are not registered
operations. This is the ALPN-based stealth mapping (alknet mono-repo
`endpoint.md`; in alkhttp, [ADR-010](010-alpn-router-and-endpoint.md) and
this crate's `server` spec) —
clients that don't offer the call-protocol ALPNs get the HTTP handler, and
unknown HTTP paths get the decoy. The decoy is a two-way-door config
default (an operator picks what to serve); the *existence* of the stealth
path is fixed by ADR-010.
### `/healthz` and operational endpoints
`GET /healthz` is a raw HTTP route outside the call protocol — no auth, no
operation registration. It exists for infrastructure (load balancers,
orchestrators). Other operational endpoints (metrics, dashboard) are
call-protocol operations if built (`/metrics/list`, `/dashboard/view`),
not raw HTTP routes. `healthz` is the one exception: it must be callable
without auth before identity is resolvable.
## Consequences
**Positive:**
- One routing surface. The HTTP handler does not maintain a second routing
table; it projects the call protocol's `/{service}/{op}` paths directly.
No sync drift between the operation registry and the HTTP routes.
- `to_openapi` is a pure projection (generate a spec that *describes* the
existing surface), not a routing authority. The generated spec is always
consistent with what the handler actually serves because they're the same
paths.
- External HTTP clients (curl, axios, browser `fetch`) can call alkhttp
operations without knowing about the call protocol — the HTTP surface is
a standard REST-like API.
- The abort cascade (alkcall ADR-020) is preserved: an HTTP client
disconnecting
mid-subscription is detected as a stream close, and the HTTP handler
sends `call.aborted` for the in-flight subscription, which cascades to
descendants.
- The HTTP method mapping (`Query``GET`, `Mutation``POST`,
`Sub``SSE`) is the standard REST projection — no surprise
verbs, no exotic method semantics.
**Negative:**
- The HTTP surface inherits the call protocol's `/{service}/{op}` path
shape. An operation named `fs/readFile` is served at `POST /fs/readFile`,
not at a REST-nested `POST /fs/files/:id/read` or any other
REST-conventional path. Operations that want a REST-nested HTTP path
must declare it in spec metadata (a two-way-door extension); the
default is the operation path verbatim. This is a deliberate
least-surprise-for-alkhttp choice, not a REST-purist choice.
- HTTP request/response semantics don't map cleanly onto every call
protocol operation. A `Query` with a large input has to put the input in
the body (GET-with-body is non-standard). A `Mutation` that is
idempotent doesn't get `PUT` semantics unless it declares them. The
projection is lossy at the edges; operations that need precise HTTP
semantics declare them.
- `to_openapi` is a published compatibility contract ([ADR-017](017-call-protocol-client-and-adapter-contract.md)
Consequences:
once external clients build against the generated spec, the mapping is
one-way). The generated spec's versioning (tied to the registry's
`External` operation set version) must be emitted as a spec marker so
consumers can detect mapping changes. This is alknet OQ-17's
published-artifact concern, applied to the HTTP projection.
## Assumptions
1. **The operation path IS the HTTP path.** An operation `fs/readFile` is
served at `/fs/readFile`. There is no separate HTTP path mapping layer.
If a deployment wants different HTTP paths (e.g., a REST-nested
convention), that's a future projection layer, not a change to this
mapping.
2. **`External` operations are the HTTP surface.** `Internal` operations
(composition-only, [ADR-015](015-privilege-model-and-authority-context.md))
are not served over HTTP — they return `404`
on the HTTP handler, matching the call protocol's `NOT_FOUND` for wire
calls to Internal ops. The HTTP handler dispatches only `External`
operations.
3. **HTTP auth is Bearer-only.** The HTTP handler resolves identity from
the `Authorization: Bearer` header via `resolve_from_token`. Basic auth,
API keys in query params, and other HTTP auth schemes are not
implemented. A deployment that needs a different auth scheme adds it as
middleware (two-way door), but the default surface is Bearer-only.
## References
- [ADR-004](004-auth-as-shared-core.md) — `IdentityProvider`, Bearer →
`resolve_from_token` (the auth model this ADR uses, unchanged)
- [ADR-010](010-alpn-router-and-endpoint.md) — stealth mode as ALPN
dispatch (the HTTP handler on standard ALPNs serves the decoy)
- [ADR-015](015-privilege-model-and-authority-context.md) — External/Internal
visibility (Internal ops are not served over HTTP; the decision record
is alkcall ADR-017)
- alkcall ADR-020: Abort Cascade for Nested Calls (alknet ADR-016) — abort
cascade (HTTP client disconnect → `call.aborted` → cascade to
descendants)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
`to_openapi` as a projection; published-spec compatibility contract
(the decision record is alkcall ADR-022)
- [ADR-023](023-operation-error-schemas.md) — error schema fidelity in
`from_openapi`/`to_openapi`; HTTP status mapping (the decision record is
alkcall ADR-016)
- [ADR-042](042-openapi-gateway-pattern.md) — supersedes this ADR's
`to_openapi` clause (the per-operation-paths projection is replaced by
the 5-endpoint gateway pattern — extended with `/publish` in alkhttp,
ADR-068; the direct-call surface this ADR defines is unchanged — *at
the time*; ADR-047 later removes it)
- [ADR-047](047-remove-direct-call-http-surface.md) — supersedes this
ADR's routing decision (the direct-call surface is removed; the
gateway `/call` is the sole invoke path). This ADR's non-routing
clauses survive.
- alknet OQ-13 (resolved) — operation path format `/{service}/{op}`
- alknet mono-repo `docs/research/alknet-http/phase-0-findings.md` DH-3 —
the decision this ADR resolves
- `http-server.md` (in this crate's `docs/architecture/`) — the spec that
implements this mapping (alknet original: `crates/http/http-server.md`)
## Port notes
- Renames: "alknet-http" → alkhttp; "alknet" (the system/node being
described) → alkhttp where the HTTP-served surface is meant.
- `OperationType::Subscription``Sub` (alkcall rename) in the method
table and the SSE section. alkcall ADR-046 later added
`OperationType::Pub` (producer→consumer streaming via `call.published`,
`HandlerKind::Sink`); `Pub` has no row in this ADR's HTTP method table —
its HTTP projection is the `/publish` gateway endpoint (alkhttp
ADR-068).
- **Gateway endpoint count**: the alknet record's "5 fixed gateway
endpoints" statements are preserved verbatim as history; alkhttp extends
the set with `/publish` (alkhttp ADR-068) — marked with an inline
annotation in the amendment blockquote, not silently rewritten.
- Cross-reference remappings (verified alknet→alkcall ADR mapping): alknet
ADR-016 (abort cascade) → alkcall ADR-020. ADR-004/010/015/017/023 are
ported to this crate under the same numbers and linked; their alkcall
record numbers are noted alongside where cited (015→alkcall ADR-017,
017→alkcall ADR-022, 023→alkcall ADR-016).
- Producer/consumer terminology: the original contained no call-protocol
server/client role framing; "external HTTP clients (curl, axios, browser
`fetch`)" and similar retain their names — those are inherent-directionality
roles of the HTTP/OpenAPI surface, not call-protocol roles.
- Spec-document links converted per alkhttp conventions:
`crates/http/http-server.md``http-server.md` in this crate's
`docs/architecture/`; `operation-registry.md` → textual "the alkcall
crate's docs/architecture/operation-registry.md"; `auth.md` and
`endpoint.md` are alknet mono-repo spec-tree documents cited textually
(the auth model is ADR-004, the stealth mapping ADR-010).
- OQ references (OQ-11, OQ-13, OQ-17) are alknet OQ-tracker items, cited
textually; the operation path format is owned by the alkcall crate.
- The `docs/research/alknet-http/phase-0-findings.md` pointer is kept as a
historical alknet mono-repo reference (no alkhttp equivalent exists).
- No decision content changed — the three-option framing, the direct-path
decision (retained as the historical record of the ADR-047-superseded
routing), the method table, SSE projection, auth, stealth decoy,
`/healthz`, consequences, and assumptions are verbatim from the alknet
ADR modulo the adaptations above.