docs(http): add ADR-048 and websocket.md — WS carries native session, not gateway
Promote the WebSocket browser path from a section in http-server.md to a first-class spec (websocket.md) and commit the contract-pattern decision (ADR-048): a WS connection carries the native EventEnvelope call-protocol session, not the HTTP gateway shape. The gateway endpoints are HTTP-only; discovery on WS is via services/list/services/schema as ordinary call-protocol ops; subscriptions project as native call.responded events (no SSE). ADR-044 already decided WS as the v1 browser bidirectional path; ADR-048 clarifies the shape of what ADR-044 committed (§1 implies native session; the ADR makes it an explicit implementer-visible rule). The from_wss adapter (importing a remote node's ops over WS) is recorded as out-of-scope with a concrete reversal trigger so it is not re-derived later. Spec cleanup: http-server.md WS section collapsed to a stub pointer; websocket.md Why section references ADRs rather than re-arguing them; length-prefix decision made canonical (no prefix on WS — message boundary is the delimiter); default upgrade path pinned (/alknet/call) with HTTP/2 extended CONNECT noted; indexes (README, http/README, overview) updated.
This commit is contained in:
@@ -18,7 +18,7 @@ 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.
|
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.
|
||||||
|
|
||||||
**alknet-http specs drafted and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1` server + WebSocket browser path + `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 twelve ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface — **routing superseded by ADR-047**, non-routing clauses survive), [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 — **superseded by ADR-044**; its correction of the two-way-door-as-deferral anti-pattern stands, its specific decision is reversed by the scope deferral), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — **parked** per ADR-044; revives unchanged when WebTransport revives), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface; supersedes ADR-036's original `to_openapi` clause), [ADR-043](decisions/043-webtransport-bidirectional-alpn-substrate.md) (WebTransport as a bidirectional ALPN transport substrate — **parked** per ADR-044; §2/§3 transfer to WebSocket for v1), [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) (defer `h3`/WebTransport; browsers use WebSocket for the bidirectional call-protocol path; a scope decision per ADR-009 §"What this framework is NOT"; reversal trigger = a concrete ALPN-stream-proxy use case; states the "browser is not a peer" rationale — addressability vs. bidirectionality — that amends ADR-034 §4), and [ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) (`to_openapi` published-spec versioning — `info.version` semver tracks the gateway endpoint contract, not the operation set; resolves OQ-39), and [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) (assembly-layer custom HTTP routes on HttpAdapter — `extra_routes: Option<Router>` for deployment-specific endpoints like an OAI-compatible proxy; default surface unchanged, takes precedence on collision), and [ADR-047](decisions/047-remove-direct-call-http-surface.md) (remove the direct-call `POST /{service}/{op}` surface — the gateway `/call` is the sole invoke path; the simplified contract is the few-fixed-endpoints model, not a per-operation REST tree; ADR-036's non-routing clauses survive). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http` → `alknet-call` dependency edge). A consistency review pass corrected drift from the mid-spec pivot (the `to_openapi` gateway pattern landed in the prose but not in cross-references; the WebTransport specs inherited the OpenAPI/MCP direction assumption that doesn't hold for the call protocol) — ADR-036's `to_openapi` clause is now amended as superseded by ADR-042, ADR-034 §5's "deferral bucket" wording is corrected (the decision stands), and the http specs now name the one-directional HTTP projection vs. the bidirectional WebSocket (and, when revived, WebTransport) substrate. The specs are in draft; implementation has not started. Two open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040) and OQ-40 (reqwest client config — since resolved by the `ClientWithMiddleware` + middleware stack design). OQ-39 (`to_openapi` published-spec versioning) is resolved by ADR-045.
|
**alknet-http specs drafted and consistency-reviewed.** The alknet-http crate (HTTP interface — `h2`/`http/1.1` server + WebSocket browser path + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, websocket, http-adapters, http-mcp, webtransport) and thirteen ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping; direct-call surface — **routing superseded by ADR-047**, non-routing clauses survive), [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 — **superseded by ADR-044**; its correction of the two-way-door-as-deferral anti-pattern stands, its specific decision is reversed by the scope deferral), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — **parked** per ADR-044; revives unchanged when WebTransport revives), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface; supersedes ADR-036's original `to_openapi` clause), [ADR-043](decisions/043-webtransport-bidirectional-alpn-substrate.md) (WebTransport as a bidirectional ALPN transport substrate — **parked** per ADR-044; §2/§3 transfer to WebSocket for v1), [ADR-044](decisions/044-defer-webtransport-browsers-use-websocket.md) (defer `h3`/WebTransport; browsers use WebSocket for the bidirectional call-protocol path; a scope decision per ADR-009 §"What this framework is NOT"; reversal trigger = a concrete ALPN-stream-proxy use case; states the "browser is not a peer" rationale — addressability vs. bidirectionality — that amends ADR-034 §4), and [ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) (`to_openapi` published-spec versioning — `info.version` semver tracks the gateway endpoint contract, not the operation set; resolves OQ-39), and [ADR-046](decisions/046-assembly-layer-custom-http-routes.md) (assembly-layer custom HTTP routes on HttpAdapter — `extra_routes: Option<Router>` for deployment-specific endpoints like an OAI-compatible proxy; default surface unchanged, takes precedence on collision), and [ADR-047](decisions/047-remove-direct-call-http-surface.md) (remove the direct-call `POST /{service}/{op}` surface — the gateway `/call` is the sole invoke path; the simplified contract is the few-fixed-endpoints model, not a per-operation REST tree; ADR-036's non-routing clauses survive), and [ADR-048](decisions/048-websocket-native-session-not-gateway.md) (WebSocket carries the native `EventEnvelope` call-protocol session, not the HTTP gateway shape — the gateway endpoints are HTTP-only; discovery via `services/list`/`services/schema` as call-protocol ops; clarifies the WS-path shape ADR-044 committed). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http` → `alknet-call` dependency edge). A consistency review pass corrected drift from the mid-spec pivot (the `to_openapi` gateway pattern landed in the prose but not in cross-references; the WebTransport specs inherited the OpenAPI/MCP direction assumption that doesn't hold for the call protocol) — ADR-036's `to_openapi` clause is now amended as superseded by ADR-042, ADR-034 §5's "deferral bucket" wording is corrected (the decision stands), and the http specs now name the one-directional HTTP projection vs. the bidirectional WebSocket (and, when revived, WebTransport) substrate. The WebSocket path is promoted to its own spec ([websocket.md](crates/http/websocket.md)) with the native-session-vs-gateway distinction made explicit (ADR-048). The specs are in draft; implementation has not started. Two open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040) and OQ-40 (reqwest client config — since resolved by the `ClientWithMiddleware` + middleware stack design). OQ-39 (`to_openapi` published-spec versioning) is resolved by ADR-045.
|
||||||
|
|
||||||
**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; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), 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`.
|
**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; `h3`/WebTransport deferred per ADR-044, browser bidirectional path uses WebSocket), 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`.
|
||||||
|
|
||||||
@@ -39,7 +39,8 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
|||||||
| [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/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/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/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 + WebSocket browser path, axum over QUIC, Bearer auth, stealth, /healthz |
|
| [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz |
|
||||||
|
| [crates/http/websocket.md](crates/http/websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape); framing, dispatch, bidirectionality, connection-local overlay, browsers-are-not-peers, deferred `from_wss` |
|
||||||
| [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-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/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) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. |
|
| [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. |
|
||||||
@@ -100,6 +101,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
|||||||
| [045](decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Proposed |
|
| [045](decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Proposed |
|
||||||
| [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed |
|
| [046](decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | Proposed |
|
||||||
| [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed |
|
| [047](decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | Proposed |
|
||||||
|
| [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
|
|||||||
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
|
||||||
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
|
||||||
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
|
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
|
||||||
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044 and [http-server.md](../http/http-server.md) §"WebSocket browser path".
|
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation. **The `EventEnvelope` shape (`{ type, id, payload }`) was derived from the `@alkdev/pubsub` `EventEnvelope` (`/workspace/@alkdev/pubsub/src/types.ts`), which already has a working WebSocket client/server implementation (`event-target-websocket-client.ts` / `event-target-websocket-server.ts`) and a generalized "event target" abstraction. The call protocol refined the envelope with typed event names (`call.requested`, `call.responded`, etc.) and structured payloads; the delta is small and well-defined, making a browser (and Node) WebSocket client straightforward to derive from the pubsub prior art. See ADR-044, [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md), and [websocket.md](../http/websocket.md).
|
||||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
|
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
|
||||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
|
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
|
||||||
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.
|
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
|||||||
| Document | Status | Description |
|
| Document | Status | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
|
| [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` + WS upgrade), axum over QUIC, Bearer auth, stealth, `/healthz`, WebSocket browser path |
|
| [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1` + WS upgrade route), axum over QUIC, Bearer auth, stealth, `/healthz`; WS hands off to the native session spec |
|
||||||
|
| [websocket.md](websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape, ADR-048); framing, dispatch, bidirectionality, connection-local Layer 2 overlay, browsers-are-not-peers rationale, streaming (native `call.responded`, no SSE), deferred `from_wss` adapter |
|
||||||
| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point |
|
| [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 |
|
| [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion |
|
||||||
| [webtransport.md](webtransport.md) | deferred | `h3`/WebTransport handler — **deferred per ADR-044**; spec kept intact for revival |
|
| [webtransport.md](webtransport.md) | deferred | `h3`/WebTransport handler — **deferred per ADR-044**; spec kept intact for revival |
|
||||||
@@ -51,6 +52,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
|||||||
| [045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Published gateway doc carries `info.version` (semver) tracking the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
|
| [045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | to_openapi Gateway-Spec Versioning | Published gateway doc carries `info.version` (semver) tracking the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
|
||||||
| [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision |
|
| [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option<Router>` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision |
|
||||||
| [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive |
|
| [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive |
|
||||||
|
| [048](../../decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | WS is the native `EventEnvelope` session; the gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are HTTP-only and do not appear on WS; discovery via `services/list`/`services/schema` as call-protocol ops |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
@@ -107,16 +109,25 @@ protocol), and hosts the HTTP-backed call-protocol adapters
|
|||||||
arbitrary executable = RCE. Streamable HTTP is network-isolated,
|
arbitrary executable = RCE. Streamable HTTP is network-isolated,
|
||||||
auth-gatable, and runs under alknet's auth model. See
|
auth-gatable, and runs under alknet's auth model. See
|
||||||
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
|
[ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md).
|
||||||
6. **WebSocket is the browser bidirectional path.** A browser upgrades
|
6. **WebSocket is the browser bidirectional path, and it carries the
|
||||||
an HTTP/1.1 or HTTP/2 request to WebSocket and speaks the call
|
native call-protocol session, not the gateway shape.** A browser
|
||||||
|
upgrades an HTTP/1.1 or HTTP/2 request to WebSocket and speaks the call
|
||||||
protocol over binary WS messages — full-duplex, both sides can
|
protocol over binary WS messages — full-duplex, both sides can
|
||||||
initiate calls (the call protocol's native bidirectionality, ADR-012).
|
initiate calls (the call protocol's native bidirectionality, ADR-012).
|
||||||
HTTP/3 + WebTransport (`h3`) is deferred per
|
The `to_openapi` gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/
|
||||||
|
`/subscribe`, ADR-042/047) are the HTTP one-directional projection and
|
||||||
|
**do not appear on the WebSocket path** — WS is the call protocol's
|
||||||
|
own native session, with discovery via `services/list`/`services/schema`
|
||||||
|
as ordinary call-protocol ops (ADR-048). HTTP/3 + WebTransport (`h3`)
|
||||||
|
is deferred per
|
||||||
[ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md)
|
[ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md)
|
||||||
— a scope decision (the browser bidirectional path doesn't require
|
— a scope decision (the browser bidirectional path doesn't require
|
||||||
WebTransport's stream model; WebSocket suffices). The reversal
|
WebTransport's stream model; WebSocket suffices). The reversal trigger
|
||||||
trigger is a concrete ALPN-stream-proxy use case (a browser running
|
is a concrete ALPN-stream-proxy use case (a browser running a WASM
|
||||||
a WASM SSH/SFTP/git client).
|
SSH/SFTP/git client). See [websocket.md](websocket.md) for the full
|
||||||
|
spec, including the deferred `from_wss` adapter (out of scope — a
|
||||||
|
future `from_call`-aligned importer over WS, not needed for the v1
|
||||||
|
browser-client case).
|
||||||
7. **Browsers are not alknet peers.** A browser over WebSocket (or, when
|
7. **Browsers are not alknet peers.** A browser over WebSocket (or, when
|
||||||
it revives, WebTransport) authenticates by bearer token, gets no
|
it revives, WebTransport) authenticates by bearer token, gets no
|
||||||
`PeerId`, and its registered ops land in a connection-local Layer 2
|
`PeerId`, and its registered ops land in a connection-local Layer 2
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ last_updated: 2026-06-30
|
|||||||
# HTTP Server
|
# HTTP Server
|
||||||
|
|
||||||
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
|
The `HttpAdapter` — the `ProtocolHandler` for `h2` and `http/1.1` (and
|
||||||
WebSocket upgrade — see §"WebSocket browser path"). The `h3`/WebTransport
|
WebSocket upgrade — see [websocket.md](websocket.md)). The `h3`/WebTransport
|
||||||
path is deferred per [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
|
path is deferred per [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
|
||||||
the deferred spec is at [webtransport.md](webtransport.md). This document
|
the deferred spec is at [webtransport.md](webtransport.md). This document
|
||||||
covers how axum is run over a QUIC bidirectional stream, Bearer auth
|
covers how axum is run over a QUIC bidirectional stream, Bearer auth
|
||||||
resolution, the HTTP-to-call dispatch, the `/healthz` raw route, stealth
|
resolution, the HTTP-to-call dispatch, the `/healthz` raw route, stealth
|
||||||
decoy, and the WebSocket browser path.
|
decoy, and the WebSocket upgrade route (which hands off to the native
|
||||||
|
call-protocol session specified in [websocket.md](websocket.md)).
|
||||||
|
|
||||||
## What
|
## What
|
||||||
|
|
||||||
@@ -66,10 +67,10 @@ The endpoint's `HandlerRegistry` maps each ALPN byte string to the same
|
|||||||
adapter instance; `handle()` branches on `connection.remote_alpn()` to
|
adapter instance; `handle()` branches on `connection.remote_alpn()` to
|
||||||
pick the HTTP framing. For `http/1.1` and `h2`, the framing is hyper's
|
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. WebSocket upgrade
|
HTTP/1.1 or HTTP/2 over a QUIC bidirectional stream. WebSocket upgrade
|
||||||
(§"WebSocket browser path") layers on top of the same hyper connection
|
(see [websocket.md](websocket.md)) layers on top of the same hyper
|
||||||
driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that switches
|
connection driver — a WS upgrade is an HTTP/1.1 or HTTP/2 request that
|
||||||
protocols. The `h3` ALPN is deferred (ADR-044); the deferred handler
|
switches protocols. The `h3` ALPN is deferred (ADR-044); the deferred
|
||||||
design is at [webtransport.md](webtransport.md).
|
handler design is at [webtransport.md](webtransport.md).
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ The axum route handler:
|
|||||||
to descendants per ADR-016.
|
to descendants per ADR-016.
|
||||||
|
|
||||||
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
|
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
|
||||||
(§"WebSocket browser path" below), the subscription projects directly
|
([websocket.md](websocket.md)), the subscription projects directly
|
||||||
onto the WS connection — `call.responded` events as binary WS messages,
|
onto the WS connection — `call.responded` events as binary WS messages,
|
||||||
no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project
|
no SSE framing. WebTransport (`h3`, deferred per ADR-044) would project
|
||||||
onto WebTransport bidirectional streams; see
|
onto WebTransport bidirectional streams; see
|
||||||
@@ -222,7 +223,7 @@ SSE response — but even there, the *call* is client-initiated; only the
|
|||||||
|
|
||||||
This is a structural property of HTTP, not a design choice in this
|
This is a structural property of HTTP, not a design choice in this
|
||||||
crate. **WebSocket restores the bidirectional call model for browsers**
|
crate. **WebSocket restores the bidirectional call model for browsers**
|
||||||
(see §"WebSocket browser path" below): a WS connection is a long-lived
|
(see [websocket.md](websocket.md)): a WS connection is a long-lived
|
||||||
full-duplex channel over which either side can send `call.requested`
|
full-duplex channel over which either side can send `call.requested`
|
||||||
frames in either direction — the call protocol's native bidirectionality
|
frames in either direction — the call protocol's native bidirectionality
|
||||||
applies unchanged (ADR-012 — stream-agnostic correlation; a WS message
|
applies unchanged (ADR-012 — stream-agnostic correlation; a WS message
|
||||||
@@ -233,67 +234,27 @@ The HTTP/1.1 + HTTP/2 surface is the projection for clients that only
|
|||||||
speak HTTP; WebSocket is the surface for browser clients that speak the
|
speak HTTP; WebSocket is the surface for browser clients that speak the
|
||||||
call protocol in both directions.
|
call protocol in both directions.
|
||||||
|
|
||||||
### WebSocket browser path (ADR-044)
|
### WebSocket browser path (ADR-044, ADR-048)
|
||||||
|
|
||||||
A browser connecting to a hub upgrades an HTTP/1.1 or HTTP/2 request to
|
A browser (or any WS client) upgrades an HTTP/1.1 or HTTP/2 request to
|
||||||
WebSocket (RFC 6455). The resulting full-duplex WS connection carries
|
WebSocket (RFC 6455); the resulting full-duplex WS connection carries the
|
||||||
call-protocol `EventEnvelope` frames as binary WebSocket messages — one
|
call protocol's native `EventEnvelope` session over binary messages, and
|
||||||
envelope per message. The browser authenticates by bearer token on the
|
is the surface that **restores the call protocol's native bidirectionality
|
||||||
upgrade request (the HTTP `Authorization` header), resolved by the hub's
|
for browsers** (unlike the one-directional HTTP projection above). The WS
|
||||||
`IdentityProvider::resolve_from_token`, same as any HTTP request. The WS
|
path carries the **native session, not the HTTP gateway shape** (ADR-048):
|
||||||
connection is then a **bidirectional call-protocol session**:
|
the gateway endpoints are HTTP-only, discovery is via `services/list`/
|
||||||
|
`services/schema` as call-protocol ops, and subscriptions project as
|
||||||
|
native `call.responded` events (no SSE).
|
||||||
|
|
||||||
- The browser opens the WS connection to `/alknet/call` (or `/`).
|
The full WS handler specification — the upgrade route, framing, dispatch
|
||||||
- The handler hands the WS message stream to the call protocol's
|
handoff to the shared `Dispatcher`, bidirectionality, the connection-local
|
||||||
`Dispatcher` — the same dispatch loop the `CallAdapter` uses for
|
Layer 2 overlay, the "browsers are not alknet peers" rationale
|
||||||
`alknet/call` QUIC connections (ADR-012, stream-agnostic correlation).
|
(ADR-034 §4, amended by ADR-044 §5), the streaming projection, and the
|
||||||
- The browser writes `EventEnvelope` frames as binary WS messages; the
|
deferred `from_wss` adapter — is at [websocket.md](websocket.md).
|
||||||
handler reads them and dispatches via `OperationRegistry::invoke()`.
|
`h3`/WebTransport is deferred per ADR-044; the deferred handler design is
|
||||||
- Responses (`call.responded`, `call.error`, `call.completed`,
|
at [webtransport.md](webtransport.md). When WebTransport revives, the two
|
||||||
`call.aborted`) are written back as binary WS messages.
|
coexist: WS stays as the simpler call-protocol path; WebTransport adds the
|
||||||
|
ALPN-stream-proxy path (ADR-040). Neither replaces the other.
|
||||||
**Bidirectionality:** the WS call-protocol session inherits the call
|
|
||||||
protocol's native bidirectionality — both sides can initiate calls
|
|
||||||
(ADR-043 §2, transferred to WebSocket per ADR-044 §3). The browser calls
|
|
||||||
operations on the hub; the hub can call operations registered on the
|
|
||||||
browser's side, over the same session, using the same `PendingRequestMap`
|
|
||||||
and `EventEnvelope` framing as `alknet/call`. The browser case where the
|
|
||||||
client registers no operations of its own is the common case — the
|
|
||||||
server→client call direction is unused because the browser has nothing
|
|
||||||
to call. That is a use-case scoping, not an architectural limitation.
|
|
||||||
|
|
||||||
**No SSE translation.** A `Subscription` operation served over WebSocket
|
|
||||||
projects its `call.responded` stream directly as binary WS messages — no
|
|
||||||
SSE `data:` framing. `call.completed` closes the stream; `call.aborted`
|
|
||||||
closes it with an error frame. This is the native streaming projection
|
|
||||||
for the WS path; SSE (ADR-036) is the projection for `h2`/`http/1.1`
|
|
||||||
clients that don't upgrade to WebSocket.
|
|
||||||
|
|
||||||
**Browsers are not alknet peers.** A browser over WebSocket authenticates
|
|
||||||
by bearer token, gets no `PeerId`, does not enter `PeerCompositeEnv`, and
|
|
||||||
its registered ops (if any) land in a connection-local Layer 2 overlay —
|
|
||||||
the inbound mirror of ADR-034 §2. The rationale (addressability vs.
|
|
||||||
bidirectionality) is stated in ADR-044 §5 and amends ADR-034 §4 by
|
|
||||||
reference. In short: "peer" means an addressable node in the
|
|
||||||
call-protocol peer graph (stable `PeerId`, `PeerRef::Specific`-reachable,
|
|
||||||
identity stable across reconnects), not "any endpoint that exchanges
|
|
||||||
calls during a live session." A browser is the second thing but not the
|
|
||||||
first — it has no stable cryptographic identity of its own (it presents
|
|
||||||
a bearer token the hub issued; nothing to pin), it is ephemeral (close
|
|
||||||
the tab → connection dies → the connection-local overlay dies with it),
|
|
||||||
and it is not addressable from other nodes (another alknet node has no
|
|
||||||
way to reach "the browser currently connected to hub-A"; the hub holds
|
|
||||||
it as a live `CallConnection` handle, not a peer-graph entry). The
|
|
||||||
connection-local overlay is what gives the browser bidirectional-call
|
|
||||||
capability *without* peer-graph membership.
|
|
||||||
|
|
||||||
**What WebSocket does not provide (deferred to WebTransport, ADR-044):**
|
|
||||||
the ALPN-stream-proxy (ADR-040) — a browser running a WASM parser for
|
|
||||||
SSH/SFTP/git to reach a non-call ALPN — requires WebTransport's
|
|
||||||
multi-stream model and is the speculative use case whose deferral is
|
|
||||||
ADR-044's reversal trigger. WebSocket carries the call protocol from a
|
|
||||||
browser; it does not carry the non-call-ALPN substrate. A browser cannot
|
|
||||||
reach SSH/SFTP/git ALPNs in the v1 release. See ADR-044.
|
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
@@ -444,11 +405,14 @@ two-way door (add/remove freely). See
|
|||||||
Capabilities are used for outbound calls (`from_openapi`), never
|
Capabilities are used for outbound calls (`from_openapi`), never
|
||||||
serialized into HTTP response bodies.
|
serialized into HTTP response bodies.
|
||||||
- **`/healthz` is raw.** No auth, no call protocol. The one raw route.
|
- **`/healthz` is raw.** No auth, no call protocol. The one raw route.
|
||||||
- **WebSocket is the browser bidirectional path (ADR-044).** A browser
|
- **WebSocket is the browser bidirectional path (ADR-044, ADR-048).** A browser
|
||||||
upgrades an HTTP request to WS and speaks the call protocol over binary
|
upgrades an HTTP request to WS and speaks the call protocol over binary
|
||||||
messages. `h3`/WebTransport is deferred (ADR-044); the ALPN-stream-proxy
|
messages — the **native `EventEnvelope` session, not the gateway shape**
|
||||||
(ADR-040) is not available in v1. The `h3` ALPN and its feature gate are
|
(the gateway endpoints are HTTP-only; discovery via `services/list`/
|
||||||
not implemented in the initial release.
|
`services/schema` as call-protocol ops). `h3`/WebTransport is deferred
|
||||||
|
(ADR-044); the ALPN-stream-proxy (ADR-040) is not available in v1. The
|
||||||
|
`h3` ALPN and its feature gate are not implemented in the initial release.
|
||||||
|
Full WS handler spec: [websocket.md](websocket.md).
|
||||||
- **Custom routes are raw HTTP, not call-protocol operations
|
- **Custom routes are raw HTTP, not call-protocol operations
|
||||||
(ADR-046).** The assembly layer injects an `axum::Router` of extra
|
(ADR-046).** The assembly layer injects an `axum::Router` of extra
|
||||||
routes at `HttpAdapter` construction. They are not in the
|
routes at `HttpAdapter` construction. They are not in the
|
||||||
@@ -470,8 +434,8 @@ two-way door (add/remove freely). See
|
|||||||
| `/healthz` is a raw route | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | No auth, no call protocol |
|
| `/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 for non-gateway, non-custom, non-`/healthz` paths |
|
| Stealth decoy | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | HTTP handler on standard ALPNs serves decoy for non-gateway, non-custom, non-`/healthz` paths |
|
||||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) |
|
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source (settled) |
|
||||||
| WebSocket is the browser bidirectional path | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | Browsers upgrade to WS; `EventEnvelope` over binary messages; `h3`/WebTransport deferred |
|
| WebSocket is the browser bidirectional path | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md), [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) | Browsers upgrade to WS; `EventEnvelope` over binary messages; `h3`/WebTransport deferred. WS carries the native call-protocol session, not the gateway shape (gateway endpoints are HTTP-only). Full spec: [websocket.md](websocket.md) |
|
||||||
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) |
|
| Browsers are not alknet peers | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, connection-local overlay (addressability vs. bidirectionality) — full rationale in [websocket.md](websocket.md) |
|
||||||
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
|
| Error mapping (call codes → HTTP status) | [ADR-023](../../decisions/023-operation-error-schemas.md) | Protocol/operation codes distinct; `HTTP_<status>` prefix for imported |
|
||||||
| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision |
|
| Custom HTTP routes from the assembly layer | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | `extra_routes: Option<Router>` at construction; raw HTTP, not operations; default surface takes precedence on collision |
|
||||||
|
|
||||||
@@ -499,6 +463,12 @@ See [open-questions.md](../../open-questions.md) for full details.
|
|||||||
References the `@alkdev/pubsub` WebSocket prior art (the
|
References the `@alkdev/pubsub` WebSocket prior art (the
|
||||||
`EventEnvelope { type, id, payload }` client/server the call
|
`EventEnvelope { type, id, payload }` client/server the call
|
||||||
protocol's envelope was derived from).
|
protocol's envelope was derived from).
|
||||||
|
- [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md)
|
||||||
|
— WS carries the native `EventEnvelope` call-protocol session, not the
|
||||||
|
HTTP gateway shape; the gateway endpoints are HTTP-only.
|
||||||
|
- [websocket.md](websocket.md) — the full WS browser path spec (framing,
|
||||||
|
dispatch, bidirectionality, connection-local overlay, streaming
|
||||||
|
projection, the deferred `from_wss` adapter).
|
||||||
- [overview.md](overview.md) — crate overview, adapter location map
|
- [overview.md](overview.md) — crate overview, adapter location map
|
||||||
- [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler
|
- [webtransport.md](webtransport.md) — the deferred `h3` ALPN handler
|
||||||
(kept intact for revival)
|
(kept intact for revival)
|
||||||
|
|||||||
@@ -157,11 +157,15 @@ alknet-call (lean — no HTTP client, no HTTP server)
|
|||||||
└── CallClient (outbound connection opener)
|
└── CallClient (outbound connection opener)
|
||||||
|
|
||||||
alknet-http (owns HTTP server + HTTP client)
|
alknet-http (owns HTTP server + HTTP client)
|
||||||
├── HttpAdapter (axum server — inbound HTTP on h2/http1.1 + WS upgrade)
|
├── HttpAdapter (axum server — inbound HTTP on h2/http1.1 + WS upgrade route)
|
||||||
|
├── [WS upgrade → native session] (hands the WS message stream to the shared Dispatcher —
|
||||||
|
│ not an adapter; see websocket.md, ADR-048)
|
||||||
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
|
||||||
├── to_openapi (generate OpenAPI doc from local registry)
|
├── to_openapi (generate OpenAPI doc from local registry)
|
||||||
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
|
├── 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)
|
├── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
|
||||||
|
└── from_wss (out of scope) (future: import a remote alknet node's ops over WS —
|
||||||
|
from_call-aligned, same-protocol; see websocket.md §"Future")
|
||||||
```
|
```
|
||||||
|
|
||||||
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
|
||||||
@@ -223,10 +227,16 @@ verified against this invariant. See ADR-014 and
|
|||||||
## Architecture (component pointers)
|
## Architecture (component pointers)
|
||||||
|
|
||||||
- **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/
|
- **[http-server.md](http-server.md)** — the `HttpAdapter` for `h2`/
|
||||||
`http/1.1` (+ WebSocket upgrade): how axum is run over a QUIC
|
`http/1.1` (+ the WS upgrade route): how axum is run over a QUIC
|
||||||
bidirectional stream, Bearer auth resolution, the `/healthz` raw route,
|
bidirectional stream, Bearer auth resolution, the `/healthz` raw route,
|
||||||
stealth decoy, the HTTP-to-call dispatch (ADR-036), and the WebSocket
|
stealth decoy, the HTTP-to-call dispatch (ADR-036/042/047), and the
|
||||||
browser bidirectional path (ADR-044).
|
WS upgrade route (which hands off to the native call-protocol session).
|
||||||
|
- **[websocket.md](websocket.md)** — the WebSocket browser bidirectional
|
||||||
|
path: native `EventEnvelope` call-protocol session (not the gateway
|
||||||
|
shape, ADR-048), framing, dispatch via the shared `Dispatcher`,
|
||||||
|
bidirectionality, connection-local Layer 2 overlay, the
|
||||||
|
browsers-are-not-peers rationale, streaming (native `call.responded`,
|
||||||
|
no SSE), and the deferred `from_wss` adapter.
|
||||||
- **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse
|
- **[http-adapters.md](http-adapters.md)** — `from_openapi` (parse
|
||||||
OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi`
|
OpenAPI, build forwarding handlers with `reqwest`) and `to_openapi`
|
||||||
(generate an OpenAPI doc from the registry's `External` operations).
|
(generate an OpenAPI doc from the registry's `External` operations).
|
||||||
@@ -244,6 +254,7 @@ verified against this invariant. See ADR-014 and
|
|||||||
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | ~~Direct path mapping~~ — **routing superseded by ADR-047**; gateway `/call` is the sole invoke path; ADR-036's non-routing clauses survive (SSE, auth, `/healthz`, stealth, error mapping) |
|
| HTTP-to-call operation mapping | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | ~~Direct path mapping~~ — **routing superseded by ADR-047**; gateway `/call` is the sole invoke path; ADR-036's non-routing clauses survive (SSE, auth, `/healthz`, stealth, error mapping) |
|
||||||
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
|
| MCP stdio transport exclusion | [ADR-037](../../decisions/037-mcp-stdio-transport-exclusion.md) | Streamable HTTP only; stdio is not built (RCE vector) |
|
||||||
| Defer h3/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | `h3`/WebTransport deferred (scope, not hedging); browser bidirectional path uses WebSocket; ADR-038 superseded, ADR-040/043 parked |
|
| Defer h3/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | `h3`/WebTransport deferred (scope, not hedging); browser bidirectional path uses WebSocket; ADR-038 superseded, ADR-040/043 parked |
|
||||||
|
| WebSocket carries the native session, not the gateway shape | [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) | WS is the native `EventEnvelope` session; the gateway endpoints are HTTP-only; discovery via `services/list`/`services/schema` as call-protocol ops; subscriptions as native `call.responded` events (no SSE) |
|
||||||
| 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) |
|
| 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) |
|
||||||
| ~~HTTP/3 + WebTransport first-class~~ | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | **Superseded by ADR-044** (anti-pattern correction stands; specific decision reversed) |
|
| ~~HTTP/3 + WebTransport first-class~~ | [ADR-038](../../decisions/038-http3-and-webtransport-as-first-class.md) | **Superseded by ADR-044** (anti-pattern correction stands; specific decision reversed) |
|
||||||
| ~~WebTransport ALPN-stream-proxy~~ | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | **Parked** per ADR-044; revives unchanged when WebTransport revives |
|
| ~~WebTransport ALPN-stream-proxy~~ | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | **Parked** per ADR-044; revives unchanged when WebTransport revives |
|
||||||
|
|||||||
486
docs/architecture/crates/http/websocket.md
Normal file
486
docs/architecture/crates/http/websocket.md
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
---
|
||||||
|
status: draft
|
||||||
|
last_updated: 2026-06-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# WebSocket — the Browser Bidirectional Path
|
||||||
|
|
||||||
|
WebSocket is the v1 browser bidirectional path to the call protocol.
|
||||||
|
`h3`/WebTransport is deferred per
|
||||||
|
[ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
|
||||||
|
the deferred handler design is at
|
||||||
|
[webtransport.md](webtransport.md). This document specifies the WebSocket
|
||||||
|
upgrade handler on `HttpAdapter`, the framing, the dispatch path, the
|
||||||
|
identity model, the streaming projection, and the explicit relationship to
|
||||||
|
the HTTP gateway surface.
|
||||||
|
|
||||||
|
A WebSocket connection is a **native `EventEnvelope` call-protocol session**,
|
||||||
|
not the HTTP gateway shape — the gateway endpoints (`/search`, `/schema`,
|
||||||
|
`/call`, `/batch`, `/subscribe`, ADR-042/047) are the HTTP one-directional
|
||||||
|
projection and **do not appear on the WebSocket path**. See
|
||||||
|
[ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) for
|
||||||
|
the decision and its rationale.
|
||||||
|
|
||||||
|
## What
|
||||||
|
|
||||||
|
The WebSocket path is an axum WS upgrade handler on the same `HttpAdapter`
|
||||||
|
that serves `h2`/`http/1.1` (see [http-server.md](http-server.md)). A browser
|
||||||
|
(or any WS client — Node, a native app with a WS library) opens an HTTP/1.1
|
||||||
|
or HTTP/2 request to the upgrade path, authenticates by bearer token on the
|
||||||
|
upgrade request, and the resulting full-duplex WS connection carries
|
||||||
|
call-protocol `EventEnvelope` frames as binary WebSocket messages — one
|
||||||
|
envelope per message. The WS message stream is handed to the call protocol's
|
||||||
|
shared `Dispatcher`, which runs the same dispatch loop the `CallAdapter` uses
|
||||||
|
for `alknet/call` QUIC connections (ADR-012, stream-agnostic correlation).
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
WebSocket is the HTTP-family transport that restores the call protocol's
|
||||||
|
native bidirectionality for browsers — HTTP/1.1 + HTTP/2 are
|
||||||
|
request/response (a one-directional projection of the call protocol),
|
||||||
|
while WS is a full-duplex, long-lived, framed-message channel over which
|
||||||
|
the call protocol's native `EventEnvelope` session runs unchanged. The
|
||||||
|
decision to use WebSocket for the browser bidirectional path (deferring
|
||||||
|
`h3`/WebTransport) is [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md);
|
||||||
|
the decision that the WS path carries the native session rather than the
|
||||||
|
HTTP gateway shape is [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md).
|
||||||
|
Both decisions' rationale is in those ADRs; this spec covers what the WS
|
||||||
|
path *is* and how an implementer builds it.
|
||||||
|
|
||||||
|
### Prior art: `@alkdev/pubsub`
|
||||||
|
|
||||||
|
The browser/Node WS client is mostly already written. The
|
||||||
|
`@alkdev/pubsub` package (`/workspace/@alkdev/pubsub/`) has a working
|
||||||
|
WebSocket client (`src/event-target-websocket-client.ts`) and server
|
||||||
|
(`src/event-target-websocket-server.ts`) built on an
|
||||||
|
`EventEnvelope { type, id, payload }` shape — the envelope the alknet call
|
||||||
|
protocol's `EventEnvelope` was derived from (refined with typed event
|
||||||
|
names `call.requested`/`call.responded`/etc. and structured payloads).
|
||||||
|
The sibling `@alkdev/operations` package (`/workspace/@alkdev/operations/`)
|
||||||
|
shares the lineage, with one mechanical delta: `path.do.op` (dot-separated)
|
||||||
|
vs alknet's `path/to/op` (slash-separated). Syncing the pubsub/operations
|
||||||
|
WS client to the alknet envelope is a small adjustment (envelope shape,
|
||||||
|
event-name typing, path separator), not a from-scratch build. See
|
||||||
|
[call-protocol.md](../call/call-protocol.md) §"Transport agnosticism" and
|
||||||
|
ADR-044 §"Concrete prior art".
|
||||||
|
|
||||||
|
### Illustrative deployment: `api.alk.dev` hub-spokes-browser
|
||||||
|
|
||||||
|
A concrete topology this path serves (the early-stage deployment motivating
|
||||||
|
the spec promotion): `api.alk.dev` runs as a hub. The vast majority of
|
||||||
|
spokes are Rust processes using `CallClient` to connect to the hub over QUIC
|
||||||
|
on ALPN `alknet/call` — e.g., a git runner, container services — and are
|
||||||
|
**peers** (stable `PeerId`, fingerprint-pinned, in `PeerCompositeEnv`,
|
||||||
|
addressable via `PeerRef::Specific` per ADR-029). A browser UI for the
|
||||||
|
early stages (while a desktop app is being fleshed out) connects via
|
||||||
|
WebSocket and is a **bidirectional call target during a live session, not
|
||||||
|
a peer-graph member** (bearer token, no `PeerId`, connection-local Layer 2
|
||||||
|
overlay, dies when the tab closes). The browser UI calls `services/list`
|
||||||
|
to populate its view with only the ops its bearer-token identity is
|
||||||
|
authorized to call, calls `services/schema` for shapes, and invokes via
|
||||||
|
`call.requested` — per-privilege filtering comes free from the call
|
||||||
|
protocol's `AccessControl::check(identity)`-filtered `services/list`. The
|
||||||
|
browser operator UI sees only what its privs allow; the Rust spokes are
|
||||||
|
full-addressable peers. This is the canonical ADR-044 §5 scenario.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### The WS upgrade handler
|
||||||
|
|
||||||
|
The WS upgrade is an HTTP/1.1 or HTTP/2 request handled by an axum route on
|
||||||
|
`HttpAdapter`'s router. The handler:
|
||||||
|
|
||||||
|
1. Receives the HTTP upgrade request (axum's `WebSocketUpgrade` extractor).
|
||||||
|
2. Resolves the caller's identity from the `Authorization: Bearer` header
|
||||||
|
via `identity_provider.resolve_from_token(&AuthToken { raw:
|
||||||
|
token_bytes })` (the `AuthToken` type is from
|
||||||
|
[../core/auth.md](../core/auth.md) — a wrapper around the raw bearer
|
||||||
|
token bytes)
|
||||||
|
— the same auth path as any HTTP request
|
||||||
|
([http-server.md](http-server.md) §"Auth"). The upgrade is rejected
|
||||||
|
(`401`) if no token is present; insufficient scopes for any op the
|
||||||
|
browser later calls surface as `403`/`FORBIDDEN` at call time, not at
|
||||||
|
upgrade time (the upgrade doesn't know which ops the browser will call).
|
||||||
|
3. Upgrades to WebSocket (axum's `WebSocketUpgrade::on_upgrade`), producing
|
||||||
|
a full-duplex `WebSocket` stream.
|
||||||
|
4. Wraps the `WebSocket` stream as a `BiStream`-satisfying transport — a WS
|
||||||
|
binary message in either direction is one `EventEnvelope` frame (see
|
||||||
|
§"Framing" below for the length-prefix decision).
|
||||||
|
5. Constructs a `Dispatcher` (the shared dispatch loop,
|
||||||
|
[../call/client-and-adapters.md](../call/client-and-adapters.md)
|
||||||
|
§"Shared Dispatcher") with the `Arc<OperationRegistry>` and
|
||||||
|
`Arc<dyn IdentityProvider>` the `HttpAdapter` holds, plus a
|
||||||
|
connection-local Layer 2 overlay for any ops the browser registers (see
|
||||||
|
§"Bidirectionality" below).
|
||||||
|
6. Spawns the dispatch task (`Dispatcher::run_loop`) on a tokio task; the
|
||||||
|
WS connection is live until either side closes it or the browser drops
|
||||||
|
the handle (closes the tab).
|
||||||
|
|
||||||
|
The upgrade path is a single axum route. The **default upgrade path is
|
||||||
|
`/alknet/call`** (the deployment may override it via the `extra_routes`
|
||||||
|
mechanism of ADR-046, but a deployment that passes no custom routes gets
|
||||||
|
`/alknet/call`). The path must not collide with the reserved
|
||||||
|
gateway/`/healthz`/`/openapi.json`/MCP/custom-route paths per ADR-046's
|
||||||
|
collision rule; `/alknet/call` namespaces away from the reserved set
|
||||||
|
naturally. The upgrade runs over HTTP/1.1 (the standard `Upgrade: websocket`
|
||||||
|
header, RFC 6455) or HTTP/2 (the extended CONNECT protocol, RFC 8441);
|
||||||
|
axum/hyper supports both, and the handler does not branch on which — the
|
||||||
|
WS frame stream is the same once the upgrade completes.
|
||||||
|
|
||||||
|
### Framing: `EventEnvelope` over binary WS messages
|
||||||
|
|
||||||
|
Every message on the WS connection is a binary WebSocket message containing
|
||||||
|
one `EventEnvelope`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct EventEnvelope {
|
||||||
|
pub r#type: String, // "call.requested" | "call.responded" | "call.completed" | "call.aborted" | "call.error"
|
||||||
|
pub id: String, // Correlation key (request ID, subscription ID)
|
||||||
|
pub payload: Value, // serde_json::Value — schema depends on event type
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the call protocol's wire format verbatim (see
|
||||||
|
[../call/call-protocol.md](../call/call-protocol.md) §"Wire Format:
|
||||||
|
EventEnvelope"). The `@alkdev/pubsub` envelope (`{ type, id, payload }`) is
|
||||||
|
the shape the alknet `EventEnvelope` was derived from; the delta is typed
|
||||||
|
event names (`call.requested` etc.) and structured payloads, both of which
|
||||||
|
are already in the alknet envelope. The five event types
|
||||||
|
(`call.requested`, `call.responded`, `call.completed`, `call.aborted`,
|
||||||
|
`call.error`) carry request/response and subscription semantics exactly as
|
||||||
|
over QUIC — see [../call/call-protocol.md](../call/call-protocol.md)
|
||||||
|
§"Event Types" for the full table.
|
||||||
|
|
||||||
|
**Length-prefix decision.** The QUIC path frames `EventEnvelope` as a
|
||||||
|
4-byte big-endian length prefix + UTF-8 JSON body (see
|
||||||
|
[../call/call-protocol.md](../call/call-protocol.md) §"Wire Format"),
|
||||||
|
because a QUIC bidirectional stream is an unbounded byte stream that needs
|
||||||
|
an explicit delimiter. A WebSocket binary message is already
|
||||||
|
length-delimited by the WS frame boundary — the receiver gets one complete
|
||||||
|
message per read, no partial reads across message boundaries (ADR-044
|
||||||
|
Assumption 1, verified by the `@alkdev/pubsub` prior art). **The WS path
|
||||||
|
therefore carries no length prefix**: one `EventEnvelope` JSON object = one
|
||||||
|
binary WS message, and the WS message boundary is the delimiter. The
|
||||||
|
implementation must not prepend the QUIC length prefix on outbound WS
|
||||||
|
messages or expect it on inbound ones — the two framings are deliberately
|
||||||
|
different, matching each transport's native boundary semantics. (The
|
||||||
|
`FrameFramedReader`/`FrameFramedWriter` types the QUIC dispatch loop uses
|
||||||
|
are replaced on the WS path by direct JSON serde over the WS message type;
|
||||||
|
the `Dispatcher` itself is transport-agnostic and consumes `EventEnvelope`
|
||||||
|
values, not raw bytes — see [../call/client-and-adapters.md](../call/client-and-adapters.md)
|
||||||
|
§"Shared Dispatcher".)
|
||||||
|
|
||||||
|
Binary payloads within `EventEnvelope.payload` follow the same base64-as-
|
||||||
|
JSON-string convention the QUIC path uses
|
||||||
|
([../call/call-protocol.md](../call/call-protocol.md) §"Wire Format") —
|
||||||
|
the envelope carries `serde_json::Value` and does not interpret binary
|
||||||
|
fields; that's a handler-level concern, transport-agnostic.
|
||||||
|
|
||||||
|
Text WS messages are not used; all call-protocol frames are binary. A client
|
||||||
|
that sends a text message gets a protocol-level close (the WS handler
|
||||||
|
validates message type).
|
||||||
|
|
||||||
|
### Dispatch: the shared `Dispatcher`, unchanged
|
||||||
|
|
||||||
|
The WS message stream is handed to the `Dispatcher` — the same dispatch loop
|
||||||
|
the `CallAdapter` uses for `alknet/call` QUIC connections (ADR-017 §1; see
|
||||||
|
[../call/client-and-adapters.md](../call/client-and-adapters.md)
|
||||||
|
§"Shared Dispatcher"). The dispatch half is one implementation; the
|
||||||
|
connection-establishment half differs (WS upgrade handler vs QUIC
|
||||||
|
accept/dial), but after establishment the `Dispatcher` runs identically:
|
||||||
|
|
||||||
|
- Reads `EventEnvelope` frames from the WS message stream.
|
||||||
|
- For `call.requested`: resolves the peer's identity (the bearer-token
|
||||||
|
identity resolved at upgrade time, stored on the connection),
|
||||||
|
runs `AccessControl::check(identity)` against the op's `AccessControl`,
|
||||||
|
dispatches via `OperationRegistry::invoke()` if allowed, returns
|
||||||
|
`FORBIDDEN` (→ `call.error`) before the handler runs if not.
|
||||||
|
- For `call.responded`/`call.completed`/`call.aborted`: correlates by `id`
|
||||||
|
via `PendingRequestMap` (keyed by request ID, not by transport — ADR-012).
|
||||||
|
- Writes response `EventEnvelope` frames back as binary WS messages.
|
||||||
|
|
||||||
|
Peer authorization flows through the existing `AccessControl::check` against
|
||||||
|
the resolved identity — no `RemoteFilter`, no `remote_safe` gate (retired by
|
||||||
|
ADR-029 §3). An op with `AccessControl::default()` is callable by any
|
||||||
|
authenticated browser; an op with `required_scopes` is callable only by
|
||||||
|
browsers whose `Identity.scopes` satisfy them; an op with
|
||||||
|
`Visibility::Internal` is never callable from the wire (`NOT_FOUND` before
|
||||||
|
ACL). See [../call/client-and-adapters.md](../call/client-and-adapters.md)
|
||||||
|
§"CallClient" for the full mapping of the three `remote_safe` cases to
|
||||||
|
`AccessControl`/`Visibility`.
|
||||||
|
|
||||||
|
### Bidirectionality (ADR-043 §2 transferred to WebSocket per ADR-044 §3)
|
||||||
|
|
||||||
|
The WS call-protocol session inherits the call protocol's native
|
||||||
|
bidirectionality: both sides can send `call.requested` frames. The browser
|
||||||
|
calls operations on the hub; the hub can call operations registered on the
|
||||||
|
browser's side, over the same session, using the same `PendingRequestMap`
|
||||||
|
and `EventEnvelope` framing as `alknet/call`.
|
||||||
|
|
||||||
|
The browser case where the client registers no operations of its own is the
|
||||||
|
common case — the server→client call direction is unused because the
|
||||||
|
browser has nothing to call. That is a use-case scoping, not an architectural
|
||||||
|
limitation. A browser that *does* expose ops (e.g., a UI that registers a
|
||||||
|
`ui/dragged` op the hub can call to push live updates) registers them in the
|
||||||
|
connection-local Layer 2 overlay (see §"Connection-local overlay" below),
|
||||||
|
and the hub reaches them through the live `CallConnection` handle — not
|
||||||
|
through `PeerRef::Specific` (the browser is not a peer; see §"Browsers are
|
||||||
|
not alknet peers").
|
||||||
|
|
||||||
|
### Connection-local overlay (ADR-043 §3 transferred; ADR-024)
|
||||||
|
|
||||||
|
A browser over WebSocket has no `PeerId` on the hub's side. Any ops the
|
||||||
|
browser registers land in a **connection-local Layer 2 overlay**
|
||||||
|
(ADR-024) — a per-`CallConnection` overlay that dies when the connection
|
||||||
|
drops. This is the same mechanism ADR-034 §2 describes for the inbound
|
||||||
|
browser case: the browser is a bidirectional call target during a live
|
||||||
|
session, not a peer-graph member, and the connection-local overlay is what
|
||||||
|
gives it bidirectional-call capability *without* peer-graph membership.
|
||||||
|
|
||||||
|
When the WS connection closes (browser closes the tab, network drops), the
|
||||||
|
overlay and all its registered ops are dropped — no explicit deregistration
|
||||||
|
needed. A `PeerRef::Specific("browser-X")` from another node would route to
|
||||||
|
nothing, because there is no `PeerEntry` for the browser (see §"Browsers
|
||||||
|
are not alknet peers" below for why).
|
||||||
|
|
||||||
|
### Streaming: native `call.responded` events, no SSE
|
||||||
|
|
||||||
|
A `Subscription` operation invoked over WS streams `call.responded` events
|
||||||
|
as binary WS messages directly — **no SSE `data:` framing**. SSE is the
|
||||||
|
`h2`/`http/1.1` streaming projection (ADR-036 §Streaming, applied at the
|
||||||
|
gateway's `/subscribe` endpoint per ADR-042 §2); on WS it is unnecessary
|
||||||
|
because WS is already a framed full-duplex channel. The browser receives
|
||||||
|
`call.responded` events one per WS binary message, with the same `id`
|
||||||
|
correlating them to the original `call.requested`; `call.completed` closes
|
||||||
|
the subscription (no more events); `call.aborted` closes it with an error
|
||||||
|
frame. This mirrors how subscriptions work on the QUIC path — see
|
||||||
|
[../call/call-protocol.md](../call/call-protocol.md) §"Streaming subscribe
|
||||||
|
example".
|
||||||
|
|
||||||
|
On WS client disconnect (the browser closes the tab mid-subscription),
|
||||||
|
the WS handler detects the stream close and sends `call.aborted` for the
|
||||||
|
in-flight subscription, which cascades to descendants per ADR-016.
|
||||||
|
|
||||||
|
### Browsers are not alknet peers (ADR-034 §4, amended by ADR-044 §5)
|
||||||
|
|
||||||
|
A browser over WebSocket authenticates by bearer token, gets no `PeerId`,
|
||||||
|
does not enter `PeerCompositeEnv`, and its registered ops (if any) land in
|
||||||
|
the connection-local Layer 2 overlay (above). The rationale, stated in
|
||||||
|
ADR-044 §5 and amending ADR-034 §4 by reference, is a load-bearing
|
||||||
|
distinction:
|
||||||
|
|
||||||
|
**"Peer" in alknet means an addressable node in the call-protocol peer
|
||||||
|
graph** — a stable `PeerId`, reachable via `PeerRef::Specific`, whose ops
|
||||||
|
land in `PeerCompositeEnv`, whose identity is stable across reconnects. It
|
||||||
|
does *not* mean "any endpoint that exchanges calls during a live session."
|
||||||
|
A browser is the second thing but not the first, on three concrete grounds:
|
||||||
|
|
||||||
|
1. **No stable cryptographic identity of its own.** A `PeerEntry` is anchored
|
||||||
|
to fingerprints (Ed25519, X.509) that *the peer* presents and the local
|
||||||
|
node pins. A browser presents a bearer token the *hub* issued; the
|
||||||
|
"identity" is the hub's bookkeeping for that token, not something the
|
||||||
|
browser owns or that could be pinned by another node. There is nothing
|
||||||
|
to put in `PeerEntry.fingerprints`.
|
||||||
|
2. **Ephemeral.** Close the tab → connection dies → the connection-local
|
||||||
|
Layer 2 overlay dies with it. A `PeerEntry` keyed to a browser would be a
|
||||||
|
permanently-dead entry within seconds. `PeerRef::Specific("browser-X")`
|
||||||
|
from another node would route to nothing.
|
||||||
|
3. **Not addressable from other nodes.** `PeerRef::Specific` resolves
|
||||||
|
through `PeerEntry` → `PeerId`. Another alknet node has no way to reach
|
||||||
|
"the browser currently connected to hub-A"; the hub holds that
|
||||||
|
connection as a live `CallConnection` handle, not as a peer-graph entry.
|
||||||
|
The connection-local overlay is precisely the mechanism that gives the
|
||||||
|
browser bidirectional-call capability *without* peer-graph membership.
|
||||||
|
|
||||||
|
This is the explicit closure of the "browser as peer" path, on both the
|
||||||
|
inbound (this section) and outbound (ADR-034 §2) sides. The browser is a
|
||||||
|
**bidirectional call target during a live session**, not a **peer-graph
|
||||||
|
member**. The connection-local Layer 2 overlay (ADR-024, ADR-043 §3) is what
|
||||||
|
makes the former possible without requiring the latter. This rationale
|
||||||
|
applies transport-agnostically — to WebSocket, to WebTransport when it
|
||||||
|
revives, and to any future browser transport.
|
||||||
|
|
||||||
|
### Auth: bearer token on the upgrade request
|
||||||
|
|
||||||
|
Inbound WS auth is `Authorization: Bearer <token>` on the HTTP upgrade
|
||||||
|
request, resolved via `IdentityProvider::resolve_from_token()` — the same
|
||||||
|
path as any HTTP request ([http-server.md](http-server.md) §"Auth").
|
||||||
|
Bearer-only is the auth mechanism; other HTTP auth schemes are not
|
||||||
|
implemented for the WS upgrade (a deployment needing a different scheme
|
||||||
|
adds it as axum middleware on the upgrade route, two-way door). The
|
||||||
|
resolved identity is stored on the `Connection` for observability
|
||||||
|
(`connection.set_identity(identity)`), same as the HTTP handler.
|
||||||
|
|
||||||
|
The bearer token's `Identity` is what `AccessControl::check` runs against
|
||||||
|
when the browser calls an op via `call.requested` — the browser's
|
||||||
|
privileges are the token's privileges. This is the mechanism that gives the
|
||||||
|
browser per-privilege filtering for free: `services/list` is
|
||||||
|
`AccessControl::check(identity)`-filtered, so the browser's discovery
|
||||||
|
calls return only the ops its token is authorized to call. The
|
||||||
|
`api.alk.dev` operator UI sees only operator-authorized ops; a less-
|
||||||
|
privileged browser session sees a subset.
|
||||||
|
|
||||||
|
### What WebSocket does not provide (deferred to WebTransport, ADR-044)
|
||||||
|
|
||||||
|
WebSocket carries the call protocol from a browser; it does not carry the
|
||||||
|
non-call-ALPN substrate. The ALPN-stream-proxy (ADR-040) — a browser running
|
||||||
|
a WASM parser for SSH/SFTP/git to reach a non-call ALPN — requires
|
||||||
|
WebTransport's multi-stream model and is the speculative use case whose
|
||||||
|
deferral is ADR-044's reversal trigger. A browser cannot reach
|
||||||
|
SSH/SFTP/git ALPNs over WS in the v1 release; it can reach the call
|
||||||
|
protocol, and through the call protocol any ops the hub exposes. When
|
||||||
|
WebTransport revives, the two coexist: WSS stays as the simpler
|
||||||
|
call-protocol path; WebTransport adds the ALPN-stream-proxy path. Neither
|
||||||
|
replaces the other. See [webtransport.md](webtransport.md) for the deferred
|
||||||
|
design.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **The WS path is the native `EventEnvelope` session, not the gateway
|
||||||
|
shape (ADR-048).** The `to_openapi` gateway endpoints
|
||||||
|
(`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are the HTTP
|
||||||
|
one-directional projection and do not appear on WS. Discovery is via
|
||||||
|
`services/list` and `services/schema` as ordinary call-protocol ops,
|
||||||
|
not WS-specific endpoints. Subscriptions project as native
|
||||||
|
`call.responded` events, not SSE.
|
||||||
|
- **Bearer-only auth on the upgrade request.** `Authorization: Bearer` →
|
||||||
|
`resolve_from_token`. The resolved identity drives `AccessControl::check`
|
||||||
|
on every `call.requested` the browser sends; per-privilege filtering is
|
||||||
|
free via `services/list`'s existing `AccessControl` filtering.
|
||||||
|
- **Browsers are not alknet peers (ADR-034 §4, amended by ADR-044 §5).**
|
||||||
|
Bearer token, no `PeerId`, no `PeerCompositeEnv` entry, connection-local
|
||||||
|
Layer 2 overlay for any browser-registered ops. "Peer" means addressable
|
||||||
|
peer-graph node, not "any endpoint that exchanges calls during a live
|
||||||
|
session."
|
||||||
|
- **`EventEnvelope` frames are binary WS messages; one envelope per message,
|
||||||
|
no length prefix (ADR-044 Assumption 1).** The WS message boundary is the
|
||||||
|
delimiter — the QUIC path's 4-byte length prefix is not carried on the WS
|
||||||
|
path (a WebSocket binary message is already length-delimited by the WS
|
||||||
|
frame boundary). Text messages are rejected. The property is verified by
|
||||||
|
the `@alkdev/pubsub` prior art.
|
||||||
|
- **WebSocket is the v1 browser bidirectional path; `h3`/WebTransport is
|
||||||
|
deferred (ADR-044).** The ALPN-stream-proxy (ADR-040) is not available
|
||||||
|
in v1. The `h3` ALPN and its feature gate are not implemented in the
|
||||||
|
initial release; the WS path uses native axum WS support, no new
|
||||||
|
dependency.
|
||||||
|
- **WS upgrade over HTTP/1.1 (`Upgrade: websocket`, RFC 6455) or HTTP/2
|
||||||
|
(extended CONNECT, RFC 8441) is supported by the axum/hyper stack
|
||||||
|
natively (ADR-044 Assumption 2).** `axum::extract::ws` provides the
|
||||||
|
upgrade handler; the underlying connection is the same hyper HTTP
|
||||||
|
connection the `h2`/`http/1.1` handler already drives. The handler does
|
||||||
|
not branch on which HTTP version upgraded — the WS frame stream is the
|
||||||
|
same once the upgrade completes. No new framing library is needed.
|
||||||
|
- **The shared `Dispatcher` runs over the WS message stream unchanged
|
||||||
|
(ADR-012).** A WS message stream is another `BiStream`-satisfying
|
||||||
|
transport; the `Dispatcher` and `PendingRequestMap` are transport-
|
||||||
|
agnostic. Only the connection-establishment half differs (WS upgrade
|
||||||
|
vs QUIC accept/dial).
|
||||||
|
- **The default WS upgrade path is `/alknet/call`; it must not collide
|
||||||
|
with reserved paths (ADR-046).** The gateway endpoints, `/healthz`,
|
||||||
|
`/openapi.json`, the MCP route, and any custom routes take precedence;
|
||||||
|
`/alknet/call` namespaces away from the reserved set naturally. A
|
||||||
|
deployment may override the path via the `extra_routes` mechanism
|
||||||
|
(ADR-046).
|
||||||
|
|
||||||
|
## Future: `from_wss` adapter (out of scope, named for discoverability)
|
||||||
|
|
||||||
|
A `from_wss` adapter — importing a remote alknet node's operations over a
|
||||||
|
WebSocket connection, mirroring `from_call`'s pattern with WS as the
|
||||||
|
transport — is **out of scope for the current `alknet-http` work** and is
|
||||||
|
recorded here so it is not re-derived later. It is architecturally closer
|
||||||
|
to `from_call` than to `from_openapi`: `from_openapi` imports a
|
||||||
|
*foreign-protocol* surface (OpenAPI) and translates; `from_call` imports a
|
||||||
|
*same-protocol* endpoint over a different transport (QUIC) with no
|
||||||
|
translation. `from_wss` is the latter pattern with WS as the transport
|
||||||
|
instead of QUIC — open a WS connection, run `services/list` +
|
||||||
|
`services/schema` over it, register forwarding handlers that forward
|
||||||
|
`call.requested` over the WS connection. Because the `Dispatcher` is
|
||||||
|
stream-agnostic (ADR-012), it is `from_call` with a different
|
||||||
|
`BiStream`-satisfying transport.
|
||||||
|
|
||||||
|
Why it is out of scope now: it is a genuinely separate use case from the
|
||||||
|
browser-client case (it is "import a remote alknet node's ops over WSS,"
|
||||||
|
not "be a browser client"), and the consumers are not yet concrete enough
|
||||||
|
to commit the adapter's exact shape. The decision is made when a concrete
|
||||||
|
consumer arrives — a node that wants to import another node's ops but can
|
||||||
|
only reach it over WS (e.g., a browser-mediated relay, a restrictive-
|
||||||
|
network deployment). It is not needed for the v1 `api.alk.dev` topology
|
||||||
|
(Rust spokes use `from_call` over QUIC; the browser is a client, not an
|
||||||
|
importer). When revived, `from_wss` implements the `OperationAdapter` trait
|
||||||
|
(ADR-017 §5) and lives in `alknet-http` alongside `from_openapi`/`from_mcp`,
|
||||||
|
reusing the `Dispatcher` and the WS framing specified in this document.
|
||||||
|
|
||||||
|
This is a genuine scope decision (per ADR-009 §"What this framework is
|
||||||
|
NOT" — a decision that "genuinely doesn't need to be made yet because the
|
||||||
|
use case isn't concrete"), not a two-way-door deferral. The reversal
|
||||||
|
trigger is a concrete deployment needing to import a remote alknet node's
|
||||||
|
ops over WSS.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | ADR | Summary |
|
||||||
|
|----------|-----|---------|
|
||||||
|
| Defer `h3`/WebTransport; browsers use WebSocket | [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md) | WS is the v1 browser bidirectional path; `h3`/WebTransport deferred (scope); reversal trigger = concrete ALPN-stream-proxy use case |
|
||||||
|
| WS carries the native `EventEnvelope` session, not the gateway shape | [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md) | The gateway endpoints are HTTP-only; WS is the call protocol's native session; discovery via `services/list`/`services/schema` as call-protocol ops; subscriptions as native `call.responded` events, no SSE |
|
||||||
|
| Call protocol stream model (stream-agnostic correlation) | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | A WS message stream is another `BiStream`-satisfying transport; the `Dispatcher` and `PendingRequestMap` run unchanged |
|
||||||
|
| Call protocol client and adapter contract (`Dispatcher` shared) | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) §1 | The WS handler constructs a `Dispatcher` and calls `run_loop`, same as `CallAdapter`/`CallClient` — the dispatch half is one implementation |
|
||||||
|
| Operation registry layering (Layer 2 connection-local overlay) | [ADR-024](../../decisions/024-operation-registry-layering.md) | Browser-registered ops (if any) land in a connection-local overlay that dies with the WS connection |
|
||||||
|
| Bidirectionality transferred from WebTransport to WebSocket | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) §2 (parked; §2/§3 transfer per ADR-044 §3) | Both sides can `call.requested`; the browser case where the client registers no ops is a use-case scoping, not an architectural limitation |
|
||||||
|
| No-`PeerId` connection-local overlay transferred from WebTransport | [ADR-043](../../decisions/043-webtransport-bidirectional-alpn-substrate.md) §3 (parked; transfers per ADR-044 §3) | A browser over WS has no `PeerId`; ops land in the connection-local overlay |
|
||||||
|
| Browsers are not alknet peers (rationale: addressability vs bidirectionality) | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by ADR-044 §5) | Bearer token, no `PeerId`, no `PeerCompositeEnv` entry; "peer" means addressable peer-graph node, not "any endpoint that exchanges calls during a live session" |
|
||||||
|
| Peer-graph routing model (peer authorization via `AccessControl`) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) §3 | `AccessControl::check(identity)` gates every `call.requested` from the browser; no `remote_safe`/`trusted_peer` (retired) |
|
||||||
|
| Abort cascade on WS disconnect | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | WS close mid-subscription sends `call.aborted`, cascading to descendants |
|
||||||
|
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | WS upgrade request credential source (same as HTTP) |
|
||||||
|
| Browsers require X.509 (TLS) | [ADR-027](../../decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | The WS upgrade runs over the same `h2`/`http/1.1` TLS as HTTP; browsers don't support RFC 7250 raw keys |
|
||||||
|
| Stealth: HTTP handler on standard ALPNs serves WS upgrade | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | The WS upgrade route is on `HttpAdapter`'s default surface; a port scanner sees the decoy for unknown paths |
|
||||||
|
| Custom routes collision rule | [ADR-046](../../decisions/046-assembly-layer-custom-http-routes.md) | The WS upgrade route must not collide with reserved default-surface paths; it namespaces away naturally |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
See [open-questions.md](../../open-questions.md) for full details.
|
||||||
|
|
||||||
|
- **OQ-38** (open, scope): WebTransport standalone relay service scope —
|
||||||
|
the standalone relay (future `alknet-relay`, fork of iroh-relay) is
|
||||||
|
distinct from the in-process ALPN-stream-proxy (ADR-040, parked) and
|
||||||
|
from the WebSocket browser path (this spec). The relay is a separate
|
||||||
|
service for NAT traversal, not a mode of the WS handler; it does not
|
||||||
|
affect the WS path.
|
||||||
|
|
||||||
|
No new open questions. The `from_wss` deferral (§"Future") is a scope
|
||||||
|
decision stated explicitly, not an open question — the reversal trigger is
|
||||||
|
concrete (a deployment needing to import a remote node's ops over WSS),
|
||||||
|
and there is no architectural question to resolve before then.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-044](../../decisions/044-defer-webtransport-browsers-use-websocket.md)
|
||||||
|
— the ADR that committed WS as the v1 browser bidirectional path;
|
||||||
|
§"Concrete prior art" references the `@alkdev/pubsub` WS client/server;
|
||||||
|
§5 states the "browser is not a peer" rationale this spec carries.
|
||||||
|
- [ADR-048](../../decisions/048-websocket-native-session-not-gateway.md)
|
||||||
|
— the ADR that commits the WS path as the native `EventEnvelope` session,
|
||||||
|
not the HTTP gateway shape.
|
||||||
|
- [http-server.md](http-server.md) — the `HttpAdapter` that hosts the WS
|
||||||
|
upgrade handler; the one-directional HTTP projection (the gateway) the
|
||||||
|
WS path is contrasted against.
|
||||||
|
- [webtransport.md](webtransport.md) — the deferred `h3`/WebTransport
|
||||||
|
handler; kept intact for revival. When WebTransport revives, the two
|
||||||
|
coexist (WSS for the call protocol; WebTransport for the ALPN-stream-
|
||||||
|
proxy).
|
||||||
|
- [../call/call-protocol.md](../call/call-protocol.md) — `EventEnvelope`
|
||||||
|
wire format, the `Dispatcher`, the `PendingRequestMap`, stream model,
|
||||||
|
bidirectional calls, §"Transport agnosticism" (the pubsub-lineage note).
|
||||||
|
- [../call/client-and-adapters.md](../call/client-and-adapters.md) — the
|
||||||
|
shared `Dispatcher` (§"Shared Dispatcher"), `services/list` filtering,
|
||||||
|
the `CallClient` (the QUIC-side counterpart to the WS browser client).
|
||||||
|
- [../call/operation-registry.md](../call/operation-registry.md) —
|
||||||
|
`OperationRegistry::invoke()`, Layer 2 connection-local overlay.
|
||||||
|
- `/workspace/@alkdev/pubsub/src/event-target-websocket-client.ts`,
|
||||||
|
`/workspace/@alkdev/pubsub/src/event-target-websocket-server.ts` —
|
||||||
|
TypeScript prior art: the `EventEnvelope { type, id, payload }` over WS
|
||||||
|
binary messages. The alknet `EventEnvelope` is a refined superset (typed
|
||||||
|
event names, structured payloads); the delta is small and well-defined.
|
||||||
|
- `/workspace/@alkdev/operations/src/` — TypeScript sibling package sharing
|
||||||
|
the pubsub lineage; the `path.do.op` (dot-separated) convention vs
|
||||||
|
alknet's `path/to/op` (slash-separated) is the minor mechanical delta a
|
||||||
|
sync adjusts.
|
||||||
@@ -9,7 +9,7 @@ last_updated: 2026-06-30
|
|||||||
> This spec is kept intact for revival. `h3`/WebTransport is not
|
> This spec is kept intact for revival. `h3`/WebTransport is not
|
||||||
> implemented in the initial `alknet-http` release; the browser
|
> implemented in the initial `alknet-http` release; the browser
|
||||||
> bidirectional path uses WebSocket (see
|
> bidirectional path uses WebSocket (see
|
||||||
> [http-server.md](http-server.md) §"WebSocket browser path"). ADR-038
|
> [websocket.md](websocket.md)). ADR-038
|
||||||
> is superseded; ADR-040 and ADR-043 are parked (their decisions revive
|
> is superseded; ADR-040 and ADR-043 are parked (their decisions revive
|
||||||
> unchanged when WebTransport revives). The reversal trigger is a
|
> unchanged when WebTransport revives). The reversal trigger is a
|
||||||
> concrete deployment needing the ALPN-stream-proxy (a browser running
|
> concrete deployment needing the ALPN-stream-proxy (a browser running
|
||||||
|
|||||||
@@ -219,7 +219,8 @@ require it for the common case.
|
|||||||
browser bidirectional path, ADR-044), subscriptions project onto the
|
browser bidirectional path, ADR-044), subscriptions project onto the
|
||||||
WS connection directly as binary messages — the gateway's `/subscribe`
|
WS connection directly as binary messages — the gateway's `/subscribe`
|
||||||
is the `h2`/`http/1.1` SSE path; the WebSocket path is the native
|
is the `h2`/`http/1.1` SSE path; the WebSocket path is the native
|
||||||
call-protocol session (`http-server.md` §"WebSocket browser path").
|
call-protocol session (`websocket.md`; the gateway shape does not
|
||||||
|
appear on WS per [ADR-048](048-websocket-native-session-not-gateway.md)).
|
||||||
WebTransport (`h3`, deferred per ADR-044) would project onto
|
WebTransport (`h3`, deferred per ADR-044) would project onto
|
||||||
WebTransport streams; the deferred design is at
|
WebTransport streams; the deferred design is at
|
||||||
`webtransport.md`.
|
`webtransport.md`.
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# ADR-048: WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-044 (Accepted) deferred `h3`/WebTransport and committed WebSocket as the v1
|
||||||
|
browser bidirectional path: a browser upgrades an HTTP/1.1 or HTTP/2 request
|
||||||
|
to WebSocket and the resulting full-duplex WS connection carries call-protocol
|
||||||
|
`EventEnvelope` frames as binary WS messages. ADR-044 §1 established that the
|
||||||
|
call protocol's `call.requested`/`call.responded`/`call.completed`/`call.aborted`
|
||||||
|
exchange "works over WebSocket with no protocol change — the same `Dispatcher`,
|
||||||
|
the same `PendingRequestMap`, the same correlation by request ID."
|
||||||
|
|
||||||
|
ADR-044 §1 also established *what shape the WS session carries* — the native
|
||||||
|
`EventEnvelope` call-protocol session — but it does so as part of a larger
|
||||||
|
argument about why WebTransport isn't required, not as a crisp rule an
|
||||||
|
implementer is told not to violate. Two facts make the distinction worth its
|
||||||
|
own explicit decision record:
|
||||||
|
|
||||||
|
1. **The HTTP surface has a deliberate, well-documented invoke contract: the
|
||||||
|
`to_openapi` gateway pattern** (ADR-042, ADR-047). The gateway is 5 fixed
|
||||||
|
endpoints (`/search`, `/schema`, `/call`, `/batch`, `/subscribe`), where
|
||||||
|
`/call` takes `{ "operation": "/fs/readFile", "input": {...} }` and invokes
|
||||||
|
through `OperationRegistry::invoke()`. It is a well-shaped, simple contract.
|
||||||
|
An implementer writing the WS handler could plausibly ask: "should the WS
|
||||||
|
path expose the same 5-endpoint gateway shape, so a WS client looks like an
|
||||||
|
HTTP client?" That is a reasonable question, and the answer is no — but the
|
||||||
|
answer is currently implicit in ADR-044's framing, not stated as a rule.
|
||||||
|
|
||||||
|
2. **The two surfaces serve different architectural roles, and that difference
|
||||||
|
is load-bearing.** The HTTP gateway is, by HTTP's nature, a *one-directional
|
||||||
|
projection* — client initiates, server responds (see
|
||||||
|
[http-server.md](../crates/http/http-server.md) §"One-directional
|
||||||
|
projection"). The whole reason WebSocket exists in this architecture is to
|
||||||
|
*restore the call protocol's native bidirectionality for browsers* (ADR-044
|
||||||
|
§4): a WS connection is full-duplex, so both sides can initiate
|
||||||
|
`call.requested` frames. Putting the gateway's one-directional shape on WS
|
||||||
|
re-introduces the one-directional limitation that WS exists to fix. The two
|
||||||
|
surfaces are not interchangeable; they are deliberately different tools for
|
||||||
|
deliberately different jobs.
|
||||||
|
|
||||||
|
### The two invoke contracts, contrasted
|
||||||
|
|
||||||
|
| Aspect | HTTP gateway (`/call`, ADR-042/047) | WS native session (this ADR) |
|
||||||
|
|---------|--------------------------------------|------------------------------|
|
||||||
|
| Direction | One-directional (client→server calls only) | Bidirectional (either side can `call.requested`) |
|
||||||
|
| Wire unit | HTTP request/response | One `EventEnvelope` = one binary WS message |
|
||||||
|
| Invoke shape | `POST /call` with `{ "operation": "/fs/readFile", "input": {...} }` | `call.requested` event with `{ operation, input }` payload (the call protocol's native shape) |
|
||||||
|
| Discovery | `GET /search` (gateway endpoint) | `services/list` as an ordinary call-protocol op |
|
||||||
|
| Schema | `GET /schema` (gateway endpoint) | `services/schema` as an ordinary call-protocol op |
|
||||||
|
| Streaming | `GET /subscribe` (SSE frames) | `call.responded` events as binary WS messages (no SSE) |
|
||||||
|
| Dispatcher | axum route handler → `OperationRegistry::invoke()` | shared `Dispatcher` (ADR-012, stream-agnostic) |
|
||||||
|
| Multiplexing | HTTP/2 native; HTTP/1.1 sequential | By request ID (ADR-012), not by stream |
|
||||||
|
|
||||||
|
The WS row is the call protocol's own native session, with WebSocket as the
|
||||||
|
transport instead of QUIC. The HTTP row is a projection of that session into
|
||||||
|
HTTP's one-directional shape, with the gateway as the deliberate interface for
|
||||||
|
clients that only speak HTTP.
|
||||||
|
|
||||||
|
### Why the gateway shape is wrong on WS
|
||||||
|
|
||||||
|
Three concrete reasons:
|
||||||
|
|
||||||
|
1. **It duplicates the native invoke path with a lossier one.** The call
|
||||||
|
protocol's `call.requested` event *is* the invoke primitive; the gateway's
|
||||||
|
`/call` is that primitive wrapped in an HTTP envelope. On WS, the envelope
|
||||||
|
is unnecessary — there is no HTTP request/response cycle to fit into. A
|
||||||
|
gateway-on-WS would be a translation layer translating the call protocol to
|
||||||
|
itself, losing bidirectionality in the process.
|
||||||
|
|
||||||
|
2. **It loses the per-caller filtering property the native session already
|
||||||
|
has.** The gateway's `/search` exists to give HTTP clients the
|
||||||
|
`AccessControl::check(identity)`-filtered discovery that the call protocol
|
||||||
|
provides natively via `services/list`. On WS, `services/list` is already a
|
||||||
|
call-protocol op the browser can call directly — the filtering is already
|
||||||
|
there. A gateway-on-WS re-implements a filtering property the native session
|
||||||
|
already provides.
|
||||||
|
|
||||||
|
3. **It breaks the symmetry with the QUIC path.** The `alknet/call` QUIC path
|
||||||
|
(ADR-012, ADR-017) is the native `EventEnvelope` session over QUIC
|
||||||
|
bidirectional streams. WS is the same session over WS messages. Making the
|
||||||
|
WS path different from the QUIC path (by putting the gateway on WS) creates
|
||||||
|
two browser-reachable invoke contracts for no architectural reason — the
|
||||||
|
QUIC path is the reference, and WS should mirror it, not diverge from it.
|
||||||
|
|
||||||
|
### The prior art is already native-session-shaped
|
||||||
|
|
||||||
|
The `@alkdev/pubsub` WebSocket client/server (`event-target-websocket-client.ts`,
|
||||||
|
`event-target-websocket-server.ts`) — the working prior art ADR-044 cites as
|
||||||
|
the reason the WS path is cheap — already carries the `EventEnvelope { type, id,
|
||||||
|
payload }` shape over WS binary messages, with no gateway-style wrapping. The
|
||||||
|
alknet call protocol's `EventEnvelope` was derived from the pubsub envelope
|
||||||
|
(refined with typed event names and structured payloads); the delta is small
|
||||||
|
and well-defined (see [call-protocol.md](../crates/call/call-protocol.md)
|
||||||
|
§"Transport agnosticism"). A browser/Node WS client derived from the pubsub
|
||||||
|
prior art speaks the native session shape, not a gateway shape. The
|
||||||
|
gateway-on-WS variant would require *un-translating* the pubsub client's
|
||||||
|
native-session shape into the gateway's `{ operation, input }` shape — work
|
||||||
|
that has no payoff because the native shape is what both the QUIC path and the
|
||||||
|
pubsub prior art use.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. A WebSocket connection is a native `EventEnvelope` call-protocol session, not the HTTP gateway shape
|
||||||
|
|
||||||
|
The WS handler on `HttpAdapter` hands the WS message stream to the call
|
||||||
|
protocol's shared `Dispatcher` — the same dispatch loop the `CallAdapter` uses
|
||||||
|
for `alknet/call` QUIC connections (ADR-012, stream-agnostic correlation; a
|
||||||
|
WS message stream is another `BiStream`-satisfying transport). The browser
|
||||||
|
writes `EventEnvelope` frames as binary WS messages; the handler reads them
|
||||||
|
and dispatches via `OperationRegistry::invoke()`. Responses
|
||||||
|
(`call.responded`, `call.error`, `call.completed`, `call.aborted`) are written
|
||||||
|
back as binary WS messages.
|
||||||
|
|
||||||
|
The `to_openapi` gateway endpoints (`/search`, `/schema`, `/call`, `/batch`,
|
||||||
|
`/subscribe` — ADR-042, ADR-047) **do not appear on the WebSocket path**. They
|
||||||
|
are the HTTP one-directional projection's invoke contract; WS carries the
|
||||||
|
call protocol's own native session, which is a different (and richer) thing.
|
||||||
|
|
||||||
|
### 2. Discovery and schema are call-protocol ops, not WS-specific endpoints
|
||||||
|
|
||||||
|
The browser calls `services/list` and `services/schema` as ordinary
|
||||||
|
`call.requested` events over the WS connection. They are call-protocol
|
||||||
|
operations, not WS endpoints. There is no `/search` or `/schema` on WS — those
|
||||||
|
are the HTTP gateway's names for the same discovery primitives. The
|
||||||
|
filtering the gateway provides via `AccessControl::check(identity)`-filtered
|
||||||
|
`/search` (ADR-042 §3) is provided on the WS path by the same mechanism the
|
||||||
|
call protocol uses everywhere: `services/list` is `AccessControl`-filtered
|
||||||
|
natively (see [client-and-adapters.md](../crates/call/client-and-adapters.md)
|
||||||
|
§"services/list"). No WS-specific discovery surface exists or is needed.
|
||||||
|
|
||||||
|
### 3. Subscriptions project as native `call.responded` events, not SSE
|
||||||
|
|
||||||
|
A `Subscription` operation invoked over WS streams `call.responded` events as
|
||||||
|
binary WS messages directly — no SSE `data:` framing (that is the
|
||||||
|
`h2`/`http/1.1` projection for `/subscribe`, ADR-036 §Streaming; on WS it is
|
||||||
|
unnecessary because WS is already a framed full-duplex channel). `call.completed`
|
||||||
|
closes the stream; `call.aborted` closes it with an error frame. This is the
|
||||||
|
native streaming projection for the WS path, mirroring how subscriptions work
|
||||||
|
on the QUIC path.
|
||||||
|
|
||||||
|
### 4. Bidirectionality is native and unchanged from the QUIC path
|
||||||
|
|
||||||
|
The WS call-protocol session inherits the call protocol's native
|
||||||
|
bidirectionality (ADR-043 §2, transferred to WebSocket per ADR-044 §3): both
|
||||||
|
sides can send `call.requested` frames. The browser calls operations on the
|
||||||
|
hub; the hub can call operations registered on the browser's side, over the
|
||||||
|
same session, using the same `PendingRequestMap` and `EventEnvelope` framing
|
||||||
|
as `alknet/call`. The browser case where the client registers no operations of
|
||||||
|
its own is the common case — the server→client call direction is unused
|
||||||
|
because the browser has nothing to call. That is a use-case scoping, not an
|
||||||
|
architectural limitation.
|
||||||
|
|
||||||
|
### 5. This is a clarifying decision, not a new one
|
||||||
|
|
||||||
|
ADR-044 §1 commits the native-session shape ("the call protocol's
|
||||||
|
`EventEnvelope` framing fits a WebSocket binary message boundary cleanly ...
|
||||||
|
the same `Dispatcher`, the same `PendingRequestMap`, the same correlation by
|
||||||
|
request ID"). This ADR does not change that decision; it makes the
|
||||||
|
implication an explicit, implementer-visible rule: **the WS path is the
|
||||||
|
native session, and the gateway shape is deliberately not applied to it.** An
|
||||||
|
implementer reading ADR-044 alone could plausibly ask "should the WS path
|
||||||
|
expose the gateway endpoints too?" — this ADR's job is to make the answer
|
||||||
|
discoverable as a decision record, not implicit in framing.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- One invoke model for the call protocol, regardless of transport. The QUIC
|
||||||
|
path and the WS path run the same `EventEnvelope` session through the same
|
||||||
|
`Dispatcher`; the HTTP gateway is the one-directional projection for clients
|
||||||
|
that only speak HTTP. An implementer building the WS handler reuses the
|
||||||
|
`Dispatcher` and `OperationRegistry::invoke()` dispatch path verbatim — no
|
||||||
|
WS-specific routing, no WS-specific discovery surface, no second invoke
|
||||||
|
contract to design or maintain.
|
||||||
|
- The `@alkdev/pubsub`/`@alkdev/operations` TypeScript clients sync to the
|
||||||
|
alknet call protocol with no translation layer: their `EventEnvelope` shape
|
||||||
|
is already the native session shape, and the call protocol's envelope is a
|
||||||
|
refined superset of the pubsub envelope (ADR-044 §"Concrete prior art").
|
||||||
|
The gateway-on-WS variant would have required un-translating the pubsub
|
||||||
|
client's native-session shape; this decision avoids that un-translation
|
||||||
|
entirely.
|
||||||
|
- Per-caller `AccessControl`-filtered discovery is already a property of the
|
||||||
|
native session (`services/list`). No WS-specific filtering surface to build
|
||||||
|
or document; the call protocol's authorization model applies unchanged.
|
||||||
|
- The browser is a bidirectional call target during a live session, not a
|
||||||
|
peer-graph member (ADR-044 §5, ADR-034 §4). The native session shape is what
|
||||||
|
makes this clean: the browser gets bidirectional call capability through the
|
||||||
|
connection-local Layer 2 overlay (ADR-024, ADR-043 §3) without peer-graph
|
||||||
|
membership, and the gateway shape would not have changed this — but it would
|
||||||
|
have made the WS path diverge from the QUIC path for no benefit.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- A WS client cannot use the gateway's `{ "operation": ..., "input": ... }`
|
||||||
|
body shape — it must speak the call protocol's native `call.requested`
|
||||||
|
event. This is honest (the WS path *is* the call protocol), but a developer
|
||||||
|
who learned the gateway shape from the HTTP surface must learn the
|
||||||
|
`EventEnvelope` shape for WS. The pubsub prior art and the
|
||||||
|
`@alkdev/operations` TypeScript client already speak this shape, so the
|
||||||
|
delta is small for the primary consumer — but it is a real difference from
|
||||||
|
the HTTP gateway's simpler `{ operation, input }` invoke body.
|
||||||
|
- The 5 gateway endpoint names (`/search`, `/schema`, `/call`, `/batch`,
|
||||||
|
`/subscribe`) are HTTP-specific and do not carry over to WS. A deployment
|
||||||
|
documenting its surface for both HTTP and WS clients documents two invoke
|
||||||
|
shapes (the gateway for HTTP; the native session for WS). This is the cost
|
||||||
|
of using the right tool for each transport instead of forcing one shape onto
|
||||||
|
both.
|
||||||
|
|
||||||
|
## Reversal
|
||||||
|
|
||||||
|
This ADR clarifies a decision ADR-044 already committed (§1 describes the
|
||||||
|
native `EventEnvelope` session; this ADR makes that the explicit, implementer-
|
||||||
|
visible rule). The reversal posture is therefore ADR-044's, not a separate
|
||||||
|
one: the WS path itself is not deferred (it is the v1 browser path), and
|
||||||
|
the native-session-not-gateway choice is a clarification of what that path
|
||||||
|
carries — reversing it would mean adopting the gateway shape on WS, which
|
||||||
|
would re-introduce the one-directional limitation WS exists to fix (§Context
|
||||||
|
reason 1). The realistic reversal path is not "switch the WS path to the
|
||||||
|
gateway shape" but "WebTransport revives and adds a second browser
|
||||||
|
bidirectional path" — which is ADR-044's reversal trigger (a concrete
|
||||||
|
ALPN-stream-proxy deployment), not this ADR's. When WebTransport revives,
|
||||||
|
the two coexist: WS stays as the native-session call-protocol path;
|
||||||
|
WebTransport adds the ALPN-stream-proxy path (ADR-040). This ADR's rule (WS
|
||||||
|
= native session, not gateway) is unaffected by WebTransport's revival —
|
||||||
|
the gateway shape stays HTTP-only regardless of how many browser
|
||||||
|
bidirectional transports exist.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
1. **The call protocol's `EventEnvelope` framing fits a WebSocket binary
|
||||||
|
message boundary cleanly.** An `EventEnvelope` is a self-delimited JSON
|
||||||
|
object; one envelope per WS binary message. No streaming deserializer
|
||||||
|
across message boundaries is needed. This is ADR-044 Assumption 1, already
|
||||||
|
verified by the `@alkdev/pubsub` WebSocket client/server prior art.
|
||||||
|
|
||||||
|
2. **The shared `Dispatcher` runs over a WS message stream unchanged.**
|
||||||
|
ADR-012 commits stream-agnostic correlation; a WS message stream is another
|
||||||
|
`BiStream`-satisfying transport. The `Dispatcher` and `PendingRequestMap`
|
||||||
|
are transport-agnostic; only the connection-establishment half differs
|
||||||
|
(WS upgrade handler vs QUIC accept/dial).
|
||||||
|
|
||||||
|
3. **The primary WS consumer is a browser or Node client derived from the
|
||||||
|
`@alkdev/pubsub`/`@alkdev/operations` prior art.** That client already
|
||||||
|
speaks the native `EventEnvelope` shape. The gateway's simpler
|
||||||
|
`{ operation, input }` body shape is the HTTP path's affordance for clients
|
||||||
|
that only speak HTTP; a client that has chosen WS has already opted into
|
||||||
|
the call protocol's native framing.
|
||||||
|
|
||||||
|
4. **`services/list` and `services/schema` are sufficient discovery for the
|
||||||
|
WS path.** They are `AccessControl`-filtered (per-caller) and return the
|
||||||
|
full `OperationSpec` respectively. The gateway's `/search` and `/schema`
|
||||||
|
are HTTP-shaped names for these same primitives; on WS the primitives
|
||||||
|
apply directly. No WS-specific discovery surface is needed.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-012](012-call-protocol-stream-model.md) — stream-agnostic correlation;
|
||||||
|
a WS message stream is another `BiStream`-satisfying transport
|
||||||
|
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) §5 — `to_*`
|
||||||
|
adapters are projections that consume the registry; WS is not a `to_*`
|
||||||
|
adapter (it carries the native session, it doesn't project it)
|
||||||
|
- [ADR-024](024-operation-registry-layering.md) — Layer 2 per-connection
|
||||||
|
overlay where browser-registered ops (if any) land
|
||||||
|
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §4 (amended by
|
||||||
|
ADR-044 §5) — browsers are not alknet peers; the connection-local overlay
|
||||||
|
gives the browser bidirectional-call capability without peer-graph
|
||||||
|
membership
|
||||||
|
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection for
|
||||||
|
`/subscribe` (the HTTP one-directional streaming path; on WS, subscriptions
|
||||||
|
project as native `call.responded` events, no SSE)
|
||||||
|
- [ADR-042](042-openapi-gateway-pattern.md) — the gateway pattern this ADR
|
||||||
|
clarifies is HTTP-only
|
||||||
|
- [ADR-043](043-webtransport-bidirectional-alpn-substrate.md) §2/§3 —
|
||||||
|
bidirectionality and the no-`PeerId` connection-local overlay, transferred
|
||||||
|
to WebSocket per ADR-044 §3
|
||||||
|
- [ADR-044](044-defer-webtransport-browsers-use-websocket.md) — the ADR that
|
||||||
|
committed WS as the v1 browser path; this ADR clarifies the shape of what
|
||||||
|
ADR-044 committed (§1 implies the native session; this ADR makes it an
|
||||||
|
explicit rule)
|
||||||
|
- [ADR-047](047-remove-direct-call-http-surface.md) — the gateway as the
|
||||||
|
sole HTTP invoke path (the HTTP-only contract this ADR clarifies does not
|
||||||
|
extend to WS)
|
||||||
|
- `crates/http/websocket.md` — the spec that implements this decision
|
||||||
|
- `crates/http/http-server.md` §"WebSocket browser path" — the original
|
||||||
|
section this decision promotes into `websocket.md`
|
||||||
Reference in New Issue
Block a user