docs(http): draft alknet-http architecture specs and ADRs 036-039

First speccing pass for alknet-http (HTTP interface crate: h2/http1.1/h3
server + from_openapi/to_openapi/from_mcp/to_mcp adapters).

Specs (crates/http/):
- README.md, overview.md — crate index, two-roles-in-one-crate framing,
  adapter location map, feature gates (h3, mcp), no-env-vars invariant
- http-server.md — HttpAdapter for h2/http1.1, axum over QUIC stream,
  Bearer auth, SSE projection for subscriptions, /healthz, stealth decoy
- http-adapters.md — from_openapi (reqwest) and to_openapi (projection),
  error fidelity (HTTP_<status> per ADR-023), type definitions
- http-mcp.md — from_mcp/to_mcp (feature-gated), streamable-HTTP-only
- webtransport.md — h3/WebTransport handler, browser streaming path,
  HTTP/3 request vs WebTransport session distinguished at framing layer

ADRs:
- ADR-036 HTTP-to-Call Operation Mapping (Proposed) — direct path
  mapping; to_openapi is projection, not router (the load-bearing one-way
  door from Phase 0 DH-3)
- ADR-037 MCP Stdio Transport Exclusion (Proposed) — streamable HTTP
  only; stdio is not built (RCE-vector security position)
- ADR-038 HTTP/3 and WebTransport as First-Class HTTP Transports
  (Proposed) — corrects the Phase 0 DH-2 deferral framing; h3 is in
  scope, not deferred, per ADR-009 §'What this framework is NOT'
- ADR-039 HTTP Server and Client Host Colocated in alknet-http
  (Proposed) — one crate for server + client host (shared HTTP deps,
  shared operation-spec->HTTP mapping)
- ADR-003 Amendment 1 — clarifies alknet-call is a protocol-foundation
  crate (the alknet-http -> alknet-call dependency edge)

Open questions (OQ-38, OQ-39, OQ-40 added under 'Theme: alknet-http'):
- OQ-38 WebTransport relay-as-proxy scope (genuine scope question, not
  a deferral — the decision is made when the use case becomes concrete)
- OQ-39 to_openapi published-spec versioning (one-way after first
  publication)
- OQ-40 reqwest client config and connection pooling (two-way-door)

Architecture README and overview updated with doc table, ADR table
(036-039), current-state note, and crate graph (alknet-http ->
alknet-call edge).

Reviewed by architecture-reviewer subagent: 3 critical, 4 warning, 5
suggestion issues found and fixed (missing ADR-039, WebTransport stream
routing conflation, undefined types, stale OQ-37 deferral language,
README OQ table completeness, Bearer-only attribution, cross-references,
ADR-038 ALPN quote, feature-gate placeholder, MCP temporal language).
This commit is contained in:
2026-06-29 05:53:38 +00:00
parent dd5ccf4983
commit ab47dac4ad
14 changed files with 2343 additions and 12 deletions

View File

