Working through the WebTransport implementation path surfaced a scope question distinct from the hedging-as-deferral anti-pattern ADR-038 was written to correct. Three findings drove the re-evaluation: 1. The browser bidirectional call-protocol path doesn't require WebTransport — WebSocket is full-duplex, EventEnvelope fits a WS binary message boundary cleanly, and the Dispatcher is stream- agnostic (ADR-012). What WebTransport gives over WebSocket (native multi-stream multiplexing, the ALPN-as-stream substrate) benefits the proxy use case, not the call protocol. 2. WebTransport is a draft standard (-07, not RFC) on an experimental Rust dependency stack (wtransport/h3 both self-describe as not production-ready). Either choice puts a draft protocol on the security surface of the first release. 3. The ALPN-stream-proxy (ADR-040) is speculative — its WASM parser consumers (browser SSH/SFTP/git clients) don't exist yet, and the downstream crates WebTransport deferral blocks (SSH, git, SFTP) expose their ALPNs natively over QUIC regardless. This is a scope decision (per ADR-009: a decision that 'genuinely doesn't need to be made yet because the use case isn't concrete'), not hedging. The reversal trigger is concrete: a real deployment needing the ALPN-stream-proxy. ADR-038 is superseded (its anti-pattern correction stands; its specific 'h3 in scope now' decision is reversed). ADR-040 and ADR-043 are parked, not superseded — their designs revive unchanged when WebTransport revives, with §2 (bidirectionality) and §3 (no-PeerId overlay) of ADR-043 transferring to WebSocket for v1. ADR-044 §5 also states the 'browser is not a peer' rationale that ADR-034 §4 closed without arguing: peer = addressable node in the call-protocol peer graph (stable PeerId, PeerRef::Specific-reachable, identity stable across reconnects), not 'any endpoint that exchanges calls during a live session.' A browser is the second but not the first (no stable crypto identity of its own, ephemeral, not addressable from other nodes). ADR-034 §4 and Assumption 2 are amended by reference. The wtransport-vs-hyperium dependency question is recorded (not resolved — WebTransport is deferred) in ADR-044 §'Research note' and webtransport.md so the revival doesn't re-derive it: wtransport probably isn't the right choice (axum-bridge friction — it owns its own HTTP serving path); the hyperium stack (h3 + h3-quinn + h3-webtransport) fits the axum integration better but its server-side WebTransport API needs verification before commitment. Reviewed by architecture-review subagent; all critical cross-reference issues (ADR-034 §5 stale 'in scope' assertion, ADR-036 Context listing h3 as implemented, webtransport.md Design Decisions table) resolved.
219 lines
11 KiB
Markdown
219 lines
11 KiB
Markdown
# ADR-036: HTTP-to-Call Operation Mapping
|
|
|
|
## Status
|
|
|
|
Proposed
|
|
|
|
## Context
|
|
|
|
`alknet-http` 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 alknet 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 alknet-http Phase 0 research
|
|
(`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 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}` (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
|
|
|
|
**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). 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 "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 is **unchanged** — ADR-042 only
|
|
> changes what `to_openapi` *describes*, not what the HTTP handler
|
|
> *serves*. A traditional per-operation-paths OpenAPI projection remains
|
|
> available as an additive alternative (ADR-042 §5).
|
|
|
|
### HTTP method semantics
|
|
|
|
The call protocol's `OperationType` (`Query`, `Mutation`, `Subscription`,
|
|
per 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. |
|
|
| `Subscription` | `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 `Subscription` 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()` (auth.md's handler table —
|
|
`HttpAdapter`, Bearer header, `resolve_from_token`). This is settled by
|
|
ADR-004 and 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 from endpoint.md —
|
|
clients that don't offer alknet 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 alknet
|
|
operations without knowing about the call protocol — the HTTP surface is
|
|
a standard REST-like API.
|
|
- The abort cascade (ADR-016) 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`,
|
|
`Subscription`→`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-alknet 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 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 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) 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)
|
|
- [ADR-016](016-abort-cascade-for-nested-calls.md) — 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
|
|
- [ADR-023](023-operation-error-schemas.md) — error schema fidelity in
|
|
`from_openapi`/`to_openapi`; HTTP status mapping
|
|
- [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; the direct-call surface this ADR
|
|
defines is unchanged)
|
|
- OQ-13 (resolved) — operation path format `/{service}/{op}`
|
|
- `docs/research/alknet-http/phase-0-findings.md` DH-3 — the decision this
|
|
ADR resolves
|
|
- `crates/http/http-server.md` — the spec that implements this mapping |