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,303 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user