From ab47dac4add92392eb973fde84c1997f8d469bed Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Mon, 29 Jun 2026 05:53:38 +0000 Subject: [PATCH] docs(http): draft alknet-http architecture specs and ADRs 036-039 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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). --- docs/architecture/README.md | 17 +- docs/architecture/crates/http/README.md | 103 ++++++ .../architecture/crates/http/http-adapters.md | 326 ++++++++++++++++++ docs/architecture/crates/http/http-mcp.md | 245 +++++++++++++ docs/architecture/crates/http/http-server.md | 292 ++++++++++++++++ docs/architecture/crates/http/overview.md | 243 +++++++++++++ docs/architecture/crates/http/webtransport.md | 232 +++++++++++++ .../decisions/003-crate-decomposition.md | 29 +- .../036-http-to-call-operation-mapping.md | 197 +++++++++++ .../037-mcp-stdio-transport-exclusion.md | 173 ++++++++++ ...8-http3-and-webtransport-as-first-class.md | 233 +++++++++++++ ...9-http-server-and-client-host-colocated.md | 154 +++++++++ docs/architecture/open-questions.md | 107 +++++- docs/architecture/overview.md | 4 +- 14 files changed, 2343 insertions(+), 12 deletions(-) create mode 100644 docs/architecture/crates/http/README.md create mode 100644 docs/architecture/crates/http/http-adapters.md create mode 100644 docs/architecture/crates/http/http-mcp.md create mode 100644 docs/architecture/crates/http/http-server.md create mode 100644 docs/architecture/crates/http/overview.md create mode 100644 docs/architecture/crates/http/webtransport.md create mode 100644 docs/architecture/decisions/036-http-to-call-operation-mapping.md create mode 100644 docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md create mode 100644 docs/architecture/decisions/038-http3-and-webtransport-as-first-class.md create mode 100644 docs/architecture/decisions/039-http-server-and-client-host-colocated.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ed12f18..32dc167 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 (030–033) 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 (030–033) 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 036–038 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 diff --git a/docs/architecture/crates/http/README.md b/docs/architecture/crates/http/README.md new file mode 100644 index 0000000..3377957 --- /dev/null +++ b/docs/architecture/crates/http/README.md @@ -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_` 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) \ No newline at end of file diff --git a/docs/architecture/crates/http/http-adapters.md b/docs/architecture/crates/http/http-adapters.md new file mode 100644 index 0000000..024c307 --- /dev/null +++ b/docs/architecture/crates/http/http-adapters.md @@ -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, 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, + pub components: Option, + // ... 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, + pub default_headers: HashMap, +} + +pub enum HttpAuthScheme { + Bearer, // Authorization: Bearer + ApiKey { header_name: String }, // e.g., X-API-Key: + Basic, // Authorization: Basic +} +``` + +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` 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` +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`, 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("")` (e.g., `"openai"`, `"vastai"`, +`"github"`), extracts the credential, and injects it into the outbound +HTTP request: + +- Bearer token → `Authorization: Bearer `. +- API key → the header the OpenAPI spec declares (e.g., `X-API-Key: + `, or `Authorization: ApiKey ` — the `HTTPServiceConfig.auth` + in the TS prior art shows the three auth types: `bearer`, `apiKey`, + `basic`). +- Basic auth → `Authorization: Basic `. + +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: } + '404': { schema: } # where http_status = 404 + '429': { 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_`.** 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_` 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_` 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) \ No newline at end of file diff --git a/docs/architecture/crates/http/http-mcp.md b/docs/architecture/crates/http/http-mcp.md new file mode 100644 index 0000000..5349acd --- /dev/null +++ b/docs/architecture/crates/http/http-mcp.md @@ -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, + /// 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, 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` the +registry dispatches. `alknet-call` never sees rmcp. + +### to_mcp + +```rust +pub fn to_mcp_service( + registry: Arc, + identity_provider: Arc, +) -> 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 = + 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`) \ No newline at end of file diff --git a/docs/architecture/crates/http/http-server.md b/docs/architecture/crates/http/http-server.md new file mode 100644 index 0000000..8b2dd3d --- /dev/null +++ b/docs/architecture/crates/http/http-server.md @@ -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` (constructor injection, same pattern as +`SshAdapter` — see [auth.md](../core/auth.md)) and an +`Arc` (for dispatching HTTP requests to call-protocol +operations). It implements `ProtocolHandler` for the standard HTTP ALPNs. + +```rust +pub struct HttpAdapter { + identity_provider: Arc, + registry: Arc, + /// 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` and `Arc` 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 `, 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`, + 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_` 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_` 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 \ No newline at end of file diff --git a/docs/architecture/crates/http/overview.md b/docs/architecture/crates/http/overview.md new file mode 100644 index 0000000..9de05b5 --- /dev/null +++ b/docs/architecture/crates/http/overview.md @@ -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` 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` 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_` 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 \ No newline at end of file diff --git a/docs/architecture/crates/http/webtransport.md b/docs/architecture/crates/http/webtransport.md new file mode 100644 index 0000000..c5aa319 --- /dev/null +++ b/docs/architecture/crates/http/webtransport.md @@ -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) \ No newline at end of file diff --git a/docs/architecture/decisions/003-crate-decomposition.md b/docs/architecture/decisions/003-crate-decomposition.md index 84711dc..8c09314 100644 --- a/docs/architecture/decisions/003-crate-decomposition.md +++ b/docs/architecture/decisions/003-crate-decomposition.md @@ -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 \ No newline at end of file +- 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`). \ No newline at end of file diff --git a/docs/architecture/decisions/036-http-to-call-operation-mapping.md b/docs/architecture/decisions/036-http-to-call-operation-mapping.md new file mode 100644 index 0000000..542f416 --- /dev/null +++ b/docs/architecture/decisions/036-http-to-call-operation-mapping.md @@ -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: +`, 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 `, 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 \ No newline at end of file diff --git a/docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md b/docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md new file mode 100644 index 0000000..e20fe9d --- /dev/null +++ b/docs/architecture/decisions/037-mcp-stdio-transport-exclusion.md @@ -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` \ No newline at end of file diff --git a/docs/architecture/decisions/038-http3-and-webtransport-as-first-class.md b/docs/architecture/decisions/038-http3-and-webtransport-as-first-class.md new file mode 100644 index 0000000..9430b3e --- /dev/null +++ b/docs/architecture/decisions/038-http3-and-webtransport-as-first-class.md @@ -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 \ No newline at end of file diff --git a/docs/architecture/decisions/039-http-server-and-client-host-colocated.md b/docs/architecture/decisions/039-http-server-and-client-host-colocated.md new file mode 100644 index 0000000..d62eca4 --- /dev/null +++ b/docs/architecture/decisions/039-http-server-and-client-host-colocated.md @@ -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_` + 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) \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 384d655..2c16440 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -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) \ No newline at end of file + [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`, 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) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index fdd9a99..834f333 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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.