docs(arch): sync specs to transport generalization sweep (ADR-064, ADR-065)

Three code commits landed a clean sweep discovered when building an
external app against the crates. This syncs the architecture specs to
match the codebase and amends the affected decisions.

ADR-064 (supersedes ADR-005): irpc was never integrated — no .rs file in
the workspace ever imported it. The wire protocol (wire.rs) is hand-rolled
length-prefixed JSON; the EventEnvelope shape was derived from the
@alkdev/pubsub TypeScript prior art (ADR-013), not from irpc. ADR-005's
premise ('irpc as the call protocol foundation') was never implemented as
stated. Superseded with a clear header; the body is kept as historical
record.

ADR-065: Connection::from_stream / from_bidi — Connection now accepts any
AsyncRead + AsyncWrite pair via ConnectionKind::Stream (yield-once
accept_bi contract: QUIC yields many streams, everything else yields one
then ConnectionClosed). Unblocks TCP+TLS, SSH channel dispatch,
WebTransport streams, and wasm streams through the same HandlerRegistry
as QUIC connections, with zero handler code changes. MockConnection /
ConnectionKind::Mock removed (tests use from_stream with sink/empty).
Stream-level Mock variants renamed to Stream (they were already generic).

Amended ADRs: ADR-003 (irpc removed from dep table), ADR-007 (from_stream
opens the server-side door; MockConnection removed), ADR-010 (TCP+TLS can
now dispatch through the registry via from_bidi; iroh 1.0 migration noted).