@@ -18,7 +18,9 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
**Next step**: The storage/repo-pattern ADRs (030033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http (Phase 0 findings in `docs/research/alknet-http/`), which consumes the `CredentialStore` trait and the `OperationAdapter` contract.
**alknet-http specs drafted.** The alknet-http crate (HTTP interface — `h2`/`http/1.1`/`h3` server + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and four new ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class, correcting the Phase 0 deferral framing), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http``alknet-call` dependency edge). The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport relay-as-proxy scope), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
**Next step**: The storage/repo-pattern ADRs (030033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted, ADRs 036038 proposed), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
## Architecture Documents
@@ -35,6 +37,12 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration |
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call / from_jsonschema, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern |
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1, axum over QUIC, Bearer auth, stealth, /healthz |
| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest) and to_openapi (projection); no-env-vars injection point |
| [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion |
| [crates/http/webtransport.md](crates/http/webtransport.md) | draft | h3/WebTransport handler — the browser streaming path |
| [crates/vault/README.md](crates/vault/README.md) | stable | alknet-vault crate index |
| [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | stable | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [crates/vault/encryption.md](crates/vault/encryption.md) | stable | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
@@ -80,6 +88,10 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Accepted |
| [034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and the Three Peer Roles | Accepted |
| [035](decisions/035-concrete-persistence-adapter-shapes.md) | Concrete Persistence Adapter Shapes — Read/Write Split, honker+SQLite | Accepted |
| [036](decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Proposed |
| [037](decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Proposed |
| [038](decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | Proposed |
| [039](decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | Proposed |
## Open Questions
@@ -127,6 +139,9 @@ See [open-questions.md](open-questions.md) for the full tracker.
- **OQ-32**: Multi-hop federation — the one-hop model is the architectural commitment; multi-hop is a feature extension that doesn't break downstream
- **OQ-36**: ~~Concrete persistence adapter shapes~~**resolved by ADR-035** (read-sync / write-async / honker-NOTIFY cache invalidation; `alknet-store-sqlite` crate; `IdentityStore` write trait; `CredentialStore::put`/`delete` async)
- **OQ-37**: ~~X.509 outgoing-only case~~**resolved by ADR-034** (three remote roles named: public X.509 endpoint, transport relay, hub; `PeerEntry` asymmetry is correct; client-side verifier selection by `PeerEntry` presence)
- **OQ-38**: WebTransport relay-as-proxy scope — does the proxy live in `alknet-http` or a separate relay crate? (scope question, not deferral; ADR-038 brought h3 into scope)
- **OQ-39**: `to_openapi` published-spec versioning — versioning strategy for generated OpenAPI specs (one-way after first publication)
- **OQ-40**: reqwest client config and connection pooling — two-way-door config shape for the outbound HTTP client
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable

View File

@@ -0,0 +1,103 @@
---
status: draft
last_updated: 2026-06-29
---
# alknet-http
HTTP interface for alknet: serves HTTP/1.1, HTTP/2, and HTTP/3 (WebTransport)
on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
(`from_openapi`, `to_openapi`, `from_mcp`, `to_mcp`).
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
| [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1`), axum over QUIC, Bearer auth, stealth, `/healthz` |
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
| [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion |
| [webtransport.md](webtransport.md) | draft | `h3`/WebTransport handler — the browser streaming path |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | `HttpAdapter` registers on standard HTTP ALPNs |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | `HttpAdapter` implements `ProtocolHandler` |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | `alknet-http` depends on `alknet-core` + `alknet-call` (protocol-foundation exception, Amendment 1) |
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | Bearer → `resolve_from_token` |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | `HttpAdapter` receives `Connection`, accepts a stream for hyper |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Stealth mode = HTTP handler on standard ALPNs |
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow | `from_openapi`/`from_mcp` are the credential injection point |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model | Adapter-registered ops are `Internal` by default |
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `OperationAdapter` trait; `to_*` are projections; published-spec contract |
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, Composition Authority | `from_openapi`/`from_mcp` produce leaf bundles |
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | `from_openapi`/`to_openapi` error fidelity; `HTTP_<status>` error codes |
| [027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | TLS Identity Redesign | Browsers require X.509; WebTransport requires X.509 |
| [034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Outgoing-Only X.509 and Three Peer Roles | Browsers are not alknet peers; WebTransport relay-as-proxy recorded |
| [036](../../decisions/036-http-to-call-operation-mapping.md) | HTTP-to-Call Operation Mapping | Direct path mapping; `to_openapi` is projection, not router |
| [037](../../decisions/037-mcp-stdio-transport-exclusion.md) | MCP Stdio Transport Exclusion | Streamable HTTP only; stdio not built |
| [038](../../decisions/038-http3-and-webtransport-as-first-class.md) | HTTP/3 and WebTransport as First-Class HTTP Transports | `h3` in scope, not deferred |
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-11 | Handler-level auth resolution observability | resolved | HTTP handler stores resolved identity on `Connection` via `set_identity` |
| OQ-12 | TLS identity provisioning | resolved | Browsers require X.509 (gates the entire `h3` feature) |
| OQ-13 | Operation path format | resolved | `/{service}/{op}` is the HTTP path (ADR-036) |
| OQ-17 | Call protocol client and adapter contract | resolved | `OperationAdapter` trait; `to_*` projections |
| OQ-24 | Operation error schemas | resolved | `from_openapi`/`to_openapi` error fidelity |
| OQ-26 | OperationAdapter error type | resolved | `AdapterError` variants reused by HTTP adapters |
| OQ-37 | X.509 outgoing-only / three peer roles | resolved | Browsers are not peers; hub with mixed fingerprints |
| OQ-38 | WebTransport relay-as-proxy scope | open (scope, not deferral) | Does the proxy live in `alknet-http` or a separate relay crate? |
| OQ-39 | `to_openapi` published-spec versioning | open | Versioning strategy for generated OpenAPI specs |
| OQ-40 | reqwest client config and connection pooling | open | Two-way-door: pooling/retry config shape |
## Key Design Principles
1. **HTTP is both a server surface and a client transport for adapters.**
Inbound HTTP (`h2`/`http/1.1`/`h3`) is served by `axum` over a QUIC
stream; outbound HTTP (`from_openapi`/`from_mcp` forwarding) uses
`reqwest`. Both directions share the same HTTP dependencies, which is
why they live in one crate rather than being split. See
[overview.md](overview.md).
2. **The HTTP surface is a projection of the call protocol.** An HTTP
request at `POST /fs/readFile` becomes a `call.requested` for
`/fs/readFile`. The HTTP path IS the operation path; `to_openapi`
describes this surface, it does not define a second one. See
[ADR-036](../../decisions/036-http-to-call-operation-mapping.md).
3. **Standard ALPNs, not alknet ALPNs.** `h2`, `http/1.1`, `h3` are
IANA-registered ALPN strings. Any HTTP client (browser, curl, axios)
connects without knowing about alknet — the TLS handshake negotiates
`h2` or `http/1.1` normally. This is the stealth mapping (ADR-010).
4. **`from_openapi`/`from_mcp` are the no-env-vars injection point.** The
forwarding handlers read `context.capabilities`, not `std::env::var`.
This is the architectural mechanism that makes aisdk's env-var reads
unreachable. See ADR-014,
[client-and-adapters.md](../call/client-and-adapters.md).
5. **MCP streamable HTTP only; stdio is not built.** stdio = spawn
arbitrary executable = RCE. Streamable HTTP is network-isolated,
auth-gatable, and runs under alknet's auth model. See
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
6. **HTTP/3 + WebTransport is a first-class transport, not a deferral.**
The browser streaming path uses QUIC streams directly. See
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
7. **`h3` requires X.509.** Browsers don't support RFC 7250 raw keys
(ADR-027). A node serving WebTransport must have an X.509 identity.
This is a browser limitation, not an alknet decision.
## References
- `docs/research/alknet-http/phase-0-findings.md` — Phase 0 research
(directionally close; DH-2's deferral framing is corrected by ADR-038)
- `docs/research/alknet-call-completion/gap-analysis.md` — adapter
location map, no-env-vars invariant
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
transport examples
- `/workspace/wtransport/` — pure-Rust WebTransport reference
implementation (the `h3` feature's candidate dependency)

View File

@@ -0,0 +1,326 @@
---
status: draft
last_updated: 2026-06-29
---
# HTTP Adapters — from_openapi and to_openapi
The OpenAPI-direction adapters: `from_openapi` imports external HTTP APIs
as call-protocol operations (reqwest-backed forwarding handlers), and
`to_openapi` generates an OpenAPI spec from the local registry's
`External` operations. This document covers both, the error fidelity
(ADR-023), and the no-env-vars credential injection point.
## What
Two adapters, both in `alknet-http`:
1. **`from_openapi`** — parses an OpenAPI document, constructs a
`HandlerRegistration` bundle per OpenAPI operation with a forwarding
handler that calls the external HTTP endpoint via `reqwest`, and
returns the bundles for registration in the `OperationRegistry`. The
adapter implements `OperationAdapter` (the async trait from
`alknet-call`, ADR-017 §5). Provenance is `FromOpenAPI` (leaf,
`composition_authority: None`, `scoped_env: None`, `Internal` by
default — ADR-015/022).
2. **`to_openapi`** — generates an OpenAPI document from the local
registry's `External` operations. A pure projection: it consumes the
registry, it does not produce entries for it (ADR-017 §5 — the `to_*`
adapters are outbound projections, not `OperationAdapter`
implementations). Served at `GET /openapi.json` by the HTTP server.
### from_openapi
```rust
pub struct FromOpenAPI {
spec: OpenAPISpec,
config: HttpServiceConfig,
}
#[async_trait]
impl OperationAdapter for FromOpenAPI {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
#### Type definitions
```rust
/// A parsed OpenAPI document. The concrete type is a two-way-door
/// implementation detail (openapiv3::OpenApi, a local alknet-http
/// type, or a serde_json::Value-based parse); the one-way constraint is
/// that `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and
/// `to_openapi` produces one. Both directions share the same type.
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
pub components: Option<Components>,
// ... OpenAPI 3.x fields as needed
}
/// Configuration for an HTTP-backed adapter (`from_openapi`). Carries
/// the base URL, auth credentials (from `Capabilities` at registration,
/// not env vars — the no-env-vars invariant), and optional headers. The
/// `auth` field is the auth scheme the external API expects (bearer,
/// apiKey, basic); the credential itself is read from
/// `OperationContext.capabilities` at call time, not stored here.
pub struct HttpServiceConfig {
pub namespace: String,
pub base_url: String,
pub auth: Option<HttpAuthScheme>,
pub default_headers: HashMap<String, String>,
}
pub enum HttpAuthScheme {
Bearer, // Authorization: Bearer <token>
ApiKey { header_name: String }, // e.g., X-API-Key: <key>
Basic, // Authorization: Basic <credentials>
}
```
The adapter:
1. Parses the OpenAPI document (`OpenAPISpec``paths`, `components`,
`$ref` resolution). On parse failure, returns
`AdapterError::SchemaParse`. The TS prior art
(`@alkdev/operations/src/from_openapi.ts`) shows the parsing patterns:
`resolveRef` for `$ref`, `resolveRefsRecursive` for nested refs,
`buildInputSchema` (parameters + request body → input JSON Schema),
`buildOutputSchema` (200/201 response → output JSON Schema),
`detectOperationType` (SSE response → `Subscription`, GET → `Query`,
else `Mutation`).
2. For each `(path, method, operation)` in `spec.paths`, constructs a
`HandlerRegistration`:
- `spec.name` = the `operationId` (or a generated
`${method}_${path_parts}` name if `operationId` is absent — same
normalization as the TS `normalizeOperationId`).
- `spec.namespace` = the `config.namespace` (the importing
deployment's name for the service, not the OpenAPI doc's `info.title`).
- `spec.op_type` = `Query` / `Mutation` / `Subscription` (detected
from the method + response content type, same as TS).
- `spec.visibility` = `Internal` (adapter-registered ops are
composition material, not directly callable from the wire —
ADR-015).
- `spec.input_schema` / `output_schema` = the JSON Schemas built
from the OpenAPI parameters/responses.
- `spec.error_schemas` = the `ErrorDefinition`s built from the
non-2xx OpenAPI responses (ADR-023 §5 — see Error Fidelity below).
- `spec.access_control` = `AccessControl::default()` (the adapter
doesn't declare scopes; the composing handler that reaches the
imported op gates access).
- `handler` = a forwarding handler (see Forwarding Handler below).
- `provenance` = `FromOpenAPI`, `composition_authority: None`,
`scoped_env: None` (leaf — ADR-022).
- `capabilities` = the credentials the forwarding handler needs (the
bearer token / API key for the external HTTP endpoint, injected by
the assembly layer at registration — see No-Env-Vars below).
3. Returns the bundles. The caller (the assembly layer) registers them
in the `OperationRegistry`.
### Forwarding handler
The forwarding handler is the `Arc<dyn Handler>` stored in the
`HandlerRegistration`. At call time, it:
1. Reads the call input (`serde_json::Value`).
2. Builds the outbound HTTP request:
- URL path: substitutes path parameters (`{id}` → input value),
appends query parameters from input fields not in the path.
- Method: the OpenAPI operation's method.
- Headers: `Content-Type: application/json` + the auth header built
from `context.capabilities` (see No-Env-Vars below).
- Body: the `body` field of the input (for `Mutation`/`Subscription`).
3. Sends the request via the shared `reqwest::Client` (see HTTP Client
below).
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
binary — same content-type branching as the TS `createHTTPOperation`),
wraps it in a `ResponseEnvelope`, returns.
5. For a `Subscription` (`text/event-stream` response): streams
`call.responded` events as the SSE chunks arrive (same SSE parsing as
the TS `parseSSEFrames`), then `call.completed` on stream end.
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
HTTP status code (see Error Fidelity below), returns a `CallError`.
The handler is opaque to the `CallAdapter` — it's an `Arc<dyn Handler>`
the registry dispatches. `alknet-call` never sees `reqwest`.
### HTTP client (reqwest)
`alknet-http` maintains a shared `reqwest::Client` (constructed once,
reused across all `from_openapi`/`from_mcp` forwarding handlers). The
client handles connection pooling, keep-alive, and TLS. The aisdk
`core/client.rs` reference shows the pattern worth referencing: a shared
client with `OnceLock<reqwest::Client>`, retry logic (exponential
backoff, `Retry-After` header), and separate streaming vs non-streaming
clients. `alknet-http` owns its HTTP client; it does not inherit aisdk's.
The retry/pooling config comes from `StaticConfig` or `DynamicConfig`
(hot-reloadable). The credential injection happens per-request (from
`OperationContext.capabilities`), not at client construction — the
client is shared across all operations, the credentials are per-call.
The exact pooling/retry config is a two-way-door implementation detail
(OQ-40); the one-way constraint is that `alknet-http` owns its `reqwest`
client (no env-var-based client config, no shared global client).
### No-Env-Vars credential injection
The forwarding handler is the **credential injection point** for the
no-env-vars architecture. The handler reads
`context.capabilities.get("<service>")` (e.g., `"openai"`, `"vastai"`,
`"github"`), extracts the credential, and injects it into the outbound
HTTP request:
- Bearer token → `Authorization: Bearer <token>`.
- API key → the header the OpenAPI spec declares (e.g., `X-API-Key:
<key>`, or `Authorization: ApiKey <key>` — the `HTTPServiceConfig.auth`
in the TS prior art shows the three auth types: `bearer`, `apiKey`,
`basic`).
- Basic auth → `Authorization: Basic <credentials>`.
The credential comes from `Capabilities`, which was populated by the
dispatch path from the `HandlerRegistration.capabilities` bundle
(ADR-022 §6), which was populated by the assembly layer from the vault
(ADR-014). The handler never reads `std::env::var`. This is the
spec-level invariant: no handler reads outbound credentials from any
source other than `OperationContext.capabilities`. See
[overview.md](overview.md) and
[client-and-adapters.md](../call/client-and-adapters.md).
### to_openapi
```rust
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
```
`to_openapi` generates an OpenAPI document from the local registry's
`External` operations:
1. For each `External` operation in the registry, generate an OpenAPI
path entry:
- Path: `/{service}/{op}` (the operation path, ADR-036 — the HTTP
path IS the operation path).
- Method: the operation's `OperationType` → HTTP method (`Query`→GET,
`Mutation`→POST by default, `Subscription`→GET with
`text/event-stream` response).
- `operationId`: the operation name.
- `parameters` / `requestBody` / `responses`: built from the
operation's `input_schema` / `output_schema` / `error_schemas`.
2. The `components.schemas` section holds the reusable schemas
referenced by `$ref` from the paths.
3. The `info` section carries the API title, version, and description.
This is a pure projection — it consumes the registry and produces a
spec. It does not modify the registry; it does not register operations;
it is not an `OperationAdapter`. The HTTP server serves the generated
spec at `GET /openapi.json` (or a configured path).
### Error Fidelity (ADR-023)
`from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
`from_openapi` must not produce error codes that collide with the five
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
`INTERNAL`, `TIMEOUT`). The adapter prefixes imported error codes with
`HTTP_` and the status number:
```rust
// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
```
`to_openapi` projects `error_schemas` back to OpenAPI response
definitions:
```yaml
responses:
'200': { schema: <output_schema> }
'404': { schema: <error_schemas[i].schema> } # where http_status = 404
'429': { schema: <error_schemas[j].schema> } # where http_status = 429
```
This makes the adapter contract from ADR-017 faithful on the error axis —
no silent dropping of error contracts. See ADR-023.
## Why
`from_openapi` is how alknet composes external HTTP APIs (OpenAI,
Anthropic, vast.ai, GitHub) into the call protocol. An operation
imported via `from_openapi` is a first-class operation: it has a spec,
it's discoverable via `services/list`, it can be composed by handlers,
its errors are typed. The agent crate's LLM provider calls go through
`from_openapi`-imported operations — that's how the no-env-vars
invariant makes aisdk's env-var reads unreachable.
`to_openapi` is how external systems discover the alknet operation
surface. An API gateway, a client generator, or a human developer reads
the OpenAPI doc to learn what operations exist and how to call them.
The generated spec is a compatibility contract (ADR-017 Consequences) —
once published, the mapping is one-way.
## Constraints
- **`from_openapi`/`from_mcp` handlers read credentials from
`OperationContext.capabilities`, not `std::env::var`.** This is the
no-env-vars invariant (ADR-014). The handler implementations are
verified against this invariant.
- **`from_openapi`-registered ops are `Internal` by default.** They are
composition material, not directly callable from the wire (ADR-015).
The handler that composes them is `External`.
- **`from_openapi` error codes are prefixed `HTTP_<status>`.** No
collision with protocol-level codes (ADR-023, review #002 W20).
- **`to_openapi` is a pure projection.** It consumes the registry, does
not produce entries for it. Not an `OperationAdapter`.
- **Published `to_openapi` specs are compatibility contracts.** The
generated spec's versioning (tied to the registry's `External`
operation set version) must be emitted so consumers can detect mapping
changes (ADR-017 Consequences, OQ-39).
- **`alknet-http` owns its `reqwest::Client`.** Shared across all
forwarding handlers, constructed once. No env-var-based client config.
Pooling/retry config is a two-way door (OQ-40).
- **TLS for outbound calls uses the system trust store by default.**
Standard HTTPS to external APIs (OpenAI, Anthropic). Custom CA bundle
+ client certs are an optional config for self-hosted API gateways.
This is a two-way-door implementation detail; the credential (API
key/token) comes from `Capabilities`, the TLS trust comes from the
system.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| `from_openapi` is an `OperationAdapter` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
| `to_openapi` is a projection, not an adapter | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi` ops are composition material |
| `from_openapi` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
| HTTP path = operation path | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `to_openapi` paths mirror `/{service}/{op}` |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-39** (open): `to_openapi` published-spec versioning — the
versioning strategy for generated OpenAPI specs (tied to the
registry's `External` operation set version). One-way after first
publication.
- **OQ-40** (open): reqwest client config and connection pooling —
two-way-door: the exact pooling/retry config shape, hot-reloadable
via `DynamicConfig`.
## References
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
— `OperationAdapter` trait, `to_*` are projections
- [ADR-023](../../decisions/023-operation-error-schemas.md) — error
fidelity, `HTTP_<status>` prefix rule
- [overview.md](overview.md) — adapter location map, no-env-vars
invariant
- [../call/client-and-adapters.md](../call/client-and-adapters.md) —
`OperationAdapter` trait, `AdapterError` variants (OQ-26), no-env-vars
invariant
- `/workspace/@alkdev/operations/src/from_openapi.ts` — TypeScript prior
art (parsing, SSE, auth headers, `createHTTPOperation`)
- `/workspace/aisdk/src/core/client.rs` — HTTP client reference (pooling,
retry, streaming vs non-streaming)

View File

@@ -0,0 +1,245 @@
---
status: draft
last_updated: 2026-06-29
---
# HTTP MCP — from_mcp and to_mcp
The MCP-direction adapters (feature-gated behind `mcp`): `from_mcp`
imports remote MCP tools as call-protocol operations over streamable
HTTP (reqwest client), and `to_mcp` exposes local operations as MCP
tools over streamable HTTP (axum server). This document covers both, the
rmcp integration, and the stdio exclusion (ADR-037).
## What
Two adapters, both in `alknet-http`, both behind the `mcp` feature gate:
1. **`from_mcp`** — discovers remote MCP tools via the MCP
`tools/list` call over streamable HTTP, and registers each as a
`HandlerRegistration` bundle with a forwarding handler that calls the
remote tool via `tools/call`. Uses rmcp's
`StreamableHttpClientTransport` (reqwest-based). Provenance is
`FromMCP` (leaf, `composition_authority: None`, `scoped_env: None`,
`Internal` by default — ADR-015/022). Implements `OperationAdapter`.
2. **`to_mcp`** — exposes the local registry's `External` operations as
MCP tools over streamable HTTP, using rmcp's `StreamableHttpService`
(an axum-compatible tower service). An external MCP client (an editor,
an AI tool) discovers and calls alknet operations through the MCP
protocol. A pure projection (consumes the registry, does not produce
entries — ADR-017 §5).
### Streamable HTTP only (ADR-037)
MCP defines two transports: streamable HTTP and stdio. **alknet-http
supports only streamable HTTP.** Stdio is not built — it is the spawn-
arbitrary-executable RCE vector that the rest of the architecture is
designed to avoid (ADR-037). The `mcp` feature gate pulls in rmcp with
the streamable HTTP transport features only; the stdio transport
(`transport-child-process`) is not a dependency, not optional, not
behind a separate feature.
If an operator wants a stdio-only MCP server, they run a small
streamable-HTTP-to-stdio bridge themselves, outside alknet. The bridge
is where the RCE risk lives, explicitly in the operator's hands. See
ADR-037.
### from_mcp
```rust
pub struct FromMCP {
/// The MCP server's streamable HTTP endpoint URL.
endpoint: String,
/// Bearer token for the MCP server (from Capabilities at registration).
auth_token: Option<String>,
/// The importing deployment's name for this MCP server (becomes the
/// operation namespace).
namespace: String,
}
#[async_trait]
impl OperationAdapter for FromMCP {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
The adapter:
1. Connects to the MCP server's streamable HTTP endpoint using rmcp's
`StreamableHttpClientTransport::from_uri(endpoint)` (the rmcp
`streamable_http.rs` client example shows the pattern: `client_info
.serve(transport).await`, then `client.list_tools()`,
`client.call_tool()`). On connection failure, returns
`AdapterError::DiscoveryFailed`; on 401, `AdapterError::Unauthorized`.
2. Calls `tools/list` → the list of MCP tools (name, description,
`inputSchema`, optional `outputSchema`).
3. For each tool, constructs a `HandlerRegistration`:
- `spec.name` = the tool name (or `namespace/tool_name` if a
namespace prefix is configured — same local-naming sugar as
`from_call`'s `FromCallConfig::namespace_prefix`, ADR-029 §5).
- `spec.namespace` = the configured `namespace`.
- `spec.op_type` = `Mutation` (MCP tools are call/response; the MCP
spec doesn't have a native streaming/tool-subscription distinction
`tools/call` returns a result. If MCP adds a streaming-tool
extension, a `Subscription` mapping would be added.)
- `spec.visibility` = `Internal` (adapter-registered, ADR-015).
- `spec.input_schema` = the tool's `inputSchema` (JSON Schema).
- `spec.output_schema` = the tool's `outputSchema`, or
`Type.Unknown()` if absent (the TS `from_mcp.ts` shows this
fallback).
- `spec.error_schemas` = the MCP tool's error description mapped to
`ErrorDefinition` (ADR-023 — MCP tool definitions carry error
descriptions; the adapter maps them).
- `spec.access_control` = `AccessControl::default()`.
- `handler` = a forwarding handler (see Forwarding Handler below).
- `provenance` = `FromMCP`, `composition_authority: None`,
`scoped_env: None` (leaf — ADR-022).
- `capabilities` = the bearer token for the MCP server (injected by
the assembly layer at registration — see No-Env-Vars below).
4. Returns the bundles. The caller (the assembly layer) registers them
in the `OperationRegistry`.
### Forwarding handler
At call time, the `from_mcp` forwarding handler:
1. Reads the call input (`serde_json::Value` — the tool arguments).
2. Calls `client.call_tool({ name: tool_name, arguments: input })` via
the rmcp client (the `streamable_http.rs` example shows
`client.call_tool(CallToolRequestParams::new(name).with_arguments(...))`).
3. On success: extracts `structuredContent` (if present) or maps the
`content` blocks (the TS `mapMCPContentBlocks` shows the mapping:
text/image/audio/resource/resource_link → `MCPContentBlock`),
wraps in a `ResponseEnvelope`, returns.
4. On `result.isError`: maps to a `CallError` with the MCP error content
(the TS `from_mcp.ts` handler shows the error mapping), returns.
5. The rmcp client connection is maintained for the lifetime of the
registration (the MCP server is a persistent streamable HTTP
endpoint, not a per-call connection).
The handler is opaque to the `CallAdapter``Arc<dyn Handler>` the
registry dispatches. `alknet-call` never sees rmcp.
### to_mcp
```rust
pub fn to_mcp_service(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> StreamableHttpService<...>;
```
`to_mcp` exposes the local registry's `External` operations as MCP tools
over streamable HTTP, using rmcp's `StreamableHttpService` (an
axum-compatible tower service). The rmcp
`simple_auth_streamhttp.rs` server example shows the pattern:
```rust
// From the rmcp example:
let mcp_service: StreamableHttpService<Counter, LocalSessionManager> =
StreamableHttpService::new(
|| Ok(Counter::new()),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
let protected_mcp_router = Router::new()
.nest_service("/mcp", mcp_service)
.layer(middleware::from_fn_with_state(token_store, auth_middleware));
```
`alknet-http`'s `to_mcp` follows the same pattern: the local operations
are exposed as an MCP server (an rmcp `Service` impl that wraps the
`OperationRegistry`), the `StreamableHttpService` nests into the axum
`Router` at `/mcp`, and a Bearer auth middleware gates access (the
`simple_auth_streamhttp.rs` `auth_middleware` + `extract_token` pattern).
The `to_mcp` service:
1. On MCP `tools/list`: returns the local registry's `External`
operations as MCP tools (name, description, `inputSchema`).
2. On MCP `tools/call`: dispatches to the `OperationRegistry::invoke()`
— the same dispatch path the HTTP server uses for HTTP requests
(ADR-036). The MCP tool call becomes a `call.requested` internally.
The result is mapped back to the MCP `tools/call` response shape
(`structuredContent` or `content` blocks).
3. Auth: the Bearer middleware resolves the token via
`IdentityProvider::resolve_from_token()`, same as the HTTP server's
auth (ADR-004). The MCP client authenticates by bearer token; no
`PeerId` (browsers and MCP clients are not alknet peers — ADR-034 §4).
### No-Env-Vars
The `from_mcp` forwarding handler reads the MCP server's bearer token
from `context.capabilities` (the same injection path as `from_openapi`),
not from `std::env::var`. The assembly layer injects the token at
registration; the handler reads it per-call. This is the no-env-vars
invariant (ADR-014, [overview.md](overview.md)).
## Why
MCP is the protocol editors and AI tools use to discover and call tools.
`from_mcp` lets alknet compose external MCP servers (a remote tool
server, a third-party MCP endpoint) into the call protocol — the same
composition pattern as `from_openapi` and `from_call`. `to_mcp` lets
external MCP clients (an editor, an AI tool) discover and call alknet
operations through the MCP protocol, without those clients needing to
speak EventEnvelope.
The streamable-HTTP-only constraint (ADR-037) is a security position:
alknet does not import the MCP stdio RCE vector. The streamable HTTP
path is network-isolated, auth-gatable, and runs under alknet's
auth/identity/capabilities machinery — the same machinery that gates
every other HTTP request.
## Constraints
- **Streamable HTTP only.** Stdio is not built (ADR-037). The `mcp`
feature pulls in rmcp with streamable HTTP transport features only.
- **`from_mcp`-registered ops are `Internal` by default.** Composition
material, not directly callable from the wire (ADR-015).
- **`from_mcp` handlers read credentials from
`OperationContext.capabilities`.** No env vars (ADR-014).
- **`to_mcp` is a pure projection.** Consumes the registry, does not
produce entries. Not an `OperationAdapter`.
- **MCP clients are not alknet peers.** A browser or MCP client
connecting to `to_mcp` authenticates by bearer token, gets no
`PeerId`, is not in the peer graph (ADR-034 §4).
- **The `mcp` feature is optional.** A deployment that doesn't need MCP
doesn't compile rmcp. The default feature set is `h2` + `http1`.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| MCP stdio transport excluded | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built |
| `from_mcp` is an `OperationAdapter` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
| `to_mcp` is a projection | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_mcp` ops are composition material |
| `from_mcp` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
| Error fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | MCP tool errors mapped to `ErrorDefinition`s |
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
| MCP clients are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-40** (open): reqwest client config — the shared `reqwest::Client`
used by `from_mcp` (same client as `from_openapi`).
## References
- [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) — the
stdio exclusion this document enforces
- [overview.md](overview.md) — adapter location map, feature gates
- [../call/client-and-adapters.md](../call/client-and-adapters.md) —
`OperationAdapter` trait, `AdapterError` variants
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
transport
- `/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs`
— streamable HTTP MCP server with Bearer auth (the `to_mcp` pattern)
- `/workspace/rust-sdk/examples/clients/src/streamable_http.rs`
streamable HTTP MCP client (the `from_mcp` pattern)
- `/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
(`createMCPClient`, `mapMCPContentBlocks`, the `MCPClientLoader`)

View File

@@ -0,0 +1,292 @@
---
status: draft
last_updated: 2026-06-29
---
# HTTP Server
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
`h3`, covered in [webtransport.md](webtransport.md)). This document
covers how axum is run over a QUIC bidirectional stream, Bearer auth
resolution, the HTTP-to-call dispatch, the `/healthz` raw route, and
stealth decoy.
## What
The `HttpAdapter` is constructed by the assembly layer with an
`Arc<dyn IdentityProvider>` (constructor injection, same pattern as
`SshAdapter` — see [auth.md](../core/auth.md)) and an
`Arc<OperationRegistry>` (for dispatching HTTP requests to call-protocol
operations). It implements `ProtocolHandler` for the standard HTTP ALPNs.
```rust
pub struct HttpAdapter {
identity_provider: Arc<dyn IdentityProvider>,
registry: Arc<OperationRegistry>,
/// The default handler for paths that are not registered operations
/// (stealth decoy). Configurable: a static site, a fake 404, a
/// redirect. Two-way-door default (ADR-010).
decoy: DecoyConfig,
}
/// The stealth decoy surface for paths that are not registered
/// operations (and not `/healthz`, `/openapi.json`, or the MCP route).
/// Set by the assembly layer at `HttpAdapter` construction. The
/// existence of the decoy path is fixed by ADR-010; the variant is a
/// two-way-door config default.
pub enum DecoyConfig {
/// Serve a fake `404 Not Found` (the default — matches the reference
/// implementation's "fake nginx 404").
NotFound,
/// Serve a static site from a configured directory (the directory
/// path is the payload). For deployments that want a real decoy
/// website.
StaticSite { root: PathBuf },
/// Redirect to a configured URL.
Redirect { to: String },
}
#[async_trait]
impl ProtocolHandler for HttpAdapter {
fn alpn(&self) -> &'static [u8]; // returns the configured ALPN
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
```
The `HttpAdapter` registers for multiple ALPNs (`http/1.1`, `h2`, `h3`).
The endpoint's `HandlerRegistry` maps each ALPN byte string to the same
adapter instance; `handle()` branches on `connection.remote_alpn()` to
pick the HTTP framing. For `http/1.1` and `h2`, the framing is hyper's
HTTP/1.1 or HTTP/2 over a QUIC bidirectional stream; for `h3`, it's the
WebTransport/HTTP/3 path (see [webtransport.md](webtransport.md)).
## Why
HTTP is the standard external interface. Browsers, curl, axios, API
gateways, and load balancers all speak HTTP. Serving HTTP on the standard
ALPNs means any HTTP client can connect without knowing about alknet —
the TLS handshake negotiates `h2` or `http/1.1` normally. This is the
stealth mapping (ADR-010): the HTTP surface is the decoy for clients that
don't offer alknet ALPNs, and the real external API surface for clients
that do know about alknet.
## Architecture
### Running axum over a QUIC stream
The `HttpAdapter::handle()` method for `h2`/`http/1.1`:
1. Accepts one bidirectional stream from the QUIC connection
(`connection.accept_bi()``(SendStream, RecvStream)`).
2. Wraps the `(SendStream, RecvStream)` pair as a hyper
`TokioIo`-compatible duplex stream — the same byte stream hyper
expects for an HTTP connection.
3. Constructs the axum `Router` (built once at adapter construction,
cloned per connection — axum `Router` is `Clone` and cheap to clone).
4. Hands the duplex stream + the axum router to hyper's connection
driver (`hyper::server::conn::http1::Builder` or
`http2::Builder::serve_connection`), which reads HTTP frames, parses
them, dispatches to axum routes, and writes HTTP responses.
5. Returns when the HTTP connection closes (the client disconnects or
the stream ends).
The axum `Router` is built once at adapter construction with the
`Arc<OperationRegistry>` and `Arc<dyn IdentityProvider>` embedded in its
state; cloning the `Router` per connection clones the `Arc`s (cheap,
shared state), so every request handler has access to the registry and
identity provider through the router's state.
The axum `Router` is the single routing surface for HTTP requests. It
contains:
- The call-protocol projection routes (`POST /{service}/{op}`
`call.requested` dispatch — ADR-036).
- `GET /healthz` (raw route, no auth, no call protocol).
- `GET /openapi.json` (serves the `to_openapi` projection).
- The stealth decoy fallback (unknown paths).
- (Feature-gated) `POST /mcp` (the `to_mcp` streamable HTTP service —
[http-mcp.md](http-mcp.md)).
A single HTTP/2 or HTTP/1.1 connection multiplexes multiple requests
over the one bidirectional stream (HTTP/2 multiplexing is native;
HTTP/1.1 is sequential). The axum router handles each request on a
tokio task; the hyper driver manages the connection lifetime.
### HTTP-to-call dispatch (ADR-036)
An HTTP request at `POST /fs/readFile` (or `GET /services/list`, or any
`/{service}/{op}` path matching a registered `External` operation) is
dispatched to the call protocol:
1. The axum route handler extracts the operation name from the path
(`/fs/readFile``fs/readFile`, stripping the leading slash — the
registry form).
2. It resolves the caller's identity from the `Authorization: Bearer`
header via `identity_provider.resolve_from_token(&AuthToken { raw:
token_bytes })`.
3. It parses the request body as the operation input (JSON).
4. It constructs the root `OperationContext` (caller identity, the
registration bundle's capabilities, the connection's env composition)
and dispatches through the `OperationRegistry::invoke()` — the same
dispatch path the `CallAdapter` uses for `alknet/call` wire requests.
5. The response (`ResponseEnvelope`) is serialized as the HTTP response
body (JSON). Errors map to HTTP status codes (see Error Mapping
below).
`Internal` operations (ADR-015) 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.
### Streaming projection (SSE)
A `Subscription` operation served over `h2`/`http/1.1` projects its
`call.responded` stream as Server-Sent Events. The axum route handler:
- Sets `Content-Type: text/event-stream`.
- For each `call.responded` event, writes an SSE `data:` frame (the
event's `output` serialized as JSON).
- On `call.completed`, closes the SSE stream (normal end).
- On `call.aborted`, closes the stream with an SSE error event.
- On HTTP client disconnect (detected as the response writer closing),
sends `call.aborted` for the in-flight subscription, which cascades
to descendants per ADR-016.
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebTransport
(`h3`), the subscription projects directly onto a WebTransport
bidirectional stream — no SSE framing (see [webtransport.md](webtransport.md)).
### Auth
Inbound HTTP auth is `Authorization: Bearer <token>`, resolved via
`IdentityProvider::resolve_from_token()` (the auth.md handler table:
`HttpAdapter`, Bearer header, `resolve_from_token`). Bearer-only is the
auth mechanism for the default surface; other HTTP auth schemes (Basic,
API key in query param) are not implemented and would be added as axum
middleware (two-way door). This is recorded in
[ADR-036](../../decisions/036-http-to-call-operation-mapping.md) §Auth;
the resolution mechanism (`resolve_from_token`) is from
[ADR-004](../../decisions/004-auth-as-shared-core.md), and the
connection-level observability (`set_identity`) is OQ-11 (resolved).
- Bearer-only is the auth mechanism. 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 axum middleware
(two-way door), but the default surface is Bearer-only.
- The `HttpAdapter` constructor-injects `Arc<dyn IdentityProvider>`,
same pattern as `SshAdapter`.
- An unauthenticated request to an operation with `AccessControl`
restrictions returns `401` (no token) or `403` (token present but
insufficient scopes). The call protocol's `FORBIDDEN` protocol code
maps to `403`; `NOT_FOUND` (Internal op) maps to `404`.
- The HTTP handler stores the resolved identity on the `Connection` for
observability (`connection.set_identity(identity)`), same as the call
protocol handler.
### Error Mapping
Call-protocol `CallError` codes (ADR-023) map to HTTP status codes:
| Call `code` | HTTP status | Notes |
|-------------|-------------|-------|
| `NOT_FOUND` (operation not registered, or Internal op) | `404` | |
| `FORBIDDEN` (insufficient scopes, or unauthenticated) | `401` (no token) / `403` (token present) | |
| `INVALID_INPUT` (schema mismatch) | `422` | |
| `TIMEOUT` | `504` | `retryable: true` |
| `INTERNAL` | `500` | |
| Operation-level domain code with `http_status` (ADR-023) | the declared `http_status` | `from_openapi`-imported ops carry the original status |
| Operation-level domain code without `http_status` | `500` | |
The `retryable` field from `CallError` maps to an HTTP `Retry-After`
hint for `503`/`429`-class errors. The mapping is a two-way-door
default (the exact status for ambiguous codes can be refined
additively); the one-way constraint is that protocol-level and
operation-level codes are distinct (ADR-023) and `from_openapi`-imported
codes are prefixed `HTTP_<status>` to avoid collision with protocol
codes.
### `/healthz` (raw route)
`GET /healthz` is a raw HTTP route outside the call protocol — no auth,
no operation registration, no `OperationContext`. It returns `200 OK`
with a plain-text body (e.g., `"ok"`) if the endpoint is healthy. This
is the infrastructure endpoint load balancers and orchestrators call;
it must work before identity is resolvable.
Other operational endpoints (metrics, dashboard) are call-protocol
operations if built (`/metrics/list`, `/dashboard/view`), not raw HTTP
routes. `healthz` is the one exception. See ADR-036.
### Stealth decoy
For paths that are not registered operations (and not `/healthz`,
`/openapi.json`, or the MCP route), the HTTP handler serves a decoy. The
decoy is configurable (`DecoyConfig`):
- A fake `404 Not Found` (the default — matches the reference
implementation's "fake nginx 404").
- A static site (served from a configured directory).
- A redirect (to a configured URL).
The decoy is the stealth surface: a port scanner or a client that
doesn't offer alknet ALPNs connects on `h2`/`http/1.1` and sees the
decoy. Real services use `alknet/ssh`, `alknet/call`, etc. The decoy
config is a two-way-door default (an operator picks what to serve); the
*existence* of the stealth path is fixed by ADR-010.
## Constraints
- **The HTTP path IS the operation path.** `POST /fs/readFile`
`call.requested` for `fs/readFile`. No second routing table. See
ADR-036.
- **`External` operations only.** `Internal` operations return `404`
on the HTTP handler.
- **Bearer-only auth.** `Authorization: Bearer`
`resolve_from_token`. Other HTTP auth schemes are not implemented.
- **No secret material in HTTP responses.** The call protocol carries no
secret material (ADR-014); the HTTP handler inherits this constraint.
Capabilities are used for outbound calls (`from_openapi`), never
serialized into HTTP response bodies.
- **`/healthz` is raw.** No auth, no call protocol. The one raw route.
- **The `h3` ALPN is a first-class transport.** The `HttpAdapter`
registers for `h3` when the `h3` feature is enabled (ADR-038). The
`h3` handler is covered in [webtransport.md](webtransport.md); this
document covers the `h2`/`http/1.1` path.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Direct path mapping (HTTP path = operation path) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}``call.requested` |
| SSE projection for subscriptions over h2/http1.1 | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `call.responded` stream → SSE frames |
| `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol |
| Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy |
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) |
| `h3` is first-class (not deferred) | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | The `h3` ALPN handler lives in this crate |
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-39** (open): `to_openapi` published-spec versioning — the
generated OpenAPI spec is a compatibility contract (ADR-017
Consequences); the versioning strategy needs specifying.
- **OQ-40** (open): reqwest client config and connection pooling —
two-way-door config shape for the outbound HTTP client used by
`from_openapi`/`from_mcp`.
## References
- [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) — the
HTTP-to-call mapping this server implements
- [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
— the `h3`/WebTransport companion to this server
- [overview.md](overview.md) — crate overview, adapter location map
- [webtransport.md](webtransport.md) — the `h3` ALPN handler
- [http-adapters.md](http-adapters.md) — `from_openapi`/`to_openapi`
- [../core/auth.md](../core/auth.md) — `IdentityProvider`, Bearer →
`resolve_from_token`
- [../core/endpoint.md](../core/endpoint.md) — stealth mode as ALPN
dispatch
- [../call/operation-registry.md](../call/operation-registry.md) —
`OperationRegistry::invoke()`, the dispatch path HTTP requests hit

View File

@@ -0,0 +1,243 @@
---
status: draft
last_updated: 2026-06-29
---
# alknet-http — Overview
The HTTP interface crate: serves inbound HTTP on standard ALPNs and hosts
the HTTP-backed call-protocol adapters. This document covers the crate's
two roles, its dependency edges, and the adapter location map. Component
details are in the sibling documents.
## What
`alknet-http` is the HTTP protocol handler for the ALPN-as-service
architecture. It serves two roles in one crate:
1. **HTTP server** — a `ProtocolHandler` (`HttpAdapter`) that accepts
HTTP/2, HTTP/1.1, and HTTP/3 (WebTransport) connections on the
standard IANA ALPNs (`h2`, `http/1.1`, `h3`). It serves REST APIs, the
`to_openapi`/`to_mcp` projections of local call-protocol operations,
the `/healthz` operational endpoint, and the decoy surface for
stealth mode.
2. **HTTP client host** — the home of the HTTP-transport-backed call
adapters: `from_openapi` (import external HTTP APIs as call
operations, using `reqwest` for outbound calls) and `from_mcp` (import
remote MCP tools over streamable HTTP, using `reqwest`). The reverse
projections `to_openapi` (generate an OpenAPI doc from the local
registry's `External` operations) and `to_mcp` (expose local ops as
MCP tools over streamable HTTP, using `axum`) also live here.
Both directions share the same HTTP dependencies (`axum` for serving,
`reqwest` for calling out), which is why they live in one crate rather
than being split into a server crate and a client crate. See
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
for the full rationale.
## Why
The crate's purpose is to be the HTTP interface library for downstream
crates that need to expose an HTTP interface. A downstream consumer (the
CLI binary, a hub deployment, a browser-facing service) wires
`HttpAdapter` into the `HandlerRegistry` for the standard HTTP ALPNs and
gets a full HTTP surface: REST projection of the call protocol, OpenAPI
discovery, MCP tool exposure, and WebTransport for browsers.
The key architectural insight that shapes the crate: **HTTP is both a
server surface and a client transport for adapters.** The server side
serves HTTP to external clients (browsers, curl, axios); the client side
makes outbound HTTP calls to external APIs (OpenAI, Anthropic, vast.ai)
through the `from_openapi`/`from_mcp` forwarding handlers. Both
directions share HTTP dependencies and HTTP-specific concerns (TLS,
headers, streaming, SSE), so they belong in one crate. See
[ADR-039](../../decisions/039-http-server-and-client-host-colocated.md)
for the colocation decision.
## Dependencies
```
alknet-http
├── alknet-core (ProtocolHandler, Connection, AuthContext, IdentityProvider, Capabilities)
├── alknet-call (OperationAdapter, OperationSpec, Handler, HandlerRegistration,
│ OperationRegistry, AdapterError, OperationProvenance)
├── axum (HTTP server — Router, extractors, middleware)
├── reqwest (HTTP client — from_openapi/from_mcp forwarding)
├── hyper (HTTP/1.1 + HTTP/2 framing; axum is built on hyper)
├── wtransport (HTTP/3 + WebTransport — feature-gated behind `h3`)
└── rmcp (MCP streamable HTTP — feature-gated behind `mcp`)
```
### The `alknet-call` dependency (ADR-003 Amendment 1)
`alknet-http` depends on `alknet-call`. ADR-003's rule is "no handler
crate depends on another handler crate," but `alknet-call` is both a
handler (it implements `ProtocolHandler` on `alknet/call`) *and* the
protocol-foundation crate that `alknet-agent`, `alknet-napi`, and now
`alknet-http` consume. `alknet-http` depending on `alknet-call` is
"HTTP uses the call protocol types" (`OperationSpec`, `Handler`,
`HandlerRegistration`, `OperationAdapter`), not "HTTP depends on SSH."
See [ADR-003 Amendment 1](../../decisions/003-crate-decomposition.md).
`alknet-call` stays lean — it has no `reqwest`, no `axum`, no HTTP
dependencies. The `from_openapi`/`from_mcp` forwarding handlers are
opaque `Arc<dyn Handler>` from the registry's perspective: constructed by
`alknet_http::from_openapi()` at registration time, stored in
`HandlerRegistration`, dispatched by the `CallAdapter` which doesn't
know `reqwest` is involved.
## ALPNs
| ALPN | Handler | Transport | Browser? |
|------|---------|-----------|----------|
| `http/1.1` | `HttpAdapter` | HTTP/1.1 over QUIC stream | No |
| `h2` | `HttpAdapter` | HTTP/2 over QUIC stream | No |
| `h3` | `HttpAdapter` | HTTP/3 / WebTransport | Yes (X.509 required) |
These are standard IANA ALPN strings, not `alknet/`-prefixed. Any HTTP
client connects without knowing about alknet — the TLS handshake
negotiates `h2` or `http/1.1` normally, and the `HttpAdapter` serves
HTTP. This is the stealth mapping (ADR-010): clients that don't offer
alknet ALPNs get the HTTP handler, just like port scanners in stealth
mode.
The `HttpAdapter` registers for all three ALPNs (when the corresponding
features are enabled). The endpoint's `HandlerRegistry` maps each ALPN to
the same `HttpAdapter` instance; the handler branches on
`connection.remote_alpn()` to pick the right framing.
## Adapter Location Map
The decomposition principle (settled in
[client-and-adapters.md](../call/client-and-adapters.md)): the adapter
trait lives where the types live (`alknet-call`); the adapter
implementations live where their transport dependencies live.
```
alknet-call (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, ADR-017 §5)
├── from_call (QUIC — discovers remote ops via call protocol)
├── from_jsonschema (pure parse — caller fetches the doc, passes it in)
└── CallClient (outbound connection opener)
alknet-http (owns HTTP server + HTTP client)
├── HttpAdapter (axum server — inbound HTTP on h2/http1.1/h3)
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
├── to_openapi (generate OpenAPI doc from local registry)
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
```
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
perspective. `alknet-call` stays lean; `alknet-http` owns both HTTP
directions.
## Feature Gates
```toml
[features]
default = ["h2", "http1"] # the non-browser HTTP surface
h3 = ["dep:wtransport"] # HTTP/3 + WebTransport (browser path; X.509 required)
mcp = ["dep:rmcp"] # from_mcp / to_mcp (streamable HTTP only — ADR-037)
```
- `h2` + `http1` (default): the `axum` + `hyper` HTTP/1.1 + HTTP/2
server. This is the surface non-browser clients use.
- `h3`: the `wtransport` (or quinn HTTP/3 extension) dependency. Adds
the `h3` ALPN handler and the WebTransport streaming path. See
[webtransport.md](webtransport.md) and
[ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md).
- `mcp`: the `rmcp` dependency with streamable HTTP transport features
only. Adds `from_mcp`/`to_mcp`. See [http-mcp.md](http-mcp.md) and
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
A deployment that only needs the REST surface (no browsers, no MCP) uses
the default features. A browser-facing hub enables `h3`. A deployment
that wants MCP tool import/export enables `mcp`.
## The No-Env-Vars Invariant
The `from_openapi`/`from_mcp` forwarding handlers are the **credential
injection point** for the no-env-vars architecture. The path (from the
gap analysis):
```
vault → assembly layer → Capabilities → HandlerRegistration.capabilities
→ OperationContext.capabilities → from_openapi handler reads
context.capabilities.get("openai") → injects into HTTP Authorization
header → reqwest request goes out with vault-derived credential
```
This makes aisdk's `std::env::var("OPENAI_API_KEY")` reads unreachable —
the assembly layer never calls `Default::default()` on a provider; it
constructs them with vault-derived credentials, or routes HTTP calls
through `from_openapi` operations that carry the credential in
`Capabilities`.
**This is a spec-level invariant**: no handler reads outbound
credentials from any source other than `OperationContext.capabilities`.
The `from_openapi`/`from_mcp` implementations in `alknet-http` are
verified against this invariant. See ADR-014 and
[client-and-adapters.md](../call/client-and-adapters.md).
## Architecture (component pointers)
- **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/
`http/1.1`: how axum is run over a QUIC bidirectional stream, Bearer
auth resolution, the `/healthz` raw route, stealth decoy, and the
HTTP-to-call dispatch (ADR-036).
- **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse
OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi`
(generate an OpenAPI doc from the registry's `External` operations).
Error fidelity per ADR-023.
- **[http-mcp.md](http-mcp.md)** — `from_mcp`/`to_mcp` (feature-gated),
streamable HTTP only (ADR-037), the rmcp integration.
- **[webtransport.md](webtransport.md)** — the `h3` ALPN handler,
WebTransport session/stream handling, the browser streaming path
(ADR-038).
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Direct path mapping; `to_openapi` is projection, not router |
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
| HTTP/3 + WebTransport first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | `h3` in scope, not deferred; browser streaming uses QUIC streams |
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy for unknown paths (settled) |
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi`/`from_mcp` produce `Internal` leaves (settled) |
| `OperationAdapter` trait is async | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | HTTP adapters implement the async trait (settled) |
| `to_*` adapters are projections | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `to_openapi`/`to_mcp` consume the registry, don't produce entries (settled) |
| Error schema fidelity | [ADR-023](../../decisions/023-operation-error-schemas.md) | `from_openapi` maps HTTP status → `HTTP_<status>` codes; `to_openapi` projects back (settled) |
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3`/WebTransport needs X.509 (settled) |
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Browser over WebTransport/HTTPS = bearer token, no `PeerId` (settled) |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format `/{service}/{op}` — the
HTTP path.
- **OQ-26** (resolved): `AdapterError` variants — reused by HTTP
adapters; `#[non_exhaustive]` allows extension.
- **OQ-37** (resolved): Browsers are not peers; `h3` hub is a
mixed-fingerprint `PeerEntry`.
- **OQ-38** (open, scope): WebTransport relay-as-proxy — does the proxy
live in `alknet-http` or a separate relay crate?
- **OQ-39** (open): `to_openapi` published-spec versioning — versioning
strategy for generated OpenAPI specs.
- **OQ-40** (open): reqwest client config and connection pooling —
two-way-door config shape.
## References
- `docs/research/alknet-http/phase-0-findings.md` — Phase 0 research
- `docs/research/alknet-call-completion/gap-analysis.md` — adapter
location map, no-env-vars invariant
- `/workspace/@alkdev/operations/src/from_openapi.ts`,
`/workspace/@alkdev/operations/src/from_mcp.ts` — TypeScript prior art
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP examples
- `/workspace/wtransport/` — pure-Rust WebTransport reference

View File

@@ -0,0 +1,232 @@
---
status: draft
last_updated: 2026-06-29
---
# WebTransport — the h3 ALPN handler
The `HttpAdapter` registration for the `h3` ALPN: HTTP/3 and
WebTransport. This document covers the WebTransport session/stream
handling, the browser streaming path, and the relationship to the `h2`/
`http/1.1` server. The `h3` support is a first-class transport, not a
deferral (ADR-038).
## What
The `h3` ALPN handler is the same `HttpAdapter` instance that serves
`h2`/`http/1.1`, registered for the `h3` ALPN when the `h3` feature is
enabled. It serves two things on a single `h3` connection:
1. **HTTP/3 requests** — the standard HTTP/3 over QUIC framing. An
HTTP/3 request is dispatched through the same axum `Router` as `h2`/
`http/1.1` requests (ADR-036 — the HTTP path IS the operation path).
From the axum router's perspective, an HTTP/3 request is just
another HTTP request; the framing difference is handled below the
router.
2. **WebTransport sessions** — the browser streaming path. A WebTransport
session is a long-lived connection over which the browser opens
bidirectional and unidirectional streams. A WebTransport stream that
targets the call protocol is handed to the call protocol's dispatch
loop directly — a WebTransport bidirectional stream is a QUIC
bidirectional stream, the same stream type the call protocol already
speaks (ADR-012).
### Why h3 is a first-class transport
WebTransport is the browser streaming transport. QUIC streams are cheap
(multiplexed over one connection, no head-of-line blocking), and
WebTransport is supported in major browsers. The call protocol's
subscription/streaming model maps onto WebTransport streams with no
translation loss — a `call.responded` stream over a WebTransport
bidirectional stream is the native representation, not an SSE
translation (which is the projection for `h2`/`http/1.1` clients per
ADR-036).
The Phase 0 research framing ("defer h3/WebTransport past v1") was a
residual of the "two-way door as deferral" anti-pattern (ADR-009 §"What
this framework is NOT"). WebTransport is in scope, in this crate, as a
first-class transport. See ADR-038.
## Architecture
### The h3 handler entry
The `HttpAdapter::handle()` method for the `h3` ALPN drives two
distinct stream types, distinguished at the HTTP/3 framing layer (not by
peeking application bytes):
1. **HTTP/3 request streams** — standard HTTP/3 GET/POST carrying
`:method`/`:path`. These are the same request model as `h2`/
`http/1.1`, just over HTTP/3 framing. Dispatched through the axum
`Router` (same router as `h2`/`http/1.1`, ADR-036). An HTTP/3 request
is never a WebTransport stream — the stream type is set by the
HTTP/3 frame that opens it.
2. **WebTransport sessions** — opened by a browser's
`new WebTransport(url)` call, which triggers an HTTP/3 extended
CONNECT request. The handler accepts the session (the `wtransport`
crate's `Endpoint::server(config)?.accept().await.await?.accept()
.await?` pattern, or the quinn HTTP/3 endpoint's WebTransport
extension — the exact library is a two-way-door implementation
detail, ADR-038). Within an established session, the browser opens
bidirectional streams via `transport.createBidirectionalStream()`;
the handler accepts each via `session.accept_bi()`.
The two stream types are not disambiguated by "reading the first frame"
— they are distinguished by the HTTP/3 frame type that opens them
(regular request headers vs. extended CONNECT). The "first frame"
routing below applies *within* a WebTransport session, not between an
HTTP/3 request and a WebTransport stream.
### WebTransport session and stream handling
Once a WebTransport session is established (via extended CONNECT), the
browser creates bidirectional streams within it. The handler accepts
each stream (`session.accept_bi()`) and reads the first frame to
determine the sub-protocol:
- **Call-protocol `EventEnvelope`** — the stream is a call-protocol
stream. The handler hands the `(SendStream, RecvStream)` pair to the
call protocol's `Dispatcher` (see [../call/call-protocol.md](../call/call-protocol.md)
for `EventEnvelope` and [../call/client-and-adapters.md](../call/client-and-adapters.md)
§"Shared Dispatcher" for the `Dispatcher` — the same dispatch loop
the `CallAdapter` uses for `alknet/call` connections, ADR-012,
stream-agnostic correlation). The browser speaks the EventEnvelope
wire format directly over the WebTransport stream.
- **Other sub-protocols** — a session may carry other framing
conventions (e.g., a future WT-native RPC framing). The session's
purpose is declared at CONNECT time (by path/origin), so the handler
knows which sub-protocol to expect; the first-frame tag is a
belt-and-suspenders disambiguator for sessions that multiplex
sub-protocols. For the call-protocol session, the first frame is an
`EventEnvelope` JSON object; the handler dispatches accordingly.
The browser's `WebTransport` JS API is the client side of this:
`new WebTransport('https://hub.example.com')`
`transport.createBidirectionalStream()` → write an `EventEnvelope` frame
→ read `call.responded` frames. No SSE translation, no HTTP framing —
the call protocol speaks directly over the WebTransport stream.
### Subscription projection (native, not SSE)
A `Subscription` operation served over WebTransport projects its
`call.responded` stream directly onto the WebTransport bidirectional
stream — each `call.responded` event is a frame on the stream, no SSE
`data:` framing. `call.completed` closes the stream; `call.aborted`
closes the stream with an error frame. This is the native streaming
projection; SSE (ADR-036) is the projection for `h2`/`http/1.1` clients
that don't speak WebTransport.
### The TLS constraint (browsers require X.509)
Browsers do not support RFC 7250 raw public keys (ADR-027, OQ-12). A
WebTransport session from a browser requires an X.509 cert — the `h3`
handler is a domain-hosted-service concern, not a P2P concern. A node
serving WebTransport must have an X.509 identity
(`TlsIdentity::X509` or `TlsIdentity::Acme`).
This is a property of the browser, not a decision this spec makes. It's
recorded so the spec doesn't pretend a raw-key node can serve browsers.
A raw-key node serves `h2`/`http/1.1` (for curl, axios, alknet-native
clients) but not `h3`/WebTransport (for browsers). A browser-facing hub
has a `PeerEntry` with mixed fingerprints (Ed25519 for P2P, X.509 for
browsers — ADR-030, ADR-034 §3).
### Browsers are not alknet peers
A browser connecting to a hub over WebTransport is served by the `h3`
handler. The browser authenticates by bearer token (HTTP `Authorization`
header on the WebTransport session request), resolved by the hub's
`IdentityProvider::resolve_from_token` against the hub's
`PeerEntry.auth_token_hash` or `ApiKeyEntry`. The browser is **not** an
alknet peer (ADR-034 §4): it gets no `PeerId`, does not enter
`PeerCompositeEnv`, and its "ops" are WebTransport streams served by
the `h3` handler, not entries in the call-protocol peer-keyed overlay.
### Stealth on h3
The `h3` handler participates in the same stealth model as `h2`/
`http/1.1` (ADR-010, ADR-036): a client that offers `h3` gets the HTTP
handler. Unknown WebTransport paths and unknown HTTP/3 paths get the
decoy (the same configurable `DecoyConfig` — fake 404, static site,
redirect). Real services use `alknet/ssh`, `alknet/call`, etc.
### Implementation reference: wtransport
The `wtransport` crate (`/workspace/wtransport/`, v0.7.1) is a pure-Rust
WebTransport implementation built on `quinn` + `h3`/`qpack`. Its API:
```rust
// Server (from the wtransport README):
let config = ServerConfig::builder()
.with_bind_default(4433)
.with_identity(&identity) // X.509 identity
.build();
let connection = Endpoint::server(config)?
.accept().await // await connection
.await? // await session request
.accept().await?; // await ready session
let stream = connection.accept_bi().await?;
```
`wtransport` is a candidate dependency for the `h3` feature gate. The
exact WebTransport library choice (wtransport vs a quinn-native HTTP/3
+ WebTransport extension) is a two-way-door implementation detail
(ADR-038); the one-way constraint is that `h3` is served by this crate
as a first-class transport.
## Constraints
- **`h3` requires X.509.** Browsers don't support RFC 7250 (ADR-027).
A node serving `h3` must have an X.509 identity. Raw-key-only nodes
serve `h2`/`http/1.1` but not `h3`.
- **`h3` is behind the `h3` feature gate.** The `wtransport` (or
quinn HTTP/3 extension) dependency is heavier than `h2`/`http/1.1`;
non-browser-facing deployments don't compile it.
- **Browsers are not alknet peers.** A browser over WebTransport
authenticates by bearer token, gets no `PeerId` (ADR-034 §4).
- **WebTransport streams target the call protocol directly.** A
WebTransport bidirectional stream carrying an `EventEnvelope` is
handed to the call protocol's `Dispatcher` — no SSE translation, no
HTTP framing. The browser speaks the call protocol wire format
directly.
- **The HTTP/3 request path uses the same axum `Router` as `h2`/
`http/1.1`.** An HTTP/3 request is just another HTTP request from
the router's perspective (ADR-036).
- **WebTransport is a draft standard.** The `wtransport` README notes
the protocol is not yet standardized; the API may change. The `h3`
feature gate isolates the risk.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| `h3`/WebTransport is first-class | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | In scope, not deferred; browser streaming uses QUIC streams |
| Browsers require X.509 | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | `h3` needs X.509 (browser limitation) |
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Bearer token, no `PeerId` |
| WebTransport streams → call protocol directly | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Stream-agnostic; WebTransport stream = QUIC bidirectional stream |
| Stealth on h3 | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Unknown paths get the decoy |
| HTTP path = operation path (for HTTP/3 requests) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | Same axum `Router` as h2/http1.1 |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-38** (open, scope): WebTransport relay-as-proxy — a proxy that
terminates the browser's WebTransport connection and forwards to a
P2P hub's Ed25519 endpoint (so the hub need not expose a public
X.509 cert). Recorded in ADR-034 §5. Does the proxy live in
`alknet-http` or a separate relay crate? This is a genuine scope
question (the proxy use case is not yet concrete enough to decide the
crate boundary), not a deferral.
## References
- [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md)
— the decision that `h3` is in scope
- [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) —
the HTTP-to-call mapping (the HTTP/3 request path uses the same
axum `Router`)
- [overview.md](overview.md) — crate overview, feature gates
- [http-server.md](http-server.md) — the `h2`/`http/1.1` companion
- `/workspace/wtransport/` — pure-Rust WebTransport reference
implementation (the `h3` feature's candidate dependency)

View File

@@ -72,4 +72,31 @@ alknet-napi is a thin projection layer — it exposes the Rust call protocol cli
- ADR-001: ALPN-based protocol dispatch
- ADR-002: ProtocolHandler trait
- ADR-004: Auth as shared core (IdentityProvider)
- ADR-005: irpc as call protocol foundation
- ADR-005: irpc as call protocol foundation
## Amendments
### Amendment 1 (2026-06-29): `alknet-call` is a protocol-foundation crate
The Decision table lists `alknet-call` as a handler crate that "depends
on alknet-core, irpc." The dependency-flow diagram and the "No handler
crate depends on another handler crate" rule were written before
`alknet-http` (which implements `from_openapi`/`from_mcp`/`to_openapi`/
`to_mcp` and therefore needs `alknet-call`'s `OperationSpec`, `Handler`,
`HandlerRegistration`, and `OperationAdapter` trait) was specced.
**Clarification:** `alknet-call` is both a handler crate (it implements
`ProtocolHandler` on ALPN `alknet/call`) *and* the protocol-foundation
crate that `alknet-agent`, `alknet-napi`, and `alknet-http` consume for
the operation registry, adapter contract, and call client. The "no
handler crate depends on another handler crate" rule applies to peer
handler crates (e.g., `alknet-http` does not depend on `alknet-ssh`);
`alknet-call` is a protocol-foundation crate in the same spirit that
`alknet-core` is, just at a different layer (operations/RPC vs.
transport/auth/config).
`alknet-http` depending on `alknet-call` is "HTTP uses the call protocol
types," not "HTTP depends on SSH." This is within the spirit of this
ADR's decomposition. The `alknet-call``alknet-http` edge is recorded
in the `alknet-http` spec (`crates/http/overview.md`) and in the adapter
location map (`crates/call/client-and-adapters.md`).

View File

@@ -0,0 +1,197 @@
# ADR-036: HTTP-to-Call Operation Mapping
## Status
Proposed
## Context
`alknet-http` implements `ProtocolHandler` for the standard HTTP ALPNs (`h2`,
`http/1.1`, `h3`). 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.
### 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 WebTransport (`h3`), the
subscription projects directly onto a WebTransport bidirectional stream —
no SSE framing is needed (see ADR-038 for the WebTransport path).
### 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
- 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

View File

@@ -0,0 +1,173 @@
# ADR-037: MCP Stdio Transport Exclusion
## Status
Proposed
## Context
The Model Context Protocol (MCP) defines multiple transports for
communicating between an MCP client and an MCP server. The MCP Rust SDK
(`rmcp` at `/workspace/rust-sdk/`) implements two:
1. **Streamable HTTP** (`transport-streamable-http-client-reqwest` for
clients, `transport-streamable-http-server` for servers). The client
connects to an HTTP endpoint; the server serves an HTTP endpoint.
Network-isolated, auth-gatable (Bearer token middleware, per the rmcp
`simple_auth_streamhttp.rs` example), and runs under whatever auth/
identity/capabilities machinery the host applies to HTTP.
2. **stdio** (`transport-child-process`). The client spawns the MCP server
as a child process and pipes JSON-RPC over its stdin/stdout. This is
the model the MCP spec promotes for "just download an MCP server and
run it locally."
The alknet-http crate implements `from_mcp` (import remote MCP tools as
call-protocol operations) and `to_mcp` (expose local operations as MCP
tools). Both are feature-gated behind an `mcp` feature (the rmcp
dependency is optional). The question this ADR resolves is which MCP
transports alknet-http supports.
### The stdio security problem
MCP stdio transport is `transport-child-process` — the rmcp client calls
`StdioClientTransport { command, args, env, cwd }`, which spawns an
arbitrary executable and pipes JSON-RPC over its stdin/stdout. An MCP
server is an arbitrary program that the MCP client executes with whatever
privileges the client process has.
The "download untrusted MCP servers and run them via stdio" model is
indistinguishable from `curl | sh` with extra steps:
- **Arbitrary code execution.** The MCP server is an executable. Running
it is RCE. There is no sandbox — the child process has the full
privileges of the client process (filesystem, network, environment
variables, ability to spawn further processes).
- **No auth boundary.** The MCP protocol messages flow over stdin/stdout;
there is no TLS, no auth token, no identity resolution. The server is
trusted by construction (you spawned it).
- **The "download untrusted MCP server" UX.** The MCP ecosystem's
promoted workflow is: find an MCP server on a registry, install it,
point your client at it. This is the npm-without-the-checksums model,
but the "package" is a process with full local privileges, not a
library that runs in-process.
alknet's security posture is the opposite of this. alknet-vault is
local-only by construction (ADR-025); the no-env-vars invariant
(ADR-014, client-and-adapters.md) exists specifically to avoid the
"download untrusted code that reads your secrets" pattern; capabilities
are injected by the assembly layer, not read from the environment a
spawned process can inspect. Building stdio support into alknet-http
would import the exact RCE vector the rest of the architecture is
designed to avoid.
## Decision
**alknet-http supports only streamable HTTP for MCP. Stdio is not built.**
The `mcp` feature gate pulls in rmcp with the streamable HTTP transport
features only:
```toml
[features]
mcp = [
"dep:rmcp",
# rmcp client transport (for from_mcp) — streamable HTTP only
# rmcp server transport (for to_mcp) — streamable HTTP only
]
```
The stdio transport (`transport-child-process`) is explicitly **not** a
dependency and **not** feature-gated. It is not built, not optional, not
"behind a separate feature." alknet-http's `from_mcp` uses rmcp's
`StreamableHttpClientTransport` (reqwest-based); `to_mcp` uses rmcp's
`StreamableHttpService` (axum-based, a tower service that nests into an
axum `Router` — see the rmcp `simple_auth_streamhttp.rs:134-159`
example). No stdio code path exists in the crate.
### If someone wants stdio MCP
They run it themselves, outside alknet. An operator who wants to use a
stdio-only MCP server can spawn it as a subprocess, run a small
streamable-HTTP-to-stdio bridge, and point `from_mcp` at the bridge's
HTTP endpoint. That bridge is the operator's responsibility — alknet
does not ship it, does not endorse it, and the bridge is where the RCE
risk lives, explicitly in the operator's hands, not hidden behind an
alknet feature flag.
This is the same posture as ADR-025 (remote vault access requires a
separate crate with its own ADR and threat model): the dangerous thing
is not built by default; if someone wants it, they build it themselves
and own the security model.
## Consequences
**Positive:**
- alknet-http does not import the MCP stdio RCE vector. There is no code
path in alknet-http that spawns an arbitrary executable.
- The streamable HTTP path is network-isolated, auth-gatable (Bearer
middleware), and runs under alknet's auth/identity/capabilities
machinery — the same machinery that gates every other HTTP request.
- `from_mcp` operations (imported MCP tools) are `Internal` by default
(ADR-015, ADR-022) — composition material, not directly callable from
the wire. The MCP server is reached over HTTP with a Bearer token from
`Capabilities` (the no-env-vars invariant), not by spawning a process
that could read the environment.
- `to_mcp` (expose local ops as MCP tools) serves an axum route with
Bearer auth middleware, matching the rmcp
`simple_auth_streamhttp.rs` pattern. An external MCP client (an
editor, an AI tool) discovers and calls alknet operations through
streamable HTTP, with alknet's auth/identity model applied at the
HTTP boundary.
**Negative:**
- MCP servers that only support stdio (a significant fraction of the
current MCP ecosystem) cannot be consumed by `from_mcp` directly. The
operator runs a bridge (above). This is a deliberate exclusion, not a
feature gap.
- The "just download an MCP server and run it" UX that the MCP
ecosystem promotes is not supported. An alknet user who wants that UX
has to build the bridge and own the RCE risk. This is the correct
tradeoff for alknet's threat model, but it means alknet-http is not a
drop-in client for the stdio MCP ecosystem.
## Assumptions
1. **Streamable HTTP is the supported MCP transport in alknet.** This
is a one-way door: removing stdio support later (if it were ever
added) would break deployments that depend on it; not adding it is
the stable position. The streamable HTTP transport is the MCP
spec's network-isolated path and is what the rmcp examples use for
auth-gated servers.
2. **The MCP ecosystem's stdio UX is not a target.** alknet is not trying
to be a drop-in client for "download untrusted MCP servers." If a
user wants that, the bridge approach puts the RCE risk explicitly
in their hands.
3. **rmcp's streamable HTTP features are the right subset.** The
`mcp` feature gate pulls in `transport-streamable-http-client-reqwest`
(for `from_mcp`) and `transport-streamable-http-server` (for
`to_mcp`). The exact rmcp feature names are a two-way-door
implementation detail (rmcp may rename features across versions); the
one-way constraint is "streamable HTTP only, no stdio."
## References
- [ADR-014](014-secret-material-flow-and-capability-injection.md) — the
no-env-vars invariant; spawned processes reading env vars is the
pattern this ADR's exclusion prevents
- [ADR-015](015-privilege-model-and-authority-context.md) —
adapter-registered ops (`from_mcp`) are `Internal` by default
- [ADR-022](022-handler-registration-provenance-and-composition-authority.md)
`from_mcp` provenance is a leaf
- [ADR-025](025-vault-local-only-dispatch.md) — the analogous "dangerous
thing is not built by default; a separate crate with its own ADR" pattern
- `docs/research/alknet-http/phase-0-findings.md` §4 (MCP stdio exclusion)
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp v1.8.0); streamable HTTP
transport
- `/workspace/rust-sdk/examples/servers/src/simple_auth_streamhttp.rs`
streamable HTTP MCP server with Bearer auth (the `to_mcp` pattern)
- `/workspace/rust-sdk/examples/clients/src/streamable_http.rs`
streamable HTTP MCP client (the `from_mcp` pattern)
- `crates/http/http-mcp.md` — the spec that implements `from_mcp`/`to_mcp`

View File

@@ -0,0 +1,233 @@
# ADR-038: HTTP/3 and WebTransport as First-Class HTTP Transports
## Status
Proposed
## Context
The alknet-http Phase 0 research findings
(`docs/research/alknet-http/phase-0-findings.md`, decision point DH-2)
framed HTTP/3 + WebTransport as "deferred past v1" — a two-way-door
addition to land "when the agent service needs browser streaming." That
framing was a residual of the "two-way door as deferral" anti-pattern
that ADR-009 §"What this framework is NOT" was later written to prevent.
The deferral framing is rejected here; this ADR records the decision as
made.
WebTransport is not a "later" feature. It is the browser-streaming
transport — QUIC streams are cheap (better than WebSocket/SSE by far for
multiplexed bidirectional streaming), and WebTransport is supported in
major browsers. The `alknet-http` crate's purpose is to be the HTTP
interface library for downstream crates that need to expose HTTP, and
browser streaming is a first-class requirement of that purpose, not a
fast-follow.
### The ALPN registry already reserves h3
The overview ALPN Registry maps `h3` to `HttpAdapter (HTTP/3 +
WebTransport)`. The `h3` ALPN is reserved; the implementation lands as
part of `alknet-http`, not as a future crate. This ADR confirms that
reservation and records the architectural decision: HTTP/3 + WebTransport
is in scope, in this crate, as a first-class transport alongside `h2` and
`http/1.1`.
### Why WebTransport matters
- **QUIC streams are cheap.** A browser opens a WebTransport connection
once and multiplexes many bidirectional streams over it. This is
fundamentally better than WebSocket (one stream, one connection) or
SSE (one-directional, one stream per subscription) for the
subscription-heavy, streaming-heavy patterns the call protocol
supports natively.
- **Browser support.** WebTransport is supported in modern Chromium-based
browsers (Chrome, Edge). The browser path for alknet is WebTransport,
not WebSocket.
- **The call protocol maps cleanly onto WebTransport streams.** A
`call.requested` over a WebTransport bidirectional stream is the same
EventEnvelope framing over a different QUIC stream source. The
`CallConnection`/`Dispatcher` dispatch loop is stream-agnostic
(ADR-012) — the `h3` handler hands a bidirectional stream to the call
protocol the same way the `h2`/`http/1.1` handler hands a hyper
connection to axum.
### The TLS constraint (browsers require X.509)
Browsers do not support RFC 7250 raw public keys (ADR-027, OQ-12). A
WebTransport session from a browser requires an X.509 cert — meaning the
`h3` handler is a domain-hosted-service concern, not a P2P concern. A
node serving WebTransport must have an X.509 identity (`TlsIdentity::X509`
or `TlsIdentity::Acme`). This is a property of the browser, not a
decision this ADR makes — it's recorded so the spec doesn't pretend a
raw-key node can serve browsers.
### WebTransport relay-as-proxy
A distinct WebTransport feature — a proxy that terminates the browser's
WebTransport connection and forwards encrypted traffic to a P2P hub's
Ed25519 endpoint (so the hub need not expose its own public X.509 cert)
— was recorded in ADR-034 §5. That feature does not change the auth model
(bearer token + `PeerEntry.auth_token_hash`; the proxy is transport-only)
and was explicitly placed in the same bucket as the rest of h3/
WebTransport. With this ADR, h3/WebTransport is in scope; the
relay-as-proxy is a genuine scope question (does the proxy belong in
alknet-http or in a separate relay crate?), tracked as OQ-38 — not
deferred, just scoped to a separate decision when the proxy use case
becomes concrete.
## Decision
**HTTP/3 + WebTransport is a first-class HTTP transport in `alknet-http`,
implemented alongside `h2` and `http/1.1`. It is not deferred.**
The `HttpAdapter` implements `ProtocolHandler` for three ALPNs:
| ALPN | Transport | Use case | Browser? |
|------|-----------|----------|----------|
| `http/1.1` | HTTP/1.1 over QUIC stream | Legacy clients, curl | No (browsers use h2/h3) |
| `h2` | HTTP/2 over QUIC stream | Modern HTTP clients, curl | No (browsers use h3) |
| `h3` | HTTP/3 / WebTransport | Browser streaming | Yes (X.509 required) |
All three are served by `alknet-http`. The `h3` ALPN handler upgrades to
WebTransport sessions and serves both HTTP/3 requests (the standard
HTTP/3 over QUIC framing) and WebTransport streams (the bidirectional/
unidirectional stream API). The handler dispatches HTTP/3 requests
through the same axum `Router` as `h2`/`http/1.1`; WebTransport streams
that target the call protocol are handed to the call protocol's dispatch
loop directly (a WebTransport stream is a QUIC bidirectional stream, the
same stream type the call protocol already speaks).
### WebTransport as the browser streaming path
The `h3` handler drives two distinct stream types, distinguished at the
HTTP/3 framing layer (not by peeking application bytes):
1. **HTTP/3 request streams** — standard HTTP/3 GET/POST carrying
`:method`/`:path`. Dispatched through the axum `Router`, same as
`h2`/`http/1.1` (ADR-036). These are not WebTransport streams.
2. **WebTransport sessions** — opened by a browser's
`new WebTransport(url)` call (an HTTP/3 extended CONNECT request).
The handler accepts the session (the `wtransport` crate's
`Endpoint::server` + `accept` + `accept_bi` pattern, or the quinn
HTTP/3 endpoint's WebTransport extension). Within an established
session, the browser creates bidirectional streams via
`transport.createBidirectionalStream()`; the handler accepts each
and dispatches by sub-protocol. For a call-protocol session, the
first frame is an `EventEnvelope` and the handler hands the stream
to the call protocol's `Dispatcher` — no SSE translation, no HTTP
framing. The browser's `WebTransport` JS API speaks to this handler
directly.
The stream-type distinction (HTTP/3 request vs. WebTransport session)
is made at the HTTP/3 frame layer (regular request headers vs. extended
CONNECT), not by reading the first application byte. The first-frame
routing applies *within* a WebTransport session (determining the
sub-protocol), not between an HTTP/3 request and a WebTransport stream.
This means the browser's subscription/streaming path uses WebTransport
streams directly, not the SSE projection (ADR-036) that HTTP/1.1 + HTTP/2
clients use. The same `Subscription` operation is served as SSE over
`h2` and as a native WebTransport stream over `h3` — the projection is
transport-dependent, the operation is the same.
### h3 and the stealth mapping
The `h3` handler participates in the same stealth model as `h2`/
`http/1.1` (ADR-010, ADR-036): a client that offers `h3` gets the HTTP
handler. Unknown WebTransport paths and unknown HTTP/3 paths get the
decoy (configurable). Real services use `alknet/ssh`, `alknet/call`, etc.
### Implementation reference: wtransport
The `wtransport` crate (`/workspace/wtransport/`, v0.7.1) is a pure-Rust
WebTransport implementation built on `quinn` + `h3`/`qpack`. It provides
the `Endpoint::server` + `accept` + `accept_bi` API that the `h3`
handler uses. `wtransport` is a candidate dependency for the `h3`
feature gate; the exact WebTransport library choice (wtransport vs a
quinn-native HTTP/3 + WebTransport extension) is a two-way-door
implementation detail. The one-way constraint is: `h3` is served, by this
crate, as a first-class transport.
### Feature gating
The `h3`/WebTransport support is behind an `h3` feature gate (the
WebTransport/HTTP/3 dependencies are heavier than `h2`/`http/1.1`):
```toml
[features]
default = ["h2", "http1"]
h3 = ["dep:wtransport"] # or the quinn h3 extension
mcp = ["dep:rmcp"] # MCP feature gate (ADR-037)
```
A deployment that only needs `h2`/`http/1.1` (a non-browser-facing
node) does not compile the WebTransport dependencies. A browser-facing
node enables `h3`.
## Consequences
**Positive:**
- Browser streaming uses QUIC streams directly, not SSE-over-HTTP/2.
The call protocol's subscription model maps onto WebTransport streams
with no translation loss — a `call.responded` stream over a
WebTransport bidirectional stream is the native representation.
- The `h3` ALPN reservation in the overview is honored — the
implementation lands in this crate, not a future one.
- A browser-facing node (a hub with an X.509 cert) serves the same
operations over `h3` as a non-browser-facing node serves over `h2`
the operation registry is transport-agnostic, the projection is
transport-dependent.
- The WebTransport relay-as-proxy (ADR-034 §5) has a clear home: it's a
feature that lives in or near `alknet-http`'s `h3` handler, scoped by
OQ-38.
**Negative:**
- `alknet-http` gains the `wtransport` (or equivalent HTTP/3 +
WebTransport) dependency behind the `h3` feature. This is a heavier
dependency than `h2`/`http/1.1`. The feature gate keeps it out of
non-browser-facing builds.
- WebTransport is still a draft standard (the `wtransport` README notes
it). The API may change. This is inherent to being an early adopter
of WebTransport; the `h3` feature gate isolates the risk.
- Browsers require X.509 (ADR-027). A raw-key-only node cannot serve
WebTransport. This is a browser limitation, not an alknet decision,
but it means the `h3` feature is useful only on domain-hosted nodes
with X.509 certs.
## Assumptions
1. **WebTransport is the browser streaming transport for alknet.** The
browser path is WebTransport, not WebSocket and not SSE-over-HTTP/2.
SSE remains the streaming projection for non-WebTransport HTTP
clients (curl, axios over h2); WebTransport is the native path for
browsers.
2. **The `wtransport` crate (or an equivalent quinn-native HTTP/3 +
WebTransport implementation) is the dependency for the `h3` feature.**
The exact library is a two-way-door implementation detail; the
one-way constraint is that `h3` is served by this crate.
3. **The WebTransport relay-as-proxy (ADR-034 §5) is a separate scope
decision (OQ-38).** It is not deferred — it's a feature with a clear
home (the `h3` handler or a sibling relay crate) that gets designed
when the browser-to-P2P-peer proxy use case becomes concrete.
## References
- [ADR-009](009-one-way-door-decision-framework.md) §"What this framework
is NOT" — the anti-pattern this ADR corrects (two-way door as deferral)
- [ADR-010](010-alpn-router-and-endpoint.md) — the ALPN router; `h3` is
one of the ALPNs the `HttpAdapter` registers for
- [ADR-012](012-call-protocol-stream-model.md) — stream-agnostic
correlation; a WebTransport stream is a QUIC bidirectional stream
- [ADR-027](027-tls-identity-redesign-acme-rawkey-decoupling.md) — the
browser limitation (no RFC 7250); WebTransport requires X.509
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 (browsers
are not alknet peers) and §5 (WebTransport relay-as-proxy, recorded
for this bucket)
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection
for `h2`/`http/1.1` that WebTransport replaces for the browser path
- `docs/research/alknet-http/phase-0-findings.md` DH-2 — the deferral
framing this ADR rejects
- `/workspace/wtransport/` — pure-Rust WebTransport (the `h3` feature's
reference implementation)
- `crates/http/webtransport.md` — the spec that implements the `h3`
handler

View File

@@ -0,0 +1,154 @@
# ADR-039: HTTP Server and Client Host Colocated in alknet-http
## Status
Proposed
## Context
`alknet-http` has two roles: an HTTP server (the `HttpAdapter`
`ProtocolHandler` for `h2`/`http/1.1`/`h3`, built on `axum`/`hyper`)
and an HTTP client host (the `from_openapi`/`from_mcp` forwarding
handlers, built on `reqwest`). The question is whether these two
directions live in one crate (`alknet-http`) or are split into two
crates (`alknet-http-server` + `alknet-http-client`).
ADR-003 lists `alknet-http` as a single crate with dependency
`alknet-core, axum` and justifies the per-handler-crate decomposition
with "each handler is self-contained — it receives a byte stream and
manages its own protocol." That rationale covers the server side (the
`HttpAdapter` is self-contained), but it does not address the
within-crate dual-role question: should the inbound HTTP server and
the outbound HTTP client (the adapter forwarding handlers) be
colocated, or split?
This is a load-bearing choice. Once published, downstream consumers
build import paths against the crate boundary; the shared `reqwest::Client`
and the no-env-vars invariant boundary (ADR-014) are scoped by it; the
`to_openapi`/`to_mcp` projections are pure-registry-consumers that
*describe* the server surface but live where the adapter types do.
Splitting later would be a rewrite of every consumer's import paths,
not a cheap revert. It needs an ADR.
## Decision
**One crate — `alknet-http` houses both the HTTP server and the HTTP
client host (the adapter forwarding handlers and the `to_*` projections).**
The two directions share the HTTP dependencies and HTTP-specific
concerns that make splitting them counterproductive:
- **Shared HTTP dependencies.** Both `axum` (server) and `reqwest`
(client) pull in `hyper`, `http`, `http-body`, `rustls`/TLS stack
types, and the HTTP header/status code types. A split into two crates
would either duplicate these dependencies across both crates or
force a third shared-types crate, neither of which is an improvement.
- **Shared HTTP-specific concerns.** Both directions care about HTTP
headers, status codes, content types, SSE framing, streaming vs
non-streaming bodies, and TLS trust stores. The `from_openapi`
forwarding handler's error mapping (HTTP status → `HTTP_<status>`
error codes, ADR-023) and the `to_openapi` projection's error mapping
(`ErrorDefinition.http_status` → HTTP response status) are *the same
mapping* read in two directions — splitting them would put the two
halves in different crates.
- **The `to_*` projections describe the server surface.** `to_openapi`
generates an OpenAPI doc whose paths mirror the `/{service}/{op}`
HTTP routes the `HttpAdapter` serves (ADR-036). `to_mcp` exposes the
same operations as MCP tools. These projections consume the
`OperationRegistry` and produce specs; they live with the adapter
types (in `alknet-http`, per the adapter location map — see
[client-and-adapters.md](../crates/call/client-and-adapters.md))
because they share the operation-spec→HTTP mapping logic with the
server's request dispatch.
- **The no-env-vars invariant boundary is crate-scoped.** The
`from_openapi`/`from_mcp` forwarding handlers are the credential
injection point (ADR-014). The invariant — "no handler reads outbound
credentials from any source other than `OperationContext.capabilities`"
— is verified against the handler implementations in this crate. A
split would put the invariant verification boundary across two crates.
### What this does NOT change
- ADR-003's rule "no handler crate depends on another handler crate"
applies to peer handler crates (`alknet-http` does not depend on
`alknet-ssh`). The `alknet-http``alknet-call` edge is the
protocol-foundation exception (ADR-003 Amendment 1). This ADR is
about the *internal* structure of `alknet-http`, not its dependency
edges.
- The adapter location map (the `OperationAdapter` trait in
`alknet-call`; the HTTP-backed adapter implementations in
`alknet-http`) is unchanged. This ADR records *why* the HTTP-backed
adapters live in the same crate as the HTTP server, not whether they
live in `alknet-http` vs `alknet-call`.
## Consequences
**Positive:**
- One crate, one set of HTTP dependencies, one HTTP-specific concern
surface. No duplicated `hyper`/`http` types across two crates, no
shared-types crate needed.
- The `to_*` projections live with the server whose surface they
describe, and with the adapter types they consume. The operation-spec
→ HTTP mapping logic is in one place.
- The no-env-vars invariant verification boundary is one crate. The
`from_openapi`/`from_mcp` handlers and the credential injection
logic they share are co-located.
- A downstream consumer wires one crate (`alknet-http`) into the
`HandlerRegistry` and gets the full HTTP surface — server + adapters +
projections. No two-crate wiring.
**Negative:**
- A deployment that only needs the HTTP server (no `from_openapi`/`from_
mcp` forwarding) still compiles the `reqwest` dependency. Mitigated:
the `mcp` feature is already gated (ADR-037); the `from_openapi`
forwarding is always available but the `reqwest` client is only
constructed if a `from_openapi`/`from_mcp` adapter is registered at
assembly time. The dependency is compiled, the client is lazy.
- A deployment that only needs the HTTP client (e.g., an agent crate
that only uses `from_openapi` forwarding, no inbound HTTP) still
compiles `axum`/`hyper`. This is the rarer case — the agent crate
(`alknet-agent`) consumes `alknet-call` directly for tool dispatch
and uses `from_openapi` via `alknet-http`'s adapter, but doesn't
serve inbound HTTP itself. In practice, the agent deployment wires
`alknet-http` for the adapters and the CLI wires it for the server;
the compile cost is paid once per workspace, not once per deployment.
- The crate is larger than a single-direction crate would be. This is
the cost of colocating shared concerns; the alternative (two crates
+ a shared types crate) is more crates, not less code.
## Assumptions
1. **The shared-HTTP-dependencies argument holds.** `axum` and
`reqwest` both pull in `hyper` and the `http` crate's types; the
shared types (headers, status codes, method, URI) are the same. If
a future version of `axum` or `reqwest` diverges its HTTP types
(e.g., `axum` moves to a different HTTP implementation), this
argument weakens. As of `axum` 0.7+ and `reqwest` 0.12+, both are
built on `hyper` 1.x and share `http` types.
2. **The `to_*` projections share enough mapping logic with the server
to justify colocation.** The operation-spec → HTTP path/method/
error-status mapping is the same in both directions. If the
projections turn out to be pure registry-consumers with no
HTTP-mapping logic (just spec serialization), the colocation
argument is weaker — but the current design (ADR-036, ADR-023)
has them sharing the mapping.
## References
- [ADR-003](003-crate-decomposition.md) — crate decomposition (this
ADR addresses the within-`alknet-http` dual-role question, not the
dependency edge; Amendment 1 covers the `alknet-call` edge)
- [ADR-014](014-secret-material-flow-and-capability-injection.md) —
the no-env-vars invariant whose verification boundary is crate-scoped
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
adapter contract; `to_*` are projections
- [ADR-023](023-operation-error-schemas.md) — the error mapping shared
between `from_openapi` (status → code) and `to_openapi` (code →
status)
- [ADR-036](036-http-to-call-operation-mapping.md) — the HTTP path =
operation path mapping shared between server dispatch and `to_openapi`
- [ADR-037](037-mcp-stdio-transport-exclusion.md) — the `mcp` feature
gate
- `crates/http/overview.md` — the crate overview (the inline rationale
for this decision is replaced by a pointer to this ADR)

View File

@@ -736,17 +736,108 @@ is a feature extension, not an unmade architecture decision.
all (no CA fallback) and fails closed — same model as iroh.
**Downstream, not blocking, recorded so they don't get lost:**
WebTransport relay-as-proxy (browser → proxy → P2P hub) is deferred
with the rest of h3/WebTransport (alknet-http DH-2); ADR-030 §6's
fingerprint normalization already keeps the proxied path clean. On-
chain / smart-contract peer discovery (relays syncing git repos via
iroh gossip) is a *source* of `PeerEntry` records, fits the OQ-36
repo/adapter pattern (`alknet-peer-store-onchain` implementing
`IdentityProvider`), and does not change the auth model.
WebTransport relay-as-proxy (browser → proxy → P2P hub) is the
remaining scope question tracked as OQ-38 (h3/WebTransport itself is
now in scope, ADR-038); ADR-030 §6's fingerprint normalization already
keeps the proxied path clean. On-chain / smart-contract peer
discovery (relays syncing git repos via iroh gossip) is a *source* of
`PeerEntry` records, fits the OQ-36 repo/adapter pattern
(`alknet-peer-store-onchain` implementing `IdentityProvider`), and
does not change the auth model.
Not blocking the ADR-029 migration — the Ed25519 path is the primary
use case and was already resolved; this ADR closes the X.509
outgoing-only remainder.
- **Cross-references**: ADR-027, ADR-029, ADR-030, ADR-033, ADR-034,
OQ-29, OQ-36, [client-and-adapters.md](crates/call/client-and-adapters.md),
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
[endpoint.md](crates/core/endpoint.md), [auth.md](crates/core/auth.md)
## Theme: alknet-http
### OQ-38: WebTransport Relay-as-Proxy Scope
- **Origin**: [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
§5, [webtransport.md](crates/http/webtransport.md)
- **Status**: open (scope, not deferral)
- **Door type**: One-way (crate boundary), two-way (mechanism)
- **Priority**: low
- **Resolution**: A WebTransport proxy that terminates the browser's
WebTransport connection and forwards encrypted traffic to a P2P hub's
Ed25519 endpoint (so the hub need not expose its own public X.509
cert) is a real feature for the browser-to-P2P-peer case. ADR-034 §5
recorded it in the h3/WebTransport bucket; ADR-038 brought h3/
WebTransport into scope, so this OQ is now the remaining scope
question: does the proxy live in `alknet-http` (as a mode of the `h3`
handler) or in a separate relay crate?
This is a genuine scope question, not a deferral. The proxy use case
is not yet concrete enough to decide the crate boundary — no
deployment has asked for it yet, and the design (transport-only
proxy, no auth-model change per ADR-034 §5) is clear but the home is
not. The decision is made when the browser-to-P2P-peer proxy use
case becomes concrete; until then it is tracked here, not deferred
with "v1/later" language. The proxy does not change the auth model
(bearer token + `PeerEntry.auth_token_hash`; proxy is transport-
only), so it does not block any other ADR.
- **Cross-references**: ADR-027, ADR-030, ADR-034, ADR-038,
[webtransport.md](crates/http/webtransport.md)
### OQ-39: `to_openapi` Published-Spec Versioning
- **Origin**: [ADR-017](decisions/017-call-protocol-client-and-adapter-contract.md)
Consequences, [http-adapters.md](crates/http/http-adapters.md)
- **Status**: open
- **Door type**: One-way (after first publication), two-way (before)
- **Priority**: medium
- **Resolution**: ADR-017 Consequences notes that published `to_*`
specs are compatibility contracts: once a generated OpenAPI spec is
published and external clients build against it, the mapping
semantics (e.g., subscriptions → SSE long-poll, error codes → HTTP
statuses) become a de facto contract. Changing the mapping later
breaks every client. `to_openapi` mapping choices are two-way *before*
first publication but one-way *after*.
The versioning strategy for generated OpenAPI specs needs
specifying: version the generated spec (e.g., an OpenAPI `info.version`
tied to the registry's `External` operation set version) and emit a
spec version marker so consumers can detect mapping changes. The
exact versioning scheme (semver tied to operation additions/changes,
a content-hash, a monotonically-increasing counter) is a two-way-door
implementation detail before first publication; the one-way constraint
is that the version marker is emitted and consumers can detect
breaking changes. This is the "published artifact is a contract"
blind spot in ADR-009's framework (it classifies doors by reversal
cost in the codebase, not by compatibility cost for external
consumers).
- **Cross-references**: ADR-009, ADR-017, ADR-023, ADR-036,
[http-adapters.md](crates/http/http-adapters.md)
### OQ-40: reqwest Client Config and Connection Pooling
- **Origin**: [http-adapters.md](crates/http/http-adapters.md),
[http-mcp.md](crates/http/http-mcp.md), the alknet-http Phase 0
findings DH-7
- **Status**: open
- **Door type**: Two-way
- **Priority**: low
- **Resolution**: `alknet-http` maintains a shared `reqwest::Client`
(constructed once, reused across all `from_openapi`/`from_mcp`
forwarding handlers) with connection pooling, keep-alive, and TLS.
The aisdk `core/client.rs` reference shows the pattern worth
referencing: `OnceLock<reqwest::Client>`, retry logic (exponential
backoff, `Retry-After` header), and separate streaming vs
non-streaming clients.
The exact pooling/retry config (pool size, retry policy, timeout
defaults, hot-reloadability via `DynamicConfig`) is a two-way-door
implementation detail. The one-way constraints are: (1)
`alknet-http` owns its `reqwest::Client` (no env-var-based client
config, no shared global client), (2) credential injection happens
per-request (from `OperationContext.capabilities`), not at client
construction (the client is shared across all operations, the
credentials are per-call), and (3) TLS for outbound calls uses the
system trust store by default (custom CA bundle + client certs are
an optional config for self-hosted API gateways). This OQ tracks the
two-way-door config shape; the constraints are settled.
- **Cross-references**: ADR-014, ADR-017,
[http-adapters.md](crates/http/http-adapters.md)

View File

@@ -49,7 +49,7 @@ alknet-core
├── alknet-git (depends on alknet-core, gix)
├── alknet-sftp (depends on alknet-core, russh-sftp)
├── alknet-msg (depends on alknet-core)
├── alknet-http (depends on alknet-core, axum)
├── alknet-http (depends on alknet-core, alknet-call, axum, reqwest, wtransport, rmcp)
├── alknet-dns (depends on alknet-core, hickory-proto)
├── alknet-napi (depends on alknet-call, napi-rs)
@@ -101,7 +101,7 @@ See [ADR-002](decisions/002-protocol-handler-trait.md) and [ADR-007](decisions/0
| `alknet/msg` | MessageAdapter | E2E encrypted messaging, mixnet |
| `alknet/http` | HttpAdapter | axum REST API, dashboard, MCP endpoint |
| `alknet/dns` | DnsAdapter | DNS over QUIC/TLS, pkrr service discovery |
| `h3` | HttpAdapter (WebTransport upgrade) | Browser-compatible WebTransport, then ALPN upgrade |
| `h3` | HttpAdapter (HTTP/3 + WebTransport) | Browser-compatible WebTransport + HTTP/3 (first-class, ADR-038) |
| `h2` / `http/1.1` | HttpAdapter | Standard HTTP for browsers, curl |
> **Note**: `alknet/agent` is not in the ALPN registry. The agent service is a future consumer that builds on top of `alknet-call` (it depends on `alknet-call`, not `alknet-core` directly — see ADR-003). It uses the call protocol for tool dispatch and exposes agent operations (e.g., `/agent/chat`) as call-protocol operations in the `OperationRegistry`, not as a separate ALPN. The agent is a mental model that informed the core architecture (capabilities, scoped env, abort cascade) but is not specced yet — its design will change as it's built out against the implemented core crates.