Updated specs: core-types.md (Connection/SendStream/RecvStream sections),
endpoint.md (TCP section, iroh note), call-protocol.md, operation-registry
(irpc Integration section replaced), overview.md, http-server.md,
webtransport.md, call README. OQ-09 (WASM) resolution amended: the
Connection door is now open via from_stream; the accept-loop runtime door
remains closed (tokio doesn't run on WASM).

See docs/research/transport-generalization/findings.md for the full trace.
This commit is contained in:
glm-5.2 committed 2026-07-09 07:40:51 +00:00
1 parent 865fef6210
commit 2aa6363e57
20 files changed
+782 -87

No files matched your search

+26 -5
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-08
last_updated: 2026-07-09
---
# Alknet Architecture
@@ -26,6 +26,25 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
**alknet-docker specs drafted.** The alknet-docker crate (docker operations on the shared `alknet/call` ALPN + `DockerTtyBackend` behind a `tty` feature) now has architecture specs: [crates/docker/](crates/docker/) (overview, docker-operations, docker-tty-backend) and six ADRs — [ADR-058](decisions/058-alknet-docker-on-alknet-call.md) (docker ops register on `alknet/call`, not a separate `alknet/docker` ALPN; the raw-carriage handoff the POC struggled with is dissolved by the alknet-tty extraction — interactive attach moved to `alknet/tty` via `DockerTtyBackend`, no `carriage` field on `call.requested`), [ADR-059](decisions/059-bollard-021-dependency-and-features.md) (bollard 0.21, verified current on crates.io; features `http`+`pipe`+`time`, no `ssl`/`ssh`/`websocket`/`buildkit` — single-host by construction, fleet is a call-protocol concern), [ADR-060](decisions/060-container-resource-model-and-label-namespace.md) (ADR-050 application to bollard: `alknet.managed`/`alknet.owner` labels; `list` `owned_only` flag; hosted-services operator role via the static-resource fallback; handler-driven `revoke` on `remove` with autonomous-death tolerance), [ADR-061](decisions/061-docker-tty-backend-in-alknet-docker.md) (`DockerTtyBackend` in alknet-docker behind a `tty` feature, not a sibling crate; attach vs exec mode; the POC's `drive_attach_raw` as the reference), [ADR-062](decisions/062-docker-client-injection-via-closure-capture.md) (the `Docker` client + `OwnershipStore` are closure-captured at registration time, not read from `OperationContext` and not smuggled through `Capabilities` — `Capabilities` is for secret material only per ADR-014; matches the `from_openapi` pattern), [ADR-063](decisions/063-exit-code-on-terminal-call-responded.md) (non-interactive exec puts `{ "exitCode": N, "terminal": true }` on a final `call.responded` before `call.completed` — `call.completed` stays empty, ADR-012 unchanged). The specs are grounded in the alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`, `/workspace/alknet-docker-poc/`), which validated the hard parts (interactive attach, logs subscription, exec with exit code); the remaining lifecycle operations are mechanical bollard wrapping. The two use cases — disposable dev containers (coordinator-spawned, ownership-recorded) and long-running hosted services (operator-managed, static-resource fallback, per `/workspace/system/dev1/docker.md`) — both work through one `AccessControl` model (ADR-050/060). The `DockerTtyBackend` fills the `TtyBackend` row the alknet-tty spec left open. Four OQs (048–051) track deferred scope: network/volume ops, buildkit, system events subscription, and the full `CreateContainerOptions` surface (deferred to v1 implementation).
**Transport generalization sweep (2026-07-09).** Three commits landed a
clean sweep discovered when building an external app against the crates:
(1) the dead `irpc` / `irpc-derive` workspace deps were removed (no `.rs`
file ever imported irpc — the wire protocol is hand-rolled), recorded by
[ADR-064](decisions/064-irpc-never-integrated-hand-rolled-framing.md)
(supersedes ADR-005, which had accepted "irpc as the call protocol
foundation" based on the previous architecture but was never implemented
as stated); (2) the iroh dep migrated `0.35 → 1.0.2` (6 API surface edits,
no architectural change — unblocks `alknet-blobs`); (3)
[ADR-065](decisions/065-connection-from-stream-generic-single-stream.md)
adds `Connection::from_stream` / `from_bidi` — `Connection` now accepts any
`AsyncRead + AsyncWrite` pair, unblocking TCP+TLS, SSH channel dispatch,
WebTransport streams, and wasm streams through the same `HandlerRegistry`
as QUIC connections, with zero handler code changes. The
`MockConnection` / `ConnectionKind::Mock` test variants are removed (tests
use `from_stream` with `tokio::io::sink`/`empty`). See
[`docs/research/transport-generalization/findings.md`](../research/transport-generalization/findings.md)
for the full trace.
## Architecture Documents
| Document | Status | Description |
@@ -33,13 +52,13 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [overview.md](overview.md) | draft | Workspace-level overview, crate graph, shared types, design principles |
| [open-questions.md](open-questions.md) | draft | OQ index — theme-grouped tables + Deferred/Blocked section; per-OQ files in [`questions/`](questions/) |
| [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index |
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection, BiStream, StreamError |
| [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection (QUIC + `from_stream`), BiStream, StreamError |
| [crates/core/endpoint.md](crates/core/endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, shutdown |
| [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow |
| [crates/core/config.md](crates/core/config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle |
| [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration |
| [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery (hand-rolled, no irpc) |
| [crates/call/client-and-adapters.md](crates/call/client-and-adapters.md) | draft | CallClient (outbound connection opener), from_call / from_jsonschema, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern |
| [crates/http/README.md](crates/http/README.md) | draft | alknet-http crate index |
| [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map |
@@ -72,7 +91,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | Accepted |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | Accepted |
| [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core (IdentityProvider) | Accepted |
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | Accepted |
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | ~~Accepted~~ → **Superseded** by ADR-064 (irpc was never integrated) |
| [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | Accepted |
| [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | Accepted |
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Accepted |
@@ -131,6 +150,8 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
| [061](decisions/061-docker-tty-backend-in-alknet-docker.md) | DockerTtyBackend in alknet-docker | Accepted |
| [062](decisions/062-docker-client-injection-via-closure-capture.md) | Docker Client and OwnershipStore Injection via Closure Capture | Accepted |
| [063](decisions/063-exit-code-on-terminal-call-responded.md) | Exit Code on a Terminal `call.responded` for Non-Interactive Exec | Accepted |
| [064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | irpc Was Never Integrated — Hand-Rolled EventEnvelope Framing | Accepted (supersedes ADR-005) |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` — Generic Single-Stream Connections | Accepted |
## Open Questions
+10 -8
View File
@@ -1,19 +1,19 @@
---
status: draft
last_updated: 2026-06-27
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration pending.
last_updated: 2026-07-09
review: call/review-call passed 2026-06-23 — registry, protocol, ADR (005/012/014/015/016/017/022/023/024), security, and pattern-consistency checks all conformant; 159 unit/integration tests green; `cargo build`, `cargo clippy -- -D warnings`, `cargo fmt --check`, `cargo test` clean. Call-completion gap (ADR-017 client/adapter surface) addressed 2026-06-26; ADR-029 migration pending. Transport generalization sweep (ADR-064 supersedes ADR-005; ADR-065 `from_stream`) synced 2026-07-09.
---
# alknet-call
Structured RPC over QUIC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`.
Structured RPC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`. Runs over QUIC (quinn/iroh) and, via `Connection::from_stream` (ADR-065), over any `AsyncRead + AsyncWrite` transport.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, irpc integration |
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-064), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-064) |
| [client-and-adapters.md](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 |
## Applicable ADRs
@@ -22,10 +22,12 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alknet/call` |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core and irpc |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core (no irpc — ADR-064) |
| [013](../../decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | irpc provides framing and service dispatch |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | ~~Accepted~~ → **Superseded** by [ADR-064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated; framing is hand-rolled) |
| [064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | Wire format, registry, dispatch are hand-rolled in alknet-call; supersedes ADR-005 |
| [065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | Generic single-stream connections; unblocks TCP+TLS/SSH/WT/wasm dispatch |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alknet/call` ALPN, one ALPN per connection |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
@@ -72,7 +74,7 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
2. **Protocol is symmetric**: Both sides can initiate calls. The server calling a client uses the same EventEnvelope format and correlation.
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-024.
5. **irpc is one dispatch backend**: Local operations dispatch directly. irpc service calls (in-process, type-safe) are internal. The call protocol is the external interface.
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-064). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-049), not an irpc service.
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014.
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
+12 -6
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-02
last_updated: 2026-07-09
---
# Call Protocol
@@ -23,7 +23,10 @@ The protocol must be:
- **Stream-agnostic**: QUIC provides stream multiplexing; the protocol shouldn't impose additional constraints
- **Discoverable**: Clients can query what operations exist and their schemas
See ADR-005 for the decision to use irpc as the call protocol's foundation and ADR-012 for the stream model decision.
See ADR-064 for the decision that the call protocol uses hand-rolled
`EventEnvelope` framing (irpc was never integrated — ADR-005, which
accepted "irpc as the call protocol foundation," is superseded) and ADR-012
for the stream model decision.
## Architecture
@@ -210,7 +213,10 @@ The `Value` type is `serde_json::Value`. The envelope is JSON because it must be
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
This is the same framing used by irpc. The Rust implementation in alknet-call is canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference and browser adaptation, not a parallel implementation (see ADR-013).
This is hand-rolled length-prefixed JSON framing (ADR-064), coincidentally
the same shape irpc uses. The Rust implementation in alknet-call is
canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference
and browser adaptation, not a parallel implementation (see ADR-013).
### Event Types
@@ -546,7 +552,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
- 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.
- 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-064 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.
- **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.
@@ -554,7 +560,7 @@ Handlers clean up resources when their call is cancelled (in Rust, the future is
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch |
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-005 (irpc was never imported) |
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| ALPN per connection | [ADR-006](../../decisions/006-alpn-convention-and-connection-model.md) | `alknet/call` is a distinct ALPN, one connection per ALPN |
| ProtocolHandler receives Connection | [ADR-007](../../decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
@@ -615,7 +621,7 @@ See [open-questions.md](../../open-questions.md) for full details.
- [operation-registry.md](operation-registry.md) — OperationSpec, Handler, AccessControl, service discovery
- [client-and-adapters.md](client-and-adapters.md) — CallClient, from_call, OperationAdapter, peer-keyed composition env
- ADR-005: irpc as call protocol foundation
- ADR-064: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-005)
- ADR-012: Call protocol stream model
- ADR-029: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
- ADR-030: PeerEntry and Identity.id decoupling (`PeerId` source)
@@ -5,7 +5,7 @@ last_updated: 2026-07-05
# Operation Registry
OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, and irpc integration.
OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, and the hand-rolled framing (no irpc — ADR-064).
## What
@@ -185,7 +185,11 @@ pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
```
Both handlers are async — many operations (file I/O, HTTP service calls,
irpc service calls, LLM streaming) are inherently asynchronous. A handler
LLM streaming) are inherently asynchronous. A handler (whether it wraps a
local function, an HTTP-backed OpenAPI operation, an LLM stream, or a
`from_call` remote) is a `Future` (for `Query`/`Mutation`) or a `Stream`
(for `Subscription`, ADR-049). The registry's `Handler` /
`StreamingHandler` trait objects (ADR-049) abstract over this. A handler
receives:
- `input: Value` — the deserialized `payload` from the `call.requested` event
@@ -447,7 +451,7 @@ The CLI binary (or assembly layer) constructs the registry and passes it to the
### OperationEnv
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally or on a remote node.
**`OperationEnv` is request/response-only** (ADR-049). It returns a single `ResponseEnvelope` — no streaming variant exists. Calling `invoke()` on a `Subscription` op produces `CallError { code: "INVALID_OPERATION_TYPE", ... }` — composition cannot truncate a stream to its first value. Stream composition (filter, map, combine, window, dedupe) is a handler-level concern, not a protocol composition concern; see ADR-049 for the rationale and the `@alkdev/pubsub` `operators.ts` prior art.
@@ -736,7 +740,7 @@ Two things happen in `invoke()`:
1. **Reachability check**: before constructing the child context, `invoke()` checks whether the requested op is in the parent's scoped env. If not, `NOT_FOUND`. This is the reachability control — a handler can only compose declared ops.
2. **Authority propagation**: the child's `identity` is the parent's `handler_identity` (the parent's composition authority becomes the caller). The child's `handler_identity` is the child's own registration's `composition_authority` — so if the child itself composes further, its children inherit the child's authority. This is the principal/agent chain from ADR-015, now wired via ADR-022.
Future work may add irpc service dispatch and remote call protocol dispatch as additional backends. The handler-facing API stays the same.
Future work may add remote call protocol dispatch as an additional backend. The handler-facing API stays the same.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the curated base + active peer-keyed connection overlays + session overlay, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-017 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-029) composes on top of the same trait — it overrides the new peer-routing methods, not the base dispatch. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns, and would prevent the peer-keyed routing model from composing. This is the same integration-point pattern as `IdentityProvider` (ADR-004). See OQ-19, ADR-024, and ADR-029.
@@ -774,18 +778,21 @@ from wire `operationId`s before lookup, so `services/schema` accepts both
client reading the schema can produce typed error enums instead of generic
error handling.
### irpc Integration
### Operation Registry (hand-rolled, no irpc)
irpc and the operation registry serve different scopes:
The operation registry is hand-rolled in alknet-call. ADR-005 accepted
"irpc as the call protocol foundation," but no `.rs` file in the workspace
ever imported irpc — the wire format (`wire.rs`), the operation registry,
and the dispatch are all hand-rolled. ADR-064 supersedes ADR-005 and
records the actual state. The table that previously contrasted "call
protocol (external, JSON)" with "irpc services (internal, postcard)" is
moot — there is no irpc layer.
| Layer | Mechanism | Serialization | Scope |
|-------|-----------|---------------|-------|
| Call protocol (external) | `EventEnvelope` over QUIC streams | JSON | Cross-language, cross-node |
| irpc services (internal) | `#[rpc_requests]` derive macro, `Service` trait | postcard (binary) | Rust-to-Rust, in-process or in-cluster |
irpc services are an internal dispatch mechanism — they are not directly exposed on the call protocol. alknet-call itself uses irpc for its call-protocol framing (ADR-005); the vault no longer uses irpc (ADR-025 — direct method calls on `VaultServiceHandle`). The vault is accessed by the assembly layer (CLI binary) at startup, not by handlers at call time. See ADR-008 and ADR-014.
If a handler internally uses an irpc-based service, the handler bridges the two: it receives JSON input from the call protocol, calls the irpc service in-process (postcard, type-safe), and serializes the result back to JSON for the call protocol response. This layering preserves irpc's type safety for internal calls while keeping the external interface cross-language.
If a handler internally uses a postcard/binary RPC for in-process calls,
that's a handler-internal choice, not an alknet-call integration. The
operation registry's external interface is always JSON (the `EventEnvelope`
wire format); the internal handler dispatch is a `Handler` /
`StreamingHandler` trait object (ADR-049), not an irpc `Service`.
### Operation Registration at Startup
@@ -874,8 +881,8 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
## Constraints
- The registry is **layered by trust boundary** (ADR-024). The curated layer (`Local` provenance) is immutable after construction — adding a `Local` op requires restarting the process, which re-enters the startup trust boundary. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic at their respective scopes (per-session, per-connection). The pre-ADR-024 blanket immutability claim was inherited by analogy from ADR-010's `HandlerRegistry` (ALPN-level) and did not apply to the operation registry — the TLS-config argument that justifies `HandlerRegistry` immutability does not touch the operation registry, which lives behind the single ALPN `alknet/call`.
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built.
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. Internal handler dispatch is via `Handler` / `StreamingHandler` trait objects (ADR-049), not a binary RPC framework.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths.
- The call protocol does not depend on any database. Operation specs are in-memory, populated at startup.
- `OperationContext.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (composition authority for ACL), it does not skip ACL — see ADR-015, ADR-022.
- **Operations have External/Internal visibility.** `Internal` operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The assembly layer declares visibility at registration. See ADR-015.
@@ -890,7 +897,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch |
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-064](../../decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-005 |
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-024 (curated immutable, session/imported dynamic) |
| Vault integration via assembly layer | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
@@ -949,7 +956,7 @@ See [open-questions.md](../../open-questions.md) for full details.
## References
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
- ADR-005: irpc as call protocol foundation
- ADR-064: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-005)
- ADR-008: Vault integration point
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-024 for the distinction)
- ADR-012: Call protocol stream model
+70 -15
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-23
last_updated: 2026-07-09
---
# Core Types
@@ -47,11 +47,15 @@ Handler panics are caught by tokio's task isolation. The connection is dropped,
## Connection
An opaque type wrapping a QUIC connection. Handlers receive a `Connection` in `handle()`.
An opaque type wrapping a transport connection. Handlers receive a
`Connection` in `handle()`. The connection may be QUIC (quinn or iroh) or a
generic single stream (TCP+TLS, SSH channel, WebTransport stream, wasm
stream) — see ADR-065 for the `from_stream` generalization.
```rust
pub struct Connection {
// Private: wraps the underlying QUIC connection or test mock
// Private: wraps the underlying connection — QUIC (quinn/iroh) or a
// generic single-stream pair (ConnectionKind::Stream).
// Private: handler-resolved identity for observability (OQ-11)
identity: OnceLock<Identity>,
}
@@ -65,6 +69,24 @@ impl Connection {
#[cfg(feature = "iroh")]
pub fn from_iroh(conn: iroh::Connection) -> Self;
/// Construct from any pre-split read/write pair. `accept_bi()` yields
/// this pair once, then returns `ConnectionClosed`. `open_bi()` returns
/// `StreamClosed`. No feature gate — generic, no transport deps.
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
/// Convenience for a single bidirectional stream (e.g.
/// `TlsStream<TcpStream>`). Splits internally via `tokio::io::split`.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
pub fn remote_alpn(&self) -> &[u8];
@@ -75,14 +97,32 @@ impl Connection {
}
```
- `accept_bi()`: Wait for the peer to open a bidirectional stream. Returns `(SendStream, RecvStream)`.
- `open_bi()`: Open a bidirectional stream to the peer. Returns `(SendStream, RecvStream)`.
- `accept_bi()`: Yield the next bidirectional stream this connection
provides. **Transport semantics (ADR-065):** QUIC (quinn/iroh) returns a
new bidi stream on each call, `ConnectionClosed` when the underlying
connection closes; a single-stream connection (TCP+TLS, SSH channel,
WebTransport stream, wasm stream) yields the underlying stream on the
first call, then `ConnectionClosed` on all subsequent calls. Handlers
that loop `accept_bi` (TtyAdapter) get one session per single-stream
connection; handlers that call once (HttpAdapter) get the stream
directly. Both correct, no branching on transport.
- `open_bi()`: Open a bidirectional stream to the peer. Returns
`(SendStream, RecvStream)`. On a single-stream connection, returns
`StreamClosed` — a single stream cannot open new application streams.
- `remote_alpn()`: The ALPN negotiated for this connection. Always present.
- `remote_addr()`: The peer's address, if available. Informational (NAT/proxy).
- `close()`: Close the connection with an error code and reason.
- `close()`: Close the connection with an error code and reason. The
`code`/`reason` args are QUIC-specific (application-level close codes);
for a raw stream they're ignored — the drop is the close.
- `set_identity()`: Store the handler-resolved identity for observability (OQ-11). Write-once-read-many — a second call returns an error. Handlers that resolve identity inside `handle()` call this; the identity is read by handler-side logging (the handler logs which identity it resolved) and is available on the `Connection` for any code that holds a reference to it. The endpoint does **not** read `identity()` after `handle()` returns — the `Connection` is moved into the spawned handler task (endpoint.md), so the endpoint no longer has a reference. Connection-level observability (remote addr, ALPN, connection ID) is logged by the endpoint before the move; identity-level observability is logged by the handler. See OQ-11 for the full resolution.
The `Connection` type does not expose quinn types in its public API. It wraps `quinn::Connection` internally, but the wrapper allows test implementations.
The `Connection` type does not expose quinn/iroh types in its public API.
It wraps the underlying connection internally via `ConnectionKind` enum
dispatch (`Quinn` / `Iroh` / `Stream`), with the QUIC variants feature-gated
and the `Stream` variant always available (no transport deps). See
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md)
for the `from_stream` generalization and the yield-once `accept_bi`
contract.
See [ADR-007](../../decisions/007-bistream-type-definition.md) for why handlers receive Connection instead of BiStream.
@@ -96,18 +136,21 @@ pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
Handlers that only need a single stream can obtain one via `connection.accept_bi()` and treat the `(SendStream, RecvStream)` pair as a BiStream. The `BiStream` trait is a convenience for:
- Client-side code that has a single bidirectional stream
- Test mocks that need to simulate a stream
- Future transport abstractions (WebTransport, raw TCP) that produce bidirectional byte streams
- Test scenarios that need to simulate a stream
- Transports that produce a single bidirectional byte stream (TCP+TLS via `from_bidi`, SSH channels, WebTransport streams, wasm streams) — all dispatchable through the same `HandlerRegistry` as QUIC connections via `Connection::from_stream` (ADR-065)
See [ADR-007](../../decisions/007-bistream-type-definition.md) for why BiStream is a trait.
## SendStream and RecvStream
Concrete types wrapping QUIC stream halves. Both quinn and iroh produce QUIC connections — `SendStream` and `RecvStream` need to wrap either source.
Concrete types wrapping transport stream halves. Both quinn and iroh
produce QUIC connections; `from_stream` adds a generic single-stream source.
`SendStream` and `RecvStream` wrap any of the three via internal enum
dispatch.
```rust
pub struct SendStream { /* wraps quinn::SendStream or iroh::SendStream or test mock */ }
pub struct RecvStream { /* wraps quinn::RecvStream or iroh::RecvStream or test mock */ }
pub struct SendStream { /* wraps quinn::SendStream, iroh::SendStream, or a generic Box<dyn AsyncWrite> */ }
pub struct RecvStream { /* wraps quinn::RecvStream, iroh::RecvStream, or a generic Box<dyn AsyncRead> */ }
impl AsyncWrite for SendStream { ... }
impl AsyncRead for RecvStream { ... }
@@ -115,9 +158,18 @@ impl AsyncRead for RecvStream { ... }
- `SendStream` implements `AsyncWrite`. Write bytes to the peer.
- `RecvStream` implements `AsyncRead`. Read bytes from the peer.
- These are concrete wrapper types that use internal enum dispatch to delegate to the appropriate QUIC stream type (quinn or iroh) in production, and to test mocks in tests.
- These are concrete wrapper types that use internal enum dispatch to
delegate to the appropriate stream source: quinn or iroh (QUIC,
feature-gated) in production, or `Stream` (a generic
`Box<dyn AsyncRead/Write + Send + Unpin>`, no feature gate) for
single-stream connections constructed via `from_stream` / `from_bidi`.
Since the endpoint supports both quinn and iroh connection sources (ADR-010), streams may come from either. `Connection::from_quinn()` / `Connection::from_iroh()` wrap the appropriate stream source based on where the connection came from.
Since the endpoint supports both quinn and iroh connection sources
(ADR-010), and `from_stream` adds the generic single-stream source
(ADR-065), streams may come from any of the three. `Connection::from_quinn()`
/ `from_iroh()` wrap the appropriate QUIC stream source based on where the
connection came from; `Connection::from_stream()` / `from_bidi()` wrap a
generic `AsyncRead + AsyncWrite` pair as the `Stream` variant.
## StreamError
@@ -145,6 +197,8 @@ When a handler encounters a `StreamError` and needs to return from `handle()`, i
Handlers that manage multiple streams (SSH, call) may catch `StreamError::StreamClosed` per-stream and continue serving other streams on the same connection — only `ConnectionClosed` forces `handle()` to return.
**Note on single-stream connections (ADR-065):** `StreamClosed` from `open_bi` on a `ConnectionKind::Stream` (single-stream) connection is terminal for that connection — no other streams exist to continue with. The "connection may still be usable" framing above applies to the QUIC case (a per-stream closure where the connection lives); the single-stream case is a transport property (one stream is all there is), not a mid-operation stream closure. `accept_bi` on a single-stream connection returns `ConnectionClosed` after the first yield (not `StreamClosed`), so handlers that loop `accept_bi` exit cleanly.
The mapping is provided as a `From` impl so handlers can use the `?` operator:
```rust
@@ -245,8 +299,9 @@ registration bundle.
|----------|-----|---------|
| ProtocolHandler receives Connection, not BiStream | [ADR-007](../../decisions/007-bistream-type-definition.md) | Handlers that need multiple streams (SSH, call) have direct access to the Connection |
| BiStream is a trait | [ADR-007](../../decisions/007-bistream-type-definition.md) | WASM door preserved, test mocks possible |
| `Connection::from_stream` — generic single-stream connections | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `from_stream`/`from_bidi` accept any `AsyncRead + AsyncWrite`; yield-once `accept_bi` contract; unblocks TCP+TLS, SSH channels, WebTransport, wasm; QUIC variants feature-gated, `Stream` variant always available; `MockConnection`/`ConnectionKind::Mock` removed (tests use `from_stream` with `sink`/`empty`) |
| HandlerError is non-fatal | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Handler errors close the connection, not the endpoint |
| SendStream/RecvStream wrap quinn + iroh | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Internal enum dispatch for both QUIC sources |
| SendStream/RecvStream wrap quinn + iroh + generic streams | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | Internal enum dispatch for QUIC sources and the generic `Stream` variant |
| Connection stores handler-resolved identity | OQ-11 (resolved) | `set_identity` via `OnceLock` — write-once-read-many; read by handler-side logging, not by the endpoint (C13 resolved) |
| Capabilities type | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Non-serializable, zeroized, immutable after construction; `Clone` for composition propagation |
+26 -5
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-22-17
last_updated: 2026-07-09
---
# Endpoint
@@ -37,9 +37,27 @@ A node can be reachable through different paths depending on its network context
These are not interchangeable transports — they are **complementary connectivity modes**. A node behind NAT that also has a public IP can use both simultaneously. Both produce QUIC connections that dispatch through the same `HandlerRegistry` by ALPN string.
### TCP is NOT an endpoint concern
### TCP is NOT an endpoint struct concern (but CAN dispatch through the registry)
Bare TCP (SSH over port 22) does not use QUIC or ALPN. In the new model, TCP access is handled by individual handlers — the SSH handler can listen on a TCP socket independently. This is a handler-specific concern, not a core endpoint concern.
Bare TCP (SSH over port 22) does not use QUIC or ALPN. TCP access is not
owned by the `AlknetEndpoint` struct — there is no `tcp:
Option<TcpListener>` field. The endpoint manages QUIC connection sources
(quinn + iroh) only.
This does **not** mean TCP+TLS can't participate in ALPN dispatch. Since
[ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md),
`Connection::from_bidi(tls_stream, alpn, remote_addr)` constructs a
`Connection` from any `TlsStream<TcpStream>` (or any `AsyncRead +
AsyncWrite` pair). A TCP+TLS accept loop built *outside* the endpoint (by
the assembly layer or a handler) can wrap each TLS stream as a
`Connection` and dispatch through the **same `HandlerRegistry`** the
endpoint uses, by the ALPN negotiated in the TLS handshake. This is not a
parallel listener bypassing the core — it's the same ALPN dispatch, over
a non-QUIC transport. `HttpAdapter`, `TtyAdapter`, and the call handler
all work over the single stream unchanged (ADR-065's yield-once
`accept_bi` contract). The TCP+TLS accept loop itself is a follow-up
commit, not part of `AlknetEndpoint`; the primitive it needs
(`from_bidi`) is in place.
The reference implementation's TCP transport (`alknet-main/crates/alknet-core/src/transport/tcp.rs`) is SSH-specific. It doesn't generalize to the ALPN model.
@@ -228,7 +246,10 @@ Note: `TlsIdentity::RawKey` uses `Ed25519SecretKey` (alknet-core-owned,
backed by `ed25519-dalek`), not `iroh::SecretKey`. It is available in
quinn-only builds without the `iroh` feature. When the iroh transport is
also configured, `build_iroh_endpoint` converts the key to
`iroh::SecretKey::from_bytes` (ADR-027).
`iroh::SecretKey::from_bytes` (ADR-027). The iroh dep is on `1.0`
(`default-features = false, features = ["tls-aws-lc-rs"]`, matching the
quinn path's aws-lc-rs crypto provider); migrated from `0.35` in commit
`acd049e` (2026-07-09) — 6 API surface edits, no architectural change.
## Graceful Shutdown
@@ -289,7 +310,7 @@ Non-fatal errors within a handler. See [core-types.md](core-types.md) for detail
|----------|-----|---------|
| Multi-connectivity endpoint (quinn + iroh) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Both optional, both feed same ALPN router |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Two-way door, start static, add ArcSwap later |
| TCP is not an endpoint concern | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | TCP SSH is a handler concern, not core |
| TCP is not an endpoint struct concern (but dispatches via `from_stream`) | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md), [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | `AlknetEndpoint` is QUIC-only (no `tcp` field); a TCP+TLS loop outside the endpoint wraps streams via `from_bidi` and shares the registry |
| No byte-peeking, ALPN dispatch only | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | TLS layer handles protocol detection |
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy via ALPN routing, not byte-peek |
| Network identity ≠ auth identity | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | TLS cert/NodeId = network, SSH key/token = auth |
+10 -4
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-02
last_updated: 2026-07-09
---
# HTTP Server
@@ -84,12 +84,18 @@ that do know about alknet.
## Architecture
### Running axum over a QUIC stream
### Running axum over a bidirectional stream
The `HttpAdapter::handle()` method for `h2`/`http/1.1`:
1. Accepts one bidirectional stream from the QUIC connection
(`connection.accept_bi()` → `(SendStream, RecvStream)`).
1. Accepts one bidirectional stream from the connection
(`connection.accept_bi()` → `(SendStream, RecvStream)`). Over QUIC
this is one of many streams the connection provides; over a
single-stream connection (TCP+TLS via `Connection::from_bidi`,
ADR-065) it is the one stream, yielded once then `ConnectionClosed`.
Either way, `accept_bi` returns the `(SendStream, RecvStream)` pair
the adapter needs — the handler code is transport-agnostic (ADR-065's
yield-once contract).
2. Wraps the `(SendStream, RecvStream)` pair as a hyper
`TokioIo`-compatible duplex stream — the same byte stream hyper
expects for an HTTP connection.
@@ -259,9 +259,14 @@ protocol (SSH, SFTP, git) reads/writes the WebTransport stream as a
`BiStream` (ADR-007). The `BiStream` trait (`AsyncRead + AsyncWrite +
Send + Unpin`) was designed for this — a browser implements it over a
WebTransport stream, and the WASM parser speaks the protocol over it.
The WASM parsers are downstream artifacts (the SSH WASM client, the
SFTP WASM client), not part of `alknet-http`; `russh-sftp`'s WASM
targeting demonstrates feasibility, SSH is the next target.
On the server side, the `h3` handler wraps each WebTransport stream as a
`Connection` via `Connection::from_stream` (ADR-065) before handing it to
the target ALPN handler — the target handler runs its normal protocol over
the stream, unchanged from its QUIC path (the yield-once `accept_bi`
contract makes a single WebTransport stream look like a one-stream
connection). The WASM parsers are downstream artifacts (the SSH WASM
client, the SFTP WASM client), not part of `alknet-http`; `russh-sftp`'s
WASM targeting demonstrates feasibility, SSH is the next target.
**Auth for proxied ALPN sessions:** the browser authenticates by bearer
token on the WebTransport session request (the HTTP `Authorization`
@@ -24,10 +24,10 @@ The workspace decomposes into the following crates:
| Crate | Responsibility | Depends on |
|-------|---------------|------------|
| `alknet-core` | ProtocolHandler trait, ALPN router, endpoint, BiStream, AuthContext, IdentityProvider, config, ArcSwap dynamic config | tokio, quinn, rustls, irpc, iroh (feature-gated, added by ADR-010) |
| `alknet-core` | ProtocolHandler trait, ALPN router, endpoint, BiStream, AuthContext, IdentityProvider, config, ArcSwap dynamic config | tokio, quinn, rustls, iroh (feature-gated, added by ADR-010) |
| `alknet-vault` | Local key vault: BIP39/SLIP-0010/AES-GCM key derivation, encryption | (standalone, no alknet-core) |
| `alknet-ssh` | SshAdapter (russh, SOCKS5, port forwarding) | alknet-core, russh |
| `alknet-call` | CallAdapter (JSON-RPC via irpc, operation registry, pub/sub, access control, call protocol client, adapter traits) | alknet-core, irpc |
| `alknet-call` | CallAdapter (JSON-RPC via hand-rolled EventEnvelope framing, operation registry, pub/sub, access control, call protocol client, adapter traits) | alknet-core |
| `alknet-agent` | Agent service: LLM execution loop (forked aisdk), tool dispatch via call protocol, provider key retrieval via vault | alknet-call |
| `alknet-git` | GitAdapter (gix, pkt-line protocol) | alknet-core, gix |
| `alknet-sftp` | SftpAdapter (russh-sftp protocol core) | alknet-core, russh-sftp |
@@ -72,7 +72,7 @@ alknet-napi is a thin projection layer — it exposes the Rust call protocol cli
- ADR-001: ALPN-based protocol dispatch
- ADR-002: ProtocolHandler trait
- ADR-004: Auth as shared core (IdentityProvider)
- ADR-005: irpc as call protocol foundation
- ADR-005: irpc as call protocol foundation (superseded by ADR-064)
## Amendments
@@ -125,4 +125,19 @@ exception remains for alknet-http/agent/napi (which use alknet-call's
not framing glue); it no longer covers alknet-tty. See
[ADR-057](057-alknet-tty-no-alknet-call-dep.md) for the full decision
and the three options considered (duplicate / promote to core / use
alknet-call).
alknet-call).
### Amendment 3 (2026-07-09): irpc is not a dependency of any crate
The Decision table listed `irpc` as a dependency of `alknet-core` ("tokio,
quinn, rustls, irpc, iroh") and `alknet-call` ("alknet-core, irpc"). This
was carried over from the previous architecture and never verified against
the implementation: **no `.rs` file in the workspace ever imported irpc**.
The call protocol's wire format (`crates/alknet-call/src/protocol/wire.rs`)
is hand-rolled length-prefixed JSON; the `EventEnvelope` shape was derived
from the `@alkdev/pubsub` TypeScript prior art (ADR-013), not from irpc.
The dead `irpc` / `irpc-derive` workspace deps and the `alknet-call` consumer
dep were removed in commit `668d777`. See
[ADR-064](064-irpc-never-integrated-hand-rolled-framing.md) for the full
record (ADR-005, which accepted "irpc as the call protocol foundation," is
superseded).
@@ -2,7 +2,22 @@
## Status
Accepted
~~Accepted~~ → **Superseded** by [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md)
> **Superseded 2026-07-09.** This ADR accepted "irpc as the call protocol
> foundation" based on the previous architecture's use of irpc. When the
> call protocol was implemented, it turned out that **no `.rs` file in the
> workspace ever imported irpc** — the `irpc` / `irpc-derive` workspace deps
> were a Cargo.toml entry with no corresponding import. The wire protocol
> (`crates/alknet-call/src/protocol/wire.rs`) is hand-rolled length-prefixed
> JSON; the `EventEnvelope` shape was derived from the `@alkdev/pubsub`
> TypeScript prior art (ADR-013), not from irpc. ADR-064 supersedes this
> ADR and records the actual state: hand-rolled framing, no irpc
> integration. The architectural properties this ADR sought (proven
> length-prefixed JSON framing, cross-language JSON wire format, streaming)
> are preserved by the hand-rolled implementation. The text below is kept
> as the historical record of the decision that was made (and never
> implemented as stated).
## Context
@@ -53,8 +68,11 @@ local-only by construction, not remote-capable by default).
## References
- **Superseding ADR**: [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md) — irpc was never integrated; hand-rolled framing is the actual state
- ADR-013: Rust as canonical implementation (the `@alkdev/pubsub` prior art the `EventEnvelope` shape was actually derived from)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; ADR-064 confirms irpc was never in alknet-call either)
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-003: Crate decomposition
- ADR-004: Auth as shared core (IdentityProvider)
- irpc reference: `docs/research/references/iroh/irpc/` (see individual docs in that directory)
- Call protocol wire format (actual): `crates/alknet-call/src/protocol/wire.rs`
- The previous architecture had an equivalent decision in ADR-024 (bidirectional call protocol with EventEnvelope framing), which is archived in the reference implementation at `/workspace/@alkdev/alknet-main/`.
@@ -116,4 +116,54 @@ The BiStream trait is a thin convenience — `AsyncRead + AsyncWrite + Send + Un
- ADR-006: ALPN string convention and connection model
- OQ-01: BiStream type definition (resolved by this ADR)
- iroh ProtocolHandler pattern: `docs/research/references/iroh/iroh/`
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
## Amendments
### Amendment 1 (2026-07-09): `Connection::from_stream` opens the server-side door
This ADR's "WASM constraint" section argued that if `Connection` (or
BiStream) were bound to a QUIC library, WASM targets and alternative
transports couldn't implement it — and that a trait-based `BiStream`
preserves the *client-side* door. That argument was correct for `BiStream`
(the trait) but incomplete for `Connection`: until ADR-065, `Connection`
was a concrete type with only QUIC variants (`ConnectionKind::Quinn` /
`ConnectionKind::Iroh`), plus a `ConnectionKind::Mock` test stub. There was
no way to construct a `Connection` from a non-QUIC stream, which meant
TCP+TLS, SSH channels, WebTransport streams, and wasm streams could not
be dispatched through the `HandlerRegistry` — the *server-side* dispatch
door was closed.
**Resolved by [ADR-065](065-connection-from-stream-generic-single-stream.md):**
`Connection::from_stream(send, recv, alpn, remote_addr)` and
`Connection::from_bidi(stream, alpn, remote_addr)` construct a `Connection`
from any `AsyncRead + AsyncWrite` pair. A new `ConnectionKind::Stream`
variant holds a single read/write pair behind a `Mutex<Option<...>>` with a
yield-once `accept_bi` contract (QUIC yields many streams; everything else
yields one, then `ConnectionClosed`). Every existing `ProtocolHandler`
works over the new kind **unchanged** — handlers that loop `accept_bi`
(TtyAdapter) get one iteration; handlers that call once (HttpAdapter) get
the stream directly. Both correct, no branching on transport.
The stream-level `SendStreamKind::Mock` / `RecvStreamKind::Mock` variants
(already generic `Box<dyn AsyncRead/Write>` — the name was wrong) are
renamed to `Stream` and made load-bearing: `from_stream` calls
`SendStream::from_stream` / `RecvStream::from_stream`.
### Amendment 2 (2026-07-09): `MockConnection` / `ConnectionKind::Mock` removed
This ADR's Decision section and the `Connection` sketch referenced "test
mock" as one of the things `Connection` wraps. The implementation had a
`MockConnection` trait and `ConnectionKind::Mock` variant for test-only
full-connection mocks. ADR-065 removed both entirely: test stubs now use
`Connection::from_stream(tokio::io::sink(), tokio::io::empty(), alpn,
addr)` — `tokio::io::empty()` yields immediate EOF on read (handler exits
cleanly), and `accept_bi` returns `ConnectionClosed` after the first take
(run loop exits). One connection kind for production and tests, not two.
The "test mock" concept this ADR references is now subsumed by
`from_stream` — a test connection is just a single-stream connection with
EOF-on-read.
The server-side WASM door (OQ-09) is no longer closed by `Connection` being
QUIC-bound — `from_stream` accepts any `AsyncRead + AsyncWrite`, including
wasm-compatible streams. See OQ-09 for the updated resolution.
@@ -64,7 +64,7 @@ This is analogous to the reverse-proxy admin key pattern (ADR-028 in the reverse
## References
- ADR-003: Crate decomposition (alknet-vault is standalone)
- ADR-005: irpc as call protocol foundation (for alknet-call; the vault no longer uses irpc — see ADR-025)
- ADR-005: irpc as call protocol foundation (superseded by ADR-064 — irpc was never integrated; the vault no longer uses irpc — see ADR-025)
- ADR-009: One-way door decision framework
- ADR-014: Secret material flow and capability injection (specifies the mechanism this ADR described in prose)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; direct method calls only)
@@ -198,4 +198,60 @@ pub enum HandlerError {
- iroh Router pattern: `docs/research/references/iroh/`
- Reference implementation: `alknet-main/crates/alknet-core/src/server/serve.rs`
- Reference stealth mode: `alknet-main/crates/alknet-core/src/server/stealth.rs`
- Reference iroh transport: `alknet-main/crates/alknet-core/src/transport/iroh_transport.rs`
- Reference iroh transport: `alknet-main/crates/alknet-core/src/transport/iroh_transport.rs`
## Amendments
### Amendment 1 (2026-07-09): TCP+TLS can dispatch through the ALPN router via `from_stream`
This ADR's Decision section states: **"TCP mode is not an endpoint concern."**
The rationale was that bare TCP (SSH over port 22) does not use QUIC or
ALPN, so TCP access is handled by individual handlers listening on a TCP
socket independently — a handler-specific concern, not a core endpoint
concern.
That rationale holds for *bare TCP* (no TLS, no ALPN). But
**[ADR-065](065-connection-from-stream-generic-single-stream.md)** adds
`Connection::from_stream` / `from_bidi`, which construct a `Connection`
from any `AsyncRead + AsyncWrite` pair — including a
`TlsStream<TcpStream>`. A TCP+TLS accept loop can now call
`Connection::from_bidi(tls_stream, alpn, remote_addr)` and dispatch through
the **same `HandlerRegistry`** as QUIC connections, by the ALPN negotiated
in the TLS handshake. This is not a parallel listener bypassing the core —
it's the same ALPN dispatch, over a non-QUIC transport.
**Revised reading of "TCP is not an endpoint concern":** the
`AlknetEndpoint` struct (quinn + iroh) remains QUIC-only — the endpoint
does not own a TCP+TLS accept loop. But a TCP+TLS accept loop can be
constructed *outside* the endpoint (by the assembly layer or a handler)
and feed connections into the same `HandlerRegistry` the endpoint uses.
The endpoint is one accept-loop source; a TCP+TLS loop is another source
that shares the registry. The "not an endpoint concern" framing is
preserved at the struct level (no `tcp: Option<TcpListener>` on
`AlknetEndpoint`); the "TCP can't participate in ALPN dispatch" framing
is **reversed** — `from_stream` is the primitive that lets TCP+TLS
participate without changing the endpoint design.
The unblocked follow-ups (not part of ADR-065, but enabled by it):
- **Standard HTTP over TCP+TLS** (`api.alk.dev`'s requirement): a TLS
accept loop wraps each `TlsStream<TcpStream>` as a `Connection` via
`from_bidi` and dispatches to `HttpAdapter` by the negotiated ALPN
(`h2`/`http/1.1`). `HttpAdapter::handle` calls `accept_bi` once (yielded
by the single stream), then runs hyper over it — unchanged from the
QUIC path. No handler code changes.
- **SSH channel dispatch**: an SSH handler wraps each russh channel as a
`Connection` via `from_stream` and dispatches by channel-type (treated as
the ALPN string) through `HandlerRegistry`. One SSH connection carries
heterogeneous channels — a multiplexing power QUIC's per-connection ALPN
doesn't provide natively.
- **WebTransport stream dispatch** (parked per ADR-044, unblocked
structurally): the WT handler wraps each WT stream via `from_stream`.
The `iroh 0.35 → 1.0.2` migration (commit `acd049e`, 2026-07-09) is a
related cleanup: it bumps the iroh dep to 1.0, unblocking `alknet-blobs`
(which pulls `iroh 1.0` transitively). It is not an architectural change —
6 API surface edits in `endpoint.rs` / `types.rs` (the `Endpoint::builder`
preset, `SecretKey::from_bytes`/`generate` signatures,
`Connection::remote_id`/`alpn` return types). No ADR needed; the endpoint
design is unchanged.
@@ -201,8 +201,9 @@ makes the freeze explicit and enforceable by review.
## References
- ADR-003: Crate decomposition (alknet-vault is standalone)
- ADR-005: irpc as call protocol foundation (irpc remains the foundation
for alknet-*call*; the vault no longer uses irpc — see ADR-025)
- ADR-005: irpc as call protocol foundation (superseded by ADR-064 — irpc
was never integrated into alknet-call; the vault no longer uses irpc
either — see ADR-025)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; the
vault uses direct method calls, no actor, no remote capability)
- ADR-008: Vault integration point (CLI-embedded, assembly-layer only)
@@ -312,8 +312,11 @@ version of ADR-018's intent.
## References
- ADR-005: irpc as call protocol foundation (this ADR amends the vault
reference in ADR-005's Decision and Consequences; irpc remains the
foundation for alknet-*call*, just not for alknet-*vault*)
reference in ADR-005's Decision and Consequences; ~~irpc remains the
foundation for alknet-*call*, just not for alknet-*vault*~~ — **this
claim is itself superseded by [ADR-064](064-irpc-never-integrated-hand-rolled-framing.md)**,
which records that irpc was never integrated into alknet-call either;
neither the vault nor the call protocol uses irpc)
- ADR-008: Vault integration point (the vault is a capability source
accessed at assembly time — this ADR makes that the *only* mode)
- ADR-014: Secret material flow and capability injection (`DerivedKey`
@@ -0,0 +1,164 @@
# ADR-064: irpc Was Never Integrated — Hand-Rolled EventEnvelope Framing
## Status
Accepted
## Context
ADR-005 accepted "irpc as the call protocol foundation" based on the
previous architecture's use of irpc. When the call protocol was implemented,
it turned out that **no `.rs` file in the workspace ever imported irpc**.
The workspace `Cargo.toml` declared `irpc = "0.16"` / `irpc-derive = "0.16"`
as workspace dependencies, and `crates/alknet-call/Cargo.toml` declared
`irpc = { workspace = true }`, but the import was never written. The wire
protocol (`crates/alknet-call/src/protocol/wire.rs`) is hand-rolled
length-prefixed JSON — 4-byte big-endian length prefix + UTF-8 JSON body —
not an irpc service.
This was discovered when building an external app against the crates: the
`irpc 0.16` workspace dep was a version-gap blocker for `alknet-blobs` (which
pulls `irpc 0.17` transitively via `iroh-blobs 0.103`). A grep for any `irpc`
import in the workspace found zero hits — the dep was dead weight carried
over from the previous architecture without verification.
The framing, operation registry, dispatch, and subscription patterns that
ADR-005 attributed to irpc are all hand-rolled in alknet-call:
- **Framing**: `FrameFramedReader` / `FrameFramedWriter` in `wire.rs` —
length-prefixed JSON, hand-written against `tokio::io::AsyncRead`/
`AsyncWrite`. Not an irpc service.
- **Operation registry**: `OperationSpec`, `Handler`, `OperationRegistry`,
`AccessControl` — hand-rolled in alknet-call, not irpc's `Service` trait.
- **Event types**: `call.requested`, `call.responded`, `call.completed`,
`call.aborted`, `call.error` — the alknet call protocol's own event
vocabulary, not irpc's.
- **Subscription/streaming**: `StreamingHandler` / `invoke_streaming()`
(ADR-049) — hand-rolled, not irpc's streaming patterns.
The `EventEnvelope { type, id, payload }` shape was derived from the
`@alkdev/pubsub` TypeScript `EventEnvelope` (`/workspace/@alkdev/pubsub/src/
types.ts`), not from irpc. ADR-005's claim that "the wire format is irpc's
EventEnvelope framing" was wrong — irpc was never imported, and the envelope
shape has a different origin (the pubsub prior art, ADR-013). The framing
coincidentally resembles irpc's (both are length-prefixed JSON), which is
how the misattribution went unnoticed.
### What ADR-005 got right
Despite the irpc misattribution, ADR-005's *architectural* decisions are
correct and stand unchanged:
- The call protocol uses length-prefixed JSON `EventEnvelope` framing
(hand-rolled, not irpc-supplied).
- The wire format is cross-language and consumable from TypeScript, Python,
any language (JSON is inherently cross-language — ADR-005's "mitigated:
it's length-prefixed JSON" note was the load-bearing point, not the irpc
attribution).
- Operations use JSON Schema discovery. The `OperationSpec` shape is
hand-rolled, JSON-Schema-compatible — the same property ADR-005 attributed
to irpc, achieved without irpc.
### Why a new ADR rather than an amendment
ADR-005's Decision and Consequences are built on the premise "alknet-call
uses irpc as its foundation — irpc IS the call protocol's core." That
premise is false. Amending ADR-005 to say "actually it's hand-rolled" would
leave an ADR whose Context, Decision, and Consequences sections all argue
for a choice that was never made. The correct record is: ADR-005 is
superseded; the call protocol uses hand-rolled framing (this ADR-064); the
architectural properties ADR-005 sought (proven framing, cross-language
JSON, streaming) are preserved, but the mechanism is hand-rolled, not
irpc-sourced.
### The dead dep removal
The `irpc` / `irpc-derive` workspace deps and the `alknet-call` consumer dep
were removed in commit `668d777` (2026-07-09). `irpc` may be re-added as
`0.17` when `alknet-blobs` lands (it pulls `irpc 0.17` transitively via
`iroh-blobs 0.103`), but that would be a *transitive* dependency of
`alknet-blobs`, not a direct dependency of `alknet-call` — alknet-call does
not import irpc and has no plans to. See
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
§3.1 for the removal trace.
## Decision
1. **ADR-005 is superseded.** The call protocol does not use irpc. irpc was
never imported by any `.rs` file in the workspace. The dead `irpc` /
`irpc-derive` workspace and crate deps are removed.
2. **The call protocol uses hand-rolled `EventEnvelope` framing.** The wire
format is length-prefixed JSON (4-byte big-endian length + UTF-8 JSON
body), implemented in `crates/alknet-call/src/protocol/wire.rs`. The
`EventEnvelope { type, id, payload }` shape was derived from the
`@alkdev/pubsub` TypeScript prior art (ADR-013), not from irpc. The
framing, operation registry, dispatch, and streaming patterns are all
hand-rolled in alknet-call.
3. **The architectural properties ADR-005 sought are preserved by the
hand-rolled implementation:**
- Proven framing — length-prefixed JSON is a well-understood,
battle-tested pattern; the hand-rolled implementation is tested (207
lib + 2 integration tests passing).
- Cross-language — JSON is inherently consumable from any language;
NAPI, WASM, and browser clients speak the same wire format.
- Streaming — `StreamingHandler` / `invoke_streaming()` (ADR-049) provide
the subscription/streaming patterns ADR-005 attributed to irpc,
hand-rolled.
4. **irpc is not a planned dependency for alknet-call.** If `alknet-blobs`
pulls irpc transitively, it will be a transitive dependency of that
crate, not a direct dependency of alknet-call. alknet-call's framing,
registry, and dispatch are hand-rolled and will remain so. The "mitigated:
irpc is lightweight and we can fork if needed" caveat in ADR-005 is moot
— there is nothing to fork because nothing was integrated.
5. **The vault's irpc drop (ADR-025) stands.** ADR-025 dropped irpc from
alknet-vault. With this ADR, irpc is also confirmed absent from
alknet-call. The vault and call decisions are now consistent: neither
crate uses irpc. The only difference is that ADR-025 *removed* a real
(but unused-for-its-primary-path) irpc dependency from the vault, while
this ADR records that alknet-call's irpc dependency was never integrated
at all — it was a Cargo.toml entry with no corresponding import.
## Consequences
**Positive:**
- The spec matches the code. ADR-005's irpc claims were a spec/code
divergence that surfaced only when the `irpc 0.16` version gap blocked
`alknet-blobs`. This ADR closes the divergence.
- `alknet-blobs` is unblocked — the dead `irpc 0.16` workspace dep is
gone; `iroh-blobs 0.103` (which pulls `irpc 0.17` transitively) no longer
conflicts with a workspace-pinned older irpc.
- The call protocol's framing, registry, and dispatch are documented
accurately as hand-rolled — readers of the spec aren't sent looking for
an irpc integration that doesn't exist.
- The cross-language story is unchanged: JSON wire format, JSON Schema
discovery. The mechanism changed (hand-rolled vs irpc), but the property
ADR-005 sought is preserved.
**Negative:**
- The call protocol does not inherit irpc's testing or production pedigree
for its framing. Mitigation: length-prefixed JSON is a trivial,
well-understood pattern; the hand-rolled implementation is tested; and
the framing is small enough to audit completely (~30 lines in `wire.rs`).
- ADR-005's claim that "the call protocol inherits irpc's streaming and
subscription patterns" was wrong — those patterns are hand-rolled
(ADR-049). The streaming implementation is younger and less battle-tested
than irpc's would have been, but it is also simpler and fully owned.
## References
- ADR-005: irpc as call protocol foundation (superseded by this ADR)
- ADR-025: Vault local-only dispatch (dropped irpc from the vault; this ADR
records irpc was never integrated into alknet-call either)
- ADR-013: Rust as canonical implementation (the `@alkdev/pubsub` prior art
that the `EventEnvelope` shape was actually derived from)
- ADR-049: Streaming handler for subscriptions (the hand-rolled streaming
dispatch path)
- Call protocol wire format: `crates/alknet-call/src/protocol/wire.rs`
- Transport generalization findings:
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
§3.1 (dead `irpc` dep removal)
- Removal commit: `668d777` (2026-07-09)
@@ -0,0 +1,236 @@
# ADR-065: Connection::from_stream — Generic Single-Stream Connections
## Status
Accepted
## Context
ADR-007 defines `Connection` as a concrete type wrapping a QUIC connection
(quinn or iroh). ADR-010 establishes the endpoint as a multi-connectivity
QUIC acceptor — quinn and iroh, both producing QUIC connections dispatched by
ALPN. The `ProtocolHandler` trait (ADR-002) receives a `Connection`, and
handlers call `accept_bi()` / `open_bi()` to get bidirectional streams.
This design is **welded to QUIC**. Both real `ConnectionKind` variants
(`Quinn`, `Iroh`) are QUIC. The `HttpAdapter::handle` method calls
`connection.accept_bi().await` to get a bidi stream and serves HTTP over it
— "HTTP over QUIC," not "HTTP over TCP+TLS." There is no way to serve the
standard HTTP interface that `api.alk.dev` (an external app being built
against the crates) requires without either bypassing the
`HandlerRegistry` (a parallel listener, defeating the ALPN-router design)
or generalizing `Connection` to accept a non-QUIC stream.
The same welding blocks `alknet-ssh` (needs to dispatch SSH channels —
each channel is a read/write pair — through the same `HandlerRegistry` as
QUIC connections) and WebTransport stream dispatch (each WT stream is a
read/write pair). The `TtyAdapter` and `CallAdapter` dispatch loops are
already transport-agnostic in their inner logic — only the
`connection.accept_bi()` call is QUIC-coupled, because `accept_bi` only
works when `Connection` is QUIC-kind.
### The yield-once contract composes
QUIC's `accept_bi` returns a new bidi stream per call (many). A generic
single-stream connection's `accept_bi` returns the underlying stream on the
first call, then `ConnectionClosed` on all subsequent calls. This is the
contract that makes the abstraction compose:
- Handlers that loop `accept_bi` (TtyAdapter) get one session per
single-stream connection — the loop body runs once, then
`ConnectionClosed` breaks the loop. Correct.
- Handlers that call `accept_bi` once (HttpAdapter) get the stream
directly. Correct.
No branching on transport. The handler code is unchanged across QUIC
(many streams) and TCP+TLS / SSH channels / WebTransport streams (one
stream). The `ProtocolHandler` trait shape is not touched — this is an
additive change to `Connection`, not a trait revision.
### The stream-level Mock variants were already generic
`SendStreamKind::Mock(Box<dyn AsyncWrite>)` and `RecvStreamKind::Mock(Box<dyn
AsyncRead>)` were already generic stream holders — the name was wrong
(carried over from a test-only context). The generalization renames them
to `Stream` and makes them load-bearing: `Connection::from_stream` calls
`SendStream::from_stream` / `RecvStream::from_stream` to wrap the halves of
the single stream.
### The connection-level Mock is removed
The findings doc (`docs/research/transport-generalization/findings.md`)
proposed keeping `ConnectionKind::Mock` / `MockConnection` for test-only
full-connection mocks. The implementation went further: `MockConnection`
and `ConnectionKind::Mock` are removed entirely. Test stubs that
previously used `Connection::from_mock(Arc<StubConnection>)` now use
`Connection::from_stream(tokio::io::sink(), tokio::io::empty(), alpn,
addr)` — `tokio::io::empty()` yields immediate EOF on the read side,
causing the handler's `handle_stream` to exit cleanly, and `accept_bi`
returns `ConnectionClosed` after the first take (driving the run loop to
exit). This is simpler (one connection kind, not two) and the test stubs
are shorter. `from_stream` subsumes the test-mock use case because a test
connection is just a single-stream connection with EOF-on-read.
### Why not change the ProtocolHandler trait
An earlier analysis proposed changing `ProtocolHandler::handle` to take a
single `Channel` instead of a `Connection`, moving the multiplexing loop
from the handler to the endpoint. This ADR does **not** do that:
1. **TtyAdapter already establishes the pattern.** The handler loops
`accept_bi` and dispatches each stream internally. SSH does the same —
parse channels, dispatch each. The multiplexing loop belongs in the
handler, not the endpoint.
2. **The trait shape is a one-way door (ADR-009).** Changing
`handle(Connection)` → `handle(Channel)` would require migrating every
handler and would lock in a specific multiplexing model. `from_stream`
is additive — it extends `Connection` without touching the trait. If a
trait change is ever warranted, it can come later; `from_stream` doesn't
preclude it.
See `docs/research/transport-generalization/findings.md` §6 for the full
argument against the trait shape change.
## Decision
### Add `ConnectionKind::Stream`
A new variant holding a single read/write pair behind a
`Mutex<Option<(SendStream, RecvStream)>>` — the yield-once semantic. No
feature gate (generic, no transport deps). `StreamConn` is always
available; the quinn/iroh variants remain feature-gated.
### Add `Connection::from_stream` and `Connection::from_bidi`
```rust
/// Construct a Connection from a pre-split read/write pair.
/// `accept_bi()` yields this pair once, then returns `ConnectionClosed`.
/// `open_bi()` returns `StreamClosed` (a single stream can't open new streams).
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
/// Convenience for a single bidirectional stream (e.g. TlsStream<TcpStream>).
/// Splits internally via tokio::io::split.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self;
```
### Make `accept_bi`'s yield-once contract explicit
The `accept_bi` doc comment now states the transport semantics: QUIC yields
many streams, single-stream yields once then `ConnectionClosed`. This is
the contract that makes the abstraction compose — handlers don't branch
on transport.
### Rename stream-level `Mock` → `Stream`
`SendStreamKind::Mock` → `SendStreamKind::Stream`,
`RecvStreamKind::Mock` → `RecvStreamKind::Stream`.
`SendStream::from_mock` → `from_stream`, `RecvStream::from_mock` →
`from_stream`. The variants were already generic stream holders; the name
was wrong. Drop the `#[allow(dead_code)]` — `from_stream` is now
load-bearing.
### Remove `MockConnection` / `ConnectionKind::Mock`
The connection-level test mock trait and variant are removed. Test stubs
use `Connection::from_stream` with `tokio::io::sink()` / `tokio::io::empty()`
(immediate EOF on read → handler exits cleanly → `accept_bi` returns
`ConnectionClosed` → run loop exits). One connection kind for both
production and tests, not two.
### `open_bi` on `Stream` returns `StreamClosed`
A single stream cannot open new application streams. `open_bi` on
`ConnectionKind::Stream` returns `StreamError::StreamClosed`. Handlers that
call `open_bi` (the call protocol's server→client direction) work over
QUIC but not over a single-stream connection — this is inherent to the
transport, not a flaw. A handler that needs `open_bi` should not be
dispatched over a single-stream connection (or should multiplex its own
sub-streams within the one stream, as the call protocol does over a single
WebTransport stream).
### What does NOT change
- `ProtocolHandler` trait shape — `handle(&self, connection: Connection,
auth: &AuthContext)` stays. This is an additive change to `Connection`,
not a trait revision (ADR-009: the trait is a one-way door).
- `HandlerRegistry` — unchanged.
- All handler code (`HttpAdapter`, `TtyAdapter`, `CallAdapter`) —
unchanged. `HttpAdapter` is one `accept_bi` call away from
transport-agnostic (it already is — the call works over `from_stream`).
- `BiStream` trait — unchanged (ADR-007). `from_stream` is a server-side
connection constructor; `BiStream` is a client-side/test convenience
trait. They're complementary, not competing.
- The endpoint's accept loops (quinn/iroh) — unchanged. The TCP+TLS accept
loop that *uses* `from_stream` is a follow-up, not this ADR.
## Consequences
**Positive:**
- Every existing `ProtocolHandler` works over TCP+TLS, SSH channels,
WebTransport streams, and wasm streams **unchanged** — dispatch through
the same `HandlerRegistry` by ALPN string, no handler code changes.
- `api.alk.dev`'s HTTP blocker is resolved: a TCP+TLS accept loop can call
`Connection::from_bidi(tls_stream, alpn, remote_addr)` and dispatch
through `HandlerRegistry` — `HttpAdapter` works unchanged over the
single stream. (The accept loop itself is a follow-up commit; the
primitive it needs is now in place.)
- `alknet-ssh` is unblocked: the SSH handler wraps each russh channel via
`from_stream` and dispatches by channel-type (treated as the ALPN string)
through `HandlerRegistry`. One SSH connection carries heterogeneous
channels (`alknet/tty`, `alknet/call`, `h2`, ...) — a multiplexing power
QUIC's per-connection ALPN doesn't give natively.
- WebTransport stream dispatch is unblocked: the WT handler wraps each WT
stream via `from_stream` and dispatches through `HandlerRegistry` (the
primitive exists; WT itself is parked per ADR-044).
- The server-side WASM door (OQ-09) is no longer closed by `Connection`
being QUIC-bound — `from_stream` accepts any `AsyncRead + AsyncWrite`,
including wasm-compatible streams. (The accept-loop runtime remains
tokio-bound; the *connection* door is now open.)
- One connection kind for production and tests (no `MockConnection`
trait) — simpler type, shorter test stubs.
- No new deps, no `Cargo.toml` change — `tokio::io::split` is already
available via the existing tokio dep.
**Negative:**
- `open_bi` on a single-stream connection returns `StreamClosed` —
handlers that need server→client stream initiation (the call protocol's
bidirectional call direction) don't work over a single-stream
connection. This is inherent to the transport, not a design flaw: a
single TCP+TLS stream is not a multiplexed transport. Handlers that need
`open_bi` should run over QUIC, or multiplex their own sub-streams within
the one stream (as the call protocol does over a single WebTransport
stream — the `EventEnvelope` framing is stream-agnostic, ADR-012).
- The `close()` method's `code`/`reason` args are QUIC-specific
(application-level close codes). For a raw stream they're ignored — the
drop is the close. This is the same best-effort semantic `close` already
had for the removed `Mock` variant.
- A `Mutex` on the `StreamConn` — a single lock per `accept_bi` / `close`
call. Negligible cost (one `take()`), but it is a lock where the QUIC
variants have none.
## References
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-007: BiStream type definition (amended by this ADR — `Connection` is
no longer QUIC-only; the server-side WASM door is open)
- ADR-009: One-way door decision framework (why the trait shape is not
changed — `from_stream` is additive)
- ADR-010: ALPN router and endpoint (amended by this ADR — "TCP is not an
endpoint concern" is revised; `from_stream` lets TCP+TLS participate in
ALPN dispatch via a handler-internal accept loop)
- ADR-012: Call protocol stream model (the `EventEnvelope` framing is
stream-agnostic — composes over `from_stream`)
- OQ-09: WASM target boundaries (resolution amended — the server-side
dispatch door is no longer closed by `Connection` being QUIC-bound)
- Transport generalization findings:
[`docs/research/transport-generalization/findings.md`](../../research/transport-generalization/findings.md)
- Implementation commit: `865fef6` (2026-07-09)
+2 -1
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-07-08
last_updated: 2026-07-09
---
# Open Questions
@@ -171,6 +171,7 @@ filtering the tables above.
- **Blocked on**: A concrete server-side WASM use case, or a deliberate confirmation that WASM stays a client-side design constraint. Tracked as `architecture/oq-09-wasm-server-use-case` in `tasks/architecture/`.
- **Priority**: low
- **Amendment (2026-07-09)**: The `Connection` door is now open via `Connection::from_stream` (ADR-065) — a `Connection` can be constructed from any wasm-compatible stream. What remains closed is the **accept-loop runtime** (`tokio::spawn` does not run on WASM; `PendingRequestMap`/`CallAdapter` use tokio channels). The blocking condition (a concrete server-side WASM use case) is unchanged.
- **Full file**: [OQ-09](questions/009-wasm-target-boundaries.md)
### OQ-10: Git Adapter Scope — Smart Protocol Only or Full Server?
+7 -7
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-06-23
last_updated: 2026-07-09
---
# Alknet Overview
@@ -35,7 +35,7 @@ alknet-core
│ └── StaticConfig, DynamicConfig (ArcSwap)
│
├── alknet-ssh (depends on alknet-core, russh)
├── alknet-call (depends on alknet-core, irpc)
├── alknet-call (depends on alknet-core)
│ ├── CallAdapter (server: ProtocolHandler for alknet/call)
│ ├── Call client (send/receive over QUIC)
│ ├── OperationSpec, OperationRegistry, AccessControl
@@ -95,7 +95,7 @@ See [ADR-002](decisions/002-protocol-handler-trait.md) and [ADR-007](decisions/0
| ALPN | Handler | Description |
|------|---------|-------------|
| `alknet/ssh` | SshAdapter | SSH-2 handshake, channel multiplexing, SOCKS5, port forwarding |
| `alknet/call` | CallAdapter | JSON-RPC via irpc: operations, streaming, pub/sub |
| `alknet/call` | CallAdapter | JSON-RPC via hand-rolled EventEnvelope framing: operations, streaming, pub/sub |
| `alknet/git` | GitAdapter | Git smart protocol over QUIC (gix, pkt-line) |
| `alknet/sftp` | SftpAdapter | SFTP protocol (russh-sftp core) |
| `alknet/msg` | MessageAdapter | E2E encrypted messaging, mixnet |
@@ -142,11 +142,11 @@ See [ADR-008](decisions/008-secret-service-integration.md) and [ADR-014](decisio
## Call Protocol
alknet-call uses irpc as its foundation. The wire format is length-prefixed JSON (EventEnvelope framing). Operations are registered in an irpc registry with JSON Schema discovery. The call protocol supports request/response, streaming subscriptions, and pub/sub.
alknet-call uses hand-rolled `EventEnvelope` framing (length-prefixed JSON). The wire format, operation registry, and dispatch are all hand-rolled in alknet-call — irpc was never integrated (ADR-064 supersedes ADR-005, which had accepted "irpc as the call protocol foundation" based on the previous architecture but was never implemented as stated). Operations are registered in a hand-rolled registry with JSON Schema discovery. The call protocol supports request/response, streaming subscriptions, and pub/sub.
The call protocol's adapter contract (from_openapi, from_mcp, from_call, to_openapi, to_mcp) enables bidirectional composition — operations can be imported from external sources and exported to external protocols. These adapter traits are defined in Rust in alknet-call. The existing TypeScript `@alkdev/operations` library informed the design and may be adapted for browser use (see ADR-013).
See [ADR-005](decisions/005-irpc-as-call-protocol-foundation.md) for the full rationale.
See [ADR-064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) for the full rationale (supersedes [ADR-005](decisions/005-irpc-as-call-protocol-foundation.md)).
## WASM Compatibility
@@ -159,7 +159,7 @@ The following types live in alknet-core and are used across handler crates:
| Type | Purpose |
|------|---------|
| `ProtocolHandler` | The trait every handler implements |
| `Connection` | QUIC connection (or mock) — handlers open/accept streams on it |
| `Connection` | Transport connection (QUIC via quinn/iroh, or a generic single stream via `from_stream` — ADR-065) — handlers open/accept streams on it |
| `BiStream` | Trait: `AsyncRead + AsyncWrite + Send + Unpin` — bidirectional byte stream |
| `AuthContext` | Resolved identity for a connection (may be partial) |
| `Identity` | Authenticated peer identity (inbound) |
@@ -195,7 +195,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | One trait replaces StreamInterface/MessageInterface |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | One crate per protocol handler, core provides shared infra |
| [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core | IdentityProvider in core, handlers extract credentials |
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | Call protocol uses irpc for registry, framing, dispatch |
| [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | ~~Accepted~~ → **Superseded** by [ADR-064](decisions/064-irpc-never-integrated-hand-rolled-framing.md) (irpc was never integrated) |
| [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | `alknet/` prefix, one ALPN per connection |
| [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | BiStream is a trait, handlers receive Connection not BiStream |
| [008](decisions/008-secret-service-integration.md) | Vault Integration Point | CLI-embedded, vault is a capability source accessed at assembly time |
@@ -5,5 +5,33 @@
- **Door type**: One-way (when applicable)
- **Priority**: low
- **Blocked on**: A concrete server-side WASM use case, or a deliberate confirmation that WASM stays a client-side design constraint. Tracked as `architecture/oq-09-wasm-server-use-case` in `tasks/architecture/`.
- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. **BiStream being a trait preserves the *client-side* stream door** — a browser can implement BiStream over WebTransport streams. **The *server-side* dispatch door is NOT preserved by ADR-007 and is a known, accepted closure**: `Connection` is a concrete quinn-bound struct (not a trait), the accept loop uses `tokio::spawn` (tokio does not run on WASM), and the call-protocol dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio `oneshot`/`mpsc` channels. A WASM server-side peer would require a `Connection` trait and a runtime-abstracted accept loop — not planned. The browser path is client-side via a JS SDK, not server-side Rust-to-WASM. This is an explicit one-way door, not an oversight.
- **Cross-references**: ADR-007, ADR-009
- **Resolution**: Not an active question — WASM compatibility is a design constraint (see ADR-009, overview.md design principles), not a deliverable. Specific WASM targeting decisions will be made when individual crates are implemented. **BiStream being a trait preserves the *client-side* stream door** — a browser can implement BiStream over WebTransport streams. **The *server-side* connection door is now open via `Connection::from_stream` (ADR-065):** `from_stream` accepts any `AsyncRead + AsyncWrite` pair, including wasm-compatible streams, so a `Connection` can be constructed from a wasm stream and dispatched through the `HandlerRegistry` like any QUIC connection. What *remains* closed is the **accept-loop runtime**: the `AlknetEndpoint` accept loops use `tokio::spawn` (tokio does not run on WASM), and the call-protocol dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio `oneshot`/`mpsc` channels. A WASM server-side peer would require a runtime-abstracted accept loop (not `tokio::spawn`) and a runtime-abstracted channel set — the `Connection` door is open, the runtime door is not. The browser path is client-side via a JS SDK, not server-side Rust-to-WASM. This is an explicit one-way door (the runtime), not an oversight; the `Connection` door was a one-way door that ADR-065 opened (additively, without a trait change).
- **Cross-references**: ADR-007 (Amendment 1 — `from_stream` opens the server-side door), ADR-009, ADR-065
### Amendment (2026-07-09)
The original resolution (above) stated: "**The *server-side* dispatch door
is NOT preserved by ADR-007 and is a known, accepted closure**:
`Connection` is a concrete quinn-bound struct (not a trait), the accept
loop uses `tokio::spawn` (tokio does not run on WASM)..." That was accurate
when written: until ADR-065, `Connection` had only QUIC variants
(`ConnectionKind::Quinn` / `ConnectionKind::Iroh`) plus a
`ConnectionKind::Mock` test stub — no way to construct a `Connection` from a
non-QUIC stream.
**ADR-065 opens the `Connection` door.** `Connection::from_stream` /
`from_bidi` accept any `AsyncRead + AsyncWrite` pair, including
wasm-compatible streams. A `Connection` can now be constructed from a wasm
stream and dispatched through the `HandlerRegistry` like any QUIC
connection — the *connection* door is open. The resolution text above has
been updated to reflect this.
**What is still closed:** the **accept-loop runtime.** The
`AlknetEndpoint` accept loops use `tokio::spawn`, and the call-protocol
dispatch internals (`PendingRequestMap`, `CallAdapter`) use tokio
`oneshot`/`mpsc` channels. Tokio does not run on WASM. A WASM server-side
peer would require a runtime-abstracted accept loop and a
runtime-abstracted channel set — the `Connection` door is open, the
runtime door is not. This is the remaining one-way door, and it is still a
*runtime* door, not a *connection* door. The blocking condition (a concrete
server-side WASM use case) is unchanged.