docs: port architecture specs + 45 ADRs from alknet, renumbered

Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
This commit is contained in:
2026-08-12 07:06:57 +00:00
parent 1ceb9b785d
commit cc470a363a
58 changed files with 16729 additions and 0 deletions

161
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,161 @@
---
status: draft
last_updated: 2026-08-12
---
# alkcall
The call + channels RPC crate. Structured JSON RPC (operations, streaming
subscriptions, service discovery) and N-channel multiplexing over one
transport stream (channel 0 pre-negotiated as `alknet/call`).
This crate unifies `alknet-call` and `alknet-channels` from the alknet
mono-repo, plus the vendored core types formerly in `alknet-core`. The
source architecture docs were ported from
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
ADRs (ADR-001..045). The ALPN strings (`alknet/call`, `alknet/channels`)
are wire-stable and unchanged — see ADR-004.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [call-README.md](call-README.md) | draft | Call protocol index — adapter, stream model, registry, client (ported from alknet call/README.md) |
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (ADR-014), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic spawn_dispatch), from_call, OperationAdapter trait, no-env-vars invariant |
| [channels-README.md](channels-README.md) | draft | Channels protocol index — wire format, adapter, lifecycle, client (ported from alknet channels/README.md) |
| [channels-overview.md](channels-overview.md) | draft | The multiplexing collapse, crate dependencies, transport agnosticism, WASM |
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format, sentinels, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | ChannelBidiStreamSource, accept_bi yields one BiStream per channel |
| [channels-adapter.md](channels-adapter.md) | draft | ChannelsAdapter, ChannelManager, demux/mux contracts |
| [channel-operations.md](channel-operations.md) | draft | channel/open, channel/close, channel/control, channel/resources/subscribe |
| [channel-client.md](channel-client.md) | draft | ChannelClient — transport-agnostic from_connection primary |
## Applicable ADRs
### Core (vendored types) — ADR-001..012
| ADR | Title | Relevance |
|-----|-------|-----------|
| [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | HandlerRegistry, ALPN routing |
| [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | The trait every handler implements |
| [003](decisions/003-auth-as-shared-core.md) | Auth as Shared Core | IdentityProvider, Identity, AuthToken |
| [004](decisions/004-alpn-convention-and-connection-model.md) | ALPN String Convention | `alknet/` prefix, one ALPN per connection |
| [005](decisions/005-bistream-type-definition.md) | BiStream Type Definition | BiStream, handlers receive Connection |
| [006](decisions/006-authcontext-structure.md) | AuthContext Structure | AuthContext fields, hybrid resolution |
| [007](decisions/007-connection-from-stream-generic-single-stream.md) | Connection::from_stream | Generic single-stream connections |
| [008](decisions/008-bidistreamsource-trait.md) | BidiStreamSource Trait | Connection extension point |
| [009](decisions/009-bistream-as-the-handler-leaf.md) | BiStream as the Handler Leaf | accept_bi returns BiStream (concrete) |
| [010](decisions/010-secret-material-flow-and-capability-injection.md) | Secret Material Flow | No secrets on wire; Capabilities |
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | OwnershipProvider, resource_id_path |
| [012](decisions/012-connectioncredentials-decouple-dial-from-call.md) | ConnectionCredentials | Transport-level credentials, auth_token is per-request |
### Call protocol — ADR-013..030
| ADR | Title | Relevance |
|-----|-------|-----------|
| [013](decisions/013-irpc-as-call-protocol-foundation.md) | ~~irpc as Call Protocol Foundation~~ | **Superseded** by ADR-014 |
| [014](decisions/014-irpc-never-integrated-hand-rolled-framing.md) | Hand-Rolled EventEnvelope Framing | The call wire format ADR; supersedes ADR-013 |
| [015](decisions/015-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidi streams, EventEnvelope, ID correlation |
| [016](decisions/016-operation-error-schemas.md) | Operation Error Schemas | call.error with typed details |
| [017](decisions/017-privilege-model-and-authority-context.md) | Privilege Model | internal = authority switch; Visibility |
| [018](decisions/018-handler-registration-provenance-and-composition-authority.md) | Handler Registration | Registration bundle, provenance, composition authority |
| [019](decisions/019-operation-registry-layering.md) | Operation Registry Layering | Curated + session + connection overlays; OperationEnv trait |
| [020](decisions/020-abort-cascade-for-nested-calls.md) | Abort Cascade | call.aborted cascades; abort-dependents default |
| [021](decisions/021-streaming-handler-for-subscriptions.md) | Streaming Handler | StreamingHandler, invoke_streaming() |
| [022](decisions/022-call-protocol-client-and-adapter-contract.md) | Client and Adapter Contract | CallClient, from_call, OperationAdapter |
| [023](decisions/023-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | **Superseded** by ADR-024 |
| [024](decisions/024-peer-graph-routing-model.md) | Peer-Graph Routing Model | PeerCompositeEnv, PeerRef, AccessControl peer auth |
| [025](decisions/025-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | PeerId = Identity.id (stable) |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | Metadata only, never used by ACL |
| [027](decisions/027-from-jsonschema-as-http-adapter.md) | from_jsonschema as HTTP Adapter | FromJsonSchema provenance stays; impl in alknet-http |
| [028](decisions/028-from-call-manual-free-function.md) | from_call Is a Manual Free Function | Assembly layer calls it after dial |
| [029](decisions/029-aggregated-peer-env-wiring.md) | Aggregated Peer-Environment Wiring | Dispatcher hub wiring |
| [030](decisions/030-peer-composite-env-peer-operations.md) | PeerCompositeEnv::peer_operations | OperationEnv::peer_operations override |
### Shared — ADR-031..033
| ADR | Title | Relevance |
|-----|-------|-----------|
| [031](decisions/031-crate-decomposition.md) | Crate Decomposition | alkcall unifies core+call+channels |
| [032](decisions/032-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Reversal cost classification |
| [033](decisions/033-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TS reference |
### Channels — ADR-034..045
| ADR | Title | Relevance |
|-----|-------|-----------|
| [034](decisions/034-channels-wire-format.md) | Channels Wire Format | 8-byte chunk header; one-way door |
| [035](decisions/035-channels-pure-channel-multiplexing.md) | Pure Channel Multiplexing | No stream_type; BiStream-only; handler owns sub-mux |
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = alknet/call |
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations | channel/open, close, control, resources/subscribe |
| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel BidiStreamSource; yield-once accept_bi |
| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Demux/mux; ALPN-blind, auth-blind |
| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer; 256-channel memory bound |
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | ChannelLifecyclePolicy; 256 per PeerId |
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [043](decisions/043-channelclient.md) | ChannelClient | Transport-agnostic from_connection |
| [044](decisions/044-channels-subcrate-decomposition.md) | Channels Sub-Crate Decomposition | channels-core / channels-call (modules in alkcall) |
| [045](decisions/045-alknetclient-native-dial-seam.md) | AlknetClient Dial Seam | spawn_dispatch / from_connection take-over; dial in consumer |
## Relevant Open Questions
See [open-questions.md](open-questions.md) for the full tracker. Key
questions affecting this crate:
- **OQ-01**: Call protocol pub/sub primitive (open) — the call protocol
has `subscribe` but no `pub`; needed for channels `channel/resources/
subscribe` fan-out. This is the next ADR to write.
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
HOL-blocking deployment observation.
- **OQ-03**: Channels add/strip API shape (open) — whether the 8-byte
header add/strip is built into the read/write path or a standalone
utility.
## Key Design Principles
1. **One connection, full access**: An `alknet/call` connection gives
access to the entire operation registry.
2. **Protocol is symmetric**: Both sides can initiate calls. Producer/
consumer, not server/client.
3. **Hand-rolled framing (no irpc)**: EventEnvelope is hand-rolled
length-prefixed JSON. See ADR-014.
4. **Operation registry is layered**: Curated (static) + session +
connection overlays. OperationEnv is a trait. See ADR-019.
5. **No secret material on the wire**: Capabilities injected at assembly
layer. See ADR-010.
6. **Abort cascades to descendants**: Default abort-dependents.
See ADR-020.
7. **Peer authorization via AccessControl**: No remote_safe flag.
See ADR-024.
8. **Streams are streams**: Every channel is a BiStream. The handler
owns its sub-stream multiplexing. See ADR-035.
9. **Channel 0 is alknet/call**: Channel lifecycle is call operations on
channel 0. See ADR-036, ADR-037.
10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk
header are one-way doors. See ADR-014, ADR-034.
## References
- `@alkdev/alknet: docs/architecture/` — the source architecture docs
these were ported from (renumbered from alknet ADR-001..094 to alkcall
ADR-001..045)
- `@alkdev/alktype` — the binary struct engine, used for channels chunk
header layout and JSON payload schema validation
- `@alkdev/pubsub` — the TypeScript EventEnvelope prior art the call
wire format was derived from
> **Note**: The source ADRs and spec docs were ported from the parent
> `@alkdev/alknet` workspace where this crate originated. They are
> preserved here as the authoritative spec for alkcall; the alknet
> mono-repo will consume alkcall's versions when it is reworked.
>
> **Cross-references to non-ported ADRs**: Some spec docs and ADRs
> reference alknet ADRs by their original numbers (e.g., ADR-052 for
> TTY's wire format, ADR-082 for alknet-tls, ADR-086 for endpoint types).
> These are ADRs for sibling crates that are not part of alkcall. They
> retain their alknet numbering (052, 082, 086, etc.) — any ADR number
> outside the alkcall range 001..045 is an alknet source ADR, found at
> `/workspace/@alkdev/alknet/docs/architecture/decisions/`.

View File

@@ -0,0 +1,89 @@
---
status: draft
last_updated: 2026-07-17
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-022 client/adapter surface) addressed 2026-06-26; ADR-024 migration landed. Transport generalization sweep (ADR-014 supersedes ADR-013; ADR-007 `from_stream`) synced 2026-07-09. Crate-extraction sweep (phases 05) landed 2026-07-17: `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (ADR-012); TLS helpers in `alknet-tls` (ADR-045 §5); dial in `alknet-client` (ADR-045); `alknet-call` is a pure protocol crate with no TLS/transport deps.
---
# 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-007), over any `AsyncRead + AsyncWrite` transport. A pure protocol crate — no TLS or transport deps (the dial is in `alknet-client`, the TLS config is in `alknet-tls`).
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, hand-rolled EventEnvelope framing (no irpc — ADR-014), stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, hand-rolled framing (no irpc — ADR-014) |
| [client-and-adapters.md](client-and-adapters.md) | draft | CallClient (transport-agnostic `spawn_dispatch` primary; dial lives in `AlknetClient` per ADR-045), from_call, OperationAdapter trait, adapter location map, no-env-vars invariant, exchange-of-operations pattern (`from_jsonschema` in alknet-http per ADR-027) |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [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 (no irpc — ADR-014) |
| [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~~ | ~~Accepted~~**Superseded** by [ADR-014](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-013 |
| [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 |
| [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [066](decisions/066-from-jsonschema-as-http-adapter.md) | `from_jsonschema` as HTTP-Backed Single-Endpoint Adapter in alknet-http | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
| [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
| [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| [028](decisions/028-callclient-peer-scoped-registry-filtering.md) | ~~Peer-Scoped Registry Filtering~~ | ~~Accepted~~**Superseded** by ADR-024 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated `AccessControl`) |
| [029](decisions/029-peer-graph-routing-model.md) | Peer-Graph Routing Model | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| [030](decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `PeerId` source = `Identity.id` = `PeerEntry.peer_id` (stable); supersedes ADR-024's UUID source |
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | `forwarded_for` on `OperationContext` and `call.requested`; metadata only, never used by `AccessControl::check` |
| [033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | The dial is in `alknet-client`; `CallClient` is `spawn_dispatch` only; `alknet-call` is a pure protocol crate with no TLS/transport deps |
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial from Call Protocol | `ConnectionCredentials`/`RemoteIdentity` in `alknet-core` (not `alknet-call`); `auth_token` is a per-request payload field |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-07 | Call protocol scope within a connection | resolved (ADR-015) | Stream model, multiplexing, scope |
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-010) | None exposed for now |
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-019 to cover connection-scoped overlays. |
| OQ-25 | ~~Remote-safe marking shape~~ | **dissolved** (ADR-024) | `remote_safe`/`trusted_peer` retired; peer authorization is `AccessControl::check(peer_identity)` |
| OQ-26 | OperationAdapter error type (AdapterError variants) | **resolved** | `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`; `#[non_exhaustive]` |
| OQ-27 | from_call re-import trigger | **resolved** | `from_call` is a manual free function; the assembly layer calls it after the dial (in `AlknetClient`). `refresh()` is a genuine feature addition. See ADR-028. |
| OQ-28 | from_call namespace collision | **resolved** | Same-peer collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays) |
| OQ-29 | CallClient TLS client-auth | **resolved** | Wire quinn client-auth; key-type-aware server cert verification; fingerprint normalization |
| OQ-30 | `PeerRef::Any` routing policy | **resolved** | Insertion-order first-match; richer routing is a feature extension |
| OQ-31 | `services/list-peers` re-export semantics | **resolved** | Opt-in `services/list-peers`; `services/list` is "own ops only" |
| OQ-32 | Multi-hop federation | open (feature extension) | One-hop model is the commitment; multi-hop is a feature extension, not a deferral |
| OQ-33 | PeerId — crypto identity vs stable logical id | **resolved** (ADR-025) | `PeerId = Identity.id = PeerEntry.peer_id` (stable across key rotation) |
| OQ-34 | Persistent peer registry | **resolved** (ADR-025+033) | Core trait + in-memory default; persistence adapters are separate crates |
| OQ-35 | ~~API key asymmetry~~ | **dissolved** | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
| OQ-37 | X.509 outgoing-only case | **resolved** (ADR-034) | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; verifier by `PeerEntry` presence |
## Key Design Principles
1. **One connection, full access**: An `alknet/call` connection gives access to the entire operation registry — calls, subscriptions, batch, schema.
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-019.
5. **Hand-rolled dispatch (no irpc)**: Operations dispatch through the hand-rolled `OperationRegistry` (ADR-014). The call protocol is the external interface; internal handler dispatch uses `Handler`/`StreamingHandler` trait objects (ADR-021), 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-010.
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-020.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-017, ADR-018.
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-018. (`FromJsonSchema` is now a real HTTP-forwarding leaf per ADR-027, not a schema-only placeholder.)
11. **Connection direction is independent of call direction**: Who opens the connection is a connection-layer concern, not a protocol-layer concern. Both sides can call each other once connected. The `CallAdapter` accepts connections; the `CallClient` takes them over (`spawn_dispatch` primary; dial in `AlknetClient` per ADR-045); both produce the same `CallConnection` and dispatch through the same loop. See ADR-022, [client-and-adapters.md](client-and-adapters.md).
12. **Peer authorization via `AccessControl`**: A remote peer's call is authorized by `AccessControl::check(peer_identity)` against the op's `AccessControl` — the same mechanism that gates every other call. No `remote_safe` flag, no `trusted_peer` bypass. An op with `AccessControl::default()` is callable by any peer; an op with `required_scopes` is callable only by peers whose `Identity.scopes` satisfy them; an op with `Visibility::Internal` is never callable from the wire. See ADR-024.
13. **Adapter trait lives with the types; implementations live with their transport**: `OperationAdapter` is in `alknet-call`; `from_call` is in `alknet-call` (QUIC); `from_jsonschema`/`from_openapi`/`from_mcp`/`to_openapi`/`to_mcp` are in `alknet-http` (reqwest / axum). `alknet-call` stays lean — no HTTP client, no HTTP server. (`from_jsonschema` was originally in `alknet-call` as a schema-only placeholder; ADR-027 moved it to `alknet-http` as a real HTTP-backed adapter.) See [client-and-adapters.md](client-and-adapters.md).
14. **No handler reads outbound credentials from any source other than `OperationContext.capabilities`** (no-env-vars invariant): the credential injection path is vault → assembly layer → `Capabilities``HandlerRegistration.capabilities``OperationContext.capabilities` → handler. Downstream consumers' `std::env::var` reads are unreachable because the assembly layer never calls `Default::default()`. See ADR-010, [client-and-adapters.md](client-and-adapters.md).

View File

@@ -0,0 +1,637 @@
---
status: draft
last_updated: 2026-07-09
---
# Call Protocol
The wire protocol, stream model, framing, and adapter that alknet-call implements on ALPN `alknet/call`.
## What
The call protocol is a bidirectional, transport-agnostic RPC protocol that runs over any ordered, reliable bidirectional stream within a single `alknet/call` connection. It supports request/response calls, streaming subscriptions, batch operations, and service discovery — all using the same EventEnvelope wire format.
The `CallAdapter` implements `ProtocolHandler` for ALPN `alknet/call`. It receives a `Connection` from the endpoint (QUIC-native, or TCP+TLS/WebTransport/SSH via `Connection::from_stream` — ADR-007), accepts bidirectional streams, and dispatches incoming `EventEnvelope` messages to the operation registry.
## Why
The call protocol is the primary programmatic interface to an alknet node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
The protocol must be:
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
- **Bidirectional**: Both sides can initiate calls (server-to-client is as natural as client-to-server)
- **Stream-agnostic**: the protocol runs over any `AsyncRead + AsyncWrite` pair (QUIC streams, TCP+TLS, WebTransport, SSH channels, WebSocket — ADR-007); the transport provides the stream, the protocol provides the framing
- **Discoverable**: Clients can query what operations exist and their schemas
See ADR-014 for the decision that the call protocol uses hand-rolled
`EventEnvelope` framing (irpc was never integrated — ADR-013, which
accepted "irpc as the call protocol foundation," is superseded) and ADR-015
for the stream model decision.
## Architecture
### CallAdapter
The `CallAdapter` implements `ProtocolHandler`:
```rust
pub struct CallAdapter {
/// Layer 0 — the curated operation registry. Immutable after startup.
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
/// Layer 1 — optional session-overlay source (agent crate supplies this;
/// None for non-agent deployments). See ADR-019, OQ-19.
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
/// Default timeout for wire calls (30s). Composed calls inherit the
/// parent's remaining deadline via `OperationContext.deadline`.
default_timeout: Duration,
}
impl CallAdapter {
/// Non-agent deployment: no session overlay, default timeout.
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
Self { registry, identity_provider, session_source: None,
default_timeout: Duration::from_secs(30) }
}
/// Agent deployment: supply a session-overlay source. The agent crate
/// implements `SessionOverlaySource`; alknet-call defines the trait.
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
self.session_source = Some(source);
self
}
/// Override the default timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.default_timeout = timeout;
self
}
}
/// Session overlay integration point (ADR-019). Defined in alknet-call
/// because `CallAdapter` must name the type — alknet-call cannot depend on
/// alknet-agent (agent depends on call, not reverse). The agent crate
/// implements this trait; alknet-call defines it. This is the same pattern
/// as `IdentityProvider` (ADR-003: core defines the trait, handlers impl it).
///
/// The session overlay is an `OperationEnv` impl that wraps the curated base
/// (Layer 0). The `CallAdapter` composes it into the root
/// `OperationContext.env` per incoming call when a session is active. The
/// lookup mechanism (session ID in metadata, payload field, connection-bound
/// session state) belongs to the agent crate — this trait is the integration
/// point, not the lookup policy.
pub trait SessionOverlaySource: Send + Sync {
/// Returns the session overlay env for the given call, if a session is
/// active. `None` means no session is active for this call — the root
/// env is `curated base + connection overlay` (no session layer).
/// The agent crate determines how to map a call to its session.
fn overlay_for(&self, context: &OperationContext) -> Option<Arc<dyn OperationEnv + Send + Sync>>;
}
```
The `CallAdapter` holds the static curated registry and an optional
session-overlay source. Per-connection imported-ops overlays (Layer 2,
ADR-019) are held with the connection and composed into the root
`OperationContext.env` per incoming call. The composition env is peer-keyed
(`PeerCompositeEnv`, ADR-024 §1) to handle head→N-workers routing — a head
node with multiple worker connections holds a peer-keyed
`HashMap<PeerId, connection_overlay>`, not one overlay. See ADR-019 for the
layering model, ADR-024 for the peer-keyed extension, and `compose_root_env`
below.
### CallConnection
A `CallConnection` represents an established `alknet/call` connection,
regardless of which side opened it (ADR-022). It holds the connection's
imported-ops overlay (Layer 2, ADR-019) — the set of `from_call`-imported
operations discovered when the connection was established.
```rust
/// An established alknet/call connection (either direction — accepted or
/// opened). Holds the connection's Layer 2 overlay (imported ops).
pub struct CallConnection {
/// The underlying transport Connection (from endpoint.accept,
/// CallClient::spawn_dispatch, or AlknetClient::dial_*). May be QUIC,
/// TCP+TLS, WebTransport, SSH, or any Connection::from_stream source
/// (ADR-007).
connection: Connection,
/// Layer 2 — this connection's imported-ops overlay. Populated by
/// `from_call` discovery when the connection is established. Each
/// imported op is a `HandlerRegistration` with `provenance: FromCall`.
/// This overlay is an `OperationEnv` impl that the `CallAdapter`
/// composes into the root `OperationContext.env` per incoming call.
imported_operations: Arc<RwLock<HashMap<String, HandlerRegistration>>>,
}
impl CallConnection {
/// Register an imported operation into this connection's overlay
/// (Layer 2, ADR-019). Called by `from_call` after discovery.
pub fn register_imported(&self, registration: HandlerRegistration) {
let name = registration.spec.name.clone();
self.imported_operations.write().insert(name, registration);
}
/// Register multiple imported operations (bulk variant for `from_call`).
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
let mut overlay = self.imported_operations.write();
for reg in registrations {
overlay.insert(reg.spec.name.clone(), reg);
}
}
/// Build an `OperationEnv` impl for this connection's overlay. Used by
/// the `CallAdapter` when composing the root `OperationContext.env`.
/// Returns an `OperationEnv` that dispatches to this connection's
/// imported ops (and reports `contains` only for ops in the overlay).
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync>;
/// Call an operation on the remote peer (sends `call.requested`).
pub async fn call(&self, operation_id: &str, input: Value) -> ResponseEnvelope;
/// Subscribe to a streaming operation on the remote peer.
pub async fn subscribe(&self, operation_id: &str, input: Value) -> impl Stream<Item = ResponseEnvelope>;
/// Abort an in-flight request (sends `call.aborted`, cascades per ADR-020).
pub async fn abort(&self, request_id: &str);
}
```
**Layer 0 vs Layer 2 registration API** (ADR-019): `OperationRegistryBuilder`
builds Layer 0 (curated, immutable after startup) via `.with_local()` /
`.with_leaf()` / `.with()`. Layer 2 (per-connection) registration uses
`CallConnection::register_imported()` at runtime — the builder is
Layer-0-only; runtime overlay registration uses `CallConnection` methods.
When the connection drops, the overlay (and all imported ops) is dropped —
no explicit deregistration needed.
The adapter:
1. Accepts bidirectional streams on the connection
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
3. Resolves the peer's identity using `AuthContext` and `IdentityProvider`
4. Dispatches `call.requested` events to the operation registry
5. Writes response `EventEnvelope` frames back to the appropriate stream
6. Manages the `PendingRequestMap` for outgoing calls
The dispatch loop is **shared** with `CallClient` (ADR-022 §1): both
`CallAdapter::handle` (accept path) and `CallClient::spawn_dispatch`
(connect path — the dial is `AlknetClient::dial_*` per ADR-045) construct
a `Dispatcher` (`protocol/dispatch.rs`) and call `run_loop` — the
dispatch half is one implementation, the connection-establishment half
differs (accept vs dial). Peer authorization flows through the existing
`AccessControl::check(peer_identity)` — no `RemoteFilter`/`remote_safe` gate
(ADR-024 §3). The composition env is peer-keyed (`PeerCompositeEnv`,
ADR-024 §1) to handle head→N-workers routing. See
[client-and-adapters.md](client-and-adapters.md) for the `Dispatcher` mechanism
and [ADR-024](decisions/029-peer-graph-routing-model.md) for the
peer-graph routing model.
### Stream Model
See ADR-015 for the full rationale.
The call protocol uses bidirectional streams with EventEnvelope framing (transport-agnostic — QUIC streams, TCP+TLS, WebTransport, SSH channels, WebSocket — ADR-007). Key properties:
- **Either side can open streams**: The client opens a stream to call a server operation. The server opens a stream to call a client operation. Both use `open_bi()` and `accept_bi()`.
- **Correlation by request ID**: The `id` field in `EventEnvelope` correlates requests with responses. A response arriving on stream N can fulfill a request sent on stream M. The `PendingRequestMap` is keyed by ID, not by stream.
- **Stream usage is the client's choice**: A client may open one stream per operation, one stream for all operations, or any mix. The server processes EventEnvelopes regardless of stream origin.
- **One connection, full access**: A single `alknet/call` connection provides access to all operations (call, subscribe, batch, schema). No need for multiple connections or multiple ALPNs.
### Wire Format: EventEnvelope
Every message on the wire is a length-prefixed JSON `EventEnvelope`:
```rust
pub struct EventEnvelope {
pub r#type: String, // Event type
pub id: String, // Correlation key (request ID, subscription ID)
pub payload: Value, // serde_json::Value — schema depends on event type
}
// Frame: 4-byte big-endian length prefix + UTF-8 JSON body
```
The `Value` type is `serde_json::Value`. The envelope is JSON because it must be consumable from JavaScript, Python, and any language. The envelope itself stays JSON for cross-language compatibility.
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 hand-rolled length-prefixed JSON framing (ADR-014), 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-033).
### Event Types
Five event types carry request/response and subscription semantics:
| Event | Direction | Purpose |
|-------|-----------|---------|
| `call.requested` | Caller → Handler | Initiate a call or subscription |
| `call.responded` | Handler → Caller | Deliver a result (one for calls, many for subscriptions) |
| `call.completed` | Handler → Caller | Signal end of subscription stream |
| `call.aborted` | Either side | Cancel the call/subscription |
| `call.error` | Handler → Caller | Signal an error |
**A call is a subscribe that resolves after one event.** Both `call()` and `subscribe()` send the same `call.requested` event. The difference is consumption pattern:
- **call()**: Sends `call.requested`, resolves on first `call.responded`
- **subscribe()**: Sends `call.requested`, yields each `call.responded` until `call.completed` or `call.aborted`
The `id` field carries the `requestId` for correlation.
`call.completed` is sent only for subscriptions. A plain `call()` (request/response)
is complete after its single `call.responded`; no `call.completed` follows. The
`PendingRequestMap` entry for a `Call` is deleted on the first `call.responded`.
### `call.requested` Payload
The `payload` of a `call.requested` event has this shape:
```json
{
"operationId": "/fs/readFile",
"input": { ... },
"auth_token": "alk_...", // optional — see Identity Resolution below
"forwarded_for": { // optional (ADR-026) — present when a hub forwards a call
"id": "alice",
"scopes": ["fs:read", "docker:start"],
"resources": {}
}
}
```
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
- `forwarded_for` — optional (ADR-026). Present when a `from_call` forwarding handler propagates the originator's identity to a spoke. Carries a serialized `Identity` (id, scopes, resources) — the end user the hub authenticated. **Metadata only**`AccessControl::check` never reads it; the spoke authorizes the hub (its direct caller), not the end user. The hub may set `forwarded_for: None` if it doesn't want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-020) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
**Leading-slash convention**: `operationId` on the wire always has a leading slash (`/fs/readFile`). `OperationSpec.name` in the registry and in `services/list` responses never has a leading slash (`fs/readFile`). `OperationSpec.path()` produces the wire form (`/fs/readFile`). This is a single rule applied consistently — do not mix the two forms.
### `call.error` Payload
```json
{
"code": "FILE_NOT_FOUND",
"message": "file not found: /etc/nonexistent",
"retryable": false,
"details": { "path": "/etc/nonexistent", "errno": 2 }
}
```
Error codes use an extensible string enum. The protocol defines the following **protocol-level codes** (emitted by the dispatch machinery, not by handlers):
- `NOT_FOUND` — operation not in registry (or Internal op called from wire)
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
- `INVALID_INPUT` — input doesn't match the operation's JSON Schema
- `INVALID_OPERATION_TYPE` — wrong dispatch path for the operation's type (`invoke()` called on a `Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-021)
- `INTERNAL` — handler error, panic, connection failure
- `TIMEOUT` — request timed out (retryable: true)
Operations may also declare **operation-level domain codes** in their `error_schemas` (ADR-016) — e.g., `FILE_NOT_FOUND`, `RATE_LIMITED`, `INSUFFICIENT_CREDITS`. These are emitted by handlers and carry a `details` payload conforming to the declared `ErrorDefinition.schema`. Protocol-level errors omit `details` or carry protocol-specific context (e.g., the operation name for `NOT_FOUND`).
Fields:
- `code` — the error code (protocol-level or operation-level)
- `message` — human-readable error message. For logging and debugging, not for programmatic handling. Clients should switch on `code`, not parse `message`.
- `retryable` — whether the caller should retry. `true` for transient failures, `false` for permanent ones.
- `details` — optional. When the code matches a declared `ErrorDefinition`, `details` conforms to that definition's schema. This is the typed error payload — it makes errors structured instead of string-matched. See ADR-016.
New error codes may be added in future versions. Clients should treat unknown error codes as `INTERNAL` with `retryable: false`.
### Wire Payload Schemas
The `payload` field of `EventEnvelope` has a different shape per event type:
| Event | `payload` shape |
|-------|----------------|
| `call.requested` | `{ "operationId": "/fs/readFile", "input": {...}, "auth_token": "alk_..." (optional), "forwarded_for": { "id": "...", "scopes": [...], "resources": {} } (optional, ADR-026) }` |
| `call.responded` | `{ "output": <Value> }` — the operation's output, matching `output_schema` |
| `call.completed` | `{}` — empty object (subscription stream end signal) |
| `call.aborted` | `{}` — empty object (cancellation signal; the `id` identifies which request) |
| `call.error` | `{ "code": "...", "message": "...", "retryable": bool, "details": {...} (optional) }` |
### `ResponseEnvelope` → `EventEnvelope` Conversion
Local dispatch produces `ResponseEnvelope { request_id, result: Result<Value, CallError> }`. The `CallAdapter` converts it to `EventEnvelope` for the wire:
| `ResponseEnvelope` | `EventEnvelope` |
|--------------------|-----------------|
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`. The streaming dispatch path (`invoke_streaming()` → write each → write `call.completed`) produces these frames from a `StreamingHandler`'s stream; the single-response path (`invoke()` → write one) produces them from a `Handler`'s future. See ADR-021 and [operation-registry.md](operation-registry.md#handler).
### Protocol Operations
The call protocol defines four top-level operations, expressed through event types and operation names:
| Operation | Event Pattern | Description |
|-----------|--------------|-------------|
| **call** | `call.requested``call.responded` or `call.error` | Request/response — one result |
| **subscribe** | `call.requested` → many `call.responded``call.completed` or `call.aborted` | Streaming — zero or more results |
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
| **schema** | `call.requested` name `services/list` or `services/schema``call.responded` | Discover available operations |
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-14.
### Bidirectional Calls
Both sides of the connection can initiate calls. The server can call operations on the client just as the client calls operations on the server.
```
Client Server
│ │
│── open_bi() → stream ─────────────────────────▶│
│── call.requested { id: "c1", ... } ────────────▶│ (client calls server)
│◀─ call.responded { id: "c1", ... } ───────────│
│ │
│◀─ open_bi() ← stream ──────────────────────────│
│◀─ call.requested { id: "s1", ... } ────────────│ (server calls client)
│── call.responded { id: "s1", ... } ───────────▶│
│ │
```
The server calls client operations using the same `PendingRequestMap` and the same `EventEnvelope` format. The operation registry on the client side dispatches `call.requested` events just like the server side.
This enables patterns where the server pushes notifications, requests configuration from the client, or orchestrates workflows that require the client to perform operations.
### Streaming Subscribe Example: LLM Chat
The subscribe operation pattern maps naturally to LLM streaming. An agent handler exposing `/agent/chat` as a subscription receives a `call.requested` event and streams `call.responded` events back as the LLM generates tokens. The output payloads use a normalized streaming UI format (e.g., Vercel AI SDK UI chunks — text-delta, tool-input-delta, etc.):
```
Client Server (agent handler)
│ │
│── open_bi() → stream ──────────────────────────────▶│
│── call.requested { id: "c1", │
│ operationId: "/agent/chat", │
│ input: { messages, model } } │
│ │ handler reads capabilities (API key)
│ │ handler makes HTTP request to LLM provider
│ │ handler normalizes provider SSE → UI chunks
│←─ call.responded { id: "c1", output: { type: "text-start", ... } } │
│←─ call.responded { id: "c1", output: { type: "text-delta", delta: "Hel" } }│
│←─ call.responded { id: "c1", output: { type: "text-delta", delta: "lo" } } │
│←─ call.responded { id: "c1", output: { type: "text-end", ... } } │
│←─ call.completed { id: "c1" } │
```
The API key used for the outbound LLM HTTP request comes from `OperationContext.capabilities`, not from the call protocol input and not from environment variables. See ADR-010 and [operation-registry.md → Capability Injection](operation-registry.md#capability-injection).
### PendingRequestMap
Manages in-flight calls and subscriptions. Correlates `call.responded` events back to the original `call.requested`:
```rust
pub struct PendingRequestMap {
pending: HashMap<String, PendingEntry>,
}
enum PendingEntry {
Call {
tx: oneshot::Sender<Result<Value, CallError>>,
timeout: Instant,
},
Subscribe {
tx: mpsc::Sender<Result<Value, CallError>>,
timeout: Option<Instant>,
},
}
```
When a `call.responded` event arrives:
- If `PendingEntry::Call` → resolve the oneshot, delete entry
- If `PendingEntry::Subscribe` → push to the mpsc channel, keep entry alive
When `call.completed` arrives on a subscription → close the mpsc channel, delete entry.
When `call.aborted` arrives → cancel/drop whichever side initiated it.
A `call.aborted` for an unknown `requestId` is silently discarded.
Timeouts prevent dangling entries. A background task sweeps expired entries periodically.
### CallAdapter Stream Handling
The `CallAdapter::handle()` method:
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
2. For each accepted stream, reads `EventEnvelope` frames using `FrameFramedReader`
3. Dispatches `call.requested` events to the operation registry, **branching on `op_type`** (ADR-021):
- **`Query` / `Mutation`** → `OperationRegistry::invoke()` → write one `call.responded` (or `call.error`) `EventEnvelope` frame
- **`Subscription`** → `OperationRegistry::invoke_streaming()` → write each `call.responded` `EventEnvelope` as the stream yields → write `call.completed` on natural stream end (or `call.error` if the stream yields an `Err`). `deadline: None` for subscriptions (unbounded — see Timeouts below). Abort (`call.aborted` arriving for the request ID, or the stream being dropped) cascades per ADR-020: the stream future is dropped, `Drop` guards release the handler's resources, and descendants are aborted.
4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
5. Manages `PendingRequestMap` for outgoing calls initiated by the server
The streaming branch is the server-side path that makes `Subscription` operations work end-to-end. Without it, a `Subscription` op registered with a `StreamingHandler` had no server-side dispatch path — the handler produced a stream but the dispatcher only read one `ResponseEnvelope` and closed. ADR-021 adds the `StreamingHandler` type and the `invoke_streaming()` dispatch path; this section wires them into the accept loop. See [operation-registry.md](operation-registry.md#handler) for the `Handler` / `StreamingHandler` / `HandlerKind` types.
For outgoing calls (server → client), the adapter:
1. Opens a bidirectional stream with `connection.open_bi()`
2. Sends `call.requested` on that stream
3. Adds the request ID to the `PendingRequestMap`
4. Reads responses from any stream, correlates by ID
### AuthContext and Identity Resolution
The `CallAdapter` receives an `AuthContext` from the endpoint. The call protocol resolves identity per-request, not per-connection:
**Resolution flow**:
1. The endpoint provides `AuthContext` with whatever identity it resolved at the TLS layer (e.g., client certificate fingerprint). This may be `None` — the `AuthContext.identity` field is `Option<Identity>`.
2. When a `call.requested` event arrives, the `CallAdapter` constructs an `OperationContext` with the connection-level `AuthContext.identity`.
3. If the `call.requested` payload includes an `auth_token` field, the `CallAdapter` resolves it using `IdentityProvider::resolve_from_token()`. If resolution succeeds, the resulting `Identity` replaces the connection-level identity in the `OperationContext`. If resolution fails, the request proceeds with the connection-level identity (which may be `None`).
4. The `OperationContext.identity` is passed to the `OperationRegistry` for ACL checking.
5. If `identity` is `None` and the operation's `AccessControl` has restrictions, the registry returns `FORBIDDEN` with message `"authentication required"`.
**Key point**: Identity is resolved per-request, not per-connection. This allows a single connection to upgrade authentication mid-session (e.g., after an `auth/login` operation returns a token), and allows different operations on the same connection to have different identity levels.
### Root OperationContext Construction
When a `call.requested` arrives from the wire, the `CallAdapter` constructs the root `OperationContext` — the entry point of the call tree. This is the counterpart to `OperationEnv::invoke()` (which constructs nested contexts with `internal: true`): the wire path sets `internal: false`, meaning ACL runs against the caller's `identity`, not a handler's composition authority (ADR-017, ADR-018).
```rust
// CallAdapter dispatch path — root context for an incoming wire request
fn build_root_context(
&self,
request_id: String,
operation_name: &str, // looked up in registry for the registration bundle
identity: Option<Identity>, // resolved per-request above (caller's identity)
forwarded_for: Option<Identity>, // from call.requested.forwarded_for (ADR-026)
) -> OperationContext {
let registration = self.registry.registration(operation_name);
OperationContext {
request_id,
parent_request_id: None, // wire request — top of the call tree
identity: identity.clone(), // caller's identity (inbound — gate credential)
// Composition authority from the registration bundle (ADR-018).
// None for leaves (FromOpenAPI/FromMCP/FromCall); Some for Local/Session.
// This is on the context for PROPAGATION to children via invoke(),
// not for the root's own ACL (which uses identity above).
handler_identity: registration.composition_authority.clone(),
// Forwarded-for identity (ADR-026): the originator when this call was
// forwarded by a from_call handler. Metadata only — AccessControl::check
// never reads it; ACL always authorizes `identity` (the direct caller).
// None when the call wasn't forwarded or the forwarder chose not to
// propagate it. Populated from the wire call.requested.forwarded_for
// field; NOT inherited by composed children (wire-ingress only).
forwarded_for,
capabilities: registration.capabilities.clone(), // from the registration bundle
metadata: HashMap::new(), // fresh per request
deadline: Some(Instant::now() + self.default_timeout), // root deadline (W7)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedPeerEnv::empty), // from the bundle, empty for leaves
// Per-call env composition (ADR-019 + ADR-024): the root env is a
// PeerCompositeEnv — the curated base + this connection's imported-
// ops overlay (peer-keyed in the head's aggregation env, ADR-024 §1)
// + the active session overlay (if any). The CallAdapter builds this
// composite per incoming call — same shape as per-call identity
// resolution via IdentityProvider. Handlers call env.invoke() (peer-
// agnostic) or env.invoke_peer(peer, ...) (peer-specific, ADR-024 §2);
// the composite routes to the right overlay.
env: self.compose_root_env(/* peer_id, connection_overlay, session */),
abort_policy: AbortPolicy::default(), // abort-dependents (ADR-020 Decision 6)
internal: false, // external call — ACL against caller identity
}
}
```
The `internal: false` here is what makes a wire call a wire call — ACL checks against the caller's resolved `identity`. When a handler subsequently calls `context.env.invoke(...)`, the `OperationEnv::invoke()` path (see [operation-registry.md](operation-registry.md#operationenv)) constructs a nested `OperationContext` with `internal: true`, switching authority to `handler_identity`. The two construction paths — `CallAdapter` for wire-originated, `OperationEnv::invoke()` for composition-originated — are the only places `internal` is set. Handlers cannot set it themselves (the field is module-private for writes — see [operation-registry.md](operation-registry.md#operationcontext) and ADR-017).
The per-call `env` composition (ADR-019 + ADR-024) is the operation-dispatch analogue of the per-call identity resolution the CallAdapter already does via `IdentityProvider`. Both are integration-point patterns: the trait object owns the routing, the CallAdapter supplies the right sources per call. A connection's imported-ops overlay is part of the root env only for calls arriving on that connection — and on a head node with multiple worker connections, the overlays are peer-keyed (`PeerCompositeEnv`, ADR-024 §1); a session overlay is part of the root env only when a session is active. See ADR-019, ADR-024, and the `PeerCompositeEnv` sketch in [operation-registry.md](operation-registry.md#operationenv).
### ResponseEnvelope
The universal return type from all operation invocations:
```rust
pub struct ResponseEnvelope {
pub request_id: String,
pub result: Result<Value, CallError>,
}
pub struct CallError {
pub code: String, // protocol-level (NOT_FOUND, FORBIDDEN, ...) or operation-level (ADR-016)
pub message: String, // human-readable, for logging — not for programmatic handling
pub retryable: bool,
pub details: Option<Value>, // typed error payload, conforms to ErrorDefinition.schema (ADR-016)
}
```
Local dispatch produces `ResponseEnvelope` with no serialization overhead. The `CallAdapter` converts `ResponseEnvelope` to `EventEnvelope` for the wire. When a handler returns a `CallError` whose `code` matches a declared `ErrorDefinition`, the `details` field carries the typed error payload. See ADR-016.
### Connection and Stream Lifecycle
**Connection drop**: When the transport connection closes (QUIC close, TCP FIN, WebSocket close — the `Connection` reports `ConnectionClosed`), all pending requests in the `PendingRequestMap` are failed with `call.error` code `INTERNAL` and message `"connection closed"`. All subscription channels are closed. The `CallAdapter::handle()` method returns `Ok(())` (clean shutdown) or `Err(HandlerError::ConnectionClosed)` (unexpected).
**Stream reset**: When a stream is reset mid-operation (QUIC stream reset, TCP connection drop, or any transport-level error — the `FrameFramedReader` returns an error), the `PendingRequestMap` entry is removed and the mpsc channel is closed. If the stream was carrying a call, the oneshot is resolved with an error. No `call.aborted` is sent — the stream is gone.
**Timeouts**: Default timeout for wire calls is 30 seconds, configurable via
`CallAdapter::with_timeout()`. The `build_root_context` sets
`OperationContext.deadline` to `now + default_timeout`. Composed calls
inherit the parent's deadline (children do **not** get a fresh 30s — the
root call's deadline bounds the entire call tree, preventing a depth-5
composition from running 150s). A composed call that exceeds the deadline
is cancelled (future dropped, `Drop` guards release resources) and returns
`CallError { code: "TIMEOUT", retryable: true }`. Subscriptions default to
no deadline (`deadline: None` — unbounded); the client can specify a
timeout in the `call.requested` payload. The `PendingRequestMap` sweeper
runs every 10 seconds and removes expired wire entries.
**Error handling in `CallAdapter::handle()`**: If a handler panics, the stream is closed and the `PendingRequestMap` entry (if any) is cleaned up by the next sweeper pass. Other streams and the connection are unaffected.
### Abort Cascade and Nested Calls
When a handler composes other operations via `OperationEnv::invoke()`, it creates a call tree: a parent request (r1) spawns children (r1-a, r1-b), which may spawn their own children. The `parent_request_id` field on `OperationContext` records this tree — it is the agency chain (ADR-017).
When `call.aborted` arrives for a parent request, the protocol cascades the abort to all non-terminal descendants in the tree. The CallAdapter walks the tree (indexed by `parent_request_id` in `PendingRequestMap`) and sends `call.aborted` for each descendant. The default policy is **`abort-dependents`**: aborting a request aborts everything downstream, regardless of branch. This is the correct default because aborted parent work has no consumer waiting for results — continuing is wasted work at best and unwanted side effects at worst (e.g., a `bash/exec` that keeps running after the caller stopped caring).
An opt-in **`continue-running`** policy is available for cases where long-running work should survive a parent's abort (e.g., a subscription that should keep streaming). Under `continue-running`, descendants that have already started continue to completion; descendants that haven't started yet are aborted; no new descendants start.
The abort policy is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. The `call.requested` payload does not carry an abort policy field (the wire caller doesn't know the composition tree). The root context gets the default (`abort-dependents`); a handler can opt a child into `continue-running` at `invoke()` time. See ADR-020 Decision 6.
Handlers clean up resources when their call is cancelled (in Rust, the future is dropped and `Drop` guards release resources — HTTP streams, file handles, locks). This is a handler-level concern; the protocol's job is to cascade the abort. See ADR-020.
## Constraints
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
- 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 `ProtocolHandler` implementation that receives a `Connection` (any transport — ADR-007) and dispatches `EventEnvelope` frames. **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-014 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-010.
- **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-020.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled length-prefixed JSON framing, operation registry, dispatch; supersedes ADR-013 (irpc was never imported) |
| Call protocol stream model | [ADR-015](decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| ALPN per connection | [ADR-004](decisions/006-alpn-convention-and-connection-model.md) | `alknet/call` is a distinct ALPN, one connection per ALPN |
| ProtocolHandler receives Connection | [ADR-005](decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
| Vault integration point | [ADR-008](decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| Secret material flow | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction. Client/adapter surface specced in [client-and-adapters.md](client-and-adapters.md) |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; `AccessControl`-based peer authorization; retires `remote_safe`/`trusted_peer` |
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type, `invoke_streaming()` dispatch path, `INVALID_OPERATION_TYPE` protocol code; the server-side streaming branch in `handle_stream` |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot;
`remote_safe`/`trusted_peer` retired; peer authorization is
`AccessControl::check(peer_identity)`.
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
variants (`DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision`); `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer collision
= error; cross-peer dissolved by ADR-024 (separate sub-overlays). See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth
(present Ed25519 key as raw public key client cert); key-type-aware server
cert verification; fingerprint normalization. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
first-match. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-31** (resolved): `services/list-peers` re-export semantics — opt-in;
`services/list` is "own ops only." See [client-and-adapters.md](client-and-adapters.md).
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
model is the architectural commitment; multi-hop is a feature extension
that doesn't break downstream. See [client-and-adapters.md](client-and-adapters.md).
- **OQ-33** (resolved by ADR-025): `PeerId` source — `Identity.id` from
`IdentityProvider` resolution (= `PeerEntry.peer_id`, stable across key
rotation), not a connection-assigned UUID.
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
the storage boundary is `core trait + in-memory default`; persistence
adapters are separate crates.
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three remote
roles named (public X.509 endpoint, transport relay, hub); pure-client
X.509 connections are not in the peer graph on the client side. See
[client-and-adapters.md](client-and-adapters.md).
## References
- [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-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
- ADR-015: Call protocol stream model
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing)
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source)
- ADR-026: Forwarded-for identity (`forwarded_for` on `call.requested` and `OperationContext`)
- ADR-034: Outgoing-only X.509 and the three peer roles
- ADR-021: Streaming handler for subscriptions (server-side streaming dispatch path)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -0,0 +1,190 @@
---
status: draft
last_updated: 2026-07-18
---
# channel-client.md — ChannelClient
The client side of a channels connection. ADR-043 is the decision; this doc
specifies the API.
## What
`ChannelClient` is the symmetric counterpart to `ChannelsAdapter` (ADR-039).
The server side is a `ProtocolHandler` (`ChannelsAdapter::handle`); the
client side takes over an established transport `Connection`, runs the
demux/mux, and exposes `open_channel(alpn, params) -> Channel` to the
application.
This is the channels analogue of `CallClient` (server: `CallAdapter`;
client: `CallClient`) in the call protocol.
## API
```rust
pub struct ChannelClient {
manager: ChannelManager,
// The transport-side demux/mux, running in a background task.
...
}
impl ChannelClient {
/// Construct a `ChannelClient` over a pre-established transport
/// `Connection` on ALPN `alknet/channels`. This is the
/// transport-agnostic primary constructor: the caller (or a
/// transport-specific dial helper) produces the `Connection` —
/// via `Connection::from_bidi` (TCP+TLS, WebTransport, SSH
/// `direct-tcpip`), a quinn connection, or any other `AsyncRead +
/// AsyncWrite` source — and this method takes over: installs
/// channel 0 (`alknet/call`), spawns the demux/mux, and returns
/// the client. Mirrors the server side's transport-agnostic
/// `ChannelsAdapter::handle(Connection)` and
/// `CallClient::spawn_dispatch(Connection)`.
///
/// This is the one-way-door API surface (ADR-043). It must not be
/// coupled to a transport — the channels protocol is
/// transport-agnostic (ADR-034, as amended by ADR-035; ADR-007,
/// ADR-009), and the client side is half of that protocol.
pub async fn from_connection(connection: Connection)
-> Result<Self, ChannelError>;
/// Open a data channel with the given ALPN and params. Sends
/// `channel/open` on channel 0, waits for the response, and returns
/// the channel.
pub async fn open_channel(
&self,
alpn: &str,
params: Value,
direction: ChannelDirection,
) -> Result<Channel, ChannelError>;
/// Subscribe to the peer's resource updates. Returns a stream of
/// resource-set events (ADR-037 channel/resources/subscribe). Each
/// event carries the JSON `output.resources` array from ADR-037's
/// `channel/resources/subscribe` response shape.
pub async fn subscribe_resources(&self)
-> Result<BoxStream<ResourceEvent>, ChannelError>;
/// The call-protocol connection on channel 0, for invoking channel
/// lifecycle operations and any other call ops the peer exposes.
pub fn call(&self) -> &CallConnection;
}
pub enum ChannelDirection {
InitiatorToResponder,
ResponderToInitiator,
}
pub struct Channel {
pub channel_id: u32,
/// The channel's BiStream, accessible via the BidiStreamSource
/// (accept_bi — ADR-038 as amended by ADR-035).
pub source: ChannelBidiStreamSource,
}
/// One event from `channel/resources/subscribe`. Wraps the JSON `output`
/// object from ADR-037's subscribe response — the `resources` array
/// describing what ALPNs the peer exposes and with what `access` preview.
/// The channels crate maps the JSON to this typed struct; the fields mirror
/// ADR-037's response shape.
pub struct ResourceEvent {
pub resources: Vec<ResourceEntry>,
}
pub struct ResourceEntry {
pub alpn: String,
pub backends_or_targets: Vec<String>, // ALPN-specific enumeration
pub access: Value, // preview of AccessControl (advisory)
}
```
## Transport-agnostic by construction
`ChannelClient` is the client side of the channels protocol. The channels
protocol is transport-agnostic (ADR-034 substrate modes, as amended by
ADR-035; `Connection::from_bidi`/`from_source` from ADR-007/070/092 take
any `AsyncRead + AsyncWrite`). The client side must not be welded to a
transport — that would repeat the server-side welding ADR-007 explicitly
unwound.
`from_connection(connection: Connection)` is the primary constructor and
the one-way-door API surface. It takes a pre-established `Connection` and
takes over channels establishment. The transport is the caller's concern:
`Connection::from_bidi(tls_stream, ...)` for TCP+TLS, a quinn `Connection`,
a WebTransport `BiStream`, an SSH `direct-tcpip` channel wrapped via
`from_bidi`, a WebSocket carrying `alknet/channels` (the browser path per
ADR-044) — all produce a `Connection` that `from_connection` accepts
unchanged. This mirrors the server side's `ChannelsAdapter::handle(Connection)`, which is substrate-agnostic by the same mechanism.
The dial (QUIC, TCP+TLS, iroh) lives in `AlknetClient` (`alknet-client`,
ADR-045), not on `ChannelClient`. Callers compose
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`
— two lines, the dial then the take-over. Keeping the dial off
`ChannelClient` avoids `alknet-channels-call` depending on `alknet-client`;
the protocol crates are parallel to the dial, not downstream of it.
The credential/verifier-selection rule (ADR-034) lives in the dial
(`AlknetClient`), not in `from_connection``from_connection` receives
an already-established, already-authenticated `Connection`, exactly as
`ChannelsAdapter::handle` does on the server side.
## Bidirectionality preserved
The channels protocol is bidirectional — either side can open a channel
(ADR-037 §direction semantics). `ChannelClient::open_channel` supports both
`ChannelDirection::InitiatorToResponder` and
`ChannelDirection::ResponderToInitiator`. The client is not "the client
side" in the request/response sense — it can also receive `channel/open`
requests from the peer (the peer initiates, the client's `ChannelManager`
responds). This mirrors the call protocol's operation overlay (each side
populates what operations they expose).
`ChannelClient` is one endpoint of a bidirectional channels connection. The
name follows the `CallClient` convention (the side that dialed), not a
request/response role.
## Relationship to `AlknetClient`
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes a
pre-established `Connection`. The shared *dial+TLS* seam
(`AlknetClient`, OQ-55) is [`alknet-client`](../client/README.md), which
provides `AlknetClient` with three dial methods (`dial_quic` /
`dial_tcp_tls` / `dial_iroh`), each producing a `Connection` that
`from_connection` consumes. The dial is transport-specific (QUIC,
TCP+TLS, iroh); the take-over (`from_connection`) is
transport-agnostic. The two concerns are separated.
`AlknetClient::dial_quic` is the dial that feeds `from_connection`. A
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
`AlknetClient` directly; the fallback policy is a caller concern. See
[ADR-045](decisions/089-alknetclient-native-dial-seam.md) for the
full decision and [OQ-55](../../questions/055-alknetclient-establishment-extraction.md)
(resolved).
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [080](decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `open_channel`/`Channel`; handler owns sub-stream multiplexing |
## Open Questions
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
`alknet-client` with three dial methods. `ChannelClient`'s API is
transport-agnostic (`from_connection`); the dial is the shared seam.
See [ADR-045](decisions/089-alknetclient-native-dial-seam.md).
## References
- ADR-043: ChannelClient (the decision)
- ADR-035: channels pure channel multiplexing (no `stream_types`)
- ADR-037: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-038: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-035 — `accept_bi` yields a `BiStream`)
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction
- `docs/architecture/crates/call/client-and-adapters.md``CallClient` (the
shape `ChannelClient` mirrors)

View File

@@ -0,0 +1,427 @@
---
status: draft
last_updated: 2026-07-18
---
# channel-operations.md — Channel Lifecycle on the Call Protocol
Channel lifecycle is orchestrated by the call protocol on channel 0
(ADR-036). Four operations on channel 0's `OperationRegistry` (ADR-037)
handle open, close, control, and resource discovery. All four go through
the existing `OperationContext` / `AccessControl::check` path — no new auth
machinery, no new framing.
## The four operations
### `channel/open` — open a data channel
Request (on channel 0):
```json
{
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder"
}
}
```
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
Response:
```json
{
"output": {
"channel_id": 7
}
}
```
| field | type | meaning |
|-------|------|---------|
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
**Channel ID allocation: server-assigned (DP-1).** One round-trip before
data flows — the same round-trip the call protocol makes for every
operation. All current channel types (TTY, tunnel, SSH) already require a
negotiation round-trip, so the open round-trip is not additive latency.
**Error codes** (new `CallError.code` strings, not new framing):
| code | meaning | retryable |
|------|---------|-----------|
| `channel:unknown_alpn` | ALPN not in responder's `HandlerRegistry` | false |
| `channel:forbidden` | `AccessControl::check` denied the open | false |
| `channel:allocation_failed` | Handler allocate failed | true (often transient) |
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-040) | false |
### `channel/close` — tear down a channel
```json
{
"operation": "channel/close",
"input": { "channel_id": 7, "reason": "exit" }
}
```
The responder (the side that didn't send the close) drains its reassembled
stream for `channel_id`, signals EOF to the handler, and returns
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
completes (ADR-040 — monotonic IDs with wrap-around, not a free-list).
`reason` is free-form for observability — not semantically required.
**REQ-CH-06: exit-chunk-before-close ordering.** The channel's data chunks
MUST be written and flushed before the `channel/close` operation is sent on
channel 0. The side closing must observe the data-channel pump complete
before issuing the call operation. For TTY this is the exit-chunk-is-last
invariant (ADR-055) carried forward — the exit control message rides on
TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload
format); for tunnels it is the last data byte before close. This invariant
crosses two channels (the data channel and channel 0), so the channels
layer owns the ordering guarantee.
### `channel/control` — out-of-band control on channel 0
For control that doesn't need ordering relative to data (resize, signal,
keepalive):
```json
{
"operation": "channel/control",
"input": {
"channel_id": 7,
"message": { "type": "resize", "cols": 80, "rows": 24 }
}
}
```
The channels layer routes `message` to the handler's control handle for
`channel_id`. The `message` JSON is ALPN-specific; the channels layer does
not interpret it.
### `channel/resources/subscribe` — live resource discovery
**This is a `Subscription` operation (ADR-021), not a polled Query.** The
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
tested). The first consumer (the hub aggregating worker resources) needs
live updates when workers connect/disconnect or containers start/stop.
```json
{
"operation": "channel/resources/subscribe",
"input": {}
}
```
The responder registers a `StreamingHandler` that emits a `ResponseEnvelope`
whenever the resource set changes. Each event:
```json
{
"output": {
"resources": [
{
"alpn": "alknet/tty",
"backends": ["docker", "local"],
"access": { "required_scopes": ["tty:open"] }
},
{
"alpn": "alknet/tunnel",
"targets": ["container:*", "service:postgres"],
"access": { "required_scopes_any": ["tunnel:open", "admin"] }
}
]
}
}
```
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN this side accepts `channel/open` for. |
| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these. |
| `access` | object | A preview of the `AccessControl` that `channel/open` will check. Advisory — lets the initiator fail fast. The real check happens on `channel/open`. |
The stream emits an initial snapshot immediately, then subsequent events on
any change. The stream is long-lived; the subscriber cancels by dropping the
subscription (ADR-020 abort cascade applies).
A `channel/resources` (non-subscribe, `Query`) operation is NOT provided.
The subscription's initial snapshot serves the poll use case (subscribe,
read the first event, cancel). Providing both would be redundant and would
pressure consumers toward the stale-poll path.
## Direction semantics (OQ-CH-09 — pinned)
Channel open is **bidirectional** — either side can initiate. The
`direction` field determines who is the ALPN-server (allocates the handler,
writes the negotiation response) vs the ALPN-client (writes the first
request).
| `direction` | Initiator role | Responder role | Who writes first |
|-------------|----------------|----------------|-------------------|
| `initiator-to-responder` | ALPN-client | ALPN-server | Initiator writes first (the request data); responder's handler is the server side. The common case: "open me a TTY on your docker container." |
| `responder-to-initiator` | ALPN-server | ALPN-client | Responder writes first (the negotiation response); initiator's handler is the client side. The "worker exposes, hub consumes" case: the worker initiates the open to make itself available; the hub is the client. |
**The channels layer does not enforce write order.** Write order is
ALPN-specific, determined by which side is the ALPN-server. The channels
layer routes chunks; the handlers negotiate who writes first via their
ALPN's `params` contract.
**`channel_id` allocation is always by the responder** (DP-1), regardless of
`direction`. The responder is the side that receives the `channel/open` call
operation; it allocates the ID and returns it. In the `responder-to-
initiator` case, the initiator (worker) sends the `channel/open`, so the
responder (hub) allocates the ID — even though the worker is the ALPN-server
for the channel's data. This keeps ID allocation in one place and avoids the
collision-prone client-assigned alternative.
## Control-message division (DP-4 — pinned)
| Control path | When | Examples |
|--------------|------|----------|
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
| Data-ordered bytes on the data channel's `BiStream` (handler-internal framing) | Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
(stream_type 4, inside TTY's 5-byte payload format) because it must arrive
after the last data on TTY's stdout stream_type, guaranteed by TTY's
per-stream_type chunk ordering within its own 5-byte format, not by a
call-protocol round-trip. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
**The control-message division is handler-internal.** Under ADR-035, the
channels layer has no `stream_type` concept — it carries the handler's
framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type
3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
channels layer routes by `channel_id` only; the handler owns its
sub-stream multiplexing on the `BiStream` it receives. The
"bidirectional control channel" property is a TTY-layer concern, fixed
at the TTY layer by Phase 7's split — the channels layer doesn't know
about it.
## ACL flow (end-to-end)
A browser opening a TTY channel to a spoke through a hub (ADR-042):
1. Browser's channel 0 → hub's channel 0: `channel/open`
`{ alpn: "alknet/tty", params: { backend: "docker", cmd: ["bash"], container: "abc123" } }`.
The browser's identity is a bearer token (ADR-034).
2. Hub's `CallAdapter` runs `AccessControl::check` on `channel/open` with
the browser's identity. If denied → `channel:forbidden`.
3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for` handler
constructs a `call.requested` with the hub as caller and the browser as
`forwarded_for` (ADR-026 §3). The spoke receives `channel/open` with
`caller = hub`, `forwarded_for = browser`.
4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as caller
(the spoke authorizes the hub — ADR-011). The spoke's ownership store
verifies the hub (or the `forwarded_for` browser, per policy) owns
`container:abc123`.
5. Spoke allocates the channel via `TtyAdapter` / `DockerTtyBackend`,
returns `channel_id`.
6. Hub opens a matching channel on the browser's side and bridges them
(byte-forward with `channel_id` rewrite — ADR-042).
The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
layer inherited the auth model by being a call-protocol operation.
## Per-identity channel cap (ADR-041)
A channel slot is a resource. The cap on how many channels an identity
may hold open is a quota check on that resource — parallel to
`OwnershipProvider::owns` (ADR-011) for spawned resources. Same
primitive, different resource. The cap is a **peer concern**, not a
hub-specific concern: any accepting peer (worker or hub) enforces the
cap on its inbound channels, just as it enforces `AccessControl::check`
on `channel/open`. The cap is also **symmetric** — both sides of a
channels connection enforce their cap on the other's channels.
### Why the cap is not in the channels layer
`ChannelManager` (ADR-039) is auth-blind by design — no auth state, no
identity, no scopes. That decision is load-bearing (it is what makes
the channels layer WASM-compatible, transport-agnostic, and
ALPN-blind). So the per-identity cap lives in `channels-call`, where
the identity is already on `OperationContext` (the same place
`AccessControl::check` runs). The channels layer (`channels-core`) is
unchanged. See ADR-041 §"Why the channels layer cannot hold the cap".
The channels-layer per-connection `max_channels = 256` (ADR-040) is
a **per-connection memory bound** (limits one connection's
reassembly-buffer cost), not a DoS defense. A peer can open an
unbounded number of transport connections, so a per-connection cap is
not a per-peer DoS defense. The per-identity DoS defense is the cap
documented here; see ADR-041 for the corrected DoS-defense framing.
### The `ChannelLifecyclePolicy` trait
```rust
/// Per-identity channel lifecycle policy. Consulted by the
/// `channel/open` handler (after `AccessControl::check`, before
/// allocation) and the `channel/close` handler (after deallocation).
/// Both handlers have the identity via `OperationContext`.
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-037) when the identity is over its cap. The identity is
/// the direct caller (the peer that opened this channels
/// connection); `forwarded_for` is metadata and is NOT consulted
/// (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-040 §channel-id-reuse).
fn on_close(&self, identity: &Identity);
}
```
### Default: `PerIdentityChannelPolicy::new(256)`
The default constructor enforces 256 per identity out of the box — no
"NoOp default + wire it later." A channels-accepting peer that
constructs `ChannelOperations::new(manager)` with no policy argument
gets `PerIdentityChannelPolicy::new(256)`. The default is secure;
opt-outs are explicit:
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
peer** and shared (via `Arc`) across every channels connection that
peer accepts. The sharing is what makes the cap per-identity, not
per-connection.
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)`
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
default cap for specific peers. Used by a spoke that serves a
high-fan-out hub (the hub peer's cap is set higher than a worker
peer's cap — see "Relay consequence" below).
- `NoCap` — no cap. Explicit opt-out for tests, POCs, and trusted
single-peer deployments. Not the default.
The policy is constructed once and passed to `ChannelOperations` at
registration time:
```rust
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager, policy);
channel_ops.register_on(&mut call_registry)?;
```
### Enforcement point: between `AccessControl::check` and allocation
The `channel/open` handler (above) gains the policy check after ACL
and before `next_id.fetch_add`:
1. ACL is already checked by `OperationRegistry::invoke` (the existing
`AccessControl::check` path — unchanged).
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if over cap.
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)`
(DP-1: server-assigned — unchanged).
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
the `ChannelState` (unchanged).
5. Return the `channel_id`.
The `channel/close` handler gains the decrement after the drain
completes (the same point ADR-040 marks the `channel_id` as eligible
for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-040
§channel-id-reuse).
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
per-identity count.
3. Return `{ "closed": true }` (unchanged).
### Relay consequence: the spoke caps the hub, not the browser
When the hub relays a browser's channel to a spoke (ADR-042), the
spoke sees the hub as the direct caller. `forwarded_for` carries the
browser's identity as metadata (ADR-026 — `forwarded_for` is not
authority; `AccessControl::check` never reads it). The channel cap
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
consulted with the **hub's** identity, not the browser's. The spoke
asks "does the hub have access to open another channel?" and the
hub's quota on the spoke reflects the aggregate of all relayed
channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
This is correct and consistent — the spoke authorizes the hub for
container access the same way it authorizes any peer, and the hub's
browser-relay ACL is the hub's own layer. The channel cap follows the
same pattern as any other resource ACL.
**Deployment consequence:** a spoke that serves a hub relaying for
many browsers must set the hub peer's cap higher than a worker peer's
cap, or the spoke denies legitimate relayed channels when the hub's
aggregate count exceeds a worker-sized cap. This is a per-peer-role
policy, set by the spoke via `with_per_identity_caps`. The
architecture provides the mechanism; the deployment sets the numbers.
This is not a flaw — it is the same shape as any per-peer ACL (a
spoke may authorize one peer for 1000 containers and another for 10;
the channel cap is the same kind of per-peer policy).
### Recursive channels do not bypass the cap
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
new `ChannelsAdapter` with a new `ChannelManager`. If the same
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
the inner channels are counted against the same identity. Recursion
is not a bypass; the 13-byte-per-chunk overhead is the documented
cost (ADR-035), and the cap behavior is unchanged. Recursive channels
are an edge case for edge cases and not specced further.
## Hub relay contract (ADR-042 — summary)
The hub **translates**, not transparently forwards:
1. **Call-protocol layer (channel 0): translate.** The hub terminates
channel 0 on both legs. `channel/open` from the browser → hub's
`AccessControl::check` → hub re-issues `channel/open` on the spoke leg
with `forwarded_for` → spoke returns its `channel_id` → hub maps
browser-id ↔ spoke-id.
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** The
relay reads chunks for `browser_id`, rewrites the `channel_id` field to
`spoke_id`, writes onto the spoke's channels connection — and vice versa.
The relay does not parse the payload.
`channel/control` operations on channel 0 carry `channel_id` in their JSON
payload; the hub's `CallAdapter` translates these too (rewrites
`channel_id` in the payload). The relay does not touch `channel/control`
it's a call operation, translated, not byte-forwarded.
The hub never runs a handler for `alknet/tty`, `alknet/ssh`, or
`alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/call`
(for its own hub-level operations + translation).
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing |
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
| [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check |
## References
- ADR-037: channel lifecycle operations (the decision)
- ADR-041: per-identity channel cap (the cap, the trait, the relay
consequence)
- ADR-042: hub relay (the translate contract)
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
Negotiation, §ACL and Security Model

View File

@@ -0,0 +1,153 @@
---
status: draft
last_updated: 2026-07-18
---
# alknet-channels
A multiplexing proxy: a `ProtocolHandler` on `alknet/channels` that
decomposes a single bidirectional transport stream into N logical channels,
each carrying a different ALPN. Channel 0 is pre-negotiated as `alknet/call`
(ADR-036); every other channel is opened dynamically via call operations on
channel 0 and routed through the same `HandlerRegistry` as top-level
connections. The channels layer is a re-framing proxy — it converts between
"one transport stream carrying N channels" (the wire) and "N independent
`BiStream` handles" (what handlers see) — and it does no protocol work
itself. The channels layer has no `stream_type` concept (ADR-035); the
handler owns its sub-stream multiplexing on the `BiStream` it receives.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, ALPN, transport agnosticism, WASM, relationship to existing crates |
| [channels-wire.md](channels-wire.md) | draft | The 8-byte chunk format (`[channel_id:u32 be][length:u32 be][payload]`), the add/strip composition, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
| [channels-connection.md](channels-connection.md) | draft | `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-008/074, as amended by ADR-035), `accept_bi` yields one `BiStream` per channel, recursive composition |
| [channels-adapter.md](channels-adapter.md) | draft | `ChannelsAdapter` (`ProtocolHandler` on `alknet/channels`), `ChannelManager`, demux/mux contracts (REQ-CH-01..04), the two-pump pattern (ADR-078) |
| [channel-operations.md](channel-operations.md) | draft | `channel/open`, `channel/close`, `channel/control`, `channel/resources/subscribe` — call-protocol operations on channel 0, ACL flow, `direction` semantics, the hub relay contract (ADR-042) |
| [channel-client.md](channel-client.md) | draft | `ChannelClient` — the client side of a channels connection; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045); bidirectionality preserved |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [071](decisions/071-channels-wire-format.md) | channels Wire Format — 8-Byte Chunk Header | The chunk format; channels layer has no `stream_type` concept (amended by ADR-035); substrate-agnostic; one-way door |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Is Pre-Negotiated `alknet/call` | Channel 0 = call protocol; no special control plane |
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-035 — `into_sub_streams` removed) |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-041) |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | `ChannelClient`, transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers, not sub-crates |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The `Connection` extension point `ChannelBidiStreamSource` implements |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream`; the transport-leaf decision ADR-035 builds on |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The transport-agnostic `Connection` the channels layer rides on |
| [052](decisions/052-alknet-tty-wire-format-and-two-carriage.md) | alknet-tty Wire Format | The 5-byte format carried transparently in the channels payload (control bidirectional via `STREAM_CTRL_IN`/`OUT` — Phase 7 amendment) |
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler for Subscriptions | The machinery `channel/resources/subscribe` uses |
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed channel opens |
| [003](decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-channels depends on alknet-core only; no handler-depends-on-handler |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-55 | AlknetClient / Client Establishment Extraction | resolved (ADR-045) | `ChannelClient`'s API is decided (ADR-043): transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (`alknet-client`, ADR-045) |
| OQ-56 | Full channel-level flow-control windowing | deferred(scope) | Bounded-buffer is decided (ADR-040); full windowing is an extension blocked on "a real deployment observes HOL blocking on a saturated channel where bounded buffer is insufficient" |
| OQ-57 | Two-pump helper extraction to alknet-core | deferred(scope) | The shutdown-on-completion *contract* is decided (ADR-078); the *helper* extraction is blocked on a second two-pump handler existing (shape convergence) |
| OQ-68 | Add/strip API shape (built-in vs utility) | open | Whether the 8-byte header add/strip is built into the channels read/write path or exposed as a standalone utility. The *contract* is decided (ADR-035); the *function surface* is not |
## Key Design Principles
1. **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
connection, a QUIC bidi stream — they're all `BiStream` (a concrete
`AsyncRead + AsyncWrite` newtype, per ADR-009). The differences are only
in how they're *opened* (negotiation via `channel/open` on channel 0)
and what *multiplexing layer* carries them (the 8-byte chunk format).
Once normalized, every channel is an ALPN routed through the same
`HandlerRegistry`. See [overview.md](overview.md) and ADR-034 (as
amended by ADR-035).
2. **Channel 0 is `alknet/call` pre-negotiated, not a special control
plane.** The call protocol runs on channel 0 exactly as on a top-level
`alknet/call` connection. Channel lifecycle operations
(`channel/open`, `channel/close`, `channel/control`,
`channel/resources/subscribe`) are call operations on channel 0's
`OperationRegistry`, gated by the existing `AccessControl::check`. No
new auth machinery, no new framing. See ADR-036, ADR-037.
3. **The channels layer is a re-framing proxy, not a protocol engine.** It
converts between "one transport stream carrying N channels" (the wire)
and "N independent `BiStream` handles" (what handlers see). It does no
ALPN-specific parsing, no auth, no transport coupling, and carries no
`stream_type` concept (ADR-035). This makes it WASM-compatible and
transport-agnostic by construction. See [channels-adapter.md](channels-adapter.md)
and ADR-039.
4. **The handler owns its sub-stream multiplexing.** The channels layer
yields one `BiStream` per channel; the handler sub-multiplexes it
however it wants (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol). The channels layer carries
the bytes transparently. See [channels-connection.md](channels-connection.md)
and ADR-035.
5. **`channel/resources/subscribe` is a `Subscription`, not a polled
`Query`.** The call protocol has `StreamingHandler` / `invoke_streaming`
(ADR-021, implemented and tested). The first consumer (the hub
aggregating worker resources) needs live updates. Polling would be built
and immediately reworked. See ADR-037.
6. **Bidirectional open.** Either side can open a channel to the other,
just like the call protocol's operation overlay. The `direction` field
on `channel/open` pins who is the ALPN-server vs ALPN-client. See
ADR-037 §Direction semantics.
7. **Wire-level invariants are contracts, not implementation details.**
The POC surfaced five invariants (REQ-CH-01..04, plus REQ-CH-06 for
close ordering) that hang channels silently if underspecified: shutdown
emits a zero-length sentinel; transport close drops all senders; the mux
supports dynamic registration; unknown `channel_id` is lenient-dropped;
bounded-buffer backpressure doesn't deadlock; data chunks flush before
`channel/close`. See [channels-wire.md](channels-wire.md) and
[channels-adapter.md](channels-adapter.md).
8. **The hub translates, not transparently forwards.** The hub terminates
channel 0 on both legs, runs `AccessControl::check`, and re-issues
`channel/open` on the spoke leg with `forwarded_for` (ADR-026). Data
channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
within the 8-byte header). This preserves the auth model. See ADR-042.
9. **The channel cap is per-identity, not per-connection.** A channel
slot is a resource; the cap on how many an identity may hold open is
a quota check, parallel to `OwnershipProvider::owns` (ADR-011) for
spawned resources. The cap lives in `channels-call` (the channels
layer is auth-blind by ADR-039 — no identity, no scopes), consulted
by the `channel/open` and `channel/close` handlers after
`AccessControl::check`. The default is `PerIdentityChannelPolicy::
new(256)` — 256 per `PeerId` across all the peer's connections. The
per-connection `max_channels` (ADR-040) is a memory bound, not a
DoS defense. The cap is symmetric (both sides enforce); the spoke
caps the hub as direct caller, not the browser as `forwarded_for`
(metadata, not authority — ADR-026). See ADR-041.
## References
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
(vision, hub motivation, wire format, negotiation, internals, DPs, OQs)
- `docs/research/alknet-channels/poc-summary.md` — the de-risk POC (28
tests, three validated targets, REQ-CH-01..06 wire-level invariants
surfaced; REQ-CH-07 is a cosmetic clippy item, not a wire invariant)
- `docs/research/alknet-channels/poc-plan.md` — the POC plan
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-channel-multiplexing resolution (ADR-035)
- `/workspace/alknet-channels-poc/` — the POC codebase
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
format (the seed of the channels generalization)
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
multiplexer (the prior art for N-channel multiplexing)
- `docs/architecture/crates/hub/README.md` — the hub crate (the primary
consumer; the relay implementation's home)

View File

@@ -0,0 +1,318 @@
---
status: draft
last_updated: 2026-07-18
---
# channels-adapter.md — ChannelsAdapter and ChannelManager
The two internal components of the channels crate: the read/demux half
(`ChannelsAdapter`) and the reassemble/allocate half (`ChannelManager`).
ADR-039 is the decision; this doc specifies the contracts and the demux/mux
invariants. The channels layer has no `stream_type` concept (ADR-035) —
the demux routes by `channel_id` only, and the reassembly buffer is one
per channel (not per `(channel_id, stream_type)`).
## The split
| Component | Role | What it knows |
|-----------|------|---------------|
| `ChannelsAdapter` | `ProtocolHandler` on `alknet/channels`; reads 8-byte chunk headers off every bidi stream the transport yields and routes to `ChannelManager`. Substrate-agnostic (ADR-034 §substrate modes, as amended by ADR-035). | The transport stream(s); the `ChannelManager` handle. ALPN-blind. |
| `ChannelManager` | Shared state; holds `channel_id → ChannelState`, `HandlerRegistry`. Constructs `ChannelBidiStreamSource` per channel. What `channel/open` closes over (in `channels-call`). | The channel map; the handler registry for ALPN lookup. ALPN-blind (looks up ALPNs, doesn't parse their protocols). |
The split mirrors the TTY crate's `ChunkReader`/`ChunkWriter` + adapter
pattern, generalized to N channels: the adapter drives N channels, and
channel 0 is special only in that it's pre-allocated (by `channels-call`).
## `ChannelsAdapter::handle` (substrate-agnostic)
```rust
#[async_trait]
impl ProtocolHandler for ChannelsAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/channels" }
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
// 1. Channel 0 is pre-negotiated (ADR-036). The first bidi stream
// the transport yields is channel 0. The consumer (channels-call)
// installs the CallAdapter on it.
let bidi = connection.accept_bi().await?;
self.manager.preinstall_channel_0(bidi, auth).await?;
// 2. Accept remaining bidi streams and read 8-byte headers off each.
// On an in-line transport, accept_bi() yields once and the header
// demuxes N channels from that stream. On QUIC native, accept_bi()
// yields repeatedly — each stream carries one logical channel.
// Same code path, same wire format (ADR-034 §substrate modes,
// as amended by ADR-035).
self.manager.run_demux_loop(connection).await
}
}
```
The `preinstall_channel_0` step (provided by `channels-call`, ADR-044)
constructs the reassembly buffer for `channel_id = 0`, wraps it as a
`Connection` via `Connection::from_source` with a
`ChannelBidiStreamSource` (ADR-038, as amended by ADR-035 — `accept_bi`
yields a `BiStream`), and hands that `Connection` to the `CallAdapter`.
The `ChannelsAdapter` in `channels-core` exposes the hook; `channels-call`
provides the implementation.
`run_demux_loop` continues accepting bidi streams from the transport. For
each stream, it reads 8-byte headers and routes payloads to the matching
`channel_id`'s reassembly buffer. On an in-line transport, there is only
one stream (channel 0 rides inside it via the header); the header demuxes
all channels. On QUIC, each subsequent stream is a new channel; the
header's `channel_id` correlates it. The loop is the same; only the
transport's stream count differs.
## `ChannelManager`
```rust
// In alknet-channels-core:
pub struct ChannelManager {
channels: Mutex<HashMap<u32, ChannelState>>,
handlers: Arc<HandlerRegistry>,
// Note: no call_ops field — the call-protocol coupling lives in
// channels-call (ADR-044). The ChannelManager is ALPN-blind and
// call-protocol-blind.
next_id: AtomicU32, // monotonic; wraps at u32::MAX
buffer_cap: usize, // default 1 MiB (ADR-040)
max_channels: usize, // default 256 (ADR-040) — per-connection
// memory bound, NOT a DoS defense. The
// per-identity DoS defense is the
// ChannelLifecyclePolicy consulted by the
// channel/open handler in channels-call
// (ADR-041). The auth-blindness that forces
// the cap out of this struct is ADR-039's
// "no auth state" rule.
}
struct ChannelState {
alpn: String,
/// One reassembly buffer per channel (not per (channel_id, stream_type) —
/// the channels layer has no stream_type concept per ADR-035). Yields
/// a BiStream to the handler.
reassembly: ReassemblyBuffer,
handler_task: JoinHandle<()>,
}
```
`ChannelManager` is `Clone` (cheap — `Arc` internally) so the
`ChannelsAdapter`, the `channel/open` operation handler, and relay logic can
all hold a handle.
> **Type-name convention:** `ChannelManager`, `ChannelsAdapter`,
> `ChannelBidiStreamSource`, and `ChannelClient` are the public API
> surface (contract). `ReassemblyBuffer`, `Demux`, `MuxHandle`/`MuxRunner`,
> `MpscSendStream`/`MpscRecvStream`, and `ChannelOperations` are
> illustrative internal type names — the channels crate's implementation
> may name them differently. The contracts are the invariants
> (REQ-CH-01..04, 06) and the public API; the internal names are not
> contractual.
### `ChannelManager` is ALPN-blind and auth-blind
The `ChannelManager` deliberately does **not** hold:
- **No `ProtocolHandler` implementations.** It holds a `HandlerRegistry`
reference for ALPN lookup, but it doesn't *be* a handler. Handlers live in
their crates and register on the same registry.
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
SSH frames, or tunnel target strings. It hands `params` JSON to the
handler and gets back a handler task. The channels layer carries the
handler's framing transparently in the payload — it does not interpret
the payload bytes.
- **No auth state.** Auth lives in the `OperationContext` that the call
protocol passes to `channel/open`. The `ChannelManager` doesn't check
scopes or ownership — that's `AccessControl::check` in
`OperationRegistry::invoke`, run before the `channel/open` handler.
- **No transport coupling.** It talks to the transport only through the
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
which use `AsyncRead + AsyncWrite`.
- **No `stream_type` concept.** Per ADR-035, the channels layer routes by
`channel_id` only. There is one reassembly buffer per channel (yielding
a `BiStream`), not one per `(channel_id, stream_type)`. The handler
owns its sub-stream multiplexing on the `BiStream` it receives.
This is what makes the channels layer WASM-compatible and transport-agnostic
— the `ChannelManager` is pure byte routing with no platform or protocol
dependencies.
## The `channel/open` handler
The `channel/open` (and `channel/close`, `channel/control`,
`channel/resources/subscribe`) operations are registered on the call
protocol's `OperationRegistry` at registration time. The
`ChannelOperations` constructor takes a `ChannelLifecyclePolicy`
(ADR-041) — the default is `PerIdentityChannelPolicy::new(256)` (a
real per-identity cap, not NoOp):
```rust
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager.clone(), policy);
channel_ops.register_on(&mut call_registry)?;
```
The same `Arc<PerIdentityChannelPolicy>` is shared across every
channels connection this peer accepts — that is what makes the cap
per-identity, not per-connection. A hub constructs one policy and
shares it across all worker and browser legs; a worker accepting
direct channels constructs one policy and shares it across whatever
connections it accepts. See ADR-041 for the policy trait and the
default/opt-out variants.
The `channel/open` handler (ADR-037):
1. ACL is already checked by `OperationRegistry::invoke` before this handler
runs.
2. Looks up the ALPN in `HandlerRegistry``channel:unknown_alpn` if
missing.
3. **Per-identity cap check (ADR-041):**
`policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if the identity is over its cap. The
identity is the direct caller (the peer on this channels
connection); `forwarded_for` is metadata and is NOT consulted
(ADR-026). For the hub-relay path, the spoke sees the hub as the
direct caller — the hub's quota on the spoke reflects the aggregate
of all relayed channels (ADR-041 §5).
4. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned). The per-connection `max_channels` (ADR-040) is
checked here too — the per-connection memory bound; if hit, the same
`channel:too_many_channels` error is returned (which cap fired first
is an implementation detail — ADR-041 §4).
5. Constructs the `ChannelBidiStreamSource` (ADR-038, as amended by
ADR-035) — one reassembly buffer, yielding a `BiStream`.
6. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
Identical to what `TtyAdapter::handle` does today, but on a
channels-backed `Connection`.
7. Records the `ChannelState`.
8. Returns the `channel_id`.
The `channel/close` handler (ADR-037) gains a symmetric
`policy.on_close(&op_ctx.identity)` call after the drain completes
(the same point ADR-040 marks the `channel_id` as eligible for reuse)
— decrementing the per-identity count.
## Demux invariants (REQ-CH-02, 04)
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
On transport EOF, `run_demux_loop` clears the `channels` map, dropping all
`ReassemblyBuffer` senders. Every handler's reassembled `BiStream` sees
EOF even without an explicit zero-length sentinel on the wire. Without this,
`read_to_end` / `tokio::io::copy` in handlers hangs forever waiting for a
sender that never drops. This is a teardown invariant of the
`ChannelsAdapter::handle` contract.
### REQ-CH-04: lenient unknown-`channel_id` handling
A chunk with an unallocated `channel_id` is dropped with a debug log and
an error counter (exposed via `Demux::stats()`), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown. Validated by the POC (`demux_unknown_channel_drops_lenient`).
## Mux invariants (REQ-CH-03)
### REQ-CH-03: dynamic registration (handle/runner split)
The mux frames per-channel bytes back onto the transport. The POC surfaced
that `Mux::run(self, transport)` (consume, run pre-registered pumps) does
not compose with the dynamic `channel/open` model — channels are opened
after the run loop starts.
The mux is split into:
- **`MuxHandle`** — clone-able, `register(channel_id) -> Sender<Bytes>`
callable at any time after the runner starts.
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
and per-channel write pumps.
The runner's `select!` loop exits when all `MuxHandle` clones drop (the
`new_pumps` sender closes) — the natural shutdown signal. This matches the
dynamic `channel/open` model.
## The two-pump pattern (ADR-078 — documented here for handler authors)
Handlers with a two-pump shape (two `tokio::io::copy` pumps, one per
direction — tunnel, SSH `direct-tcpip`) MUST shut down the opposite sink
when one pump completes. `tokio::try_join!` alone deadlocks: each pump
waits for the other's EOF, which only comes after the opposite pump shuts
down its sink.
```rust
let c2t = async {
tokio::io::copy(&mut recv, &mut tcp_write).await?;
tcp_write.shutdown().await.ok(); // shut down the peer's sink
Ok::<_, std::io::Error>(())
};
let t2c = async {
tokio::io::copy(&mut tcp_read, &mut send).await?;
send.shutdown().await.ok(); // shut down the peer's sink (emits sentinel — REQ-CH-01)
Ok::<_, std::io::Error>(())
};
tokio::try_join!(c2t, t2c)?;
```
The three-pump pattern (TTY's `pump_session`, coordinating via the
`exit_code` future) does not have this deadlock — the `exit_code` future is
the third signal. The two-pump pattern is documented in ADR-078; the
shutdown-on-completion contract is a handler-level concern, not a
channels-layer one.
## The hub relay interface
The hub relay (ADR-042) uses the `ChannelManager`'s interface to bridge two
channels connections:
```rust
// For channel_id=7 on browser side, channel_id=12 on spoke side:
tokio::spawn(async move {
let mut b_bidi = browser_mgr.open_channel_stream(7).await;
let mut s_bidi = spoke_mgr.open_channel_stream(12).await;
tokio::join!(
pump(&mut b_bidi, &mut s_bidi), // browser → spoke (with channel_id rewrite)
pump(&mut s_bidi, &mut b_bidi), // spoke → browser (with channel_id rewrite)
);
});
```
The relay reads opaque bytes off one `ChannelManager`'s reassembled
`BiStream` and writes them onto the other's write-half, which re-chunks
them with the other leg's `channel_id` (a 4-byte rewrite within the
8-byte header). The relay does not parse the payload — it doesn't know if
the bytes are TTY chunks, SSH frames, or tunnel data. The hub translates
`channel/open` on channel 0 (re-issues on the spoke leg with
`forwarded_for`); data channels are byte-forwarded with `channel_id`
rewrite. See ADR-042 for the full relay contract.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel per-connection memory bound, monotonic IDs (DoS defense reframed by ADR-041) |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
## References
- ADR-039: ChannelsAdapter and ChannelManager (the decision)
- ADR-035: channels pure channel multiplexing (the umbrella decision that
amends ADR-034/074/077)
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-038: ChannelBidiStreamSource (what the manager constructs per
channel, as amended by ADR-035)
- ADR-040: backpressure and limits (`buffer_cap`, `max_channels` — the
per-connection memory bound)
- ADR-041: per-identity channel cap (the `ChannelLifecyclePolicy`
consulted by the `channel/open` handler; the relay consequence for
hub-relayed channels)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
(REQ-CH-01..04, the two-pump deadlock)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the pure-multiplexing resolution

View File

@@ -0,0 +1,179 @@
---
status: draft
last_updated: 2026-07-18
---
# channels-connection.md — ChannelBidiStreamSource and `BiStream` Access
How a reassembled channel is presented to its handler as a `Connection`.
ADR-038 (amended by ADR-035) is the decision; this doc specifies the API
shape — one accessor, one `BiStream` per channel.
## What
Each channel is reassembled into a `BiStream` — a single duplex
(`AsyncRead + AsyncWrite`) byte stream. The channels layer strips its
8-byte header (`channel_id` + `length`) on read, hands the payload to the
reassembled `BiStream`, and the handler parses its own framing from the
payload. The handler sub-multiplexes its `BiStream` however it wants —
TTY sub-demuxes `stream_type` from its `BiStream` via its 5-byte format,
tunnel uses the `BiStream` as raw bytes, call length-prefixes JSON, SSH
runs its own channel protocol.
The `BiStream` is wrapped in a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-008), and a `Connection` is
constructed from it via `Connection::from_source(source, alpn)`. The
handler receives a `Connection`, calls `accept_bi()` once (yield-once per
channel), gets a `BiStream`, and drives its session — identical to how it
works on a top-level QUIC connection.
## `ChannelBidiStreamSource`
```rust
// In alknet-channels:
pub struct ChannelBidiStreamSource {
// The reassembly buffer for this channel's payload bytes (one per
// channel_id, not per (channel_id, stream_type) — the channels layer
// has no stream_type concept), plus the mux handle for writing back
// onto the transport. Constructed by ChannelManager::build_channel_connection
// (ADR-039).
...
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self)
-> Result<BiStream, StreamError>
{
// Yields the channel's BiStream on first call,
// ConnectionClosed on subsequent calls. Yield-once per channel,
// matching the POC's validated shape.
}
async fn open_bi(&self)
-> Result<BiStream, StreamError>
{
// StreamClosed — a single channel cannot open new application
// streams (same as ADR-007's Stream backend). The handler owns
// its sub-stream multiplexing on the BiStream it received.
}
fn remote_addr(&self) -> Option<SocketAddr> { ... }
fn close(&self, _code: u32, _reason: &str) { ... }
}
```
One `ChannelBidiStreamSource` instance represents **one channel** (not the
whole channels connection). The `ChannelManager` (ADR-039) constructs one
per channel at `channel/open` time and wraps it in a `Connection` via
`from_source`.
## The single path: `accept_bi()`
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. There is one accessor.
```rust
// Tunnel handler — ~15 lines, zero channels-layer awareness
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let mut bidi = connection.accept_bi().await?;
let mut tcp = TcpStream::connect(target).await?;
let (mut tcp_read, mut tcp_write) = tcp.into_split();
let (mut recv, mut send) = tokio::io::split(&mut bidi);
// Two-pump with shutdown-on-completion (ADR-078)
let c2t = async {
tokio::io::copy(&mut recv, &mut tcp_write).await?;
tcp_write.shutdown().await.ok();
Ok::<_, std::io::Error>(())
};
let t2c = async {
tokio::io::copy(&mut tcp_read, &mut send).await?;
send.shutdown().await.ok(); // emits zero-length sentinel (REQ-CH-01)
Ok::<_, std::io::Error>(())
};
tokio::try_join!(c2t, t2c)?;
Ok(())
}
```
```rust
// TTY handler (inside-channels mode) — the SAME code as direct
// mode, just a different BiStream source.
async fn handle(&self, connection: Connection, _auth: &AuthContext)
-> Result<(), HandlerError>
{
let mut bidi = connection.accept_bi().await?;
// drive_session reads the 5-byte TTY chunks off `bidi` — the same
// code as direct mode. The channels layer stripped its 8-byte
// header; TTY's 5-byte format is the payload.
drive_session(bidi, backends, ownership, identity).await
}
```
The handler calls `accept_bi()` once, gets a `BiStream`, and pumps. It
does not know it's inside a channels connection — the `Connection` looks
like any other. This is the path the POC's `EchoHandler` and
`TunnelHandler` validated.
`accept_bi()` is yield-once: the first call returns the `BiStream`;
subsequent calls return `ConnectionClosed`. This matches the POC's
validated shape and the `StreamBidiStreamSource` yield-once contract
(ADR-008, ADR-009).
## Recursive composition
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and
`Connection::from_source` wraps it. A handler that is itself
`alknet/channels` can open a sub-channels connection on a data channel —
`alknet/channels` inside `alknet/channels`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from the
payload. Each level is the same shape: `BiStream → accept_bi → N
BiStreams`. The recursion is unbounded and uniform at every level.
This is a property, not a feature. The primary use case is one level of
multiplexing. But the add/strip composition makes it cleaner than
ADR-034's group framing did — the recursion is the same operation
(strip an 8-byte header) at every level, not a different framing per
level.
## What does NOT change
- **`ProtocolHandler` trait** (ADR-002) — handlers still receive a
`Connection` and call `accept_bi()`. The `ChannelBidiStreamSource` is
internal to the channels crate; handlers see a `Connection`.
- **`BiStream`** (ADR-009) — the leaf type `accept_bi` returns. The
channels layer yields `BiStream`s; handlers parse them per their ALPN.
- **`HandlerRegistry`** — unchanged. The channels layer looks up ALPNs in
the same registry as top-level connections.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` is the only accessor |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `BiStream`-only |
| [070](decisions/070-bidistreamsource-trait.md) | BidiStreamSource Trait | The extension point `ChannelBidiStreamSource` implements |
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf | `accept_bi` returns `BiStream` (the transport-leaf decision this doc builds on) |
| [065](decisions/065-connection-from-stream-generic-single-stream.md) | `Connection::from_stream` | The yield-once path generalized for channels |
## References
- ADR-038: ChannelConnection (the decision)
- ADR-035: channels pure channel multiplexing (the umbrella decision)
- ADR-008: BidiStreamSource trait
- ADR-009: `BiStream` as the handler leaf
- ADR-007: `Connection::from_stream` (the yield-once path generalized)
- ADR-077: TTY inside channels (TTY always uses its 5-byte format,
carried transparently in the channels payload)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2 (the
yield-once `Connection::from_stream` validation)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the single-accessor resolution

View File

@@ -0,0 +1,307 @@
---
status: draft
last_updated: 2026-07-18
---
# alknet-channels — Overview
## What
`alknet-channels` is a multiplexing proxy crate. It implements
`ProtocolHandler` for the `alknet/channels` ALPN: it receives one
bidirectional transport stream, reads 8-byte chunk headers, and routes each
chunk's payload to the right logical channel. Each channel is reassembled
into a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype, per
ADR-009) and presented to its handler as a `Connection` — the handler
doesn't know it's inside a channels connection.
Channel 0 is pre-negotiated as `alknet/call` (ADR-036). Every other channel
is opened dynamically via `channel/open` on channel 0 (ADR-037) and routed
through the same `HandlerRegistry` as top-level connections. The channels
layer does no protocol work itself — it is a re-framing proxy that converts
between "one transport stream carrying N channels" (the wire) and "N
independent `BiStream` handles" (what handlers see). The channels layer has
no `stream_type` concept (ADR-035) — the handler owns its sub-stream
multiplexing on the `BiStream` it receives.
## Why
### The problem: three multiplexing models that don't compose
Before channels, alknet had three multiplexing models:
| Model | Where | Mechanism |
|-------|-------|-----------|
| Connection-level | ALPN router | One ALPN per QUIC connection |
| Stream-level | QUIC native | Many bidi streams per connection |
| Sub-stream-level | TTY chunk format | 4 logical channels within one bidi stream |
A docker client needing both JSON call operations and raw TTY sessions
required **two separate QUIC connections** with different ALPNs. The call
protocol can't say "for this operation, open a TTY stream." The hub,
bridging browsers and spokes over multiple transports, faced an
O(protocols × transports × spokes) matrix of per-protocol framing parsers
and per-ALPN connection management.
### The collapse: one multiplexing model, one connection per leg
With `alknet/channels`, one connection carries everything:
```
Browser ──WebTransport──► Hub ──QUIC──► Spoke
alknet/channels alknet/channels
┌─────────────┐ ┌─────────────┐
│ ch0: call │ │ ch0: call │
│ ch1: tty │ relay │ ch1: tty │
│ ch2: ssh │ ◄─────► │ ch2: ssh │
│ ch3: tunnel │ │ ch3: tunnel │
└─────────────┘ └─────────────┘
```
The hub's relay is channel-by-channel byte forwarding (with `channel_id`
rewrite — ADR-042), not per-protocol framing parsers. The hub's complexity
collapses from O(protocols × transports × spokes) to O(channels).
The collapse is at three levels:
1. **One connection per leg, not one per protocol.** All needs (call, TTY,
SSH, tunnel) ride as channels on one connection per leg.
2. **One multiplexing model, not three.** Connection-level, stream-level,
and sub-stream-level all become channels chunks.
3. **The call protocol orchestrates from inside.** Channel 0 is
`alknet/call` on both legs. The call protocol's `OperationRegistry`,
`AccessControl`, and `forwarded_for` machinery govern channel lifecycle
with no new auth.
### The separation: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed by
`channel_id`." It does not know about TTY's sub-streams, SSH's channel
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them (ADR-035).
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-009). The handler sub-multiplexes it however it
wants — TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw
bytes, SSH's channel protocol.
- **The channels layer has no `stream_type` concept.** Not in its 8-byte
header, not in its code, not in its mental model. `stream_type` is the
inner layer's framing byte, carried transparently in the payload.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload.
## Architecture
The crate has two internal components (ADR-039):
- **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alknet/channels`. Its `handle()` receives one `Connection`, reads 8-byte
chunk headers, and routes chunks to the `ChannelManager`. The read/demux
half.
- **`ChannelManager`** — the shared state. Holds `channel_id →
ChannelState`, the `HandlerRegistry` reference, and the
`OperationRegistry` reference. The reassemble/allocate half. What the
`channel/open` operation handler closes over.
Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource, alpn)` (ADR-008/074, as
amended by ADR-035). The handler calls `accept_bi()` once (yield-once per
channel) and gets a `BiStream` — identical to how it works on a top-level
QUIC connection.
See [channels-adapter.md](channels-adapter.md) for the full adapter/manager
design.
## Crate dependencies
```
alknet-channels-core
├── alknet-core (ProtocolHandler, Connection, HandlerRegistry,
│ BidiStreamSource, BiStream, AuthContext)
├── tokio (spawn, mpsc, io)
├── bytes (Bytes for chunk payloads)
├── async-trait
├── thiserror
└── tracing
alknet-channels-call
├── alknet-channels-core (ChannelManager, ChannelsAdapter,
│ ChannelBidiStreamSource, ChannelClient)
├── alknet-call (OperationRegistry, HandlerKind, make_handler,
│ make_streaming_handler, CallError, ResponseEnvelope)
└── tokio
alknet-hub (the existing hub crate — consumes channels)
├── alknet-channels-call
└── alknet-call (from_call, CallAdapter, forwarded_for — ADR-042)
worker crates (any crate that dials a hub — consumes channels)
└── alknet-channels-call (ChannelClient — ADR-043)
```
`alknet-channels-core` is the pure multiplexer — wire format, demux/mux,
`ChannelBidiStreamSource`, `ChannelManager`. It depends on `alknet-core`
only. No `alknet-call` dependency. ALPN-blind, call-protocol-blind,
transport-blind. This is where the "streams are streams" insight lives.
`alknet-channels-call` is the call-protocol coupling — channel 0
pre-negotiation as `alknet/call` (ADR-036), the four lifecycle operations
(ADR-037) registered on the call protocol's `OperationRegistry`, and
`ChannelClient` (ADR-043). This is where the call-protocol coupling lives,
isolated from the pure multiplexer.
The hub and worker are **consumers**, not sub-crates. The existing
`alknet-hub` crate IS the channels hub — it depends on `channels-call` and
uses channels as its substrate, with the relay logic (ADR-042) living in
`alknet-hub` alongside its existing peer lifecycle and service discovery
responsibilities. A worker is any crate that uses `ChannelClient` to dial.
There are no `channels-hub` or `channels-worker` sub-crates.
See ADR-044 for the full decomposition rationale.
## ALPN
`alknet/channels` — the ALPN the `ChannelsAdapter` registers on. One ALPN
per channels connection; the connection carries N logical channels, each
with its own ALPN (negotiated via `channel/open`).
## Transport agnosticism
The channels wire format works over any ordered, reliable bidirectional byte
stream:
| Transport | How |
|-----------|-----|
| QUIC bidi stream | `alknet/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
| TCP+TLS | `alknet/channels` ALPN on a TLS connection; the TCP stream carries all channels |
| WebTransport | `alknet/channels` session (deferred per ADR-044; the browser path uses WebSocket carrying `alknet/channels`) |
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel (channels-over-SSH) |
| Another channels connection | recursive composition (channel type `alknet/channels` inside `alknet/channels`) |
The same wire format, the same chunk reassembly, the same `Connection`
abstraction. The transport is a parameter, not a design constraint.
`Connection::from_bidi` / `from_source` (ADR-007/070/092) handles the
transport-agnostic `Connection` construction.
## WASM compatibility
The wire format's core is pure byte manipulation — `parse_header` /
`write_header` are pure functions with no platform dependencies. The de-risk
POC validated the sync core compiles under `wasm32-unknown-unknown`. The
async shell (demux/mux) wraps this core with `read_exact`/`write_all` and
`mpsc` routing.
The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind (ADR-
075) — pure byte routing with no platform or protocol dependencies. A WASM
build can read chunks from a WebTransport `BiStream`, reassemble them, and
present `AsyncRead + AsyncWrite` handles to WASM-compatible handlers. The
handlers themselves may or may not be WASM-compatible (russh's client is;
`portable_pty` is not), but the channels layer is WASM-compatible by
construction.
The async shell and `alknet-core` dep graph are not fully WASM-clean yet
(transitive `getrandom`/`rand` deps) — this is an implementation concern,
not an architecture concern. The sync core's WASM compatibility is validated.
## Relationship to existing crates
### alknet-call
Unchanged. The call protocol remains JSON-only, `EventEnvelope`-based. It
runs on channel 0 exactly as on a top-level `alknet/call` connection. The
`CallAdapter` receives a `Connection` backed by channel-0 chunk reassembly
and dispatches operations — it doesn't know it's inside channels. The call
protocol's `EventEnvelope` framing (ADR-014) is the channels payload; the
channels layer carries it transparently.
What changes: the call protocol gains a new class of operations — channel
lifecycle (ADR-037). These are registered on the `OperationRegistry` at
assembly time and dispatched through the existing `OperationContext` /
`AccessControl::check` path.
### alknet-tty
The TTY crate gains a `channels` feature that enables inside-channels
mode. In both direct mode (`alknet/tty` ALPN on a top-level connection) and
inside-channels mode (`channel/open` with ALPN `alknet/tty`), the TTY
adapter uses its own 5-byte wire format (ADR-052). The two modes differ
only in *where the `BiStream` comes from* — a top-level connection vs a
channels-backed `Connection`. The same `wire.rs` code runs in both modes
(ADR-077): the channels layer strips its 8-byte header and hands TTY the
payload bytes; TTY parses its 5-byte header from the payload. The
`TtyBackend` trait and `TtyHandle` are unchanged; backends don't know
which mode the adapter is in.
### alknet-ssh (future)
SSH as a channel type: an `alknet/ssh` channel carries the SSH binary
protocol on its `BiStream`. The channels layer hands the reassembled
`BiStream` to `SshAdapter`, which feeds it to russh. SSH as a channels
transport: an SSH `direct-tcpip` channel could carry a channels connection
(channels-over-SSH). The SSH crate doesn't need to know about channels —
it implements `ProtocolHandler` for `alknet/ssh` and accepts a
`Connection`. SSH multiplexes internally (its own channel protocol rides
the channels payload transparently).
### alknet-docker
Docker lifecycle operations are call operations on channel 0 (unchanged
from ADR-058). Interactive exec/attach opens a TTY channel via
`channel/open` with ALPN `alknet/tty` and backend `docker`. No separate
`alknet/tty` connection needed — one `alknet/channels` connection handles
both JSON operations and raw TTY sessions.
### alknet-hub
The hub is the primary consumer. With channels, the hub holds one channels
connection per leg (browser↔hub, hub↔spoke) and relays channels between
them. The hub translates `channel/open` on channel 0 (re-issues on the
spoke leg with `forwarded_for` — ADR-042) and byte-forwards data channels
with `channel_id` rewrite. The hub's complexity collapses from
O(protocols × transports × spokes) to O(channels).
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no `stream_type` concept; one-way door |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | `channel/open`/`close`/`control`/`resources/subscribe`; subscribe not poll; `direction` pinned |
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-035 — `into_sub_streams` removed) |
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 |
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs |
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract; handler-level |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite |
| [080](decisions/080-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) |
| [081](decisions/081-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this crate:
- **OQ-55** (resolved by ADR-045): `AlknetClient` core **dial+TLS seam**
— extracted as `alknet-client` with three dial methods.
`ChannelClient`'s API is transport-agnostic (`from_connection`); the
dial is the shared seam, now extracted. See
[ADR-045](decisions/089-alknetclient-native-dial-seam.md).
- **OQ-56** (deferred(scope)): Full channel-level flow-control windowing —
bounded-buffer is decided (ADR-040); full windowing is an extension
blocked on a real HOL-blocking deployment observation.
- **OQ-57** (deferred(scope)): Two-pump helper extraction to alknet-core —
the *contract* is decided (ADR-078); the *helper* is blocked on a second
two-pump handler existing.
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided (ADR-035); the *function surface* is not.

View File

@@ -0,0 +1,302 @@
---
status: draft
last_updated: 2026-07-18
---
# channels-wire.md — The 8-Byte Chunk Format
The wire format for `alknet/channels`: an 8-byte chunk header that
multiplexes N logical channels over a single ordered, reliable
bidirectional transport stream. ADR-034 (amended by ADR-035) is the
decision; this doc specifies the format and the wire-level invariants.
The channels layer has no `stream_type` concept — not in its header, not
in its code, not in its mental model. The handler owns its sub-stream
multiplexing on the `BiStream` the channels layer gives it.
## Chunk header
```
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
8 bytes of header, followed by `length` bytes of opaque payload.
| field | offset | width | meaning |
|-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN`. |
The payload is opaque to the channels layer. The handler parses its own
framing from the payload — TTY's `[stream_type:u8][length:u32][payload]`
(5-byte format, ADR-052), call's length-prefixed JSON (`EventEnvelope`
framing, ADR-014), tunnel's raw bytes, SSH's channel protocol. The
channels layer carries the bytes transparently.
### How the wire formats compose
The channels 8-byte header and the handler's framing compose by layering:
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The channels layer reads its 8-byte header (`channel_id` + `length`),
reads `length` bytes of payload, and hands the payload to the handler.
The handler parses its own framing from the payload — TTY reads its
5-byte header (`stream_type` + `length`) from the payload bytes.
The two length fields are close but not identical: `ch_len = tty_len + 5`.
This is a small amount of waste per chunk (the channels `length` is always
5 bytes more than TTY's `length`), but the trade-off is clean separation
of concerns: the channels layer has no `stream_type` concept — not in
its header, not in its code, not in its mental model. The handler owns
its framing entirely. See ADR-035 for the full cost/benefit analysis.
## `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 8 bytes, so the demux can always resync by reading the next
8-byte header.
## Channel 0 — pre-negotiated `alknet/call`
Channel 0 is not a special "control plane" with its own framing. It is
`alknet/call` pre-negotiated (ADR-036): both sides know `channel_id = 0`
is routed to the `CallAdapter` without an explicit `channel/open`
exchange.
Channel 0's chunks have `channel_id = 0` in the 8-byte header — same
format as every other channel. The call protocol's `EventEnvelope` JSON
framing (ADR-014) is the payload; the channels layer carries it
transparently. Disambiguation between channel 0 and data channels is by
`channel_id`, not by a special first-byte trick.
## Framing disambiguation
The 8-byte header is always exactly 8 bytes. `length` is bounded by
`MAX_CHUNK_LEN`. The demux reads 8 bytes, parses the header, reads
`length` bytes of payload, and routes. If a chunk is dropped (e.g.,
`ChunkTooLarge`), the demux resyncs by reading the next 8-byte header —
the format is self-synchronizing.
There is no channels-layer framing-disambiguation trick beyond the fixed
8-byte header. The channels layer does not interpret the payload — it
doesn't know if the payload is TTY chunks, call frames, or tunnel bytes.
Any framing disambiguation within the payload is the handler's concern
(see `tty-wire.md` §"Framing disambiguation" for TTY's first-byte trick,
which is internal to TTY's 5-byte format).
## Zero-length sentinel = EOF
A zero-length chunk (`length = 0`) is delivered as an empty payload,
which the reassembled stream interprets as EOF. This is the clean-shutdown
signal for a `channel_id` — the same convention as TTY (ADR-052
§Sentinels), now at the channels layer (one sentinel per channel, not
per `(channel_id, stream_type)`).
The sentinel is emitted by the write side's `AsyncWrite::shutdown` (see
REQ-CH-01 below) and consumed by the read side's `AsyncRead::poll_read` as
EOF.
## Substrate modes — same wire format, different stream counts
The 8-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the
transport yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 8-byte header → route by `channel_id` → reassemble into a `BiStream`.
On an in-line transport, `accept_bi()` yields once then
`ConnectionClosed` — the header does all the demux. On QUIC, `accept_bi()`
yields repeatedly — each stream is a channel, and the header provides
`channel_id` correlation. Same code path, same wire format, same handler
experience. See ADR-034 §substrate modes (as amended by ADR-035), ADR-039.
## Wire-level invariants (REQ-CH-01, 02, 04, 05)
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
Surfaced) surfaced invariants that hang channels silently if underspecified.
These are **contracts**, not implementation details — both sides must agree.
### REQ-CH-01: `AsyncWrite::shutdown` emits a zero-length sentinel
The reassembled stream's write half (`MpscSendStream` or equivalent) MUST
send an empty payload (the EOF sentinel) before dropping the sender on
`AsyncWrite::shutdown`. Without this, the demux never sees EOF on the
channel, and `tokio::io::copy` in the handler never
completes — the session hangs.
The TTY crate's `pump_session` emits the zero-length stdout sentinel
explicitly via its own 5-byte format's zero-length chunk; the channels
layer's per-channel write pump does NOT forward a sentinel on
sender-drop, so the send adapter must. Both sides must agree on this
convention, or channels hang on clean shutdown.
### REQ-CH-02: transport close → all channel senders drop → all handlers see EOF
The demux loop MUST clear its `channels` map on transport EOF, dropping
all `ReassemblyBuffer` senders. Every handler's reassembled `BiStream`
sees EOF even without an explicit zero-length sentinel arriving on the
wire.
Without this, `read_to_end` / `tokio::io::copy` in handlers hangs forever
waiting for a sender that never drops because the demux task is holding the
map. This is a teardown invariant of the `ChannelsAdapter::handle` contract.
### REQ-CH-04: lenient unknown-`channel_id` handling with error counter
A chunk with an unallocated `channel_id` is dropped with a debug log and
an error counter (exposed via `Demux::stats()`), and the demux continues.
This matches SSH's behavior and survives transient mis-ordering during
teardown (a chunk for a channel that was just closed may arrive after
the close is processed).
The alternative (strict — close the transport on unknown `channel_id`) is
fragile during teardown and catches bugs at the cost of reliability. The
lenient approach with an error counter provides observability without
fragility.
### REQ-CH-05: bounded-buffer backpressure does not deadlock
Each `channel_id` has an independent bounded `mpsc` buffer (default 1 MiB
— ADR-040). A slow reader on one channel does not block another channel's
reads — the demux's per-chunk route awaits the matching sender without
holding a global lock.
The 1 MiB `tunnel_large_payload` POC test exercised this end-to-end: a
channel writer faster than the TCP echo server consumer, with no deadlock
and no cross-channel blocking. This invariant must hold for all transport
shapes — the bounded-buffer approach is the decision (ADR-040).
## Sync core / async shell split
The wire format's core is pure byte manipulation:
```rust
// wire.rs — sync core, no async, no platform deps, WASM-clean
const CHUNK_HEADER_LEN: usize = 8;
const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
pub struct ChunkHeader {
pub channel_id: u32,
pub length: u32,
}
pub fn parse_header(buf: &[u8; 8]) -> Result<ChunkHeader, ChunkError> { ... }
pub fn write_header(channel_id: u32, length: u32, out: &mut [u8; 8]) { ... }
```
The async shell (demux/mux — see [channels-adapter.md](channels-adapter.md))
wraps this core with `read_exact` / `write_all` on the transport and `mpsc`
routing. The split keeps the WASM-compatible core separate from the
tokio-dependent shell. The POC validated the sync core compiles under
`wasm32-unknown-unknown`.
## The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing `wire.rs`. The handler doesn't know or care that
a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
The exact API shape of the add/strip pair (built into the read/write path
vs. a standalone utility) is an implementation detail for the channels
crate, tracked as OQ-68. The *contract* — the channels layer strips its
8-byte header on read and the handler parses its own framing from the
payload — is decided; the *function surface* is not.
## Channel lifecycle (summary)
| Phase | Mechanism | Reference |
|-------|-----------|-----------|
| Open | `channel/open` call operation on channel 0; responder allocates `channel_id`, returns it | ADR-037 |
| Data | chunks with `channel_id` routed to reassembly buffers; handler sees a `BiStream` | this doc, [channels-connection.md](channels-connection.md) |
| Control (out-of-band) | `channel/control` call operation on channel 0 | ADR-037 |
| Close | `channel/close` call operation on channel 0; data chunks flushed before close | ADR-037, REQ-CH-06 |
### REQ-CH-06: exit-chunk-before-close ordering (generalizes ADR-055)
The channel's data chunks MUST be written and flushed before the
`channel/close` operation is sent on channel 0. This is a wire-level
invariant: the side closing must observe the data-channel pump complete
before issuing the call operation.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried
forward: the exit control message (on TTY's `STREAM_CTRL_OUT` stream_type
4, inside TTY's 5-byte payload) is the last data before `channel/close`.
For tunnels it is the last data byte before close. The channels layer's
close handler observes the pump completion; the call operation is issued
after.
This invariant crosses two channels (the data channel and channel 0), so
the channels layer owns the ordering guarantee — it is not a handler
concern. The control-message division (data-ordered control vs
out-of-band control) is now entirely handler-internal: TTY's
`STREAM_CTRL_IN` / `STREAM_CTRL_OUT` are stream_types in TTY's 5-byte
payload format, not channels-layer concepts.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [071](decisions/071-channels-wire-format.md) | channels Wire Format | 8-byte chunk header (amended by ADR-035); channels layer has no `stream_type` concept; one-way door |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, `into_sub_streams` removed, `BiStream`-only, TTY always 5-byte |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this doc:
- **OQ-68** (open): Add/strip API shape — whether the 8-byte header
add/strip is built into the channels read/write path or exposed as a
standalone utility. The *contract* (channels strips, handler parses
payload) is decided; the *function surface* is not.
## References
- ADR-034: channels wire format (the decision, amended by ADR-035 — 8-byte
header, no `stream_type`)
- ADR-035: channels pure channel multiplexing (the umbrella decision that
amends ADR-034/074/077)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload)
- ADR-036: channel 0 pre-negotiated
- ADR-037: channel lifecycle operations
- ADR-040: backpressure, channel limits, ID reuse
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1, §Issues
Surfaced #4-#6 (REQ-CH-01, 02, 04)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the 8-byte format decision
- `crates/alknet-tty/src/wire.rs` — the 5-byte format implementation
(carried transparently in the channels payload)

View File

@@ -0,0 +1,855 @@
---
status: draft
last_updated: 2026-07-17
---
# alknet-call — Client and Adapters
The outbound half of the call protocol: opening connections, importing remote
operations, and the adapter contract that ties import-style adapters together.
This document covers what ADR-022 specced but the server-side implementation
(`call-protocol.md`, `operation-registry.md`) did not include — the `CallClient`
that *opens* a connection, the `from_call` adapter, and the
`OperationAdapter` trait. (`from_jsonschema` was originally specced here
too, but ADR-027 moved it to `alknet-http` — see §"from_jsonschema" below.)
The server-side `CallAdapter` and `CallConnection`
dispatch loop are covered in `call-protocol.md`; this document covers the
client-side connection-establishment half and the adapter surface.
## What
This document specifies three components, all in `alknet-call`:
1. **`CallClient`** — takes over an established transport `Connection`
on ALPN `alknet/call`, spawns the shared dispatch loop, and produces
a `CallConnection`. Transport-agnostic (`spawn_dispatch` primary;
dial lives in `AlknetClient` per ADR-045); the dispatch loop is
shared with the server-side `CallAdapter`
(ADR-022 §1); `CallClient` is the connection-take-over half, not a
parallel protocol implementation.
2. **`from_call`** — discovers operations on a remote call-protocol endpoint
via `services/list` + `services/schema` (already implemented in
`registry/discovery.rs`) and registers them in the connection's Layer 2
overlay as `FromCall`-provenance leaves with forwarding handlers.
3. **`OperationAdapter` trait** — the async trait that `from_call`,
`from_openapi`, `from_mcp`, and `from_jsonschema` all implement.
> **`from_jsonschema` moved.** ADR-027 moved `from_jsonschema` from
> `alknet-call` to `alknet-http` and gave it a real reqwest-backed
> forwarding handler (it was a broken schema-only placeholder before).
> It is now an HTTP-backed single-endpoint adapter for non-standard /
> non-OpenAPI / basic REST endpoints, functionally similar to
> `from_openapi` but one endpoint at a time. See
> [`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
> The `FromJsonSchema` provenance variant stays in `alknet-call`
> (`OperationProvenance`); only the adapter implementation moved.
It also records two cross-cutting architectural mechanisms that the adapter
surface rests on:
- The **adapter location map** — which adapters live in `alknet-call` vs
`alknet-http`, and why.
- The **no-env-vars invariant** — the architectural mechanism by which
downstream consumers' `std::env::var` credential reads are made unreachable.
And one downstream pattern this completion unblocks:
- The **exchange-of-operations pattern** (runner / container service) — the
canonical bilateral composition this client surface enables.
## Why
The server-side `CallAdapter` (accept path) and `CallConnection` (dispatch
loop) are implemented and tested. The client side is the #1 gap blocking every
downstream consumer: the runner pattern (a process that connects outward to a
hub and exposes local ops), the container-service rewrite, the bilateral
exchange, the NAPI projection, and the agent's cross-node tool dispatch all
require a `CallClient`. `from_call` is the #2 gap; the `OperationAdapter`
trait is the enabling gap for `alknet-http`'s `from_openapi`/`from_mcp`.
ADR-022 specced this surface. This document is the spec that operationally
fills the gap ADR-022 left to implementation: the `CallClient` API, the
`from_call` flow, the trait signature, the adapter location, the credential
invariant, and the bilateral pattern. The gap
analysis (`docs/research/alknet-call-completion/gap-analysis.md`) identified
four decisions (DC-1..4) needed before implementation. DC-1 was initially
resolved by ADR-023 (`remote_safe`/`trusted_peer`), but a subsequent research
pass (`docs/research/alknet-call-peer-routing/findings.md`) found that
ADR-023's model was structurally broken for the head→N-workers pattern (the
primary use case) and that its parallel `remote_safe`/`trusted_peer`
authorization system duplicated the existing `AccessControl`/`Identity`
machinery. **ADR-024 supersedes ADR-023**: peer-keyed overlays + `PeerRef`
routing, and peer authorization through the existing `AccessControl::check(peer_identity)`.
DC-2/3/4 are two-way-door defaults recorded here (DC-2→OQ-27, DC-3→OQ-28
cross-peer dissolved / same-peer stays, DC-4→OQ-26).
## Architecture
### CallClient
`CallClient` takes over an established transport `Connection` on ALPN
`alknet/call`, spawns the shared dispatch loop, and produces a
`CallConnection`. The `CallConnection` type is already implemented
(`call-protocol.md` §"CallConnection") — it wraps an established
`Connection` and holds the Layer 2 imported-ops overlay. `CallClient`
is the producer on the outbound side; `CallAdapter`'s accept path is
the producer on the inbound side. Both produce the same
`CallConnection` and hand it to the same shared dispatch loop.
`CallClient` is transport-agnostic. The call protocol runs over any
ordered, reliable bidirectional stream — QUIC, TCP+TLS, WebTransport,
SSH `direct-tcpip`, a WebSocket (ADR-007 `Connection::from_stream` /
`from_bidi`). The primary constructor (`spawn_dispatch`) takes a
pre-established `Connection` from any transport; the dial lives in
`AlknetClient` (`alknet-client`, ADR-045). This mirrors
`ChannelClient::from_connection` (ADR-043) and is the client-side
analogue of the server-side generalization ADR-007 made.
```rust
pub struct CallClient {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl CallClient {
pub fn new(registry: Arc<OperationRegistry>, idp: Arc<dyn IdentityProvider>) -> Self;
/// Transport-agnostic primary constructor. Takes a pre-established
/// `Connection` on ALPN `alknet/call` (any transport — QUIC via
/// `from_quinn`, TCP+TLS via `from_bidi`, WebTransport, SSH
/// `direct-tcpip`, a WebSocket), spawns the shared dispatch loop,
/// and returns a live `CallConnection`. Mirrors the server-side
/// `CallAdapter::handle(Connection)`. This is the one-way-door
/// API surface (ADR-022 Am. 2026-07-13) — it must not be coupled to
/// a transport.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection;
}
```
Peer authorization flows through the existing `AccessControl::check` against
the peer's resolved `Identity` (ADR-024 §3) — there is no `trusted_peer` flag
and no `remote_safe` marking. When a remote peer calls an op, the dispatch
path resolves the peer's `Identity` (from the connection's TLS fingerprint or
the `auth_token` payload, via the existing `IdentityProvider`) and runs
`AccessControl::check(peer_identity)` against the op's `AccessControl`. If
the op's required scopes/resources are satisfied, the call dispatches; if not,
`FORBIDDEN` before the handler runs (capabilities never populated — the
security property). An op that should never be callable from the wire uses
`Visibility::Internal` (existing mechanism, `NOT_FOUND` before ACL). See
[ADR-024](decisions/029-peer-graph-routing-model.md) §3 for the full
mapping of the three `remote_safe` cases to `AccessControl`/`Visibility`.
The connection is symmetric after establishment (ADR-022 §2): both sides can
send and receive `call.requested`. Connection direction (who opened it) is
independent of call direction (who calls whom). The `CallClient` is therefore
both a caller and a callee — it dispatches incoming calls from the remote
peer through the same `AccessControl`-gated path, and it initiates outgoing
calls through the `CallConnection::call()` / `subscribe()` / `abort()` API.
#### Shared Dispatcher
The shared dispatch loop lives in `protocol/dispatch.rs` as the `Dispatcher`
struct. This is the architectural mechanism that keeps `CallClient` from
becoming a parallel protocol implementation (ADR-022 §1): both `CallAdapter`'s
accept path and `CallClient`'s connect path construct a `Dispatcher` and call
`run_loop` — the dispatch half is one implementation, the
connection-establishment half differs (accept vs dial).
```rust
/// Shared dispatcher for an established CallConnection. Constructed by both
/// CallAdapter (accept path) and CallClient (connect path). Holds no
/// per-connection state; the CallConnection is passed into run_loop.
pub struct Dispatcher {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn IdentityProvider>,
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
pub default_timeout: Duration,
}
```
The dispatch path resolves the peer's `Identity`, runs `AccessControl::check`
against the op's `AccessControl`, and dispatches if allowed — the same
authorization machinery that gates every other call. No `RemoteFilter`, no
`remote_safe` gate (ADR-024 §3 retires these).
`CallClient::spawn_dispatch(connection)` is the transport-agnostic
primary constructor — it takes a pre-established `Connection`,
constructs a `CallConnection`, builds a `Dispatcher`, spawns the
dispatch task, and returns the live `CallConnection`. The dial lives in
`AlknetClient` (`alknet-client`, ADR-045): keeping a QUIC convenience
constructor on `CallClient` would make `alknet-call` depend on
`alknet-client`, contradicting the dep graph (the protocol crates are
parallel to the dial, not downstream of it). Callers compose
`AlknetClient::dial_quic` + `spawn_dispatch` — two lines, the dial then
the take-over. Tests use `spawn_dispatch` directly to wire mock/loopback
connections. The one-way-door surface is `spawn_dispatch`; the dial
lives in `alknet-client`.
This mirrors `ChannelClient::from_connection` (ADR-043) and is the
client-side analogue of the server-side generalization ADR-007 made.
The call protocol, like the channels protocol, is transport-agnostic —
`Connection::from_stream` / `from_bidi` (ADR-007) accept any
`AsyncRead + AsyncWrite`, and `spawn_dispatch` takes the resulting
`Connection` unchanged.
#### Peer-keyed composition env (ADR-024)
The composition env that aggregates multiple connections is **peer-keyed**
(ADR-024 §1). `CompositeOperationEnv`'s singular
`connection: Option<Arc<dyn OperationEnv>>` is replaced by `PeerCompositeEnv`
with peer-keyed connections:
```rust
pub struct PeerCompositeEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material — ADR-025)
```
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
(`Specific(PeerId)` / `Any`), default-impl for back-compat. See
[ADR-024](decisions/029-peer-graph-routing-model.md) §2 for the full
`invoke_peer` signature and `ScopedPeerEnv` peer-qualified reachability. The
per-`CallConnection` overlay stays flat (one connection = one peer); the
peer-keying is at the aggregation layer (the head node's composition env).
#### services/list
`services/list` filters by `AccessControl::check(calling_peer_identity)`
the calling peer sees only ops it is authorized to call. There is a
single `AccessControl`-filtered handler (no `peer_scoped` variant, no
`remote_safe` filter — both retired by ADR-024). `services/list-peers`
is the opt-in for peer-attributed re-export listing (each peer's
sub-overlay listed with attribution, filtered by the calling peer's
authorization). See [ADR-024](decisions/029-peer-graph-routing-model.md) §6.
### Credential sources for connections
The credential dimensions are split across two layers (ADR-012, amended
2026-07-17):
- **`ConnectionCredentials`** (in `alknet-core`, per ADR-012) — the
**transport-level** credential bundle, consumed by the dial
(`AlknetClient`). Carries the two transport-identity dimensions:
`local_identity` (the local node's `TlsIdentity`) and `remote_identity`
(the expected fingerprint). The dial does not depend on the call
protocol for this type.
- **`auth_token`** — a **per-request payload field**, not a
call-protocol credential bundle. `Dispatcher::resolve_identity`
reads `payload.get("auth_token")` on each `call.requested` payload.
Browsers send it directly in the WebSocket call payload; the HTTP
gateway resolves the bearer token to an `Identity` at its boundary
(the call layer sees the identity, not the token). See ADR-012 for
the credential-bundle decoupling.
Credentials come from `Capabilities` (ADR-010), never from environment
variables. The transport-identity dimensions (ADR-022 §7):
```rust
// Transport-level (alknet-core, consumed by the dial — ADR-012)
pub struct ConnectionCredentials {
pub local_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path / fail-closed)
}
// auth_token is a per-request payload field, not a credential struct.
// Browsers send it in the WebSocket call payload; the HTTP gateway
// resolves bearer → Identity at its boundary.
// Dispatcher::resolve_identity reads payload.get("auth_token").
```
`RemoteIdentity` (ADR-022 §7, extended by ADR-034 §2) carries a
fingerprint string the assembly layer derives from `Capabilities` when
the local node has a `PeerEntry` for the remote (the known-peer case →
fingerprint pin). `remote_identity: None` is the **public X.509
endpoint** case: the local node has no `PeerEntry` for the remote, so
there is no fingerprint to pin. Combined with an X.509 transport, `None`
selects CA verification (`WebPkiServerVerifier`) per the
verifier-selection rule in ADR-034 §3. Combined with an Ed25519
raw-key transport, `None` fails closed (raw-key remotes are always
known peers — no CA to fall back to). The `Option` is load-bearing, not
cosmetic: `Some(fingerprint)` means "pin this" (known peer), `None`
means "trust the CA or fail" (unknown remote). An implementer must not
default `remote_identity` to a placeholder value to "satisfy" the field
`None` is a real state that drives verifier selection.
```rust
pub struct RemoteIdentity { pub fingerprint: String }
```
There is no call-protocol credential bundle. The transport dimensions
(`local_identity`, `remote_identity`) are in `ConnectionCredentials` in
`alknet-core` per ADR-012.
- **TLS identity** — the local node's Ed25519 raw key (RFC 7250) or X.509 cert,
derived from the vault at startup (ADR-020, ADR-026, ADR-027).
- **Auth token** — an opaque call-protocol-level token, decrypted from the
vault or derived from a shared secret.
- **Remote identity verification** — the expected fingerprint/cert of the
remote node, stored as a capability. `Some` → fingerprint pin (known
peer with a `PeerEntry`); `None` → CA verification for X.509 remotes,
fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). The `None`
case is the public-X.509-endpoint path, not a missing field.
These are populated by the assembly layer at `CallClient` construction time
from vault-derived `Capabilities`. The credential path is the no-env-vars
invariant (below). The concrete shapes of `TlsIdentity`, `AuthToken`, and
`RemoteIdentity` are implementation-detail two-way doors; the one-way
constraints are that they come from `Capabilities`, not env vars (ADR-010).
**TLS client-auth presentation** (OQ-29 #1, wired): the client presents
its Ed25519 key as an RFC 7250 raw public key client cert — the client-side
equivalent of the server's `RawKeyCertResolver`. This is **wired now**, not
additive: it is what activates the `PeerEntry` fingerprint → `peer_id`
resolution path on quinn connections (ADR-025 §5). Without it, the ADR-024
peer graph doesn't populate for quinn connections — `PeerId` resolution
fails because the server has no client cert to extract a fingerprint from.
The iroh path already works (iroh uses RFC 7250 raw keys and exchanges
Ed25519 public keys during the TLS handshake automatically); the gap was
quinn-only, and OQ-29 #1 resolves it by replacing `with_no_client_auth()`
with presenting the key. The one-way constraint (credentials from
`Capabilities`, not env vars, ADR-010) is unaffected — the `auth_token`
dimension flows through the call-protocol `auth_token` payload field, not
TLS, so the no-env-vars invariant holds independently of the TLS layer.
**Remote-identity verification** (OQ-29 #2, additive): verifying the
server's fingerprint against an expected value (`credentials.remote_identity`)
is **additive** — the server-side fingerprint extraction is what matters for
`PeerId`, not the client-side verification. The verifier for raw keys can
start as "accept any, extract fingerprint" and add fingerprint-pinning later.
This is a two-way-door remainder; the one-way constraint (credentials from
`Capabilities`, not env vars) is unaffected.
**Server cert verifier selection** (OQ-29 #2 + ADR-034 §3): the client-side
`ServerCertVerifier` is selected by whether the local node has a `PeerEntry`
for the remote, not by key type alone. A pure-client
connection to a **public X.509 endpoint** (no `PeerEntry` on the local
side — e.g., dialing `api.alk.dev` or a third-party API) uses
`WebPkiServerVerifier` (CA verification), gets **no `PeerId`** on the
client side, and is **not added to `PeerCompositeEnv`** — it is not in
the call-protocol peer graph (ADR-024). Ops discovered via `from_call`
on such a connection land in the connection's Layer 2 overlay
(ADR-019) and are invoked through the `CallConnection` handle directly,
not via `PeerRef::Specific`. A connection to a **hub** (a `PeerEntry`
with mixed Ed25519 + X.509 fingerprints) uses fingerprint pinning on
both cert paths and does enter the peer graph. An unknown Ed25519
raw-key remote fails closed (no CA to fall back to — raw-key remotes
are always known peers). See
[ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md)
for the verifier selection rule and the three-role naming.
### from_call
`from_call` discovers the remote peer's `External` operations and registers
them in the connection's Layer 2 overlay as `FromCall`-provenance leaves with
forwarding handlers. The discovery mechanism (`services/list` +
`services/schema`) is already implemented in `registry/discovery.rs`;
`from_call` is the client-side consumer of that API.
```rust
pub struct FromCallConfig {
/// Namespace prefix applied to imported operation names. Optional —
/// default no prefix. Collision on import is an error (DC-3, OQ-28),
/// not last-wins.
pub namespace_prefix: Option<String>,
/// Optional filter — import only operations whose names match. None
/// imports all External ops discovered via services/list.
pub operation_filter: Option<HashSet<String>>,
}
/// Discover the remote peer's External ops and construct HandlerRegistration
/// bundles with FromCall provenance and forwarding handlers. The caller
/// registers the bundles in the connection's overlay via
/// CallConnection::register_imported_all().
pub async fn from_call(
connection: &CallConnection,
config: FromCallConfig,
) -> Result<Vec<HandlerRegistration>, AdapterError>;
```
The flow (ADR-022 §3):
1. Call `services/list` on the remote → list of `External` operations.
2. Call `services/schema` for each → input/output JSON Schemas and declared
`error_schemas` (ADR-016).
3. For each discovered op, construct a `HandlerRegistration`:
- `spec` mirrors the remote op's name (with optional prefix), namespace,
type, schemas, access control.
- `handler` is a forwarding handler, **branched on `op_type`** (ADR-021):
- `Query` / `Mutation` → a `Handler` (registered as `HandlerKind::Once`):
sends `call.requested` via `CallConnection::call_with_payload()`, awaits
the single `call.responded` (or `call.error`), returns the
`ResponseEnvelope`.
- `Subscription` → a `StreamingHandler` (registered as
`HandlerKind::Stream`): calls `CallConnection::subscribe()`, which
returns `impl Stream<Item = ResponseEnvelope>` (the client-side
streaming path, already implemented), maps it to a
`BoxStream<ResponseEnvelope>`. The remote stream flows end-to-end:
each `call.responded` the remote sends becomes a stream item; the
remote's `call.completed` ends the stream (→ wire `call.completed`);
`call.aborted` drops the stream (cascade per ADR-020). No truncation,
no first-value fallback — a `from_call`-imported subscription forwards
the full remote stream.
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaf — ADR-018).
4. The caller registers the bundles via
`CallConnection::register_imported_all()`.
**Re-import on reconnection** (DC-2, OQ-27): `from_call` is a free function;
the assembly layer calls it after the dial (in `AlknetClient`). The overlay
is per-connection (Layer 2, ADR-019), so a stale overlay dies with the
connection; re-import on reconnect is naturally scoped to the new
connection. A `CallConnection::refresh()` method for mid-connection
re-discovery is a genuine feature addition — non-breaking, additive — if a
deployment needs manual re-discovery without drop-and-reconnect. See
[ADR-028](decisions/069-from-call-manual-free-function.md).
**Namespace collision** (DC-3, OQ-28): under the peer-graph model (ADR-024),
cross-peer collision dissolves — same name on different peers is fine (they
live in separate peer sub-overlays, no prefix needed). Same-peer collision
stays an error (a peer shouldn't expose two ops with the same name).
`FromCallConfig::namespace_prefix` is optional local-naming sugar for when
the importing node wants to expose a peer's ops under a different name
*locally* — a local-naming concern, not a disambiguation concern. It defaults
to `None`.
**Trust is transitive** (recorded in `operation-registry.md`): a
`from_call`-imported operation executes the remote node's code, not yours.
The scoped env (ADR-017) bounds *which* operations are reachable, not *what*
they do. `from_call` means "I trust the remote node as much as my own
handlers." The abort cascade (ADR-020) crosses the node boundary transparently
through the forwarding handler's `parent_request_id`.
**Forwarded-for identity** (ADR-026): the `from_call` forwarding handler
populates `forwarded_for` on the `call.requested` payload it constructs to
send to the spoke. The hub reads its own `OperationContext.identity` (the
end user it authenticated) and sets `forwarded_for` to that identity when
forwarding. The spoke receives it as metadata on its `OperationContext`
available for logging, auditing, per-user rate limiting, but never used by
`AccessControl::check` (the spoke authorizes the hub, its direct caller,
not the end user). The hub may set `forwarded_for: None` if it doesn't
want to disclose the originator. See [ADR-026](decisions/032-forwarded-for-identity.md).
### from_jsonschema
`from_jsonschema` was originally specified here (ADR-022 §5) as a
schema-only adapter in `alknet-call` — a placeholder handler returning
`NOT_FOUND`. That was broken: an op in the registry needs a real handler,
and the "schema-only, no handler" concept conflated schema validation
(a planning activity that doesn't need a registry entry) with operation
registration (which always needs a handler).
[ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) moved
`from_jsonschema` to `alknet-http` as an HTTP-backed single-endpoint
adapter: the caller supplies an `OperationSpec` + `HttpServiceConfig` +
path template + method, and the adapter builds one
`HandlerRegistration` with a real reqwest forwarding handler and
`FromJsonSchema` provenance. It is functionally similar to `from_openapi`
but one endpoint at a time, for non-standard / non-OpenAPI / basic REST
endpoints that don't have a full OpenAPI document. See
[`crates/http/http-adapters.md`](../http/http-adapters.md) §"from_jsonschema".
The schema-validation-without-a-handler use case (the original stated
purpose) is served by consuming `OperationSpec` directly — the spec
already carries the input/output JSON Schemas. No adapter, no registry
entry, no handler is needed for that.
The `FromJsonSchema` provenance variant stays in `alknet-call`
(`OperationProvenance` in `registry/registration.rs`); only the adapter
implementation moved.
### OperationAdapter trait
The shared shape across import-style adapters. The trait lives in
`alknet-call` (where the types live); the implementations live where their
transport dependencies live (see "Adapter Location Map" below).
```rust
#[async_trait]
pub trait OperationAdapter: Send + Sync {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
The trait is **async** because `from_call` requires async discovery
(`services/list` + `services/schema` over a call-protocol connection,
which may be QUIC, TCP+TLS, or any other transport). Sync adapters
(`from_openapi`, `from_mcp` reading a static spec) trivially satisfy an async
trait — their `import()` bodies contain no `.await` points. This is locked by
ADR-022 §5.
The **error type** (DC-4, OQ-26) is `Result<Vec<HandlerRegistration>,
AdapterError>` where `AdapterError` is a crate-level enum covering the
failure modes real implementations hit: discovery transport failure
(`from_call` remote unreachable), schema parse failure (`from_openapi`,
`from_jsonschema`), unauthorized (HTTP 401 for `from_openapi`,
`from_mcp`). The exact `AdapterError` variants are the two-way-door
remainder; the *presence* of an error type is filled in here. ADR-022 §5
showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
type; the spec omitted the error type as an implementation-detail two-way
door, recorded here.
Implementations:
- `FromCall` — call-protocol-backed, transport-agnostic (in
`alknet-call`). `from_call` discovers ops over a `CallConnection`,
which may be QUIC, TCP+TLS, or any transport `Connection::from_stream`
supports (ADR-007).
- `FromOpenAPI` — HTTP-backed (in `alknet-http`).
- `FromJsonSchema` — HTTP-backed, single-endpoint (in `alknet-http` per
ADR-027; was a broken schema-only placeholder in `alknet-call`).
- `FromMCP` — MCP streamable-HTTP-backed (in `alknet-http`, feature-gated).
The `to_*` adapters (`to_openapi`, `to_mcp`) are outbound projections, not
`OperationAdapter` implementations — they consume the registry, they don't
produce entries for it (ADR-022 §5).
### Adapter Location Map
The decomposition principle: **the adapter trait lives where the types live
(`alknet-call`); the adapter implementations live where their transport
dependencies live.**
```
alknet-call (lean — no HTTP client, no HTTP server)
├── OperationAdapter trait (the contract — async, per ADR-022 §5)
├── from_call (transport-agnostic — discovers remote ops via
│ call protocol over any Connection)
└── CallClient (outbound connection take-over —
spawn_dispatch, transport-agnostic; dial in AlknetClient)
alknet-http (owns HTTP server + HTTP client — separate crate, separate Phase 0)
├── ProtocolHandler for h2/http1.1/h3 (axum server — inbound HTTP)
├── from_openapi (parse OpenAPI doc + reqwest forwarding handler)
├── from_jsonschema (single-endpoint reqwest forwarding handler — ADR-027)
├── to_openapi (generate OpenAPI doc from local registry)
├── from_mcp (feature-gated) (import remote MCP tools over streamable HTTP — reqwest)
└── to_mcp (feature-gated) (expose local ops as MCP tools over streamable HTTP — axum)
Not built: MCP stdio transport
— stdio = spawn arbitrary executable = built-in RCE ("download untrusted MCP servers")
— streamable HTTP is the only supported MCP transport in alknet
— recorded as an explicit security position, not a feature gap
```
`alknet-call` never sees the HTTP client. The `from_openapi`/`from_mcp`
forwarding handlers are opaque `Arc<dyn Handler>` from the registry's
perspective — constructed by `alknet_http::from_openapi()` at registration
time, stored in `HandlerRegistration`, dispatched by the `CallAdapter` which
doesn't know reqwest is involved. `alknet-call` stays lean (no reqwest, no
axum); `alknet-http` owns both HTTP directions.
**ADR-031 dependency note**: `alknet-http` implementing `from_openapi`/
`from_mcp` means `alknet-http` depends on `alknet-call` (for `OperationSpec`,
`Handler`, `HandlerRegistration`, `OperationAdapter`). ADR-031's rule is "no
handler crate depends on another handler crate" — but `alknet-call` is both
a handler *and* the protocol foundation that `alknet-agent` and `alknet-napi`
already consume. `alknet-http` depending on `alknet-call` is "HTTP uses the
call protocol types," not "HTTP depends on SSH." This is within the spirit of
ADR-031 (`alknet-call` is protocol-foundation, not a peer handler). The
`alknet-http` spec should note this explicitly; a one-line amendment to
ADR-031 clarifying that `alknet-call` is a protocol-foundation crate is
deferred to the `alknet-http` Phase 0.
### No-Env-Vars Invariant
The architectural mechanism for the env-var problem in downstream consumers
(the Rust port of Vercel's AI SDK at `/workspace/aisdk/`, whose providers all
read `std::env::var("OPENAI_API_KEY")` in their `Default` impls). The fix is
**not** to modify those consumers — it's that the env-var path is never taken
because the assembly layer never calls `Default::default()`.
The credential injection path:
```
vault (seed)
→ assembly layer (derive + decrypt at startup, per ADR-010/019/025)
→ Capabilities (non-serializable, zeroized, immutable — ADR-010)
→ HandlerRegistration.capabilities (ADR-018, the registration bundle)
→ OperationContext.capabilities (per-request, populated by dispatch
path from the bundle — ADR-018 §6)
→ from_openapi handler reads context.capabilities.get("openai")
→ injects into HTTP Authorization header
→ reqwest request goes out with vault-derived credential
```
The `from_openapi`/`from_mcp` forwarding handlers (in `alknet-http`) are the
credential injection point. They read from `context.capabilities`, not from
`std::env::var`. The downstream consumers' `Default` impls reading env vars
are simply never called — the assembly layer constructs providers with
vault-derived credentials through the builder API, or the provider's HTTP
calls are routed through `from_openapi` operations that carry the credential
in `Capabilities`.
**This is a spec-level invariant in `alknet-call`, not a runtime convention.**
The dispatch path (`build_root_context` and `OperationEnv::invoke()` per
ADR-018 §6) populates `OperationContext.capabilities` from the registration
bundle. The invariant is: *no handler reads outbound credentials from any
source other than `OperationContext.capabilities`.* This is already the
architectural intent of ADR-010; this document records it as an explicit
invariant that the `from_openapi`/`from_mcp` handler implementations (in
`alknet-http`) are verified against.
### Exchange-of-Operations Pattern (Runner / Container Service)
The canonical downstream pattern this completion unblocks, recorded here so
Phase 1 specs can reference it. Concrete example: the container service at
`/workspace/@alkdev/dispatch` (axum + russh SSH client for "reverse git
runner" over Docker/vast.ai) gets rewritten as a call-protocol service.
**Bilateral exchange**:
```
Container service (runs on a vast.ai/docker instance):
Defines Local ops: /container/exec, /container/list, /container/logs...
(real handlers — calls bollard or vast.ai API)
Connects to hub as a CallClient (outbound connection — runner pattern)
Hub (central server):
Runs CallAdapter (server) on alknet/call (already implemented)
When the container service connects:
hub runs from_call → discovers /container/* via services/list + services/schema
registers them as FromCall provenance (leaf, forwarding handlers) in the
connection's Layer 2 overlay (ADR-019)
Now the hub (or anything connected to the hub) can call /container/exec
The from_call handler forwards over the connection back to the container service
Bilateral: the container service ALSO runs from_call against the hub,
discovers the hub's External ops, and can call them.
Connection direction (container → hub) is independent of call direction
(both can call each other) per ADR-022 §2.
```
**What this requires**:
1. `CallClient` — the container service uses it to open the outbound
connection to the hub. The #1 gap.
2. `from_call` — both sides run it to populate their Layer 2 overlays with
the other side's `External` ops. The #2 gap.
3. `OperationAdapter` trait — `from_call` implements it. The #3 gap (enabling,
not blocking — `from_call` can be built as a free function before the trait
exists, but the trait is needed for `alknet-http`'s adapters).
**Why the container service doesn't need alknet-ssh**: under the call
protocol, the container service is a `CallClient` that dials the hub's
`alknet/call` ALPN (over QUIC, TCP+TLS, or any transport) — no SSH in
the loop. SSH port
forwarding becomes the *transitional* mechanism for targets that can't run a
call-protocol client (the `alknet-ssh` phase-0 findings document this
transition). Once the container service runs a `CallClient`, SSH is out of
the path entirely.
This is the "dev runner" pattern: a call-protocol client that connects back
to a hub and exposes core dev tools (bash, fs, etc.) as operations. The agent
service (`alknet-agent`, downstream) is the consumer that orchestrates these
via `env.invoke()`.
## Implementation Priority Order
Based on the gap analysis and the downstream unblock chain:
1. **`CallClient`** (critical) — outbound connection opener. Without it, no
runner, no container service, no bilateral exchange. Reuses the existing
`CallConnection` for the dispatch loop; adds only the
connection-establishment + credential-handling half. The single
highest-value piece of work in the entire `alknet-call` completion.
2. **`from_call`** (critical, depends on `CallClient`) — consumes the
already-implemented `services/list` + `services/schema` discovery API.
3. **`OperationAdapter` trait** (enabling) — the async trait. Small,
standalone, unblocks `alknet-http` Phase 1 (including `from_jsonschema`
per ADR-027).
4. **DC-1 resolution** (peer-graph routing model, ADR-024) — the
peer-keyed overlay + `AccessControl`-based peer authorization model that
replaces ADR-023's `remote_safe`/`trusted_peer`. This is a structural
change to `CompositeOperationEnv` (→ `PeerCompositeEnv`), the dispatch
path (retire `RemoteFilter`), and `OperationEnv` (gain `invoke_peer`).
See ADR-024 for the migration; the POC shapes in the research doc are the
reference.
## What This Completion Unblocks
| Downstream crate | What it needs from alknet-call | Status without completion |
|-------------------|-------------------------------|--------------------------|
| alknet-http | `OperationAdapter` trait (to implement `from_openapi`/`from_mcp`) | Blocked — can't define HTTP-backed adapters without the trait |
| alknet-ssh | Stable alknet-call types (no adapter dependency) | Not blocked — ssh depends on alknet-core, not alknet-call's adapters. Proceeds in parallel. |
| alknet-agent | `CallClient` (tool dispatch), `from_call` (remote tool import), `OperationAdapter` (provider adapters) | Blocked on `CallClient` + `from_call` |
| Container service (dispatch rewrite) | `CallClient` + `from_call` | Blocked — this is the primary consumer |
| Runner pattern (dev runner, opencode runner) | `CallClient` + `from_call` | Blocked — the runner IS a `CallClient` |
| alknet-napi | `CallClient` (Node.js calls remote ops) | Blocked — NAPI projects `CallClient` to JS |
## Constraints
- **No HTTP in alknet-call.** `from_openapi`/`from_mcp`/`from_jsonschema`/
`to_openapi`/`to_mcp` live in `alknet-http`. The `OperationAdapter`
trait and the call-protocol-backed adapter (`from_call`, transport-
agnostic) live in `alknet-call`. `from_jsonschema` was originally
(mis)placed in `alknet-call` as a schema-only placeholder; ADR-027
moved it to `alknet-http` as a real HTTP-backed adapter. See Adapter
Location Map.
- **No secret material on the wire.** `ConnectionCredentials` carries vault-derived
material for the *outbound* connection (TLS identity); `auth_token` is a
per-request payload field (browsers send it in the WebSocket call payload;
the HTTP gateway resolves bearer → `Identity` at its boundary). The
call protocol's wire format carries no private keys, API keys, or decrypted
credentials (ADR-010). The no-env-vars invariant (above) is the dispatch-side
corollary.
- **Peer authorization via `AccessControl`.** A remote peer's call is
authorized by `AccessControl::check(peer_identity)` against the op's
`AccessControl` — the same mechanism that gates every other call. No
`remote_safe` flag, no `trusted_peer` bypass (ADR-024 §3). An op with
`AccessControl::default()` is callable by any peer; an op with
`required_scopes` is callable only by peers whose `Identity.scopes` satisfy
them; an op with `Visibility::Internal` is never callable from the wire.
- **Composition env is peer-keyed.** A head node with N worker connections
holds a `PeerCompositeEnv` with `connections: HashMap<PeerId, Arc<dyn OperationEnv>>`,
not a singular connection overlay. `invoke_peer()` routes to the right peer
via `PeerRef::Specific` / `PeerRef::Any` (ADR-024 §1-2).
- **`from_call` is a manual free function.** The assembly layer calls it
after the dial (in `AlknetClient`). The overlay is per-connection so
re-import on reconnect is naturally scoped (DC-2, OQ-27). See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **`from_call` namespace collision is same-peer only.** Cross-peer collision
dissolves (same name on different peers is fine — separate sub-overlays,
ADR-024 §5). Same-peer collision stays an error. `namespace_prefix` is
optional local-naming sugar, not the disambiguation mechanism (DC-3, OQ-28).
- **`OperationAdapter::import()` returns `Result`.** Failures surface as
`AdapterError` (DC-4, OQ-26).
- **MCP stdio transport is not built.** Streamable HTTP is the only supported
MCP transport in alknet. stdio = spawn arbitrary executable = built-in RCE.
Recorded as an explicit security position, not a feature gap.
- **Pure-client X.509 connections are not in the peer graph on the client
side.** A `CallClient` connection to a public X.509 endpoint with no
local `PeerEntry` for the remote gets no `PeerId`, is not added to
`PeerCompositeEnv`, and is not addressable via `PeerRef::Specific`.
Ops discovered on it live in the connection's Layer 2 overlay and are
invoked through the `CallConnection` handle. The client-side
`ServerCertVerifier` uses CA verification (`WebPkiServerVerifier`) for
such remotes; known peers (hub with `PeerEntry`) use fingerprint
pinning. See [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md).
- **`ConnectionCredentials.remote_identity: None` is load-bearing.** `None`
means "no `PeerEntry` for this remote → use CA verification (X.509)
or fail closed (Ed25519 raw key)" per the ADR-034 §3 verifier rule.
The implementation must not default `remote_identity` to a placeholder
to satisfy the field, and must not treat `None` as "skip verification"
`None` + X.509 is CA verification, `None` + raw key is a hard
failure. `Some(fingerprint)` is the known-peer pin path.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
| `from_jsonschema` as HTTP-backed single-endpoint adapter in alknet-http | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf |
| Peer-graph routing model (DC-1, supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
| PeerEntry and Identity.id decoupling | [ADR-025](decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
| Storage boundary and repo/adapter pattern | [ADR-033](decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Layer 2 per-connection overlay where `from_call` imports land |
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | Adapter-registered ops are `Internal` by default; default-deny posture |
| Abort cascade for nested calls | [ADR-020](decisions/016-abort-cascade-for-nested-calls.md) | Cross-node abort through `from_call` forwarding handler's `parent_request_id` |
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | `error_schemas` mirrored by `from_call` from remote op's spec |
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `from_call` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`) that calls `CallConnection::subscribe()` and forwards the remote stream; `Query`/`Mutation` stay `HandlerKind::Once` |
| TLS identity redesign | [ADR-027](decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md) | RFC 7250 raw key / X.509 cert dimensions of the local `TlsIdentity` (now carried by `ConnectionCredentials.local_identity`) |
| Outgoing-only X.509 and three peer roles | [ADR-034](decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint is not a `PeerEntry` on the client side (no `PeerId`, not in peer graph); client-side verifier by `PeerEntry` presence (CA vs fingerprint pin); hub = mixed-fingerprint `PeerEntry` |
| HD derivation for encryption keys | [ADR-020](decisions/020-hd-derivation-for-encryption-keys.md) | Vault-derived TLS identity material |
| Vault key model | [ADR-026](decisions/026-vault-key-model-hd-derivation.md) | Vault-derived TLS identity material |
| Vault local-only dispatch | [ADR-025](decisions/025-vault-local-only-dispatch.md) | Vault access at assembly layer only; the credential injection path's first hop |
| Crate decomposition | [ADR-031](decisions/003-crate-decomposition.md) | `alknet-http` depends on `alknet-call` (protocol-foundation exception, noted in Adapter Location Map) |
| One-way door decision framework | [ADR-032](decisions/009-one-way-door-decision-framework.md) | Door-type classification for DC-1..4 |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
`remote_safe`/`trusted_peer` are retired; peer authorization is
`AccessControl::check(peer_identity)`. No marking to shape.
- **OQ-26** (resolved): `AdapterError` variants — `DiscoveryFailed`,
`SchemaParse`, `Transport`, `Unauthorized`, `SamePeerCollision`
(replaces flat `Conflict`). `#[non_exhaustive]`.
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
feature addition — non-breaking, additive. See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar.
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn
client-auth (present Ed25519 key as raw public key client cert);
key-type-aware server cert verification (raw key = fingerprint match,
X.509 = CA verification); fingerprint normalization (`ed25519:` across
quinn/iroh). The iroh path already works; the gap was quinn-only.
See OQ-29 in open-questions.md.
- **OQ-30** (resolved): `PeerRef::Any` routing policy — insertion-order
first-match. A richer `RoutingPolicy` is a feature extension.
- **OQ-31** (resolved): `services/list-peers` — opt-in; `services/list`
is "own ops only."
- **OQ-32** (open, feature extension): Multi-hop federation — the one-hop
model is the architectural commitment; multi-hop is a feature extension
that doesn't break downstream. The peer-keyed model extends to multi-hop
without redesign; petgraph is the candidate if path-finding becomes real
(ADR-024 §3.7).
- **OQ-33** (resolved by ADR-025): `PeerId` is a logical id. Source is
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
stable across key rotation). See OQ-33 in open-questions.md.
- **OQ-34** (resolved by ADR-025 + ADR-033): Persistent peer registry —
the storage boundary is `core trait + in-memory default` (config-backed
`ConfigIdentityProvider` now; persistence adapters additive in separate
crates). See OQ-34 in open-questions.md.
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong;
`PeerEntry` supports multiple credential paths (fingerprints +
auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity.
See OQ-35 in open-questions.md.
- **OQ-36** (resolved by ADR-035): Concrete persistence adapter shapes —
read-sync / write-async split (`IdentityStore` async write trait
extends the sync `IdentityProvider` read trait); SQLite adapter caches
in memory and uses honker NOTIFY/LISTEN for no-restart cache
invalidation; `alknet-store-sqlite` crate implements both
`IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in
open-questions.md.
- **OQ-37** (resolved by ADR-034): X.509 outgoing-only case — three
remote roles named (public X.509 endpoint, transport relay, hub).
`PeerEntry` asymmetry is correct: a pure-client connection to a public
X.509 endpoint is **not** in the call-protocol peer graph on the
client side — no `PeerEntry`, no `PeerId`, no `PeerRef::Specific`
routing. Ops discovered via `from_call`/`from_openapi`/`from_mcp`
land in the connection's Layer 2 overlay and are invoked through the
connection handle. The client-side `ServerCertVerifier` is selected
by `PeerEntry` presence: known peer → fingerprint pin; unknown X.509
remote → CA verification (`WebPkiServerVerifier`). See ADR-034 and
OQ-37 in open-questions.md.
## References
- ADR-022: Call Protocol Client and Adapter Contract (the spec this document
operationally fills)
- ADR-024: Peer-Graph Routing Model (resolves DC-1 with peer-keyed overlays
+ `AccessControl`-based peer authorization)
- `call-protocol.md``CallAdapter`, `CallConnection`, dispatch loop, stream
model (the server-side complement to this document)
- `operation-registry.md``HandlerRegistration`, provenance, capability
injection, service discovery (the discovery API `from_call` consumes)
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
implementation-state audit, the downstream unblock chain
- `docs/research/alknet-call-peer-routing/findings.md` — the peer-graph
routing research that identified ADR-023's structural gap and validated
the ADR-024 design via POC
- `/workspace/@alkdev/operations/` — TypeScript prior art (`from_openapi.ts`,
`from_mcp.ts`, `from_schema.ts`, `scanner.ts`)
- `/workspace/@alkdev/dispatch/` — concrete downstream consumer (container
service / "reverse git runner") this completion unblocks
- `/workspace/aisdk/` — downstream consumer (Rust port of Vercel AI SDK); the
no-env-vars invariant makes its `std::env::var` reads unreachable
- `/workspace/rust-sdk/` — MCP Rust SDK (rmcp); streamable HTTP transport for
`alknet-http`'s `from_mcp`/`to_mcp` (separate crate, separate Phase 0)
- `docs/research/alknet-ssh/phase-0-findings.md` — alknet-ssh Phase 0;
confirms ssh depends on alknet-core not alknet-call's adapters, so it
proceeds in parallel with this completion

View File

@@ -0,0 +1,46 @@
# ADR-001: ALPN-Based Protocol Dispatch
## Status
Accepted
## Context
The previous architecture used a three-layer model: transports produced byte streams, interfaces defined how to interpret those streams (StreamInterface, MessageInterface), and OperationEnv dispatched operations through local, irpc, or remote paths. This required a ListenerConfig enum with three variants (Stream, Http, Dns), a server accept loop handling three different listener types, and a complex dispatch model that mixed concerns across layers.
Protocol detection was done by byte-peeking — the server read the first bytes of an incoming connection and guessed which protocol the client was speaking. This is fragile, limits protocol extensibility, and cannot work with encrypted transports where the payload is opaque.
ALPN (Application-Layer Protocol Negotiation) is a TLS extension where the client advertises supported protocols during the handshake and the server selects one. QUIC builds on this natively — every QUIC connection has an ALPN. This is the same pattern iroh uses: `Router` dispatches incoming QUIC connections to `ProtocolHandler` implementations based on the ALPN string. Hickory DNS registers ALPN protocols (`dot`, `doq`, `h2`, `h3`). The reverse-proxy project at `@alkdev/reverse-proxy` uses the same pattern for TLS.
The core insight: **a service IS an ALPN**. Every protocol handler registers an ALPN string on a shared QUIC+TLS endpoint. The ALPN negotiation during the handshake routes the connection to the correct handler before any application bytes are read.
## Decision
All protocol dispatch in alknet is ALPN-based. A single QUIC+TLS endpoint accepts connections, and the ALPN string selected during the handshake determines which `ProtocolHandler` receives the connection. There is no byte-peeking, no ListenerConfig enum, and no three-layer dispatch model.
The endpoint advertises the union of all registered handlers' ALPN strings. When a client connects, the TLS/QUIC handshake negotiates the ALPN. If the client's offered ALPNs and the server's advertised ALPNs have no intersection, the handshake fails — this is the correct behavior, not an error to work around.
## Consequences
**Positive:**
- Single dispatch mechanism replaces three separate listener types
- Protocol detection happens at the TLS layer, not application layer — no byte-peeking
- Adding a new protocol is registering a new ALPN string — no server code changes
- Each handler owns its entire wire format — no shared framing layer
- QUIC connections are cheap — a client that needs multiple protocols opens one connection per ALPN, all multiplexed over the same UDP flow
- Stealth mode (byte-peek protocol detection on port 443) is unnecessary — ALPN negotiation handles this cleanly
- WASM story is clean: handlers receive byte streams, protocol parsers that operate on bytes compile to WASM
**Negative:**
- ALPN is negotiated per-connection, not per-stream — a client that wants to use multiple ALPNs (e.g., SSH and call protocol) opens separate QUIC connections for each. QUIC connections are cheap (multiplexed over the same UDP flow), so this is acceptable, but it means `alknet/call` cannot serve as a multiplexer for other ALPNs within a single connection unless explicitly designed to do so (see ADR-004).
- All protocols must be registered at endpoint creation time (or use hot-reload via ArcSwap for dynamic addition)
- Custom protocols require reserving ALPN strings — we own the `alknet/` namespace
- Debugging requires knowing which ALPN was negotiated (mitigated by logging at the endpoint level)
## References
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-002: ProtocolHandler trait
- ADR-031: Crate decomposition
- iroh reference: `docs/research/references/iroh/` (ALPN dispatch, ProtocolHandler pattern)
- Replaces the old three-layer model (StreamInterface/MessageInterface/OperationEnv)

View File

@@ -0,0 +1,66 @@
# ADR-002: ProtocolHandler Trait
## Status
Accepted
## Context
The previous architecture had two separate interface traits: `StreamInterface` (for byte-stream protocols like SSH, raw TCP) and `MessageInterface` (for message-based protocols like DNS, HTTP). This split created complexity — each interface type needed its own listener configuration, its own dispatch path, and its own framing assumptions. The `ListenerConfig` enum had three variants. The server accept loop handled three different listener types.
In practice, the distinction between "stream" and "message" protocols is artificial at the handler level. SSH starts as a byte stream but internally multiplexes channels and messages. DNS over QUIC is message-based but arrives as a stream of frames. HTTP/2 is both — bidirectional streams with message semantics. Every protocol can be modeled as "receive a byte stream, manage your own wire format."
iroh's `ProtocolHandler` trait demonstrates this: it takes a bidirectional QUIC stream and the handler is responsible for its own protocol. One trait, one dispatch point.
## Decision
A single `ProtocolHandler` trait replaces both `StreamInterface` and `MessageInterface`:
> **Note**: The signature below was revised by ADR-005. The `handle()` method
> now receives a `Connection` (not a `BiStream`) — see ADR-005 for the
> current authoritative signature. The original signature is retained here
> for historical context.
```rust
#[async_trait]
pub trait ProtocolHandler: Send + Sync + 'static {
/// The ALPN string this handler claims (e.g. b"alknet/ssh")
fn alpn(&self) -> &'static [u8];
/// Handle an incoming connection (revised by ADR-005 to receive
/// `Connection` instead of `BiStream`)
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
```
- `alpn()` returns a static byte string — the handler's ALPN identifier
- `handle()` receives a `Connection` (revised by ADR-005 from the original
`BiStream`) and an `AuthContext` carrying the authenticated identity, and
returns `HandlerError` on failure
- Every handler manages its own wire format — no shared framing, no StreamInterface/MessageInterface split
- The `ListenerConfig` enum is eliminated — ALPN advertisement configuration replaces it
**AuthContext resolution is hybrid** (see ADR-003, OQ-02 resolution): the endpoint resolves what it can before calling `handle()` (e.g., TLS client certificate fingerprint), and the handler resolves what it must inside `handle()` (e.g., AuthToken in the first frame of a call stream). The `AuthContext` passed to `handle()` may contain partial identity information — the handler is responsible for completing authentication if the endpoint didn't have enough information.
## Consequences
**Positive:**
- One trait, one dispatch point — eliminates the StreamInterface/MessageInterface split and ListenerConfig enum
- Each handler owns its wire format — no shared framing assumptions that constrain protocol design
- Adding a new protocol is implementing one trait with two methods
- Testable in isolation — give a handler a mock BiStream and AuthContext
- WASM-compatible in principle — handlers that don't need tokio runtime features compile to WASM
**Negative:**
- Every handler must implement its own framing — no shared "read a length-prefixed message" utility (mitigated: common utilities can live in alknet-core without mandating their use)
- Handlers that want message semantics must build them (mitigated: alknet-call provides this as a handler, not a mandatory layer)
- AuthContext resolution is hybrid — the endpoint resolves what it can (TLS-level auth), but handlers that need protocol-level credential extraction must do so inside handle(). This means AuthContext may be partial when handle() is called. Handlers must not assume AuthContext is fully resolved.
## References
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-001: ALPN-based protocol dispatch
- ADR-003: Auth as shared core (IdentityProvider)
- ADR-005: BiStream type definition (revised this ADR's signature from BiStream to Connection)
- iroh ProtocolHandler pattern: `docs/research/references/iroh/`
- Replaces StreamInterface, MessageInterface, and ListenerConfig

View File

@@ -0,0 +1,76 @@
# ADR-003: Auth as Shared Core (IdentityProvider)
## Status
Accepted
## Context
The previous architecture had authentication spread across multiple layers: `CredentialProvider` with four phases (AD), `AuthProtocol` as an irpc service, `server_auth` and `client_auth` as separate modules, and `IdentityProvider` as a trait in alknet-core. Different interface types presented credentials differently — SSH used key fingerprints, HTTP used Bearer tokens, DNS used query labels — but the resolution was ad-hoc and tied to the three-layer model.
The ALPN dispatch model simplifies this: every handler receives the same `AuthContext`, but the credential extraction (how a handler learns who the peer is) differs per ALPN. The resolution (turning a credential into an `Identity`) should be shared across all handlers.
## Decision
> **Note**: The original text of this decision described the handler
> "enriching or replacing" the `AuthContext`. This was superseded by
> ADR-006, which made `AuthContext` immutable in `handle()` (passed as
> `&AuthContext`). Handlers resolve identity into a local variable and
> store it on `Connection` via `set_identity()`. The text below has been
> updated to reflect the ADR-006 model.
Authentication and identity resolution live in `alknet-core` as shared infrastructure. Each handler presents credentials differently, but all resolve through the same `IdentityProvider`:
```rust
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
```
Credential presentation per handler:
| Handler | Credential presentation | Resolves via |
|---------|------------------------|-------------|
| SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` |
| CallAdapter | AuthToken in first frame | `resolve_from_token()` |
| HttpAdapter | `Authorization: Bearer` header | `resolve_from_token()` |
| DnsAdapter | AuthToken in query labels | `resolve_from_token()` |
| WebTransportAdapter | AuthToken in CONNECT headers | `resolve_from_token()` |
| GitAdapter | Signed push certificate | `resolve_from_fingerprint()` |
Auth resolution is **hybrid** — the endpoint resolves what it can, and handlers resolve what they must:
1. **Endpoint-level resolution** (before `handle()` is called): If the TLS handshake provides a client certificate, the endpoint resolves the fingerprint to an `Identity` and passes it in `AuthContext`. This is the case for SSH (where the key exchange happens at the protocol level, but the TLS layer may also provide information).
2. **Handler-level resolution** (inside `handle()`): For protocols that carry credentials in application frames (AuthToken in the first call frame, Bearer header in HTTP), the handler extracts the credential from the stream and calls `IdentityProvider` to resolve it. The handler then resolves the `Identity` into a local variable and stores it on the `Connection` via `set_identity()` for observability — it does **not** mutate the `AuthContext` (which is passed as `&AuthContext`, an immutable reference — see ADR-006). The per-request identity (for ACL) is resolved separately by the `CallAdapter` at `call.requested` time.
The `AuthContext` passed to `handle()` may be partial — containing only transport-level information if no TLS client certificate was provided. Handlers must not assume `AuthContext` contains a fully resolved `Identity`. Each handler knows its own credential extraction protocol and is responsible for completing authentication.
The `CredentialProvider` concept from the previous architecture is simplified: there is no phase progression (AD). The `IdentityProvider` has two resolution paths — fingerprint and token — and a `ConfigIdentityProvider` implementation that draws from static and dynamic config.
`alknet-vault` stays standalone. It does not depend on `alknet-core` or `IdentityProvider`. The vault provides derived keys on request; identity resolution is a separate concern.
## Consequences
**Positive:**
- Unified identity model — every handler resolves identities the same way through `IdentityProvider`
- Handlers own their credential extraction — SSH reads key fingerprints, call reads AuthTokens, HTTP reads Bearer headers
- Endpoint provides what it can for free (TLS-level auth), handlers complete what they need
- Adding a new credential type is adding a method to `IdentityProvider`, not a new phase
- alknet-secret stays standalone — no coupling between key derivation and identity resolution
- `AuthContext` is a value type — easy to construct in tests, can be partial for handler-level testing
**Negative:**
- `IdentityProvider` is in alknet-core — any change to it recompiles all handlers (mitigated: the trait should be stable; implementation changes don't force recompiles)
- Two resolution paths (fingerprint, token) may not cover all future auth schemes (mitigated: the trait can be extended, or a handler can do custom resolution after the initial AuthContext)
- Handlers must handle partial AuthContext — the endpoint may not have resolved an Identity, so handlers must be prepared to do credential extraction themselves
- WebTransport and browser-based auth needs careful design — AuthToken in CONNECT headers requires the token to be available before the stream is established
## References
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-002: ProtocolHandler trait
- ADR-031: Crate decomposition
- ADR-013: irpc as call protocol foundation
- The previous architecture had equivalent decisions in ADR-016 (unified auth) and ADR-024 (identity as core type), which are archived in the reference implementation at `/workspace/@alkdev/alknet-main/`.

View File

@@ -0,0 +1,71 @@
# ADR-004: ALPN String Convention and Connection Model
## Status
Accepted
## Context
ADR-001 establishes ALPN-based protocol dispatch. Two questions arise:
1. **ALPN string naming**: What format do custom ALPN strings follow? Should they include version numbers? How do standard ALPNs (`h2`, `http/1.1`, `h3`) coexist with custom ones?
2. **Connection model**: ALPN is negotiated per-connection in QUIC/TLS, not per-stream. A client that wants to speak both SSH and call protocol must open two separate QUIC connections, each with its own ALPN. This is different from the claim in earlier drafts that "a single connection can carry multiple protocols via additional streams" — it cannot. However, QUIC connections are cheap (multiplexed over the same UDP flow), so opening multiple connections is acceptable.
The iroh reference project uses the same model: each `ProtocolHandler` claims an ALPN, and each incoming connection is dispatched to exactly one handler based on the negotiated ALPN.
## Decision
### ALPN String Convention
Custom ALPN strings use the `alknet/` prefix:
| ALPN | Handler | Type |
|------|---------|------|
| `alknet/ssh` | SshAdapter | Custom |
| `alknet/call` | CallAdapter | Custom |
| `alknet/git` | GitAdapter | Custom |
| `alknet/sftp` | SftpAdapter | Custom |
| `alknet/msg` | MessageAdapter | Custom |
| `alknet/http` | HttpAdapter | Custom |
| `alknet/dns` | DnsAdapter | Custom |
| `h3` | WebTransport → alknet/http | Standard (IANA) |
| `h2` | HTTP/2 → alknet/http | Standard (IANA) |
| `http/1.1` | HTTP/1.1 → alknet/http | Standard (IANA) |
Rules:
- Custom ALPNs use the format `alknet/<name>` — lowercase, no version number
- Standard ALPNs (`h2`, `http/1.1`, `h3`) use their IANA-registered strings and are handled by the HTTP adapter
- No version numbers in ALPN strings initially. If protocol compatibility breaks, a new ALPN string is registered (e.g., `alknet/call/v2`). This is simpler than version negotiation and follows the QUIC convention that ALPN mismatch means connection failure
- ALPN strings are compile-time constants in each handler's `alpn()` method — no runtime registration of new ALPN strings
### Connection Model
**One ALPN per connection.** A client that wants to use multiple ALPNs opens one QUIC connection per ALPN. All connections from the same client are multiplexed over the same UDP flow (QUIC's natural connection multiplexing), so the overhead is minimal.
This means:
- `alknet/call` is a distinct ALPN with its own connection — not a multiplexer for other ALPNs
- A client interacting with both SSH and call protocol has two QUIC connections
- Within an `alknet/call` connection, multiple QUIC streams can carry independent operations (see ADR-013)
- The endpoint logs the negotiated ALPN for each connection for observability
## Consequences
**Positive:**
- Simple model: one connection, one protocol — no multiplexing layer needed inside a connection
- ALPN strings are predictable and discoverable — `alknet/<name>` is a clear namespace
- No version negotiation complexity — incompatible versions get new ALPN strings
- QUIC connection multiplexing means multiple ALPN connections share the same UDP flow
**Negative:**
- Multiple ALPNs require multiple connections — a full-featured client might have 3-5 QUIC connections open simultaneously
- No version negotiation — an incompatible change requires a new ALPN string, which means old and new clients can coexist only if the server registers both ALPNs
- The `alknet/` namespace is owned by this project — third-party extensions need their own prefix
## References
- ADR-001: ALPN-based protocol dispatch
- ADR-002: ProtocolHandler trait
- OQ-03: ALPN string naming convention (resolved by this ADR)
- OQ-06: Server-side ALPN vs client-side ALPN (resolved by this ADR)
- iroh reference: `docs/research/references/iroh/`

View File

@@ -0,0 +1,169 @@
# ADR-005: BiStream Type Definition
## Status
Accepted
## Context
OQ-01 asked whether BiStream should be a concrete type wrapping quinn's `SendStream` + `RecvStream`, a trait `BiStream: AsyncRead + AsyncWrite + Send + Unpin`, or a type alias/newtype. This is a one-way door decision: if BiStream is a concrete type bound to a specific QUIC library, WASM targets and alternative transports cannot implement it. If it's a trait, the door stays open.
### iroh's pattern
iroh's `ProtocolHandler::accept` receives a `Connection`, not a stream. The handler calls `connection.accept_bi()` to get `(SendStream, RecvStream)` pairs. This means iroh's handlers own the entire connection lifecycle and can open/accept multiple streams.
### Alknet's pattern differs
Alknet's handlers are different from iroh's for two reasons:
1. **One ALPN per connection** (ADR-004). An incoming connection is already dispatched to exactly one handler by ALPN. The handler receives the connection and can manage streams however it wants.
2. **Some handlers need connection-level ownership**. SSH multiplexes channels over multiple streams within a single connection. The call protocol opens a new stream per operation. These handlers need the connection, not just a single stream.
### WASM constraint
If alknet-core defines BiStream as `quinn::SendStream + quinn::RecvStream` joined via `tokio::io::join`, then:
- WASM targets cannot implement it (quinn doesn't compile to WASM)
- WebTransport clients in browsers cannot participate as full peers
- The cost of making BiStream a trait later would require changing every handler's signature
If BiStream is a trait, WASM targets implement it over WebTransport streams. Native targets implement it over quinn streams. The cost is minimal — a trait vs a concrete type adds a small amount of indirection and trait object overhead that is negligible compared to I/O latency.
### Testing constraint
A BiStream trait allows test implementations (in-memory channels, mock streams) without requiring a running QUIC connection. A concrete quinn type requires mocking at a higher level (connection mocking) which is more complex.
## Decision
### BiStream is a trait
```rust
pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
```
Handlers receive a `Connection` (not a single BiStream) in their `handle` method. This differs from the original ADR-002 signature and aligns with iroh's proven pattern.
### Revised ProtocolHandler signature
```rust
#[async_trait]
pub trait ProtocolHandler: Send + Sync + 'static {
fn alpn(&self) -> &'static [u8];
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
```
Where `Connection` wraps a QUIC connection (or, in test contexts, a mock) and provides:
```rust
pub struct Connection {
// Private: wraps the underlying QUIC connection or test mock
}
impl Connection {
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];
// Additional methods as needed: close, remote_addr, etc.
}
```
`SendStream` and `RecvStream` are concrete types that implement `AsyncWrite` and `AsyncRead` respectively. They wrap the underlying QUIC stream types.
### Why Connection, not BiStream, as the handler parameter
The original ADR-002 specified `handle(&self, stream: BiStream, auth: &AuthContext)`. This was modeled on the idea that a handler receives a single bidirectional stream. But:
- **SSH** needs to open/accept multiple streams (channels) on one connection
- **Call protocol** opens a new stream per operation
- **HTTP** maps requests to streams within an HTTP/2 or HTTP/3 connection
- **iroh** already uses this pattern successfully
Passing a single BiStream would force handlers that need multiple streams to somehow obtain the Connection through other means, which is awkward. Passing the Connection directly is simpler and more flexible.
Handlers that only need a single stream (simple protocols) call `connection.accept_bi().await` once and work with that stream. Handlers that need multiple streams (SSH, call) use the Connection to open/accept as needed.
### Why BiStream is still defined as a trait
Even though handlers receive a `Connection` rather than a single `BiStream`, the BiStream trait is still useful:
1. **Client-side**: A client connecting to an alknet endpoint needs a way to represent "I have a bidirectional stream to speak my protocol on." That stream should be implementable over WebTransport in WASM.
2. **Testing**: Mock BiStream implementations for unit tests.
3. **Portability**: If alknet later supports transports other than QUIC (raw TCP, iroh P2P), those transports need to produce BiStream-compatible streams.
The BiStream trait is a thin convenience — `AsyncRead + AsyncWrite + Send + Unpin` — that can be implemented by any byte transport. It does not mandate tokio or quinn.
## Consequences
**Positive:**
- WASM door stays open: browser clients can implement BiStream over WebTransport streams
- Testing is straightforward: mock BiStream implementations without QUIC infrastructure
- Handlers that need multiple streams (SSH, call) have direct access to the Connection
- Handlers that need a single stream call `accept_bi()` once — simple case stays simple
- Aligns with iroh's proven ProtocolHandler pattern
- Alternative transports (TCP, iroh P2P) can implement Connection and BiStream traits
**Negative:**
- Slight runtime overhead from trait dispatch vs concrete types (negligible compared to I/O)
- Two concepts (Connection and BiStream) instead of one (BiStream alone) — more types in alknet-core
- ADR-002's `handle` signature changes from `(BiStream, AuthContext)` to `(Connection, AuthContext)` — this is a revision to the original trait signature
- Handlers must call `accept_bi()` explicitly even for simple protocols — one additional line of code per handler
## References
- ADR-002: ProtocolHandler trait (signature revised by this ADR)
- ADR-031: Crate decomposition
- ADR-004: 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`
## 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-007, `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-007](007-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-007 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.

View File

@@ -0,0 +1,156 @@
# ADR-006: AuthContext Structure and Resolution Flow
## Status
Accepted
## Context
ADR-003 establishes the hybrid auth model: the endpoint resolves what it can (TLS client certificate fingerprint), handlers resolve what they must (AuthToken in the first frame, Bearer header, SSH key fingerprint). The `AuthContext` passed to `handle()` may be partial.
The reference implementation's `Identity` struct is:
```rust
pub struct Identity {
pub id: String,
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
}
```
And `ConfigIdentityProvider` resolves fingerprints and API keys to `Identity`. This works well and carries forward.
But the reference implementation has no `AuthContext` type — auth resolution happens inside the SSH handler before calling `IdentityProvider`. The new model needs a type that represents "what the endpoint knows about this connection's identity before the handler starts," plus a way for handlers to enrich it.
This is a one-way door: once handlers depend on `AuthContext`'s structure, changing it affects every handler. The structure must be right.
### Design considerations
1. **Handlers need identity information to make authorization decisions.** A handler that requires authentication needs to know: is the peer authenticated? Who are they? What scopes do they have?
2. **The endpoint may have zero, partial, or complete identity information.** A plain QUIC connection with no TLS client cert gives the endpoint nothing. A TLS connection with a client cert gives the endpoint a fingerprint that may resolve to an Identity. A handler that extracts an AuthToken from the first frame can complete the resolution.
3. **AuthContext must not be SSH-specific.** The reference implementation's auth types are tangled with russh (SSH key fingerprints, certificate authorities). The new model needs to be ALPN-agnostic.
4. **AuthContext is constructed by the endpoint and enriched by handlers.** The endpoint creates it from TLS-level information. The handler mutates or replaces it with protocol-level information.
5. **AuthContext must be cheap to construct.** Every incoming connection gets one, even if authentication ultimately fails.
## Decision
### AuthContext is a struct with optional fields
```rust
pub struct AuthContext {
/// The peer's authenticated identity, if resolved.
/// None means the endpoint has no identity information for this connection.
/// Some(Identity) means the endpoint resolved the peer's identity.
pub identity: Option<Identity>,
/// The negotiated ALPN for this connection.
/// Always present — the endpoint sets this from the TLS handshake.
pub alpn: Vec<u8>,
/// The peer's remote address, if available.
pub remote_addr: Option<SocketAddr>,
/// TLS client certificate fingerprint, if the client presented a certificate.
/// Set by the endpoint during TLS handshake. Handlers may use this for
/// SSH host key verification or other fingerprint-based auth.
pub tls_client_fingerprint: Option<String>,
}
```
Key design points:
- `identity: Option<Identity>` — not `Identity` with optional fields, not a separate `PartialAuthContext`. The endpoint sets it to `None` if it has no identity information, or `Some(identity)` if it resolved one. Handlers that need to complete auth call `IdentityProvider` themselves and store the resolved identity in a local variable — they do NOT mutate AuthContext (see immutability section below).
- `alpn` is always present — every connection has a negotiated ALPN.
- `remote_addr` is informational. It's available from the QUIC connection and useful for logging and rate limiting, but it's not authoritative (clients can be behind NATs/proxies).
- `tls_client_fingerprint` captures the TLS-level credential. If present, it's the SHA-256 fingerprint of the client's TLS certificate. This is separate from `identity` because a handler might need the fingerprint even when `IdentityProvider::resolve_from_fingerprint()` returns `None` (e.g., unknown cert, but the handler wants to log it).
### AuthContext is Clone
`AuthContext` derives `Clone`. Handlers can clone it for per-stream or per-channel contexts within a connection. The `Identity` inside is also `Clone`.
### Handler-level auth enrichment pattern
Handlers that need to complete authentication do so inside `handle()`:
```rust
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
let identity = if let Some(id) = &auth.identity {
id.clone() // Endpoint already resolved identity
} else {
// Extract credentials from the protocol, resolve via IdentityProvider
let token = self.extract_auth_token(&connection).await?;
self.identity_provider.resolve_from_token(&token)
.ok_or(HandlerError::AuthRequired)?
};
// ... proceed with authenticated identity
}
```
Handlers that don't need authentication (e.g., DNS resolver, health check) can ignore `auth.identity` entirely.
### Identity carries over from reference implementation
```rust
pub struct Identity {
pub id: String,
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
}
```
This is the same structure from the reference implementation, minus the russh dependency. It's ALPN-agnostic:
- `id`: A unique identifier string. For SSH key auth, this is the SHA-256 fingerprint. For API key auth, this is the key prefix. For certificate auth, this is the principal name.
- `scopes`: Authorization scopes. `["relay:connect", "secrets:derive"]` etc.
- `resources`: Named resource lists. `{"service": ["gitea", "registry"]}` etc.
### AuthToken carries raw bytes
```rust
pub struct AuthToken {
pub raw: Vec<u8>,
}
```
Unchanged from the reference implementation. Opaque bytes — the handler that extracted it knows its encoding.
### IdentityProvider carries over with minor adaptation
```rust
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
```
The implementation (`ConfigIdentityProvider`) changes from the reference: it no longer depends on russh types for key storage. Instead, it stores fingerprint strings and API key entries, drawing from `DynamicConfig` via `ArcSwap`.
### AuthContext is NOT mutable inside handle()
The `handle()` signature passes `&AuthContext` (immutable reference). Handlers that resolve identity create a local variable with the resolved identity — they don't mutate the AuthContext. This prevents accidental cross-contamination between streams on the same connection.
## Consequences
**Positive:**
- `AuthContext` is a value type — cheap to construct, clone, and pass around
- Handlers that don't need auth can ignore it entirely
- The endpoint provides what it can for free (TLS client cert fingerprint), handlers complete what they need
- No russh dependency in AuthContext — it's ALPN-agnostic
- `Option<Identity>` is explicit — there's no "partially authenticated" state that handlers have to interpret
- Handlers that need to enrich auth create local variables, not mutation — clean data flow
**Negative:**
- Handlers that need auth must call `IdentityProvider` themselves — this is intentional (ADR-003 hybrid model) but means each handler has its own auth extraction logic
- `tls_client_fingerprint` is separate from `identity` — a handler might wonder "why do I have a fingerprint but no identity?" This happens when the client presents a cert that's not in the authorized keys. The handler can log the fingerprint for debugging.
- `AuthContext` doesn't carry protocol-specific auth state (e.g., SSH auth method, HTTP auth scheme). This is by design — protocol-specific details belong inside the handler, not in the shared auth context.
## References
- ADR-002: ProtocolHandler trait
- ADR-003: Auth as shared core (IdentityProvider, hybrid auth model)
- ADR-005: BiStream type definition (Connection parameter)
- ADR-010: ALPN router and endpoint (where AuthContext is created)
- Reference implementation: `alknet-main/crates/alknet-core/src/auth/identity.rs`

View File

@@ -0,0 +1,236 @@
# ADR-007: Connection::from_stream — Generic Single-Stream Connections
## Status
Accepted
## Context
ADR-005 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-032).** 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-032: 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-005). `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-015).
- 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-005: BiStream type definition (amended by this ADR — `Connection` is
no longer QUIC-only; the server-side WASM door is open)
- ADR-032: 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-015: 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)

View File

@@ -0,0 +1,302 @@
# ADR-008: BidiStreamSource Trait — Open Connection for Extension
## Status
Accepted
## Context
ADR-007 generalized `Connection` beyond QUIC by adding
`ConnectionKind::Stream` (a yield-once read/write pair) and the
`Connection::from_stream` / `from_bidi` constructors. That closed the
server-side "QUIC-only" gap: TCP+TLS, SSH channels, WebTransport streams,
and wasm streams now dispatch through the same `HandlerRegistry` as QUIC
connections, unchanged.
What ADR-007 did **not** change is the *shape* of `Connection` itself. It
remains a closed enum:
```rust
enum ConnectionKind {
#[cfg(feature = "quinn")] Quinn(quinn::Connection),
#[cfg(feature = "iroh")] Iroh(iroh::endpoint::Connection),
Stream(StreamConn), // yield-once — ADR-007
}
```
Adding a new connection type today requires editing `alknet-core` — adding a
variant to `ConnectionKind`, adding match arms to `accept_bi` / `open_bi` /
`remote_addr` / `close`. Every downstream crate that introduces a new
connection shape (channels, a future transport, a test double beyond the
`from_stream` case) forces a core change. `Connection` is closed for
extension.
### The channels crate is the first crate that needs to extend it
The `alknet-channels` POC (`docs/research/alknet-channels/poc-summary.md`)
validated the channels multiplexer and surfaced the concrete blocker. A
channels connection carries N logical channels over one transport stream;
each channel is a bidirectional byte stream presented to a `ProtocolHandler`
as a `Connection`. With the ADR-007 shape, each channel becomes a fresh
yield-once `Connection::from_stream`, and the channels endpoint holds a *bag*
of these connections (one per channel) rather than one `ChannelConnection`
that yields N streams.
The POC confirmed this is *sufficient* (the yield-once path works — handlers
run unchanged) but *awkward*: the channels layer wants to expose a single
`ChannelConnection` that is a first-class peer of QUIC (many bidi streams),
not a collection of yield-once `Connection`s. The clean shape is for the
channels crate to implement the stream-yield interface itself, in its own
crate, without a core edit.
### The extension point is narrow and already implied by ADR-007
`Connection`'s public surface is four operations: `accept_bi`, `open_bi`,
`remote_addr`, `close`. `remote_alpn` / `set_identity` / `identity` are
`Connection`-level (not transport-level) and stay on `Connection` itself.
The four transport-level operations are the seam. Extracting them into a
trait that downstream crates can implement turns `Connection` from a closed
enum into an open trait object — the same extensibility `ProtocolHandler`
already gives handlers, applied to the connection.
### What the POC de-risked
The channels POC (28 tests passing) validated that:
1. The yield-once `Connection::from_stream` path is sufficient for per-channel
presentation — an echo `ProtocolHandler` runs through the full
demux→Connection→handler→mux path with zero channels-layer awareness
(`poc-summary.md` §"POC Target 2").
2. The `BidiStreamSource` trait is **additive** — existing callers keep working
via a `from_stream`-backed implementation of the trait, and the trait
cleanly supports a `ChannelConnection` that yields N streams
(`poc-summary.md` §"Issues Surfaced" #1).
3. The trait does not touch the `ProtocolHandler` trait shape (ADR-002) —
handlers continue to receive a `Connection` and call `accept_bi` /
`open_bi` on it. This is a `Connection` internal refactor, not a handler
API change (`poc-summary.md` §"POC Target 2").
The remaining unknowns are spec-scope (the channels crate's API), not
feasibility. This ADR makes the core-side extension point available so the
channels spec can build on it.
## Decision
### Extract `BidiStreamSource` trait
```rust
#[async_trait]
pub trait BidiStreamSource: Send + Sync + 'static {
/// Yield the next bidirectional stream this connection provides.
///
/// Transport semantics (carried from ADR-007):
/// - QUIC (quinn/iroh): returns a new bidi stream on each call,
/// `ConnectionClosed` when the underlying connection closes.
/// - Single-stream (TCP+TLS, SSH channel, WebTransport stream, wasm):
/// yields the underlying stream on the first call, then
/// `ConnectionClosed` on all subsequent calls.
/// - Channels: yields one bidi stream per channel, `ConnectionClosed`
/// when the channels connection closes.
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
/// Open a bidirectional stream to the peer.
///
/// Single-stream sources return `StreamClosed` (a single stream cannot
/// open new application streams — ADR-007). QUIC and channels sources
/// open new streams.
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
/// The peer's address, if available. Informational (NAT/proxy).
fn remote_addr(&self) -> Option<SocketAddr>;
/// Close the connection. The `code`/`reason` args are QUIC application-
/// level close codes; non-QUIC sources ignore them (the drop is the
/// close — ADR-007 §"Negative"). See REQ-CORE-02 below for the
/// rationale for keeping the QUIC-shaped signature on the trait.
fn close(&self, code: u32, reason: &str);
}
```
### `Connection` holds `Box<dyn BidiStreamSource>`
```rust
pub struct Connection {
source: Box<dyn BidiStreamSource>,
alpn: Vec<u8>,
identity: OnceLock<Identity>,
}
```
`ConnectionKind` (the private enum) is replaced by the trait object. The
public `Connection` API (`accept_bi`, `open_bi`, `remote_alpn`, `remote_addr`,
`close`, `set_identity`, `identity`) is preserved verbatim — each method
delegates to `self.source`. `remote_alpn` reads `self.alpn` (unchanged).
`set_identity` / `identity` read/write `self.identity` (unchanged).
### Constructors stay; each wraps a `BidiStreamSource` impl
| Constructor | Wraps |
|-------------|-------|
| `from_quinn` / `from_quinn_with_alpn` (feature `quinn`) | `QuinnBidiStreamSource` (crate-private) |
| `from_iroh` (feature `iroh`) | `IrohBidiStreamSource` (crate-private) |
| `from_stream` / `from_bidi` (no feature gate) | `StreamBidiStreamSource` (crate-private, yield-once) |
| `from_source` (no feature gate) | caller-supplied `impl BidiStreamSource` — the extension point for downstream crates |
`from_source(source: impl BidiStreamSource, alpn: Vec<u8>) -> Self` is the
constructor that makes the trait the extension point. A downstream crate
implements `BidiStreamSource` (e.g. the channels crate's
`ChannelBidiStreamSource`) and constructs a `Connection` from it via
`from_source` — no core edit. The built-in impls (`QuinnBidiStreamSource`,
`IrohBidiStreamSource`, `StreamBidiStreamSource`) are crate-private;
`from_source` is the only path a downstream crate uses to wrap its own
impl. The `from_quinn` / `from_iroh` / `from_stream` / `from_bidi`
constructors are convenience wrappers for the three built-in impls.
The `Stream`-backend implementations are crate-private; downstream crates
do not implement `BidiStreamSource` by wrapping `from_stream`. They
implement the trait directly (channels: `ChannelBidiStreamSource`) and
construct the `Connection` via `from_source`.
### `from_stream`-backed default impl is the compatibility path
The yield-once `StreamBidiStreamSource` is the implementation that keeps
existing callers working: `Connection::from_stream(send, recv, alpn, addr)`
constructs a `Connection` backed by a `StreamBidiStreamSource` whose
`accept_bi` yields once then returns `ConnectionClosed`, whose `open_bi`
returns `StreamClosed`, whose `close` drops the stream. Behaviorally identical
to the ADR-007 `ConnectionKind::Stream` variant. No caller change.
### REQ-CORE-02: `close()` keeps the QUIC-shaped signature on the trait
The `close(&self, code: u32, reason: &str)` signature is preserved on the
trait, rather than being split into transport-specific close methods. This
resolves the ADR-007 leftover: the `Stream` backend's `close(code, reason)`
currently takes both args and uses neither, which clippy flags under
`--no-default-features` (the channels POC's build mode) as two unused
variable warnings on `crates/alknet-core/src/types.rs:500`.
Two options were considered:
- **(a) Split `close`**: `trait BidiStreamSource { fn close(&self); }` plus a
separate `fn close_with_code(&self, code: u32, reason: &str)` default-
implemented to call `close()`. Non-QUIC impls implement only `close()`;
QUIC impls override `close_with_code`. This moves the QUIC-shaped args off
the common method.
- **(b) Keep the QUIC-shaped signature on the trait**: `fn close(&self, code:
u32, reason: &str)`. Non-QUIC impls prefix the args with `_` and document
why they're ignored (the drop is the close — ADR-007). The trait method
matches the existing public `Connection::close` signature verbatim — no
caller change, no `Connection` API split.
**Decision: (b).** Rationale:
1. **No caller breakage.** `Connection::close(code, reason)` is the existing
public signature; every caller passes both args. Option (a) would force
either a `Connection::close` that *always* takes `code`/`reason` and
dispatches to the right trait method (which means the trait still has the
QUIC-shaped method, just renamed — no actual improvement), or a
`Connection::close` that drops the args (which breaks every caller).
2. **The args are not QUIC-only in principle.** WebTransport has
application-level close codes; a future transport may as well. The
signature `close(code, reason)` is a reasonable "close with diagnostic"
shape that multiple transports can use. Only raw-stream backends (the
ADR-007 `Stream` case) have nothing to do with the args, and they're the
degenerate case.
3. **The clippy warning is fixed by the trait, not by renaming.** Under the
trait, the `StreamBidiStreamSource::close` impl prefixes the args with
`_code`/`_reason` and carries a doc comment stating they're ignored
because the drop is the close. The warning disappears; the signature
matches the public API.
The trait method's doc comment carries the "QUIC application-level close
codes; non-QUIC sources ignore them" note from ADR-007, so implementers know
the args are optional for their transport.
### What does NOT change
- **`ProtocolHandler` trait shape** — `handle(&self, connection: Connection,
auth: &AuthContext)` stays. This is an internal `Connection` refactor, not
a handler API change (ADR-032: the handler trait is a one-way door).
- **`HandlerRegistry`** — unchanged.
- **All handler code** (`HttpAdapter`, `TtyAdapter`, `CallAdapter`,
`ChannelsAdapter`) — unchanged. They receive a `Connection` and call
`accept_bi` / `open_bi` on it. The dispatch through `Box<dyn
BidiStreamSource>` is transparent to them.
- **`SendStream` / `RecvStream`** — unchanged. They continue to wrap
quinn/iroh/generic-stream sources via their own internal enum dispatch.
`BidiStreamSource` implementations construct `SendStream` / `RecvStream`
via the existing `from_quinn` / `from_iroh` / `from_stream` constructors.
- **`BiStream` trait** — unchanged (ADR-005). `BidiStreamSource` is the
server-side / connection-level seam; `BiStream` is a client-side / test
convenience trait. Complementary, not competing.
- **The endpoint's accept loops** (quinn/iroh) — unchanged. They construct
`Connection::from_quinn` / `from_iroh`, which now internally wrap a
`QuinnBidiStreamSource` / `IrohBidiStreamSource`. The accept loops
themselves don't touch the trait.
- **`Connection::remote_alpn` / `set_identity` / `identity`** — unchanged.
These are `Connection`-level (the `alpn` field and the `identity` OnceLock),
not transport-level. They stay on `Connection` and do not appear on
`BidiStreamSource`.
## Consequences
**Positive:**
- `Connection` is open for extension. The channels crate implements
`ChannelBidiStreamSource` in its own crate and constructs `Connection`
from it via `from_source` — no core edit. A future transport, test
double, or relay connection follows the same path. This is the
structural payoff: the connection type is no longer a closed enum that
every new connection shape must edit.
- A channels connection is a first-class peer of QUIC: one
`ChannelConnection` that yields N streams, rather than a bag of yield-
once `Connection`s. The channels layer's API matches its actual shape.
- The ADR-007 leftover clippy warning (unused `code`/`reason` on the
`Stream` backend under `--no-default-features`) is resolved — the
`StreamBidiStreamSource::close` impl documents why the args are ignored,
and the `_` prefix is intentional, not a missing fix.
- Existing callers, handlers, and tests are unchanged. The public
`Connection` API is preserved verbatim; the refactor is internal.
- No new deps. `async_trait` is already a core dep (used by
`ProtocolHandler`).
**Negative:**
- One dyn-dispatch indirection per `accept_bi` / `open_bi` / `close` /
`remote_addr` call. The previous enum match was also a branch, so the cost
is roughly one `Box<dyn>` method call per stream operation — negligible
next to the async I/O those operations perform. The `alpn` / `identity`
fields stay on `Connection` (not behind the dyn), so `remote_alpn` /
`set_identity` / `identity` have no new indirection.
- `BidiStreamSource: Send + Sync + 'static` is object-safe. This constrains
implementations to `Send + Sync + 'static`, matching `ProtocolHandler` —
consistent with the existing handler model.
- The `Box<dyn BidiStreamSource>` is one allocation per `Connection`. The
enum was stack-allocated (except the `StreamConn`'s inner `Mutex`).
Negligible per-connection cost; only matters if connections are
constructed in a hot loop, which they are not.
## References
- ADR-002: ProtocolHandler trait (unchanged by this ADR)
- ADR-005: BiStream type definition (amended by ADR-007; this ADR does not
touch `BiStream`; amended by ADR-009 — `BiStream` is the concrete handler
leaf, not a bare trait)
- ADR-009: `BiStream` as the handler leaf (amends this ADR's `accept_bi`
return type — `(SendStream, RecvStream)` → `BiStream`; the trait shape
and the `from_source` extension point are preserved)
- ADR-032: One-way door decision framework (why `ProtocolHandler` is not
changed — this ADR is additive to `Connection`, not a trait revision)
- ADR-010: ALPN router and endpoint (the endpoint constructs `Connection`s
via `from_quinn` / `from_iroh`; those now wrap a `BidiStreamSource` impl,
transparently)
- ADR-007: `Connection::from_stream` — generic single-stream (this ADR
generalizes `Connection` to hold a trait object; the `from_stream` /
`from_bidi` constructors and the yield-once contract are preserved via
`StreamBidiStreamSource`)
- Channels POC summary:
[`docs/research/alknet-channels/poc-summary.md`](../../research/alknet-channels/poc-summary.md)
§"Issues Surfaced" #1 (OQ-CH-13 confirmed +EV), #2 (REQ-CORE-02)
- Channels Phase 0 findings:
[`docs/research/alknet-channels/phase-0-findings.md`](../../research/alknet-channels/phase-0-findings.md)
§POC-Validated Requirements — REQ-CORE-01, REQ-CORE-02

View File

@@ -0,0 +1,626 @@
# ADR-009: `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi`
## Status
Proposed (amends ADR-008's `BidiStreamSource::accept_bi` return type;
amends ADR-007's `from_stream` / `from_bidi` constructors; amends
ADR-038's `ChannelBidiStreamSource::accept_bi` return type;
resurrects ADR-005's `BiStream` trait as the handler-facing leaf type;
supersedes the "two Phase 6 issues" framing in
`docs/research/alknet-crate-extraction/findings.md`; **`into_sub_streams()`
preservation subsequently reversed by ADR-035 (2026-07-18) — see the
note at the bottom of this ADR**)
> **Note on `into_sub_streams()` (added 2026-07-18, ADR-035):** This ADR's
> body states `into_sub_streams()` (ADR-038) is "preserved" as the
> second accessor alongside `accept_bi`, because TTY's named
> unidirectional sub-streams are the case that justifies keeping
> `SendStream` / `RecvStream`. ADR-035 reverses that preservation: the
> channels layer has no `stream_type` concept, `into_sub_streams()` is
> removed, and TTY sub-demuxes its `BiStream` via its own 5-byte format
> (the same code TTY runs in direct mode). `SendStream` / `RecvStream`
> collapse to thin newtypes over `Box<dyn Async* + Send + Unpin>` as
> this ADR specifies, but their only consumer is the channels
> reassembly path's internal join (constructing a `BiStream` from split
> halves), not `into_sub_streams()`. See ADR-035 for the resolution
> rationale (the channels layer is pure channel multiplexing; the
> handler owns its sub-stream multiplexing on the `BiStream`).
## Context
The crate-extraction findings doc
(`docs/research/alknet-crate-extraction/findings.md` Phase 6) deferred
the `alknet-http` rework on the grounds that the `QuicStream` wrapper
(44 lines, `crates/alknet-http/src/server/adapter.rs:271-314`) is a
*necessary* adapter — `accept_bi()` returns a split
`(SendStream, RecvStream)` pair, `SendStream` implements only
`AsyncWrite`, `RecvStream` implements only `AsyncRead`, and
`HttpAdapter::serve_io` needs a single `AsyncRead + AsyncWrite`. The
finding was correct about the symptom and wrong about the cause. This
ADR untangles the cause.
### The tangle: five abstractions for "a bidirectional byte stream"
Today the codebase has five abstractions for the same concept, and every
handler picks a joining strategy per-handler:
| # | Abstraction | Where | Notes |
|---|-------------|-------|-------|
| 1 | `BiStream` trait (`AsyncRead + AsyncWrite + Send + Unpin`) | `crates/alknet-core/src/types.rs:226` | **Vestigial in code.** Declared per ADR-005, named in ADR-008 as "a client-side / test convenience trait," but grep across the workspace finds **zero** consumers — no `impl BiStream`, no `dyn BiStream`, no `Box<dyn BiStream>`. The trait is the ecosystem convention (`tokio::net::TcpStream`, `TlsStream<TcpStream>`, `russh::Channel::into_stream()` all satisfy it natively) but it was never wired in. |
| 2 | `Connection` (yields `(SendStream, RecvStream)` via `accept_bi`) | `crates/alknet-core/src/types.rs:507` | The handler-facing abstraction. Leaf is split. |
| 3 | `SendStream` (AsyncWrite-only) + `RecvStream` (AsyncRead-only) | `crates/alknet-core/src/types.rs:228-294` | The actual leaves handlers receive. Each carries a quinn/iroh/generic enum (`SendStreamKind` / `RecvStreamKind`) and dispatches per-call. |
| 4 | `WsStream` trait (recv/send `axum::ws::Message`) | `crates/alknet-http/src/websocket/upgrade.rs:44` | Bypasses `Connection` entirely. The WS session runs its own dispatch loop directly over `axum::extract::ws::WebSocket`; `CallConnection::new_overlay_only` is used instead of `Connection::from_bidi`. ADR-044/048 already say "a WS message stream is another `BiStream`-satisfying transport" — the code does not. |
| 5 | `MpscSendStream` / `MpscRecvStream` (channels POC) | `/workspace/alknet-channels-poc/src/mpsc_stream.rs` | Split mpsc-backed halves fed to `Connection::from_stream`. The channels POC's `TunnelHandler` consumes them directly as two `tokio::io::copy` pumps — the split shape is right for the tunnel, wrong for HTTP. |
The two Phase 6 issues are symptoms of one root: **the leaf type is
split, so every consumer either re-joins it (HTTP's `QuicStream`,
`QuicStreamDuplex` test helper) or bypasses `Connection` entirely
(WS's `WsStream` + bespoke dispatch loop).**
### What ADR-008 left half-finished
ADR-008 extracted `BidiStreamSource` as the connection-level extension
point and kept `accept_bi` returning `(SendStream, RecvStream)`:
```rust
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
```
This preserved the existing `Connection` API verbatim (the right call
for ADR-008's scope — the trait extraction was the one-way door; the
return shape was a known leftover). But it left the join *per-handler*:
every handler that wants a single duplex stream re-implements the same
`AsyncRead + AsyncWrite` wrapper. The wrapper is small (44 lines) and
correct, but it is duplicated per-handler, and the duplication is what
forces the WS path into a bespoke `WsStream` trait instead of running
through `Connection::from_bidi` like every other transport.
### What ADR-005 already specified
ADR-005 defined `BiStream: AsyncRead + AsyncWrite + Send + Unpin` as
the leaf, and the ADR's "Why BiStream is still defined as a trait"
section (lines 86-94) lists three uses: WASM door, testing,
portability. The trait was placed in `alknet-core` and then not used
as the handler leaf — ADR-002's `handle` signature takes `Connection`
(correctly, for multi-stream handlers like TTY that loop `accept_bi`),
and `Connection::accept_bi` returns the split pair. `BiStream` became
"the trait that would have been the leaf if handlers received a single
stream." This ADR makes it the actual leaf — not by changing the
handler signature (still `Connection`), but by changing what
`accept_bi` yields.
### The ecosystem convention
`AsyncRead + AsyncWrite + Send + Unpin` (or close variants) is the
Rust ecosystem's standard "bidirectional byte stream" shape:
- `tokio::net::TcpStream`, `tokio::net::UdpSocket`
- `tokio_rustls::server::TlsStream<TcpStream>`
- `russh::Channel::into_stream()` — "Consume the Channel to produce a
bidirectional stream, sending and receiving `ChannelMsg::Data` as
`AsyncRead + AsyncWrite`"
- `tokio::io::DuplexStream`
- A WS-message adapter (the one place real adapter work is required)
All satisfy `BiStream` natively. Making `BiStream` the handler leaf
aligns alknet with the convention: `Connection::from_bidi(stream)`
accepts any of these directly, no per-handler wrapper.
### The two-pump shape is unaffected
The tunnel handler (ADR-078) and the SSH `direct-tcpip` handler
(future) use the split shape — two `tokio::io::copy` pumps, one per
direction. With `BiStream` as the leaf, these handlers call
`tokio::io::split(bidi)` to get `(ReadHalf, WriteHalf)` — the same
stdlib idiom `tokio::io::split` already provides for `TcpStream` and
`TlsStream<TcpStream>`. The split is a stdlib call at the handler
boundary, not a per-handler trait wrapper. ADR-078's
shutdown-on-completion contract applies to the `ReadHalf`/`WriteHalf`
unchanged.
### The TTY named-sub-streams case (ADR-038, ADR-077)
ADR-038 specifies `into_sub_streams()` returning
`Vec<(u8, SubStreamHandle)>` where `SubStreamHandle` is
`Send(SendStream) | Recv(RecvStream)`. ADR-077's TTY-inside-channels
mode destructures into five named handles (`stdin`, `stdout`,
`stderr`, `ctrl_in`, `ctrl_out`). **Every stream_type is
unidirectional** (ADR-034) — the typed-sub-stream leaves are
unidirectional by design, and the join is wrong for them.
This means `SendStream` and `RecvStream` cannot fully go away. They
remain as the typed-sub-stream leaves for the channels-inside-TTY
case (and any future handler that destructures a `ChannelSubStreams`).
What goes away is the *quinn-welding* in them: today `SendStreamKind`
/ `RecvStreamKind` are enums with `Quinn` / `Iroh` / `Stream` variants
that dispatch per-call. Once `accept_bi` returns a joined `BiStream`,
the quinn/iroh `accept_bi` impls do the join *once* (via
`tokio::io::join`) and yield a `BiStream`. The `SendStream` /
`RecvStream` types collapse to thin newtypes over
`Box<dyn AsyncWrite + Send + Unpin>` / `Box<dyn AsyncRead + Send +
Unpin>` — used only by `into_sub_streams()` and the channels reassembly
path, never by a top-level handler's `accept_bi` call.
## Decision
### `accept_bi` returns `BiStream`
`BidiStreamSource::accept_bi` returns a single `BiStream`, not a split
pair:
```rust
#[async_trait]
pub trait BidiStreamSource: Send + Sync + 'static {
async fn accept_bi(&self) -> Result<BidiStream, StreamError>;
async fn open_bi(&self) -> Result<BidiStream, StreamError>;
fn remote_addr(&self) -> Option<SocketAddr>;
fn close(&self, code: u32, reason: &str);
}
```
`Connection::accept_bi` / `open_bi` delegate verbatim. The public
`Connection` API is preserved except for the return type — which is a
type change every caller sees, addressed below.
### `BiStream` is a concrete newtype, not a bare trait
A bare `dyn BiStream` won't work: `AsyncRead` / `AsyncWrite` methods
take `Pin<&mut Self>`, and trait objects need `Pin<Box<dyn ...>>` or a
newtype that owns the inner stream and re-projects. The clean shape is
a concrete struct that boxes the inner joined stream:
```rust
pub struct BiStream {
inner: Box<dyn AsyncReadWrite + Send + Unpin>,
}
// Internal helper trait — the union of AsyncRead + AsyncWrite + Send +
// Unpin. Not public; exists only to give BiStream a single boxed field.
trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite> AsyncReadWrite for T {}
impl AsyncRead for BiStream { /* delegate to self.inner */ }
impl AsyncWrite for BiStream { /* delegate to self.inner */ }
```
`BiStream: AsyncRead + AsyncWrite + Send + Unpin` by construction. The
old `pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}`
(ADR-005, `types.rs:226`) is removed — the trait was never consumed,
and the concrete struct carries the same trait bounds forward as
implied bounds, not a marker trait. This is the ADR-005 resurrection:
the name and the bounds survive, the shape becomes a concrete leaf.
### The join moves into core's quinn/iroh impls (once)
```rust
#[cfg(feature = "quinn")]
async fn accept_bi(&self) -> Result<BidiStream, StreamError> {
let (send, recv) = self.conn.accept_bi().await
.map_err(map_quinn_connection_error)?;
Ok(BiStream::from_joined(send, recv)) // tokio::io::join internally
}
```
The `QuicStream` wrapper (`adapter.rs:271-314`, 44 lines) becomes
`BiStream::from_joined(send, recv)` — one line, in core, invisible to
handlers. The same applies to iroh. The join is no longer per-handler.
### `Connection::from_bidi` is the only public stream constructor;
`from_stream` is removed
Today `from_bidi` is a convenience wrapper that calls
`tokio::io::split(stream)` then `from_stream(send, recv)`, and
`from_stream` bakes the split into the constructor API — the same
split-leaf shape pushed one step earlier. With `BiStream` as the leaf,
`from_bidi` is the only public constructor that takes a joined stream.
`Connection::from_stream(send, recv, ...)` is **removed**.
The rule this normalizes: **the split never crosses a crate boundary
as part of a constructor.** A crate that produces split halves
naturally (the channels reassembly path, which produces
`MpscSendStream` / `MpscRecvStream` as distinct async types) joins
them *itself* via `tokio::io::join(send, recv)` (one line) and calls
`from_bidi`. A crate that has a joined stream (`TcpStream`,
`TlsStream<TcpStream>`, `russh::Channel::into_stream()`,
`WsBidiStream`, even a test `DuplexStream`) calls `from_bidi` directly.
`Connection` only ever holds a `BiStream`. The split is a crate-internal
concern of wherever it naturally arises.
The existing `from_stream` call sites update mechanically:
- `crates/alknet-client/src/dial/tcp_tls.rs` already uses `from_bidi`
(no change).
- `crates/alknet-endpoint/src/accept/tcp_tls.rs` already uses
`from_bidi` (no change).
- The call crate's test stubs
(`call_client.rs:91`, `protocol/connection.rs:465`,
`protocol/dispatch.rs:465`, `protocol/adapter.rs:294`,
`client/from_call.rs:428`) today do
`tokio::io::split(x)` then `from_stream(send, recv, ...)` — they
become `from_bidi(x, ...)` directly, one call, no split.
- The channels reassembly path (per ADR-038, the future
`ChannelBidiStreamSource::accept_bi` impl) joins its
`MpscSendStream` / `MpscRecvStream` via `tokio::io::join` and calls
`from_bidi` — the join is in the channels crate (where the split
exists), not in the core constructor API.
- The core test at `types.rs:768` and the `from_source_tests` helper
become `from_bidi` calls (or construct `BiStream` directly via
`BiStream::from_joined`).
`SendStream::from_stream` / `RecvStream::from_stream` (the per-half
constructors, `types.rs:267` / `types.rs:289`) are **retained** — they
are the per-half boxing for `into_sub_streams()` (ADR-038) and the
channels reassembly path's `SubStreamHandle` leaves, not constructors
that feed `Connection`. The split lives where it is natural (channels
reassembly → `SubStreamHandle`), doesn't leak into `Connection`'s API.
### `SendStream` / `RecvStream` collapse to thin newtypes
```rust
pub struct SendStream { inner: Box<dyn AsyncWrite + Send + Unpin> }
pub struct RecvStream { inner: Box<dyn AsyncRead + Send + Unpin> }
```
Used by `into_sub_streams()` (ADR-038) and the channels reassembly
path. No `SendStreamKind` / `RecvStreamKind` enum — the quinn/iroh
dispatch is gone, the join happens once in the `BidiStreamSource` impl.
`SendStream::from_quinn` / `from_iroh` (crate-private) become the
thin-boxing constructors used only by the channels reassembly path
when it needs to expose unidirectional sub-streams. The
`from_stream(impl AsyncWrite + Send + Unpin)` / `from_stream(impl
AsyncRead + Send + Unpin)` public constructors are retained.
### `HttpAdapter` drops `QuicStream`
```rust
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
if let Some(identity) = auth.identity.clone() {
let _ = connection.set_identity(identity);
}
let stream = connection.accept_bi().await
.map_err(stream_error_to_handler)?;
self.serve_io(stream).await // BiStream: AsyncRead + AsyncWrite + Unpin
}
```
`QuicStream` (44 lines) and `QuicStreamDuplex` (test helper, 38 lines)
are removed. `serve_io<I: AsyncRead + AsyncWrite + Send + Unpin>` is
unchanged — `BiStream` satisfies the bounds by construction.
### WebSocket runs through `Connection::from_bidi`
`WsBidiStream` (new, ~50-80 lines) implements `AsyncRead` / `AsyncWrite`
over `axum::extract::ws::WebSocket` binary messages: `AsyncRead`
consumes `Message::Binary` payloads (text messages close with a
protocol error, matching the current `drive_ws_session` behavior);
`AsyncWrite` frames each write as a `Message::Binary`; `poll_shutdown`
emits `Message::Close`. The WS session then runs through
`Connection::from_bidi(WsBidiStream::new(socket), alpn, addr)` +
`CallAdapter::handle` (or whatever the call-protocol's
`ProtocolHandler` is at the assembly layer) — the same path as any
other transport.
The `WsStream` trait (`upgrade.rs:44-49`), the bespoke `drive_ws_session`
loop, the `handle_inbound_envelope` / `dispatch_envelope_to_pending`
helpers, and the `run_ws_session` glue are removed. The session's
wire-level invariants (binary-only, protocol-level close on text,
`fail_all` pending on disconnect, ADR-048's `EventEnvelope` framing)
move into `WsBidiStream`'s `AsyncRead` / `AsyncWrite` / `poll_shutdown`
impls and the standard call-protocol dispatch path.
`CallConnection::new_overlay_only` stays — it's the
connection-local-overlay construction for non-peer clients
(ADR-034 §4, ADR-044 §5), orthogonal to the transport seam. What
changes is that the WS session feeds it through a `Connection` rather
than a parallel `WsStream` trait.
### Channels spec updates
ADR-038's `ChannelBidiStreamSource::accept_bi` returns `BiStream`:
```rust
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
// Yields the joined (stream_type 0, stream_type 1) pair on first
// call, ConnectionClosed on subsequent calls.
}
```
`into_sub_streams()` is unchanged in shape — it still returns
`Vec<(u8, SubStreamHandle)>` with `SubStreamHandle::Send(SendStream) |
Recv(RecvStream)`, because TTY's named sub-streams are unidirectional
(ADR-034, ADR-077). The two paths (`accept_bi` for handlers that want
the joined pair, `into_sub_streams` for handlers that want the typed
unidirectional sub-streams) are preserved per ADR-038.
The channels POC's `MpscSendStream` / `MpscRecvStream` feed
`BiStream::from_joined(send, recv)` (or `from_stream` if the channels
crate prefers to construct the joined leaf directly from the mux
handle) — the `ChannelBidiStreamSource::accept_bi` impl does the join
once, and the per-channel `Connection::accept_bi` yields a `BiStream`.
The POC's `TunnelHandler` calls `tokio::io::split(bidi)` to get its two
pump halves, the same idiom it would use over `TcpStream`.
### `BiStream` over WebSocket enables "VPN-like without being a VPN" in v1
The `webtransport.md` spec describes the "VPN-like without being a VPN"
path: a browser opens a WebTransport session to `/alknet/ssh`, the h3
handler hands each bidi stream to `SshAdapter::handle` as a
`Connection`, the browser's WASM SSH parser speaks SSH over the
stream. WebTransport is deferred per ADR-044.
With `BiStream` as the leaf, the same path exists over WebSocket in
v1: a browser opens a WS connection, `WsBidiStream` presents it as a
`BiStream`, `Connection::from_bidi` wraps it, `ChannelsAdapter::handle`
runs the channels demux over it, each channel's `accept_bi` yields a
`BiStream` that `SshAdapter::handle` receives. The WASM SSH parser
runs over a `BiStream`-over-WS-message adapter on the browser side
(the same `WsBidiStream` shape, browser-implemented). ADR-044/048's
"WS message stream is another `BiStream`-satisfying transport" becomes
literal — the code does what the spec said.
The channels POC's sync core already compiles under
`wasm32-unknown-unknown`; a `BiStream`-over-WS adapter would too. The
WASM-clean property is preserved by the unification, not blocked by
it.
### WebTransport is a channels concern, not an alknet-http concern
The `h3` handler as specified in `webtransport.md` does exactly the
channels shape: one connection, N bidi streams inside, each routed to
an ALPN by the CONNECT path. That's `ChannelsAdapter::handle` with a
different wire format (HTTP/3 extended CONNECT vs the 9-byte chunk
header). When WebTransport revives, the h3 multi-stream demux leaves
`alknet-http` and becomes a channels-variant ALPN — the `alknet-http`
h3 path becomes "register an ALPN handler that gets one `BiStream` and
serves it as HTTP/3," same as `h2`/`http/1.1`. The
ALPN-stream-proxy (ADR-040) is the channels-over-WebTransport shape,
not an `alknet-http` shape.
This is out of scope for this ADR (WebTransport is deferred per
ADR-044). It is recorded here because the unification is what makes
the future extraction clean: once `accept_bi` returns `BiStream`, the
`h3` handler's "accept a WebTransport session, yield each stream as a
`BiStream` to the ALPN handler" shape is the same code as
`ChannelsAdapter::handle`, and the extraction is a move, not a
redesign.
## What does NOT change
- **`ProtocolHandler` trait shape** — `handle(&self, connection:
Connection, auth: &AuthContext)` stays. This is an internal
`Connection` refactor; the handler trait is the ADR-032 one-way door.
- **`HandlerRegistry`** — unchanged.
- **`Connection::remote_alpn` / `set_identity` / `identity` / `close`**
— unchanged. These are `Connection`-level, not transport-level.
- **`BidiStreamSource` trait** (ADR-008) — preserved. Three signatures
change return type (`accept_bi`, `open_bi`, and the implied
`Connection::accept_bi` / `open_bi`); the trait shape and the
extension-point model are preserved.
- **`from_source` constructor** (ADR-008) — preserved. Downstream
crates implement `BidiStreamSource` and construct via `from_source`;
their `accept_bi` impls return `BiStream`.
- **`into_sub_streams()`** (ADR-038) — preserved. TTY's named
unidirectional sub-streams are the case that justifies keeping
`SendStream` / `RecvStream` (as thin newtypes, not quinn-welded
enums).
- **The two-pump pattern** (ADR-078) — preserved. Tunnel/SSH handlers
call `tokio::io::split(bidi)` for their two pump halves; the
shutdown-on-completion contract applies to the `ReadHalf` /
`WriteHalf` unchanged.
- **Yield-once contract** (ADR-007) — preserved.
`StreamBidiStreamSource::accept_bi` yields the `BiStream` once then
returns `ConnectionClosed`. The contract is about *how many times*
`accept_bi` yields, not *what shape* it yields.
- **`Connection::from_quinn` / `from_iroh`** — preserved as
convenience wrappers; internally wrap the `QuinnBidiStreamSource` /
`IrohBidiStreamSource` whose `accept_bi` does the join.
- **`Connection::from_bidi`** — promoted to the only public stream
constructor. `Connection::from_stream` is removed (the split no
longer crosses a crate boundary as part of a constructor).
- **`SendStream::from_stream` / `RecvStream::from_stream`** (per-half
constructors) — retained, but only as the boxing for
`into_sub_streams()` and the channels reassembly path's
`SubStreamHandle` leaves. Not constructors that feed `Connection`.
- **The endpoint's accept loops** (quinn/iroh) — unchanged.
## Consequences
**Positive:**
- The `QuicStream` wrapper (44 lines) and `QuicStreamDuplex` test
helper (38 lines) are removed from `alknet-http`. `HttpAdapter::handle`
becomes 4 lines. `serve_io`'s signature is unchanged.
- The `WsStream` trait, the bespoke `drive_ws_session` loop, and ~150
lines of WS-specific dispatch glue are removed from
`alknet-http/websocket/upgrade.rs`. The WS session runs through
`Connection::from_bidi` + the call-protocol handler like any other
transport. ADR-044/048's "WS message stream is `BiStream`-satisfying"
becomes literal.
- One abstraction (`BiStream`) replaces five. The leaf type matches the
ecosystem convention (`russh::Channel::into_stream()`, `TcpStream`,
`TlsStream<TcpStream>`, `DuplexStream`).
- "VPN-like without being a VPN" over WS in v1 becomes real: the same
path `webtransport.md` specified, over WS, now. The browser's WASM
parser implements `BiStream` over a WS-message adapter; the server
wraps it via `Connection::from_bidi`; `ChannelsAdapter::handle` runs
the demux; each channel's `BiStream` reaches `SshAdapter::handle`
unchanged.
- The quinn-welding in `SendStream` / `RecvStream` (the
`SendStreamKind` / `RecvStreamKind` enums and their per-call
dispatch) is gone. `SendStream` / `RecvStream` become thin newtypes
used only by the channels reassembly path and `into_sub_streams()`.
- The future WebTransport extraction is a move (h3 demux → a
channels-variant ALPN), not a redesign. The unification is what
makes it clean.
- ADR-005's `BiStream` is resurrected as the actual leaf, matching the
original intent the code never delivered.
**Negative:**
- Every `accept_bi().await` caller sees a return-type change from
`(SendStream, RecvStream)` to `BiStream`. Callers that want the
split pair call `tokio::io::split(bidi)`. The call-site change is
mechanical (`let (send, recv) = ...` → `let bidi = ...; let (recv,
send) = tokio::io::split(bidi)`), but it touches every handler. This
is the one-time cost of the unification; the alternative is
per-handler wrappers forever.
- Every `Connection::from_stream(send, recv, ...)` call site is
removed. The call crate's test stubs (5 sites) become `from_bidi`
calls. The channels reassembly path gains a one-line
`tokio::io::join` before `from_bidi`. No caller outside core and
the channels reassembly path was ever doing anything other than
`tokio::io::split` then `from_stream` — the split was always
gratuitous at the call site.
- ADR-008's `accept_bi` return shape is amended. ADR-008 explicitly
preserved the split-pair shape to keep the `Connection` API verbatim;
this ADR reverses that preservation. The trade is: one type change
across the codebase now, vs. one wrapper per handler forever.
ADR-008's trait-extraction (the one-way door) is preserved; the
return-shape is the amended part.
- ADR-038's `ChannelBidiStreamSource::accept_bi` return shape is
amended (same change, same rationale). `into_sub_streams()` is
unchanged.
- `BiStream` becomes a concrete struct (with an internal boxed
`dyn AsyncReadWrite`), not a bare trait object. This is the
`Pin<&mut Self>` projection requirement — a bare `dyn BiStream` is
not ergonomic for `AsyncRead` / `AsyncWrite` impls. The ADR-005
trait is removed; the bounds survive as implied bounds on the
concrete struct. The name and the convention are preserved; the
shape becomes a concrete leaf.
- `WsBidiStream` is real new code (~50-80 lines). The WS-message ↔
byte-stream adapter is the one place the unification requires
non-trivial work — WS messages are framed, not a byte stream, so
the adapter owns the framing. This is the same work the current
`drive_ws_session` loop does, just relocated from a bespoke loop
into the `AsyncRead` / `AsyncWrite` impls.
- The `alknet-http` crate gains a dependency on whatever crate
owns `WsBidiStream` (likely `alknet-http` itself, or a small
`alknet-ws` crate if WASM-targetability is a goal — the browser side
needs the same adapter). This is a packaging decision, not a
design one — recorded as an open question below.
## Door type
**One-way.** The `accept_bi` return shape is the handler-facing API
surface. Once handlers are written against `BiStream`, reversing to
the split-pair shape is a rewrite of every handler's call site. The
trade is one type change across the codebase now vs. one wrapper per
handler forever — this ADR takes the one-time cost.
The `BiStream` concrete-struct shape (internal `Box<dyn
AsyncReadWrite>`, `Pin` projection) is a two-way-door implementation
detail — the internal representation can change without breaking the
public `AsyncRead + AsyncWrite + Send + Unpin` bounds.
## Migration
The migration is mechanical and can be ordered to keep the workspace
compilable:
1. **Core: introduce `BiStream` as the concrete leaf.** Add the
struct, the `AsyncRead` / `AsyncWrite` impls, the `from_joined`
constructor. Change `BidiStreamSource::accept_bi` / `open_bi` return
types to `BiStream`. Update `QuinnBidiStreamSource` /
`IrohBidiStreamSource` / `StreamBidiStreamSource` impls to do the
join. `Connection::accept_bi` / `open_bi` delegate verbatim. Remove
`Connection::from_stream` (the split-pair constructor); promote
`Connection::from_bidi` to the only public stream constructor.
This is a single-crate change; every `accept_bi` and `from_stream`
caller breaks mechanically.
2. **Update every handler's `accept_bi` call sites and every
`from_stream` call site.** `HttpAdapter::handle` becomes 4 lines
(drop `QuicStream`). `TtyAdapter::handle` calls
`tokio::io::split(bidi)` for its pump halves (or uses
`into_sub_streams()` in channels mode — unchanged). The channels
POC's `TunnelHandler` and `EchoHandler` get the same
`tokio::io::split` treatment. `CallAdapter::handle` (wherever it
consumes `accept_bi`) gets the same. The call crate's test stubs
(5 `from_stream` sites) become `from_bidi` calls (drop the
`tokio::io::split` they were doing immediately before). The channels
reassembly path gains a one-line `tokio::io::join` before `from_bidi`.
3. **Collapse `SendStream` / `RecvStream` to thin newtypes.** Remove
`SendStreamKind` / `RecvStreamKind` enums; the quinn/iroh
constructors become thin-boxing. Used only by the channels
reassembly path and `into_sub_streams()`.
4. **`alknet-http`: rewrite WS through `Connection::from_bidi`.** Add
`WsBidiStream`; remove `WsStream` trait, `drive_ws_session` loop,
and the dispatch glue. The WS session runs through
`Connection::from_bidi` + the call-protocol handler. This is the
largest single change and can land after (1)-(3) — the WS path is
independent of the handler call-site updates.
5. **Update ADR-007, ADR-008, ADR-038, ADR-077** to reflect the
`BiStream` return shape. ADR-007's `from_stream` constructor is
removed; `from_bidi` is the only public stream constructor (the
rule: the split never crosses a crate boundary as part of a
constructor). ADR-008's `accept_bi` return type is amended. ADR-038's
`ChannelBidiStreamSource::accept_bi` return type is amended;
`into_sub_streams()` is unchanged. ADR-077's two-mode TTY design is
unchanged (the modes differ in *how* the adapter gets sub-streams,
not in the leaf type).
6. **Update `findings.md` Phase 6.** The "deferred" status is
replaced: the `QuicStream` wrapper is removed (not because
`accept_bi` returns streams that are already duplex, but because
`accept_bi` now returns a `BiStream`); the WS path is unified; the
h3/WebTransport extraction is recorded as a future channels-variant
move enabled by this ADR.
The Phase 6 deferral in `findings.md` is resolved by this ADR — not by
the original plan (drop the wrapper as redundant) but by the actual
fix (unify the leaf so the wrapper moves into core).
## Open questions
- **Where does `WsBidiStream` live?** If WASM-targetability is a goal
(the browser side needs the same adapter), it may want to live
somewhere a WASM client can reach — `alknet-core` (no, HTTP deps
don't belong in core), a small `alknet-ws` crate, or
`alknet-http` with the browser-side adapter extracted separately.
Default: `alknet-http` owns the server-side `WsBidiStream`; the
browser-side adapter is a separate concern (the WASM SDK, not
alknet-http). Resolved at implementation time.
- **`SendStream` / `RecvStream` long-term home.** With the quinn
enums gone, these are thin newtypes over
`Box<dyn Async* + Send + Unpin>`. They could move out of
`alknet-core` into `alknet-channels-core` (their only consumer is
`into_sub_streams()`). Default: stay in `alknet-core` for now (the
channels crate is not yet extracted); revisit at the channels
extraction.
## References
- ADR-005: `BiStream` type definition (resurrected by this ADR — the
trait is removed, the bounds survive as implied bounds on the
concrete struct)
- ADR-007: `Connection::from_stream` / `from_bidi` (amended —
`from_stream` is removed; `from_bidi` is the only public stream
constructor; the split never crosses a crate boundary as part of a
constructor)
- ADR-008: `BidiStreamSource` trait (amended — `accept_bi` / `open_bi`
return `BiStream`, not the split pair; the trait shape and the
`from_source` extension point are preserved)
- ADR-038: `ChannelBidiStreamSource` (amended — `accept_bi` returns
`BiStream`; `into_sub_streams()` is unchanged)
- ADR-077: TTY inside channels (unchanged — the two-mode design is
preserved; the modes differ in how the adapter gets sub-streams,
not in the leaf type)
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
applies to `tokio::io::split(bidi)` halves)
- ADR-044, ADR-048: WebSocket is the v1 browser bidirectional path
(this ADR makes the "WS message stream is `BiStream`-satisfying"
claim literal)
- `docs/research/alknet-crate-extraction/findings.md` Phase 6 — the
deferred `alknet-http` rework; this ADR resolves the deferral by
unifying the leaf rather than by dropping the wrapper as redundant
- `docs/architecture/crates/http/webtransport.md` — the deferred h3
handler; this ADR records the future extraction as a
channels-variant move, enabled by the unification
- `crates/alknet-core/src/types.rs:226` — the vestigial `BiStream`
trait this ADR resurrects as the concrete leaf
- `crates/alknet-http/src/server/adapter.rs:271-314` — the `QuicStream`
wrapper this ADR removes
- `crates/alknet-http/src/websocket/upgrade.rs:44-49` — the `WsStream`
trait this ADR removes
- `russh::Channel::into_stream()` — the ecosystem convention this ADR
aligns with

View File

@@ -0,0 +1,203 @@
# ADR-010: Secret Material Flow and Capability Injection
## Status
Accepted
## Context
alknet-vault holds the master seed and can derive keys and encrypt/decrypt
arbitrary data. ADR-008 established that the vault is a **capability source**:
"derived keys and decrypted credentials are injected into operation contexts
at the assembly layer, not passed as vault references to handlers." That
prose was correct but the mechanism was never specified.
The result was a contradiction in the spec documents. ADR-008 said the master
seed never crosses the network, but `operation-registry.md` showed
`vault/derive`, `vault/unlock`, and `vault/decrypt` registered as call protocol
operations — directly on the wire. Those two statements cannot both be true.
The contradiction arose because no injection mechanism existed in the
architecture, so the only way the docs could show a handler obtaining a key was
to expose vault operations over the call protocol.
This is a one-way door. Once secret material crosses the wire as a call
protocol operation, the attack surface is permanent:
- `vault/unlock` accepts a BIP39 mnemonic — the root of trust — over QUIC. A
compromised peer, a logging accident, a tracing span, and the seed is gone.
- `vault/derive` returns a `DerivedKey`. The type redacts the private key in
JSON today, but the operation's existence means a serialization change, a
binary codec addition, or a wrapper change would leak it. The surface is
the risk, not the current implementation.
- `vault/decrypt` accepts an encrypted blob and returns plaintext. Any
authorized caller can decrypt any blob they possess.
The broader problem this decision addresses is structural: the industry
default for storing LLM provider keys, API tokens, and other credentials is
plaintext config files and environment variables (e.g., the aisdk Rust port
reads `std::env::var("GOOGLE_API_KEY")` and the example backend calls
`dotenv::dotenv()`). alknet replaces that with a vault. But the vault only
solves the storage problem; the flow problem — how decrypted material reaches
the code that needs it without crossing the network — requires its own
decision.
There is a separate, second axis that the current `OperationContext` conflates
with the secret-flow problem. A handler has two orthogonal credential concerns:
- **Identity (inbound)**: who is calling me? Resolved per-request from
`AuthContext` (TLS client cert, auth token). Already in `OperationContext`.
- **Capabilities (outbound)**: what secrets can I use for outbound calls? This
is the missing axis. A handler calling Google's API needs a decrypted Google
API key. That is not the caller's identity — it is the handler's own outbound
credential, provisioned by the assembly layer.
Mixing these two into one channel (e.g., stuffing secrets into
`OperationContext.metadata: HashMap<String, Value>`) is a leak risk: metadata
propagates through nested calls via `OperationEnv::invoke()`, so a secret
placed there by one handler would flow to every downstream operation.
## Decision
**1. The vault is assembly-layer only.**
The CLI binary (the `alknet` crate, or an embedded assembly layer) is the sole
component that talks to `VaultServiceHandle` directly. It unlocks the vault at
startup, derives and decrypts what each handler needs, and constructs handlers
with the results. No vault operation (`derive`, `decrypt`, `unlock`, `lock`)
is registered as a call protocol operation. The vault has no ALPN. The master
seed and derived private keys never enter the call protocol.
**2. Capabilities are the injection mechanism.**
A `Capabilities` type carries outbound secret material from the assembly layer
into handlers. Capabilities are distinct from identity (inbound auth) and
distinct from per-request metadata. The concrete shape of the `Capabilities`
type is a two-way door — to be decided during implementation of the
`alknet-call` crate. The one-way constraint is:
- Capabilities hold non-serializable, zeroized secret material. They cannot
cross the call protocol wire even by accident — they are not
`serde_json::Value`, they do not implement `Serialize`, and they do not
appear in `EventEnvelope` payloads.
- Capabilities are injected at handler construction (the common case: a static
decrypted API key held for the handler's lifetime) or scoped per-request for
internal-only flows. They are never populated from call protocol
inputs.
**3. The call protocol carries no secret material.**
This is a wire-level constraint on the call protocol, not a handler-level
convention. Secret material (private keys, API keys, mnemonics, decrypted
credentials, raw tokens) must not appear in:
- `call.requested` payloads (inputs)
- `call.responded` payloads (outputs)
- `OperationContext.metadata`
The wire format does not enforce this — it carries `serde_json::Value` — so the
constraint is architectural, enforced by the operation registry and by
convention. Operations that need to share public key material (e.g., for
identity verification) use a dedicated operation that returns only the public
component, never the private key.
**4. Adapters take credential sources, not static tokens.**
The `from_openapi` and `from_jsonschema` adapter patterns (defined in Rust in
alknet-call per ADR-033) register HTTP-backed operations. The TypeScript
`@alkdev/operations` `from_openapi` takes `config.auth: { token: "..." }` — a
static string. The Rust adapters take a credential source wired to the assembly
layer (a resolver, a capability handle, or an injected secret), not a literal
token. This is the integration point where the vault feeds credentials into
HTTP-backed operations: the assembly layer decrypts the token at startup and
provides it to the adapter at registration time.
**5. Handlers that need per-request vault access receive a scoped capability.**
The common case (a static decrypted API key) is covered by construction-time
injection. A narrower case — a handler that derives a child key for a specific
operation (e.g., signing for GitHub authentication) — receives a
scoped capability that can only derive at a restricted path set. This is still
not a vault reference: it is a restricted handle that performs a specific
derivation and returns the result to the handler, in-process. The handler
never sees the master seed. Whether this scoped capability is a distinct type
or modeled as a pre-derived key injected at construction is a two-way door
left to the `alknet-call` and `alknet-agent` crate specs.
## Consequences
**Positive:**
- The master seed and derived private keys never cross the network. The attack
surface for the root of trust is local-only.
- The `OperationContext` gains a clean second axis (capabilities) instead of
overloading `metadata` for secrets, preventing accidental propagation of
secret material through nested calls.
- Handlers that need outbound credentials (the agent handler calling an LLM
provider) receive them directly — no indirection through a `vault/derive`
call, no latency, no failure mode where the vault must be reachable at call
time.
- The adapter contract (OQ-15) gains a concrete shape: adapters take a
credential source from the assembly layer, not a static token. This makes
the `from_openapi` / `from_jsonschema` / `from_call` patterns safe by
construction.
- The model is structurally incompatible with the env-var / plaintext-config
default. There is no `std::env::var("API_KEY")` path — the only way a handler
gets a credential is through a capability, and the only way a capability is
populated is through the assembly layer from the vault.
**Negative:**
- The assembly layer (CLI binary) has more construction-time responsibility: it
must know which handlers need which credentials and wire them. This is
expected — the CLI assembles everything (ADR-008).
- Adding a new handler that needs a new credential requires updating the
assembly layer, not just registering an operation. This is a feature, not a
bug: it forces an explicit decision about what secret material a handler
needs.
- Remote vault administration (unlock a running node's vault over the network)
is not supported by this decision. If that capability is needed in the
future, it would require a separate, heavily restricted mechanism (admin
scope, mTLS-only, never expose the mnemonic over an unauthenticated channel)
and its own ADR. This decision does not close that door; it simply does not
open it.
- The `Capabilities` type shape is not fully specified here. The one-way
constraint (non-serializable, zeroized, injection-only) is fixed; the
concrete API is a two-way door for the `alknet-call` spec.
## Assumptions
These are the load-bearing assumptions. If any of them breaks, the decision
should be revisited:
1. **Handlers need credentials at construction time or at call time, not
dynamically discovered at call time.** If a handler needs to derive a key
at an unpredictable path determined by call input, the scoped-capability
model still covers it (the handler holds a scoped vault access), but the
surface area is larger. The assumption is that this case is rare.
2. **The call protocol's threat model excludes the assembly layer.** The CLI
binary is trusted to hold the vault handle and inject capabilities. If the
assembly layer is compromised, all handlers' capabilities are compromised.
This is the same trust boundary as ADR-008.
3. **No legitimate use case requires returning a private key over the wire.**
Public key sharing (identity verification, encryption to a recipient) is
the only cross-node key material flow. If a use case for returning a
private key emerges (e.g., a key-escrow service), it needs its own ADR and a
very different threat model.
4. **Adapters are registered at startup, not at call time.** The credential
source is wired to the adapter when the operation is registered, not when
the operation is invoked. This is consistent with OQ-04 (static
registration at startup).
## References
- ADR-031: Crate decomposition (alknet-vault is standalone)
- ADR-013: irpc as call protocol foundation
- ADR-008: Vault integration point (capability source — this ADR specifies the
mechanism that ADR-008 described in prose)
- ADR-032: One-way door decision framework
- ADR-033: Rust as canonical implementation language
- OQ-15: Call protocol client and adapter contract (this ADR constrains the
adapter contract: adapters take credential sources, not static tokens)
- OQ-16: Safe vault operations for call protocol exposure (resolved by this
ADR: none, for now)
- alknet-vault implementation: `crates/alknet-vault/`

View File

@@ -0,0 +1,633 @@
# ADR-011: Dynamic Resource Ownership for Runtime-Spawned Resources
## Status
Accepted (resolves OQ-42; amends the `AccessControl::check` signature and
adds `OperationSpec.resource_id_path`. Does not amend ADR-017 or ADR-018 —
specific #4 confirms the composition authority stays static. Blocks lifted:
the alknet-docker, alknet-tty, opencode-runner wrapper, and
`alknet-container` (fleet normalization) crate specs can declare their
`AccessControl` shapes against this model.)
## Context
The alknet-docker POC (`docs/research/alknet-docker/poc-summary.md`)
surfaced a class of resource that the existing auth model doesn't handle:
**runtime-spawned resources with derived ownership**. A coordinator starts
a container and exposes docker operations over the call protocol; the
question "is this peer allowed to `docker/container/exec` against container
C?" is not answerable by the static `Identity.resources` model —
`Identity.resources` is config-sourced (from `PeerEntry` on the fingerprint
path, from `CompositionAuthority` on the composition path) and set at
registration or connection time. The container didn't exist then. Its
ownership was derived at spawn time: whoever started it owns it.
This generalizes beyond docker. Every "spawn a thing at runtime and expose
it over the call protocol" crate has the same shape:
- **alknet-docker** — containers as `AccessControl` resources.
- **alknet-tty** — terminal sessions as resources (who owns this TTY?).
- **opencode-runner wrapper** — workspace containers / processes as
resources.
- **`alknet-container`** (fleet normalization) — the fleet layer that
normalizes container operations across hosts.
None of those crate specs can declare their `AccessControl` shapes until
the core model answers: **how does `AccessControl::check` learn whether
identity X owns runtime-spawned resource R?**
OQ-42 tracked this. The OQ resolved five sub-questions:
1. Storage shape — reuse the repo/adapter pattern (ADR-033).
2. Integration point — `AccessControl::check` consults an ownership
provider directly (Option 2), with `OperationSpec` gaining a
`resource_id_path` JSON pointer.
3. Access pattern — proxy-only (spawner owns, proxy to share, teardown
revokes; no grant mechanism in core).
4. Four edge specifics — the `list` case, teardown coupling, fleet
representation, composition interaction.
This ADR writes those decisions into ADR format.
### Why the static model breaks
The current `AccessControl::check` is a pure function of `(ACL,
Identity)`:
```rust
// operation-registry.md (current)
pub struct AccessControl {
pub required_scopes: Vec<String>,
pub required_scopes_any: Option<Vec<String>>,
pub resource_type: Option<String>, // e.g., "service"
pub resource_action: Option<String>, // e.g., "read"
}
impl AccessControl {
pub fn check(&self, identity: Option<&Identity>) -> bool {
// scope check: identity.scopes ⊇ required_scopes
// resource check: identity.resources[resource_type] ∋ resource_action
}
}
```
`Identity.resources` is a `HashMap<String, Vec<String>>` — named resource
lists, populated from `PeerEntry.resources` (fingerprint path, ADR-025) or
`CompositionAuthority.resources` (composition path, ADR-018). Both are
**static**: `PeerEntry` is config-sourced; `CompositionAuthority` is set
at registration. Neither grows at runtime.
For a static resource set ("alice can access services `gitea` and
`registry`"), this works: the config lists the resources, the identity
carries them, `check` matches them. For a runtime-spawned resource
("alice can exec into container `C` which didn't exist when the config was
written"), there's nowhere to record that alice owns C. The identity was
resolved at connection time, before C was spawned. The ownership exists —
the coordinator that started C knows alice asked for it — but the auth
model has no path from that knowledge to `check`.
### The options considered
Three integration points were considered (see OQ-42 for the full
reasoning):
- **Option 1 — augment `Identity.resources` with a per-request snapshot.**
The dispatcher would pull owned resources into a per-request identity
snapshot before calling `check`, so `check` *looks* unchanged while
reading state that was never part of the static identity. **Rejected:**
the purity was always theatrical (the question "can X exec into C" was
never purely a function of identity; it just looked that way because the
resource set was static). Option 1 hides the impurity in a snapshot
pretending to be static identity.
- **Option 2 — `check` consults the ownership provider directly.**
`AccessControl::check` grows a parameter (or reads one from
`OperationContext`) for the ownership provider, and consults it for
`resource_type`/`resource_action` checks against runtime-spawned
resources. **Accepted.** This makes `check`'s signature honest about
what ACL checking *is* in the presence of dynamic resources: a function
of (ACL, Identity, current-ownership-state). The impurity is real either
way; Option 2 puts it in the signature where it's visible.
- **Option 3 — handler-level ownership check, `AccessControl` gates only
scope.** Some resources statically checked, some handler-checked.
**Rejected:** it splits the ACL story — the kind of inconsistency that
creates the "figure out how it fits with what is there" cleanup this ADR
exists to prevent.
### The two access patterns
Walking through the concrete use cases (the agent-workspace case in
particular) surfaced two patterns for how a downstream consumer reaches a
runtime-spawned resource:
- **Proxy pattern (the common case).** A coordinator starts a container
and manages its lifecycle; the end user never talks to docker directly.
The coordinator re-exports the docker operations it wants to expose (via
`from_call` — the adapter that imports a peer's operations and
re-registers them locally, ADR-022 — or by composing them in its own
handlers), and when the end user invokes one, the coordinator is the
*direct caller* to the docker endpoint. Docker's ownership store sees
the coordinator as the owner and as the caller — the check passes. The
end user's identity rides as `forwarded_for` metadata (ADR-026), and the
coordinator does whatever end-user-level ACL it wants at its own layer.
This is the kernel/user-land + forwarded-for model: the hub's authority
is used, `forwarded_for` is metadata, the hub handles its own ACL.
- **Grant pattern ("poking holes").** A downstream app wants to give an
end user *direct* call-protocol access to the docker endpoint for
specific containers — the end user calls `docker/container/exec`
themselves, not through a proxy. Docker's ownership store would need a
record that the end user has access to that container, even though the
downstream app spawned it.
The agent-workspace case — the concrete one — is entirely the proxy
pattern. The coordinator starts the workspace container; the agent
interacts with what's *inside* the container (a TTY, an opencode
instance's API surface), not with docker operations on the container.
Docker-level operations (stop, remove, inspect) are the coordinator's
job. No described use case requires the grant pattern. This ADR commits to
proxy-only (Decision 3).
## Decision
### 1. Storage: reuse the repo/adapter pattern (fourth instance)
The ownership store is a fourth instance of the established repo/adapter
pattern (ADR-033), alongside `IdentityProvider` (ADR-003), `IdentityStore`
(ADR-035), and `CredentialStore` (ADR-031). A trait in `alknet-core` with
an in-memory default adapter:
```rust
// alknet-core
/// Read side: consulted by AccessControl::check on the dispatch hot path.
/// Sync — called in the accept/dispatch loop, no .await.
pub trait OwnershipProvider: Send + Sync + 'static {
/// Does `identity` own `resource_type/resource_id` with `action`?
/// Called when AccessControl has resource_type + resource_action set
/// and the dispatcher has extracted resource_id from the input via
/// OperationSpec.resource_id_path.
fn owns(
&self,
identity: &Identity,
resource_type: &str,
resource_id: &str,
action: &str,
) -> bool;
/// What resources of `resource_type` does `identity` own?
/// Called for the `list` case (resource_type set, resource_id_path
/// absent) — the result-filter path. Returns the set of resource IDs
/// the caller owns, for the handler to filter against.
fn owned_resources(
&self,
identity: &Identity,
resource_type: &str,
) -> Vec<String>;
/// Does `identity` own *any* resource of `resource_type`?
/// Called for the `list` case — the scope-gate path. Cheap boolean
/// for the "allow if scoped" default.
fn owns_any(
&self,
identity: &Identity,
resource_type: &str,
) -> bool;
}
/// Write side: called by the handler that manages the resource lifecycle.
/// Async — not on the dispatch hot path.
#[async_trait]
pub trait OwnershipStore: Send + Sync + 'static {
/// Record that `identity` spawned `resource_type/resource_id`.
/// Called by the docker handler after `docker/container/create`
/// succeeds.
async fn record(
&mut self,
identity: &Identity,
resource_type: &str,
resource_id: &str,
) -> Result<(), OwnershipError>;
/// Revoke ownership of `resource_type/resource_id`.
/// Called by the docker handler on container exit / removal
/// (specific #2 — handler-driven teardown).
async fn revoke(
&mut self,
resource_type: &str,
resource_id: &str,
) -> Result<(), OwnershipError>;
}
/// In-memory default adapter. Carries the docker/runner cases with no
/// backend dependency — ownership is runtime state, meaningless across
/// restarts (a container ID from a previous process doesn't exist).
pub struct InMemoryOwnershipStore { /* HashMap<...> */ }
```
The read trait (`OwnershipProvider`) is sync — called from
`AccessControl::check` on the dispatch hot path, no `.await`. The write
trait (`OwnershipStore`) is async — called by handlers off the hot path.
A persistence adapter (e.g., sqlite/honker-backed, for a hub that wants
fleet ownership to survive restarts) is separable and built when a
concrete use case forces it — same as `alknet-store-sqlite` for
peer/credential persistence (ADR-035). The in-memory default carries no
persistence; ownership is runtime state. A persistence adapter would cache
in memory and use honker `NOTIFY` for invalidation — same
`ArcSwap`-backed full-reload pattern as `ConfigIdentityProvider`
(ADR-035).
**The read/write split mirrors ADR-035.** `OwnershipProvider` (read,
sync) is the trait the dispatch path depends on. `OwnershipStore` (write,
async) is the trait the handler lifecycle calls. The in-memory default
implements both. A persistence adapter would implement both with an
in-memory read cache backed by SQLite, same as `SqliteIdentityProvider`
implements `IdentityProvider` (sync, cached) + `IdentityStore` (async
write).
### 2. Integration: `AccessControl::check` consults the ownership provider
`AccessControl::check` grows a parameter for the ownership provider. The
provider is carried on `OperationContext` (populated by the dispatch path
from the registry's wiring), not threaded through every call site
manually:
```rust
pub struct AccessControl {
pub required_scopes: Vec<String>,
pub required_scopes_any: Option<Vec<String>>,
pub resource_type: Option<String>,
pub resource_action: Option<String>,
}
impl AccessControl {
/// `ownership` is None when the operation has no resource_type
/// (pure scope check) or when no ownership provider is wired
/// (the static `Identity.resources` path — backward compatible).
/// `resource_id` is None for the `list` case (resource_type set,
/// resource_id_path absent — specific #4a).
pub fn check(
&self,
identity: Option<&Identity>,
resource_id: Option<&str>,
ownership: Option<&dyn OwnershipProvider>,
) -> bool {
// 1. Scope check (unchanged): identity.scopes ⊇ required_scopes.
// If identity is None and scopes are required, deny here.
// 2. Resource check (only if self.resource_type is Some):
// a. If resource_id is Some(id) and ownership is Some(p):
// → identity must be Some (owns takes &Identity, not
// Option); if identity is None, deny. Otherwise
// → p.owns(identity.unwrap(), resource_type, id, resource_action)
// b. If resource_id is None (the `list` case) and ownership is Some(p):
// → if identity is None, deny; otherwise
// → p.owns_any(identity.unwrap(), resource_type) [scope-gate; see #4a]
// c. If ownership is None → fall back to static
// identity.resources[resource_type] ∋ resource_action
// (backward compat for non-runtime resources; identity
// may be None here — empty resources → deny if action required)
}
}
```
The `resource_id` parameter is extracted by the dispatcher from the
operation input using `OperationSpec.resource_id_path` (Decision 2a
below). When the spec has no `resource_id_path` (the `list` case), the
dispatcher passes `resource_id: None`, and `check` takes the scope-gate
path (specific #1).
**Backward compatibility.** When `ownership` is `None`, `check` falls
back to the static `Identity.resources` path. This means existing
operations with static resource sets (no runtime spawning) work unchanged
— the ownership provider is an additional check, not a replacement. The
signature change is a one-way door (every call site and test updates), but
the semantic change is additive: operations that don't wire an ownership
provider behave exactly as before.
### 2a. `OperationSpec` gains `resource_id_path`
```rust
pub struct OperationSpec {
pub name: String,
pub namespace: String,
pub op_type: OperationType,
pub visibility: Visibility,
pub input_schema: Value,
pub output_schema: Value,
pub error_schemas: Vec<ErrorDefinition>,
pub access_control: AccessControl,
/// JSON pointer into the input for the resource ID, when
/// `access_control.resource_type` is set and the operation targets a
/// specific runtime-spawned resource. e.g., `"$.containerId"` for
/// `docker/container/exec`. Absent for no-specific-resource operations
/// (the `list` case — specific #1). The dispatcher extracts the
/// resource ID from the input using this path and passes it to
/// `AccessControl::check`.
pub resource_id_path: Option<String>,
}
```
The fit with JSON Schema is load-bearing, not incidental: `input_schema`
is already a JSON Schema, so `resource_id_path` is a pointer *within* an
existing schema on the same spec. The `OperationSpec` becomes fully
self-describing for authorization — what resource type, what action, and
*which input field* drives the resource lookup. No per-namespace
conventions, no handler-level knowledge, no "the dispatcher just knows."
The contract is on the spec, where it belongs.
### 3. Access pattern: proxy-only
The base model is **"spawner owns, proxy to share, teardown revokes"** —
with no grant/transfer mechanism in the core ownership store.
When a coordinator spawns a container and wants to expose docker
operations on it to an end user, the coordinator re-exports those
operations via `from_call` (or composes them in its own handlers). The end
user invokes the re-exported operation; the coordinator is the direct
caller to the docker endpoint; docker's ownership store sees the
coordinator as owner and caller; the check passes. The end user's identity
rides as `forwarded_for` metadata (ADR-026), and the coordinator handles
its own end-user-level ACL at its own layer.
**"Poking holes" (the grant pattern) is a downstream-app concern, not a
core-model concern.** The app that owns the resources re-exports the
operations it wants to share via `from_call` with its own ACL layer,
rather than the core ownership store growing a grant API. The ADR commits
to proxy-only and explicitly states that "poking holes" is a downstream
app's job.
**A future grant mechanism is additive, not a one-way door closure.** If
a use case forces the grant pattern, it's a new method on the ownership
store trait (`grant(identity, resource)` / `revoke_grant(...)`).
`AccessControl::check` already consults the ownership provider; a
grant-aware provider would answer "yes" for grantees in addition to
owners, without a trait-shape change. The two-way-door classification
(additive) is stated here as reversal-cost classification, not as a reason
to defer the decision — the decision is made (proxy-only), and the cost of
reversing it if a future use case forces it is low. If the grant pattern
is later admitted, specifics #3 and #4 are revisited: cross-node ownership
propagation returns to the table (#3), and composition under a grant would
need `CompositionAuthority` to grow a dynamic path, amending ADR-017/022
(#4).
### 4. The four edge specifics
#### 4a. The `list` case: scope-gate + result-filter, composing
Operations with `resource_type` set but `resource_id_path` absent — e.g.
`docker/container/list` — don't reference a specific container. When a
coordinator lists containers it owns, it should see only its own — not
every container on the host. That's not just scope-gating ("can you call
`container/list` at all?") and not just result-filtering ("return only
owned") — it's **both**:
1. **Scope-gate** (the call): does the peer have the `container:list`
scope? This is the static `required_scopes` check — unchanged. If the
peer doesn't have the scope, the call is denied before the ownership
provider is consulted.
2. **Result-filter** (the response): the handler calls
`OwnershipProvider::owned_resources(identity, "container")` and filters
the result to only the containers the caller owns. The default is
"allow if scoped, filter to owned."
The scope-gate is `AccessControl::check`'s scope path (static, unchanged).
The result-filter is a handler-level concern — the handler calls the
ownership provider's `owned_resources` method and filters. The ADR states
the default ("allow if scoped, filter to owned") and the composition
(scope-gate the call, then filter the result). A spec declares it wants
this by setting `resource_type` without `resource_id_path`.
`exec`/`inspect`/`stop` against a specific container are the clean case:
`resource_id_path: Some("$.containerId")`, the dispatcher extracts the ID,
`check` calls `owns(identity, "container", id, "exec")` — a single
targeted lookup.
#### 4b. Teardown coupling: automatic, handler-driven
The ownership store's write path (revoke on teardown) is coupled to the
spawned resource's lifecycle. The "burn it and start over" capability
depends on ownership state tracking the lifecycle correctly. When a
container dies or is destroyed, the ownership entry is revoked **by the
handler that managed the lifecycle** (the docker handler calls
`OwnershipStore::revoke` on container exit), not by an operator workflow
or a background reaper.
The burn-and-start-over pattern is:
1. Destroy container → handler calls `revoke("container", id)` → ownership
revoked automatically.
2. Spawn new container → handler calls `record(identity, "container", new_id)`
→ new ownership recorded.
If teardown weren't automatic, stale ownership entries would accumulate
and the "burn" path would leave dangling ACL state — an ACL check could
reference a resource that no longer exists, and a reused container ID
could grant access to the wrong caller.
The architectural commitment is: **handler-driven revoke on lifecycle
end, not a reaper.** The coupling mechanism (explicit handler call vs. a
lifecycle-hook abstraction the handler framework provides) is two-way-door
implementation work — the docker handler calling `revoke` directly is the
initial mechanism (explicit handler call); a lifecycle-hook abstraction is
a refinement if multiple resource-spawning crates share the pattern.
#### 4c. Fleet representation: per-node ownership, downstream app tracks "who is this for"
Under the proxy pattern (Decision 3), the docker node records "coordinator
owns C" in its local ownership store. The coordinator's "I started C for
agent Y" mapping lives in the coordinator's own downstream-app state, not
in the core ownership store.
The ownership store is **per-node** — each docker node records its local
ownership. The hub's agent-to-workspace mapping is app state. There is
**no cross-node ownership propagation in the base model** — the spoke
sees the hub as the owner (the hub's `Identity` is what the spoke's
ownership store records), and the hub's "who is this for" is its own
concern, tracked in the hub's app state, carried as `forwarded_for`
metadata on the wire (ADR-026).
This simplifies fleet representation: the proxy pattern keeps ownership
local. The spoke doesn't need to know about the end user; the hub doesn't
need to push ownership records to the spoke. The hub authenticates as
itself (its own `auth_token`), the spoke records the hub as the owner, and
the hub's end-user ACL is its own layer.
#### 4d. Composition interaction: two separate checks, no change to `CompositionAuthority`
In the proxy pattern, the coordinator composes `docker/container/exec` on
behalf of an agent. Two checks must pass:
1. **Static scope check** (ADR-017/022, unchanged): the coordinator's
`CompositionAuthority` has the `container:exec` scope. This is the
existing `CompositionAuthority.scopes` check — static, set at
registration, no dynamic path.
2. **Dynamic ownership check** (this ADR): the coordinator owns this
specific container. This is the new `OwnershipProvider::owns` check —
dynamic, consults the ownership store.
The composition authority stays static — it doesn't grow a dynamic path.
The ownership store handles the dynamic resource-level check. Both must
pass; they're orthogonal. **ADR-017 and ADR-018 do not need amendment.**
The `CompositionAuthority.resources` field (ADR-018, line 180:
`resources: HashMap<String, Vec<String>>`) continues to serve its existing
purpose: static resource lists for composition authority (e.g.,
`{"service": ["vastai", "github"]}` bounds which services the handler can
reach in composition). It is not involved in the dynamic ownership check —
that's the ownership provider's job. The two are separate:
- `CompositionAuthority.resources` — static, "what services can this
handler compose," checked against the composition authority's declared
resource lists.
- `OwnershipProvider::owns` — dynamic, "does this identity own this
specific runtime-spawned resource," checked against the ownership store.
A handler composing `docker/container/exec` passes both: its composition
authority has `container:exec` in its scopes (static), and the ownership
provider confirms it owns the container (dynamic).
## Consequences
**Positive:**
- The alknet-docker, alknet-tty, opencode-runner wrapper, and
`alknet-container` crate specs can declare their `AccessControl` shapes
against a single coherent model. The block on those specs is lifted.
- The ownership store reuses the established repo/adapter pattern
(ADR-033) — no new shape invented on the storage side. The in-memory
default carries the docker/runner cases with no backend dependency; a
persistence adapter is additive when a use case forces it.
- `OperationSpec` is fully self-describing for authorization: resource
type, action, and which input field drives the resource lookup are all
on the spec. No per-namespace conventions, no handler-level knowledge.
- The proxy-only model keeps the base model simple: spawner owns, proxy
to share, teardown revokes. The `forwarded_for` metadata (ADR-026) is
the end-user-identity carrier; the coordinator handles its own ACL. No
grant API in the core ownership store.
- ADR-017/022 are unchanged. The composition authority stays static; the
ownership store is an additional check, not a modification to the
existing one. The privilege model stays coherent with the ownership
model.
- The `list` case has a clean default ("allow if scoped, filter to owned")
that composes scope-gating and result-filtering without conflating them.
- Teardown is automatic and handler-driven, so the "burn it and start
over" pattern leaves no dangling ACL state.
**Negative:**
- `AccessControl::check` gains a parameter. This is a one-way door —
every call site and test updates. Per the project's decision principle
(implementation workload is a non-issue relative to semantic
correctness and long-term clarity), this is implementation cost, not
semantic cost.
- `OperationSpec` gains a field (`resource_id_path`). Spec-constructing
code (tests, adapter registrations) must add the field. The field is
`Option<String>``None` for operations without runtime-spawned
resources, so existing specs are unchanged in shape (the field defaults
to `None`).
- `alknet-core` gains two new traits (`OwnershipProvider`,
`OwnershipStore`) and an in-memory default adapter. Each trait is a
contract downstream crates depend on. The trait shapes are the one-way
doors; the adapter shapes are two-way.
- The handler that manages a resource's lifecycle has an additional
responsibility: calling `record` on spawn and `revoke` on teardown. If
a handler forgets to call `revoke`, stale ownership entries accumulate.
This is the coupling requirement (specific #2) — it's the handler's
job, not the framework's, and the handler framework can provide a
lifecycle-hook abstraction to reduce boilerplate (two-way-door
mechanism work).
- The ownership provider is consulted on every resource-typed
`AccessControl::check`. The in-memory default is a `HashMap` lookup —
negligible. A persistence adapter caches in memory (sync read from
cache, same `ArcSwap` pattern as `ConfigIdentityProvider`), so the hot
path stays sync and fast.
- The proxy-only decision means a downstream app that wants to give an
end user direct access to a runtime-spawned resource must build its own
re-export + ACL layer, rather than using a core grant mechanism. This
is the intended trade — "poking holes" is the app's job, not the core
model's. If a future use case forces the grant pattern, it's additive
(a new trait method), not a redesign.
## Assumptions
1. **The ownership store is per-node.** Each node records its local
ownership. There is no cross-node ownership propagation in the base
model. The hub's "who is this for" mapping is app state, not core
ownership state. (Specific #4c.)
2. **The proxy pattern is sufficient for all current use cases.** The
agent-workspace case, the docker coordinator case, and the runner
wrapper case are all proxy-pattern. No described use case requires the
grant pattern. If one emerges, the grant mechanism is additive (a new
method on the ownership store trait), not a redesign.
3. **The ownership store's read trait is sync.** It is called from
`AccessControl::check` on the dispatch hot path, no `.await`. A
persistence adapter caches in memory and uses honker `NOTIFY` for
invalidation — same `ArcSwap`-backed full-reload pattern as
`ConfigIdentityProvider` (ADR-035).
4. **Ownership is runtime state, meaningless across restarts.** A
container ID from a previous process doesn't exist. The in-memory
default carries no persistence; a persistence adapter is built when a
concrete use case forces it (e.g., a hub that wants fleet ownership to
survive restarts).
5. **The handler that manages a resource's lifecycle is responsible for
calling `record` and `revoke`.** This is the coupling requirement
(specific #4b). The framework can provide a lifecycle-hook abstraction
to reduce boilerplate, but the responsibility is the handler's, not
the framework's.
6. **`CompositionAuthority.resources` (ADR-018) is not involved in the
dynamic ownership check.** It serves its existing purpose (static
resource lists for composition). The dynamic ownership check is the
ownership provider's job. The two are separate and orthogonal.
7. **`AccessControl::check`'s new `ownership` parameter is `None` for
operations without runtime-spawned resources.** This preserves
backward compatibility — operations with static resource sets work
unchanged via the `Identity.resources` fallback path.
## References
- OQ-42: Dynamic Resource Ownership for Runtime-Spawned Resources
(resolved by this ADR — the five sub-questions this ADR writes into
decision text)
- ADR-003: Auth as Shared Core (`IdentityProvider` — the first instance
of the repo/adapter pattern the ownership store reuses; ADR-033 makes
the pattern explicit, ADR-003 is the concrete first instance)
- ADR-032: One-Way Door Decision Framework (the door-type-as-deferral
anti-pattern this ADR's proxy-only decision avoids; the reversal-cost
classification of the grant pattern's additive nature)
- ADR-017: Privilege Model and Authority Context (the static
composition-authority model; this ADR adds an **orthogonal** dynamic
ownership check alongside it — ADR-017's text is **unchanged** per
specific #4d; the system gains a second check, not a modification to
the first)
- ADR-018: Handler Registration, Provenance, and Composition Authority
(`CompositionAuthority.resources` — the static resource list field this
ADR confirms is not involved in the dynamic ownership check —
**unchanged** per specific #4d)
- ADR-025: PeerEntry and Identity.id Decoupling (`Identity.resources`
the static resource path this ADR's ownership provider extends for
runtime-spawned resources)
- ADR-026: Forwarded-For Identity (Metadata, Not Authority) (`forwarded_for`
— the proxy pattern's end-user-identity carrier; the proxy-only model
relies on this)
- ADR-033: Storage Boundary and Repo/Adapter Pattern (the pattern this ADR
reuses for the ownership store — fourth instance alongside
`IdentityProvider`/`IdentityStore`/`CredentialStore`)
- ADR-035: Concrete Persistence Adapter Shapes (the sync-read + ArcSwap +
honker-NOTIFY shape this ADR's persistence adapter would follow, if
built; `IdentityStore` is the write-trait analogue)
- ADR-022: Call Protocol Client and Adapter Contract (`from_call` — the
adapter that imports a peer's operations and re-registers them locally;
the proxy pattern's re-export mechanism)
- [auth.md](../crates/core/auth.md) (`Identity.resources`,
`AccessControl::check` interaction — both under edit by this decision)
- [operation-registry.md](../crates/call/operation-registry.md)
(`AccessControl`, `OperationSpec``resource_id_path` addition)
- [alknet-docker POC summary](../../research/alknet-docker/poc-summary.md)
§"Open Unknowns" #3 (the research finding that surfaced this question)

View File

@@ -0,0 +1,424 @@
# ADR-012: `ConnectionCredentials` — Decouple the Dial Credentials from the Call Protocol
## Status
Accepted (amends ADR-045 §3 and §5; amends ADR-087's `TlsClientConfig::new`
input framing; amended 2026-07-17 — `CallCredentials` is removed, not
retained in `alknet-call`; `from_call`'s `credentials_auth_token` dead
path removed; `auth_token` is a per-request payload field, not a
call-protocol credential)
## Context
ADR-045 extracted the dial into `AlknetClient` and moved `CallCredentials`
from `alknet-call` to `alknet-core` so the dial would not depend on the
call protocol. The three dial signatures were:
```rust
dial_quic(addr, server_name, alpn, credentials: &CallCredentials) -> Connection
dial_tcp_tls(host, addr, alpn, credentials: &CallCredentials) -> Connection
dial_iroh(node_id: iroh::NodeId, alpn, local_key: &Ed25519SecretKey) -> Connection
```
Two problems surfaced on review:
### Problem 1: the iroh dial signature is asymmetric
`dial_quic` and `dial_tcp_tls` take `&CallCredentials`; `dial_iroh` takes
a bare `&Ed25519SecretKey` + a separate `node_id: iroh::NodeId`. The
asymmetry exists because iroh has its own TLS (it shares the key, not
the rustls config — ADR-087 §3), so the iroh dial bypasses
`TlsClientConfig` and reads the key directly. But the asymmetry forces
the caller to know which dimension of the credential bundle each
transport consumes, and it leaves no path for the iroh dial to receive
the same inputs as the rustls dials — even though all three consume the
same two things: a local identity (key/cert) and an expected remote
identity (fingerprint).
### Problem 2: `CallCredentials` couples the dial to the call protocol
`CallCredentials` carries three dimensions (ADR-022 §7):
1. `tls_identity: Option<TlsIdentity>` — the local node's key/cert
2. `auth_token: Option<AuthToken>` — a call-protocol-level bearer token
3. `remote_identity: Option<RemoteIdentity>` — the expected remote fingerprint
The dial uses only dimensions 1 and 3 (the transport-identity layer).
Dimension 2 (`auth_token`) is a **call-protocol** concept: it correlates
a token to an identity via `IdentityProvider::resolve_from_token` — a
mechanism that exists for two hub-dependent cases where TLS-fingerprint
identity is unavailable:
- **Browsers** — no raw-key support, no client cert the hub can
fingerprint; the browser authenticates via a bearer token over
HTTP/WebSocket, and the hub's `IdentityProvider` resolves it.
- **`alknet/register`** — a native worker that hasn't been enrolled dials
in with no prior peer relationship; a registration token (or open
registration) establishes identity, not a TLS fingerprint.
Both depend on a **hub** running `IdentityProvider` with token-to-identity
mapping. A pure P2P connection (two nodes with raw-key identities) never
needs `auth_token` — the TLS fingerprint IS the identity.
`auth_token` is not a transport credential. It is a per-request field on
`call.requested` payloads (`Dispatcher::resolve_identity` reads
`payload.get("auth_token")`; the `from_call` forwarding handler sets it
via `build_forwarded_payload`). The dial never delivers it to the
protocol take-over — `spawn_dispatch(&self, connection: Connection)`
takes no credentials, and `Connection` (a `Box<dyn BidiStreamSource>`)
carries no `auth_token` field. The `auth_token` in `CallCredentials` is
unused by the dial and dropped after `connect()` in the current code.
By moving `CallCredentials` (with `auth_token` in it) to `alknet-core`
for the dial's benefit, ADR-045 §5 would drag a call-protocol concept
into the shared-types crate *for the dial's benefit* — when the dial
doesn't use it. The dial should consume a transport-level credential
bundle, not a call-protocol one.
### The two identity models
Underneath the three transports, there are two identity-consumption
models, both consuming the same two dimensions:
| Model | Transports | Consumes | What the transport does |
|-------|-----------|----------|------------------------|
| **rustls config** | QUIC (quinn), TCP+TLS (tokio-rustls) | `local_identity``TlsClientConfig` (client cert); `remote_identity` → verifier (`FingerprintPinVerifier` / `WebPkiServerVerifier`) | Builds `rustls::ClientConfig`, hands to transport connector |
| **key-native** | iroh, SSH (future — `docs/research/references/ssh/russh/06-usage-patterns.md`) | `local_identity``Ed25519SecretKey` → transport's key type (`iroh::SecretKey`, russh key); `remote_identity` → fingerprint → transport's verifier (`NodeId` match, known_hosts) | Reads the key directly; transport handles identity internally |
The difference is *how* each model consumes the dimensions, not *what*
they are. A unified credential bundle carrying just those two dimensions
lets every dial extract what its transport's identity layer needs,
without call-protocol coupling.
## Decision
### `ConnectionCredentials` — the dial's credential bundle
A new type in `alknet-core`, carrying the two transport-identity
dimensions every dial consumes:
```rust
/// Transport-level credentials for an outbound dial. Consumed by
/// `AlknetClient`'s dial methods and (for the server side) by the
/// assembly layer when building transports. Carries only the dimensions
/// the transport's identity layer needs — the local identity (key/cert
/// presented to the transport) and the expected remote identity
/// (fingerprint, driving verifier selection per ADR-034).
///
/// This is NOT the call-protocol credential bundle. The call-protocol
/// `auth_token` (hub-correlated bearer for browsers / `alknet/register`)
/// is a per-request field on `call.requested` payloads, not a
/// transport credential. It stays in the call-protocol layer.
pub struct ConnectionCredentials {
/// The local node's identity (RFC 7250 raw key or X.509), presented
/// to the transport's identity layer. rustls dials → `TlsClientConfig`
/// (client cert via `RawKeyClientCertResolver`); iroh/SSH dials →
/// key directly (`iroh::SecretKey::from_bytes`, russh key).
pub local_identity: Option<TlsIdentity>,
/// Expected identity of the remote node. `Some(fingerprint)` → pin
/// (known peer); `None` → CA verification for X.509 remotes or
/// fail-closed for Ed25519 raw-key remotes (ADR-034 §2/§3). `None`
/// is the public-X.509-endpoint state, not a missing field.
pub remote_identity: Option<RemoteIdentity>,
}
```
`RemoteIdentity` moves with `ConnectionCredentials` to `alknet-core`
(both are transport-level types; the dial and the server-side transport
construction both consume them).
### Unified dial signatures
All three dials take `&ConnectionCredentials`:
```rust
impl AlknetClient {
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
alpn: &[u8],
creds: &ConnectionCredentials,
) -> Result<Connection, ClientDialError>;
}
```
The `node_id: iroh::NodeId` parameter on `dial_iroh` is removed — it is
derived from `creds.remote_identity.fingerprint` (`ed25519:<hex>`
`NodeId::from_bytes`), the same way the rustls dials derive their
verifier from `remote_identity`. The consistency is now in both the rule
(ADR-034) and the type.
Each dial extracts what its transport's identity layer needs:
- **rustls dials** (`dial_quic`, `dial_tcp_tls`): `creds.local_identity`
`TlsClientConfig::new` (client cert); `creds.remote_identity`
`ClientVerifierContext` (verifier selection).
- **iroh dial** (`dial_iroh`): `creds.local_identity`
`Ed25519SecretKey``iroh::SecretKey::from_bytes`;
`creds.remote_identity.fingerprint``NodeId` (verifier).
### `CallCredentials` is removed (amendment 2026-07-17)
> **This section supersedes the original "CallCredentials stays in
> `alknet-call`" decision.** The original rationale rested on a code
> path that does not exist. The trace below is the correction.
`CallCredentials` is **removed**, not retained. Once the transport
dimensions (`local_identity`, `remote_identity`) move to
`ConnectionCredentials` in `alknet-core`, `CallCredentials` would
reduce to a one-field struct `{ auth_token: Option<AuthToken> }` — and
that field has **no reader**.
**The trace (why the original rationale was wrong).** The original
section claimed the call protocol uses `CallCredentials.auth_token`
because "the `from_call` forwarding handler populates `auth_token` on
outgoing `call.requested` payloads." That chain does not connect:
- `from_call`'s signature is `from_call(connection: &CallConnection,
config: FromCallConfig)` — no `CallCredentials` parameter.
`FromCallConfig` has no credential field.
- The `auth_token` the `from_call` forwarding handlers *can* set on
payloads is sourced from `OpSummary.credentials_auth_token`, an
`Option<String>` that is **hardcoded to `None` at every construction
site** (`from_call.rs:185, 748, 757`). It is not read from
`CallCredentials.auth_token`, and it is a different type
(`Option<String>` vs `Option<AuthToken>`). The two were never
connected, even in intent.
- The consuming side — `Dispatcher::resolve_identity`
(`dispatch.rs:119`) — reads `payload.get("auth_token").as_str()` from
the per-request call payload. It does not read `CallCredentials`.
**Where `auth_token` actually originates.** It is a per-request payload
field, populated by two real paths, neither of which touches
`CallCredentials`:
- **Browsers over WebSocket** — the browser sends `auth_token` directly
in the `call.requested` JSON payload (`websocket/mod.rs:202206`); the
WS layer (`upgrade.rs:178181`) passes `envelope.payload` straight to
`dispatch_requested`. The browser is the originator; the WS layer is
a transparent passthrough.
- **HTTP gateway (bearer)** — `gateway/dispatch.rs` resolves the
`Authorization: Bearer` header to an `Identity` at the HTTP boundary
(`resolve_bearer`, line 58) and passes the `Identity` into
`build_root_context`. The call protocol sees the resolved `Identity`,
not the token. `auth_token` does not enter the call payload on this
path.
So `CallCredentials.auth_token` is a write-only field (it has a setter,
`with_auth_token`, and zero readers). `connect()` — `CallCredentials`'s
only consumer — is removed in Phase 5 of the migration. With `connect`
gone, nothing constructs or reads `CallCredentials` except the tests.
**`auth_token`'s two real use cases (confirming no call-protocol
credential bundle is needed):**
1. **HTTP auth** — the inbound case. The HTTP gateway resolves the
bearer token to an `Identity` via `IdentityProvider::resolve_from_token`
at the HTTP boundary. The call protocol receives the `Identity`, not
the token.
2. **Registration** (`alknet/register` native ALPN, `/register` HTTP
endpoint) — a client not yet associated with a hub presents a
one-time registration token; the hub creates a `PeerEntry` (a new
identity based on the fingerprint). Outbound, the vault manages the
token on the client side; inbound, the hub's registration handler
consumes it. Neither path involves `CallCredentials`.
A hub does not "forward with its own token" in the way the original
rationale assumed. Where the hub authenticates to an outside service
(another hub's HTTP interface, an external API), the vault manages that
outbound token — it is not a call-protocol credential. The
`from_call` `credentials_auth_token` path was a future hatch for a
use case that dissolved once `IdentityProvider::resolve_from_token`
solved the inbound identity problem: the hub authenticates as itself
(its `Identity` is on the connection), and the spoke authorizes the hub
as the direct caller. No per-forwarded-call token is needed.
**`from_call`'s `credentials_auth_token` is removed too.** It is the
same family of dead code — an always-`None` field of a different type
than `CallCredentials.auth_token`, never connected to anything. The
`credentials_auth_token` field on `OpSummary`, the `credentials_auth_token`
parameters on `make_forwarding_handler` / `make_streaming_forwarding_handler`,
and the `auth_token` parameter on `build_forwarded_payload` are removed.
The forwarding handlers stop emitting `auth_token` in payloads (which
they never did in practice — the source was always `None`). The two
`from_call` tests asserting the `Some` path
(`build_forwarded_payload_sets_auth_token_when_provided`,
`streaming_forwarding_handler_sets_auth_token_when_provided`) are
removed — they test a code path never exercised in production. If a
future hub needs its own token on forwarded payloads, that is a fresh,
end-to-end-wired feature, not a vestigial path.
**What does NOT move to `alknet-core`:** `ConnectionCredentials` and
`RemoteIdentity` move (the original decision). `CallCredentials` does
not move — it is removed. ADR-045 §5's move of `CallCredentials` to
core is superseded twice over: first by the original ADR-012 (move
`ConnectionCredentials` instead), and now by this amendment (remove
`CallCredentials` entirely). There is no call-protocol credential
bundle; `auth_token` is a per-request payload field, full stop.
### `TlsClientConfig::new` input framing
`TlsClientConfig::new` (ADR-087) takes a `ClientVerifierContext` derived
from the credential bundle's `remote_identity`. The rustls dials extract
`creds.local_identity` and `creds.remote_identity` from
`ConnectionCredentials` and build `ClientVerifierContext` from the latter
— the same extraction ADR-087 described, just from
`ConnectionCredentials` instead of `CallCredentials`. The `auth_token`
dimension is simply not present in `ConnectionCredentials`, so the
"stripped at the TLS boundary" framing (ADR-045's claim that the token
"travels with the Connection") is no longer needed — the token was never
in the dial's credential bundle to strip.
### Future `dial_ssh` validates the shape
An SSH dial (`docs/research/references/ssh/russh/06-usage-patterns.md`)
consumes the same two dimensions:
- `check_server_key(&mut self, key: &ssh_key::PublicKey)` — the verifier
(fingerprint pin against known_hosts = `remote_identity`)
- `authenticate_publickey("user", PrivateKeyWithHashAlg::new(...))` —
local identity (the Ed25519 key = `local_identity`)
- `channel_open_session()` → `Connection::from_bidi` (ADR-007)
`dial_ssh(addr, alpn, creds: &ConnectionCredentials)` fits the same
signature. The SSH host-key verification is fingerprint-pinning
(known_hosts), which is what `remote_identity` carries. The local SSH
key is the same Ed25519 key iroh and raw-key quinn use. The pattern is
general — `ConnectionCredentials` covers it without call-protocol
coupling. SSH itself is unspecced (not yet specced — comes after
channels, tunnels, TTY rework), but the russh usage patterns confirm
the credential dimensions.
## Consequences
**Positive:**
- **The dial is fully decoupled from the call protocol.**
`ConnectionCredentials` carries only transport-identity dimensions;
`alknet-client` has no call-protocol coupling in its credential type.
There is no call-protocol credential bundle — `auth_token` is a
per-request payload field, not a credential.
- **All three dial signatures are unified.** A caller no longer needs to
know that iroh takes a bare key while quinn/tcp take a credential
bundle — all take `&ConnectionCredentials`. The `node_id` parameter on
`dial_iroh` is derived from `remote_identity`, the same extraction
pattern the rustls dials use for the verifier.
- **The `auth_token` spec inaccuracy is fixed.** ADR-045 claimed the
`auth_token` "travels with the `Connection` into the protocol
take-over, where it is sent as the first call-protocol frame." This
was aspirational — `Connection` carries no `auth_token`, and
`spawn_dispatch` takes no credentials. With `CallCredentials` removed,
the claim is not merely unneeded; the field it described was never
read. `auth_token` is a per-request field on `call.requested` payloads,
set by browsers (in the WS payload) or resolved by the HTTP gateway at
its boundary (bearer → `Identity`).
- **`dial_ssh` fits the same shape when it arrives.** The credential
dimensions SSH needs (local key + expected host key) are exactly what
`ConnectionCredentials` carries. No future ADR needed for the SSH dial
signature.
- **A dead credential type and a dead forwarding-token path are removed
(amendment 2026-07-17).** `CallCredentials` is removed (its
`auth_token` field had no reader). `from_call`'s
`credentials_auth_token` is removed (always `None`, different type
than `CallCredentials.auth_token`, never connected). Both were future
hatches from the era before `IdentityProvider::resolve_from_token`
solved the inbound identity problem; the hatches dissolved once it
did. See the amended §"`CallCredentials` is removed" above for the
trace.
**Negative:**
- **`CallCredentials` is removed (a public type).** Callers that
constructed `CallCredentials` (the integration test; any future
assembly-layer code) switch to `ConnectionCredentials` for the dial.
`auth_token`, where needed, is a per-request payload field (browsers
send it in the WS payload; the HTTP gateway resolves bearer →
`Identity` at its boundary). This is expected — `connect()` was
`CallCredentials`'s only consumer and is removed in the same
migration. There are no external consumers (develop branch is a total
rewrite).
- **The assembly layer builds one credential bundle, not two.** Where
ADR-045 had the assembly layer build one `CallCredentials` (and the
original ADR-012 reframed it as two — `ConnectionCredentials` for the
dial + a per-request `auth_token`), the assembly layer now builds
`ConnectionCredentials` for the dial only. `auth_token` is not a
credential the assembly layer constructs; it is a per-request payload
field the browser (or the HTTP gateway's bearer resolution) supplies.
This is fewer types at the assembly site, not more.
- **ADR-045 §5's "CallCredentials moves to core" is superseded twice.**
The original ADR-012 reframed the move target as `ConnectionCredentials`
(not `CallCredentials`); this amendment removes `CallCredentials`
entirely. What moves to `alknet-core`: `ConnectionCredentials` +
`RemoteIdentity`. What does not move: `CallCredentials` (removed, not
relocated). This affects the extraction plan's Phase 0 (additive
credentials move) and Phase 5 (the call prune now removes
`CallCredentials` and the `from_call` dead path, not just `connect`
and the TLS helpers).
## Door type
**One-way.** The dial signatures (`dial_quic` / `dial_tcp_tls` /
`dial_iroh` all taking `&ConnectionCredentials`) are the public API
surface of `alknet-client`. The credential-type decoupling
(`ConnectionCredentials` in core, no call-protocol credential bundle)
determines the dep graph (`alknet-client` depends on `alknet-core` for
`ConnectionCredentials`, not on `alknet-call`). Reversing would mean
re-coupling the dial to the call protocol's credential type and
re-asymmetrizing the iroh dial. The `CallCredentials` removal
(amendment 2026-07-17) is the same door — removing a public type whose
only consumer (`connect`) is removed in the same migration. The crate
is greenfield (Phase 3 of the extraction plan), so the door is still
open now — this ADR records the decisions before implementation.
## References
- ADR-045 — `AlknetClient` native dial seam (§3 dial signatures amended
— all take `&ConnectionCredentials`; §5 move amended —
`ConnectionCredentials`/`RemoteIdentity` move to core, not
`CallCredentials`; §5 further amended 2026-07-17 — `CallCredentials`
removed, not retained in `alknet-call`)
- ADR-087 — `TlsClientConfig` not blocked on dial (input framing
amended — `ClientVerifierContext` derived from
`ConnectionCredentials.remote_identity`, not `CallCredentials`)
- ADR-034 — client-side verifier selection (the rule
`ConnectionCredentials.remote_identity` drives — unchanged)
- ADR-022 §7 — the three credential dimensions (the historical source of
`CallCredentials`'s three fields; the transport dimensions moved to
`ConnectionCredentials`, the `auth_token` dimension is a per-request
payload field, and `CallCredentials` itself is removed)
- `crates/alknet-call/src/protocol/dispatch.rs` —
`Dispatcher::resolve_identity` reads `payload.get("auth_token")`
(per-request, not connection-level — the consumer of `auth_token`)
- `crates/alknet-call/src/client/from_call.rs` — the
`credentials_auth_token` field on `OpSummary` and the
`auth_token` parameter on `build_forwarded_payload` (the removed dead
path; always `None`, different type than `CallCredentials.auth_token`,
never connected)
- `crates/alknet-http/src/gateway/dispatch.rs` — `resolve_bearer` (the
HTTP path: bearer → `Identity` at the boundary; the call layer sees
the identity, not the token)
- `crates/alknet-http/src/websocket/mod.rs` — the WS path:
`auth_token` in the browser's call payload, passed through to
`dispatch_requested` unchanged
- `docs/research/references/ssh/russh/06-usage-patterns.md` — the SSH
client usage patterns (check_server_key + authenticate_publickey)
validating the `ConnectionCredentials` shape for a future `dial_ssh`

View File

@@ -0,0 +1,78 @@
# ADR-013: irpc as Call Protocol Foundation
## Status
~~Accepted~~**Superseded** by [ADR-014](014-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-033), not from irpc. ADR-014 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
The call protocol (alknet-call) provides structured RPC — operations, request/response, streaming subscriptions, and pub/sub. This is the primary interface for programmatic interaction with an alknet node. It needs to work across platforms: Rust clients, TypeScript/JavaScript clients (via NAPI), WASM targets, and any language that can speak the wire format.
The previous implementation used `irpc` for the call protocol's operation registry, framing, and service patterns. irpc provides:
- An operation registry with schema-based discovery
- Length-prefixed JSON framing (EventEnvelope)
- Request/response and streaming patterns
- Type-safe operation definitions via derive macros
The call protocol is derived from a TypeScript implementation (`@alkdev/operations`, `@alkdev/pubsub`) that informed the design of the operation registry, EventEnvelope framing, and adapter patterns (from_openapi, from_mcp, from_call). This bidirectional composition capability is strategically important. The TypeScript code is a reference that informed the Rust design — it is not a parallel implementation (see ADR-033).
## Decision
alknet-call uses irpc as its foundation. The `CallAdapter` implements `ProtocolHandler` on ALPN `alknet/call` and delegates to irpc's operation registry, framing, and dispatch.
irpc is not replaced or wrapped in an abstraction layer — it IS the call protocol's core. The relationship is:
- irpc provides: operation registry, schema discovery, frame encoding/decoding, request/response routing, streaming
- alknet-call provides: the ProtocolHandler adapter (BiStream → irpc), AuthContext integration, access control checks, the ALPN registration
This means:
- The wire format is irpc's EventEnvelope framing — length-prefixed JSON
- Operation schemas follow irpc's schema model — JSON Schema compatible
- The TypeScript operation and pub/sub patterns that can import OpenAPI schemas, wrap MCP servers, and expose operations as endpoints are supported at the protocol level — the adapter contract (from_*, to_*) is defined in Rust (see ADR-033)
- Future NAPI and WASM clients speak the same wire format — alknet-napi projects the Rust call protocol client to Node.js; a browser SDK can be adapted from the existing TypeScript code
The `VaultProtocol` in alknet-vault previously used irpc as its service
protocol. ADR-025 dropped irpc from the vault — the vault uses direct method
calls on `VaultServiceHandle`, not irpc dispatch. irpc remains the
foundation for alknet-*call* (the call protocol), not for alknet-*vault*.
See ADR-025 for the rationale (security default inversion: the vault is
local-only by construction, not remote-capable by default).
## Consequences
**Positive:**
- Proven operation registry and framing — irpc is already tested in production (iroh uses it)
- JSON Schema compatible — OpenAPI import, MCP tool exposure, cross-language client generation
- No need to design a custom RPC wire format — irpc's is already battle-tested
- The call protocol inherits irpc's streaming and subscription patterns
**Negative:**
- alknet-call depends on irpc — if irpc has limitations or bugs, we're affected (mitigated: irpc is lightweight and we can fork if needed)
- JSON framing is not the most compact binary format — for high-throughput scenarios, a binary codec could be added later as an irpc extension
- irpc's derive macros add a compilation dependency — but this is standard for Rust RPC frameworks
- The call protocol's cross-language story depends on irpc's wire format being documented and stable (mitigated: it's length-prefixed JSON, which is inherently cross-language)
## References
- **Superseding ADR**: [ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) — irpc was never integrated; hand-rolled framing is the actual state
- ADR-033: 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-014 confirms irpc was never in alknet-call either)
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-031: Crate decomposition
- ADR-003: Auth as shared core (IdentityProvider)
- Call protocol wire format (actual): `crates/alknet-call/src/protocol/wire.rs`
- The previous architecture had an equivalent decision in ADR-019 (bidirectional call protocol with EventEnvelope framing), which is archived in the reference implementation at `/workspace/@alkdev/alknet-main/`.

View File

@@ -0,0 +1,164 @@
# ADR-014: irpc Was Never Integrated — Hand-Rolled EventEnvelope Framing
## Status
Accepted
## Context
ADR-013 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-013 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-021) — 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-013'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-033). The framing
coincidentally resembles irpc's (both are length-prefixed JSON), which is
how the misattribution went unnoticed.
### What ADR-013 got right
Despite the irpc misattribution, ADR-013'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-013'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-013 attributed
to irpc, achieved without irpc.
### Why a new ADR rather than an amendment
ADR-013'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-013 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-013 is
superseded; the call protocol uses hand-rolled framing (this ADR-014); the
architectural properties ADR-013 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-013 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-033), not from irpc. The
framing, operation registry, dispatch, and streaming patterns are all
hand-rolled in alknet-call.
3. **The architectural properties ADR-013 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-021) provide
the subscription/streaming patterns ADR-013 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-013 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-013'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-013 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-013's claim that "the call protocol inherits irpc's streaming and
subscription patterns" was wrong — those patterns are hand-rolled
(ADR-021). 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-013: 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-033: Rust as canonical implementation (the `@alkdev/pubsub` prior art
that the `EventEnvelope` shape was actually derived from)
- ADR-021: 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)

View File

@@ -0,0 +1,56 @@
# ADR-015: Call Protocol Stream Model
## Status
Accepted
## Context
The call protocol (alknet-call) operates on a QUIC connection with ALPN `alknet/call`. Within that connection, QUIC provides bidirectional streams. The question is how the call protocol uses those streams and how it correlates requests with responses — especially when both sides can initiate calls.
The reference implementation used `EventEnvelope` framing with a `PendingRequestMap` that correlates `call.requested` events to `call.responded` events by request ID, regardless of which stream carries them. This works well but the relationship between streams and operations was underspecified.
OQ-07 asked: "What is the scope of the call protocol within a connection? Should operations be multiplexed within a single stream, or should each operation get its own stream?"
## Decision
The call protocol uses **bidirectional QUIC streams with EventEnvelope framing and ID-based correlation**. The protocol does not prescribe a stream usage pattern — it works with any arrangement:
1. **EventEnvelope on every stream** — every bidirectional stream opened on the `alknet/call` connection carries length-prefixed JSON `EventEnvelope` messages. The five event types (`call.requested`, `call.responded`, `call.completed`, `call.aborted`, `call.error`) are the protocol primitives.
2. **PendingRequestMap correlates by ID, not by stream** — the `id` field in `EventEnvelope` correlates requests with responses. A response on stream 5 can fulfill a request sent on stream 3. The PendingRequestMap is keyed by request ID.
3. **Protocol is symmetric** — both sides of the connection can `open_bi()` to initiate calls and `accept_bi()` to receive them. The server calling a client operation uses the same EventEnvelope format and the same correlation mechanism.
4. **Top-level protocol operations** — the call protocol defines four operations that map to EventEnvelope event patterns:
- **call**: `call.requested``call.responded` (one response) or `call.error`
- **subscribe**: `call.requested` → one or more `call.responded``call.completed` or `call.aborted`
- **batch**: multiple `call.requested` events (with correlated IDs) → multiple `call.responded` events
- **schema**: `call.requested` (name `/services/list` or `/services/schema`) → `call.responded`
5. **Stream usage is the client's choice** — a client may open one stream per operation, one stream for all operations, or any mix. The protocol is stream-agnostic. The server accepts streams and processes EventEnvelopes regardless of which stream they arrive on.
This resolves OQ-07: the call protocol's scope within a connection is the full operation registry. One `alknet/call` connection gives access to all operations (call, subscribe, batch, schema). QUIC's built-in stream multiplexing handles concurrency — the protocol doesn't need to impose additional multiplexing.
## Consequences
**Positive:**
- Simple mental model: one connection, full access, stream-agnostic correlation
- The protocol works the same way regardless of stream usage — no "right" way to use streams
- Bidirectional calls are natural — either side can open a stream and send `call.requested`
- PendingRequestMap from the reference implementation carries forward without modification
- QUIC's stream multiplexing provides natural flow control and head-of-line blocking avoidance
- The top-level operations (call, subscribe, batch, schema) are protocol primitives, not separate ALPNs
**Negative:**
- Clients that multiplex many operations on one stream must manage request IDs carefully — but this is standard RPC practice
- The PendingRequestMap requires timeout-based cleanup to prevent memory leaks from abandoned requests — but this is already implemented and tested in the reference
- No built-in stream-level backpressure per operation when multiple operations share a stream — but QUIC provides connection-level and stream-level flow control
## References
- ADR-013: irpc as call protocol foundation
- ADR-004: ALPN string convention and connection model
- ADR-005: BiStream type definition
- OQ-07: Call protocol scope within a connection (resolved by this ADR)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -0,0 +1,417 @@
# ADR-016: Operation Error Schemas
## Status
Accepted (amended by ADR-021 — protocol-level code list extended to six)
## Context
The `OperationSpec` in alknet-call has `input_schema` and `output_schema` but
no `error_schemas`. The `call.error` payload (call-protocol.md L128134)
carries a `code` and `message`, where `code` is one of six infrastructure
codes: `NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`,
`INTERNAL`, `TIMEOUT`.
These six codes cover **protocol-level failures** — the call protocol
itself can always fail to find an operation, deny access, reject bad input,
reject the wrong dispatch method for the operation type, time out, or hit
an internal error. They are emitted by the dispatch machinery (the registry,
the adapter), not by operation handlers. `INVALID_OPERATION_TYPE` was added
by ADR-021 (streaming handler for subscriptions — `invoke()` called on a
`Subscription`, or `invoke_streaming()` on a `Query`/`Mutation`).
But operations also have **domain-level failures** that are not covered:
- `/fs/readFile` can fail because the file doesn't exist, the path is
invalid, or the caller lacks OS-level read permission. These are
operation-specific failures distinct from the protocol-level
`INVALID_INPUT` (schema mismatch) or `FORBIDDEN` (scope mismatch).
- `/vastai/createMachine` can fail because the account has insufficient
credits, the machine type is unavailable in the requested region, or the
upstream API rate-limited the request.
- `/agent/chat` can fail because the LLM provider returned an error, the
context window overflowed, or the model refused the request.
Today, these failures collapse into `INTERNAL` with a `message` string.
A client calling `/fs/readFile` has no way to know from the schema that it
might return `FILE_NOT_FOUND` vs `PERMISSION_DENIED` vs `INVALID_PATH`. The
caller has to parse `message` strings — the exact anti-pattern that typed
RPC is meant to avoid. This is a **type safety gap**: inputs and outputs are
typed, but errors are untyped strings.
### Why this matters for adapters
OpenAPI specs naturally include error information — response status codes
with schemas (e.g., `404: { schema: NotFoundError }`, `422: { schema:
ValidationError }`). MCP tool definitions carry error descriptions. The
`from_openapi` adapter (ADR-022 L113124) imports operations and mirrors
"the remote operation's name, namespace, type, schemas, and access control"
— but with no error schema field, error responses from the OpenAPI source
are dropped on import. `to_openapi` has nowhere to project error information
to. The same gap applies to `from_mcp`/`to_mcp`.
An OpenAPI operation that declares:
```yaml
responses:
'200': { schema: MachineList }
'401': { schema: AuthError }
'429': { schema: RateLimitError }
```
cannot be faithfully represented in alknet's `OperationSpec` today. The
adapter would import the `200` output schema and drop the error schemas —
a lossy import that silently discards the operation's failure contract.
### Prior art
The TypeScript reference (`/workspace/@alkdev/operations/src/types.ts`
L3847, L94, L112) defines `ErrorDefinitionSchema` and an optional
`errorSchemas?: ErrorDefinition[]` on `OperationSpec`:
```typescript
export const ErrorDefinitionSchema = Type.Object({
code: Type.String({ description: "Error Code e.g., INVALID_INPUT, NOT_FOUND, UNAUTHORIZED" }),
description: Type.String(),
schema: Type.Unknown(),
httpStatus: Type.Optional(Type.Number()),
});
```
The `mapError()` function (`error.ts` L2551) matches thrown errors against
the declared error schemas by code prefix — if a handler throws an error
whose message starts with a declared code, `mapError` rewrites it to a
typed `CallError` with that code. This is a proven pattern: operations
declare their error contract, the dispatch machinery maps runtime failures
to the declared codes, and clients get typed errors instead of string
parsing.
The translator agent omitted `errorSchemas` from the Rust spec, likely
because it's `Optional` in the TS schema (so dropping it doesn't break the
happy path) and because error schemas are semantically different from
input/output schemas (an operation returns one output but could return any
of several errors). That's a reasonable judgment call for a first
translation pass, but it leaves a real gap for adapters and clients.
### The general principle
This is the same principle as the Safe Exit protocol in the SDD process
(docs/sdd_process.md L19, L423): **make failure a typed, declared thing
rather than an untyped exception that crashes into whoever's listening.**
An operation that declares "I can fail with `FILE_NOT_FOUND`" is the same
shape as an agent that declares "I can fail with `TASK_AMBIGUOUS`" — both
turn an unknown unknown into a known known that the caller can handle
deliberately.
Complex systems survive not because every component is reliable, but
because failure is expected and typed. Cells have apoptosis (a declared
failure mode that protects the organism). Operations have error schemas (a
declared failure mode that lets the caller handle it). The alternative —
components that fail with untyped strings — is how you get brittle clients
that string-match error messages and break when the message wording
changes.
## Decision
### 1. `OperationSpec` gains an optional `error_schemas` field
```rust
pub struct OperationSpec {
pub name: String,
pub namespace: String,
pub op_type: OperationType,
pub visibility: Visibility,
pub input_schema: Value,
pub output_schema: Value,
pub access_control: AccessControl,
pub error_schemas: Vec<ErrorDefinition>, // NEW — empty vec = no declared errors
}
pub struct ErrorDefinition {
/// Machine-readable error code. e.g., "FILE_NOT_FOUND", "RATE_LIMITED",
/// "INSUFFICIENT_CREDITS". Distinct from the protocol-level codes
/// (NOT_FOUND, FORBIDDEN, etc.) — these are operation-level domain codes.
pub code: String,
/// Human-readable description of when this error occurs.
pub description: String,
/// JSON Schema for the error detail payload. The `call.error` event's
/// `details` field conforms to this schema when this error code is
/// returned. `Value` (serde_json::Value) carrying a JSON Schema, same
/// as input_schema/output_schema.
pub schema: Value,
/// HTTP status code for adapter projection. `from_openapi` maps OpenAPI
/// response status codes to error definitions; `to_openapi` projects
/// error definitions back to response status codes. Optional — not all
/// error sources are HTTP-backed.
pub http_status: Option<u16>,
}
```
`error_schemas` is a `Vec<ErrorDefinition>`, not `Option<Vec<...>>`. An
empty vec means "this operation declares no specific domain errors" (it may
still fail with protocol-level codes like `INTERNAL`). This avoids the
`None` vs `Some([])` ambiguity and matches the TypeScript reference's
optional-array convention.
### 2. The `call.error` payload gains an optional `details` field
```json
{
"code": "FILE_NOT_FOUND",
"message": "file not found: /etc/nonexistent",
"retryable": false,
"details": { "path": "/etc/nonexistent", "errno": 2 }
}
```
- `code` — the error code. Either a protocol-level code (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
`TIMEOUT`) or an operation-level domain code from `error_schemas` (e.g.,
`FILE_NOT_FOUND`, `RATE_LIMITED`).
- `message` — human-readable error message. Unstructured — for logging and
debugging, not for programmatic handling. Clients should switch on
`code`, not parse `message`.
- `retryable` — whether the caller should retry. `true` for transient
failures (`TIMEOUT`, `RATE_LIMITED`), `false` for permanent ones
(`NOT_FOUND`, `FORBIDDEN`, `FILE_NOT_FOUND`).
- `details` — optional. When the error code matches a declared
`ErrorDefinition`, `details` conforms to that definition's `schema`. When
the error is protocol-level (`NOT_FOUND`, `FORBIDDEN`, etc.), `details`
is absent or carries protocol-specific context (e.g., the operation name
for `NOT_FOUND`). This field is the typed error payload — it's what
makes errors structured instead of string-matched.
### 3. Protocol-level vs operation-level error codes
The six existing codes are **protocol-level** — emitted by the dispatch
machinery, not by handlers:
| Code | Emitted by | Meaning |
|------|-----------|---------|
| `NOT_FOUND` | Registry | Operation not registered (or Internal op called from wire) |
| `FORBIDDEN` | Registry / ACL | Caller lacks required scopes, or unauthenticated |
| `INVALID_INPUT` | Registry | Input doesn't match `input_schema` |
| `INVALID_OPERATION_TYPE` | Registry / `OperationEnv` | Wrong dispatch path for the operation's type (`invoke()` on a `Subscription`, `invoke_streaming()` on a `Query`/`Mutation`, or `OperationEnv::invoke()` on a `Subscription` during composition — ADR-021) |
| `INTERNAL` | Registry / Adapter | Handler panic, unhandled error, connection failure |
| `TIMEOUT` | Adapter | Request timed out |
Operation-level domain codes are emitted by **handlers** — the operation's
own logic determines what went wrong. They are declared in `error_schemas`
and appear in the `code` field of `call.error`. Examples: `FILE_NOT_FOUND`,
`PERMISSION_DENIED`, `RATE_LIMITED`, `INSUFFICIENT_CREDITS`,
`CONTEXT_OVERFLOW`.
The two namespaces are distinct but share the `code` field. Clients
should handle protocol-level codes uniformly (they mean the same thing
regardless of operation) and operation-level codes per-operation (they
mean what the operation's `error_schemas` says they mean). Unknown codes
— whether a future protocol code or an undeclared operation code — should
be treated as `INTERNAL` with `retryable: false` (same as the current
guidance in call-protocol.md L143).
### 4. Handler error mapping
When a handler returns an error, the dispatch machinery maps it to a
`call.error` event. The mapping:
1. If the handler returns a structured error with a `code` that matches a
declared `ErrorDefinition.code`, the `call.error` carries that code and
the error's detail payload (validated against the definition's `schema`).
2. If the handler returns a structured error with a `code` that doesn't
match any declared `ErrorDefinition`, the `call.error` carries
`INTERNAL` with the original code in `details`. This is an undeclared
error — the handler returned a typed error but didn't declare it.
3. If the handler returns an unstructured error (a string, a generic
`Error`, a panic), the `call.error` carries `INTERNAL` with
`retryable: false`. This is the current behavior for all handler
errors.
The TypeScript `mapError()` function (error.ts L2551) implements case 2
and 3 by matching error messages against declared codes. The Rust
implementation can use a typed error return from the handler (`Result<Value,
CallError>` where `CallError` carries a `code`), which is cleaner than
message-string matching — the handler returns a typed error, the registry
checks whether the code is declared, and the `call.error` is constructed
accordingly.
### 5. `from_openapi` and `to_openapi` error fidelity
`from_openapi` maps OpenAPI response status codes to `ErrorDefinition`s:
```rust
// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
```
**Normative rule (review #002 W20)**: `from_openapi` must not produce error
codes that collide with the six protocol-level codes (`NOT_FOUND`,
`FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`,
`TIMEOUT`). The adapter prefixes
imported error codes with `HTTP_` and the status number (e.g., `HTTP_404`,
`HTTP_429`) to avoid collision. This is a requirement for the adapter, not
a naming convention — the `from_openapi` example above was previously shown
producing `NOT_FOUND` from a 404, which collided with the protocol-level
`NOT_FOUND` (operation not registered). The `details` field disambiguates
in practice (present for operation-level, absent for protocol-level), but
ADR-016 says "clients should switch on `code`, not parse `message`" — so
the `code` alone must be unambiguous. Operations that hand-write their own
`ErrorDefinition`s should use domain-specific codes (`FILE_NOT_FOUND`,
`RATE_LIMITED`) rather than reusing protocol codes.
The adapter maps the OpenAPI error schema to alknet's JSON Schema format
(same conversion as input/output schemas). The `http_status` field records
the original status code so `to_openapi` can project it back.
`to_openapi` projects `error_schemas` back to OpenAPI response definitions:
```yaml
responses:
'200': { schema: <output_schema> }
'404': { schema: <error_schemas[0].schema> } # where http_status = 404
'429': { schema: <error_schemas[1].schema> } # where http_status = 429
```
This makes the adapter contract from ADR-022 faithful on the error axis —
no silent dropping of error contracts.
`from_mcp` and `to_mcp` follow the same pattern: MCP tool definitions carry
error descriptions, and the adapters map them to/from `ErrorDefinition`s.
### 6. `services/schema` exposes error schemas
`services/schema` returns the full `OperationSpec` including `error_schemas`.
A client querying `/services/schema` for `/fs/readFile` gets:
```json
{
"name": "fs/readFile",
"namespace": "fs",
"op_type": "query",
"input_schema": { ... },
"output_schema": { ... },
"error_schemas": [
{ "code": "FILE_NOT_FOUND", "description": "The file does not exist",
"schema": { "type": "object", "properties": { "path": { "type": "string" } } },
"http_status": null },
{ "code": "PERMISSION_DENIED", "description": "OS-level read permission denied",
"schema": { "type": "object", "properties": { "path": { "type": "string" }, "errno": { "type": "integer" } } },
"http_status": null }
]
}
```
This enables client code generation: a TypeScript or Rust client generator
reading the schema can produce a typed `Result<Output, FsReadFileError>`
enum instead of a generic `Result<Output, string>`.
## Consequences
**Positive:**
- Operations declare their failure modes. Clients get typed errors instead
of string-matched messages. This is the same type-safety property that
`input_schema` and `output_schema` provide, extended to the error axis.
- `from_openapi` and `to_openapi` are faithful on the error axis. An
OpenAPI operation's error contract is no longer silently dropped on
import or absent on export. The adapter contract from ADR-022 is now
complete.
- Client code generation can produce typed error enums. A client calling
`/fs/readFile` can match on `FILE_NOT_FOUND` vs `PERMISSION_DENIED`
instead of parsing `message` strings.
- The protocol-level vs operation-level distinction is explicit. Protocol
codes (`NOT_FOUND`, `FORBIDDEN`, etc.) mean the same thing regardless of
operation. Operation codes (`FILE_NOT_FOUND`, `RATE_LIMITED`) mean what
the operation declares. No conflation.
- The `details` field carries structured error context that conforms to a
schema — the error payload is typed, not a bare string. This enables
programmatic error handling (retry logic, user-facing error messages,
logging) without string parsing.
- The principle generalizes: making failure a typed, declared thing is the
same pattern as the SDD process's Safe Exit protocol (typed agent
failure) and the same pattern complex biological systems use (apoptosis
as a declared cell failure mode). The more components declare their
failure modes, the more robust the system.
**Negative:**
- `OperationSpec` gains a field. Operations that don't declare errors
(empty `error_schemas` vec) still work — the field is additive. But
operations that *should* declare errors and don't will produce `INTERNAL`
with `retryable: false`, same as today. The gap is visible but not
enforced — an operation can ship without error schemas and clients get
untyped errors for it. This is a documentation/guidance issue, not a
type-system issue.
- The `call.error` payload gains a `details` field. This is a wire-format
addition. Existing clients that only read `code` and `message` are
unaffected (they ignore `details`). New clients can read `details` for
structured error context. This is backward-compatible — `details` is
optional and absent for protocol-level errors.
- Handler error mapping adds a step to the dispatch path: the registry
checks whether the handler's error code matches a declared
`ErrorDefinition`. This is a `HashMap` lookup by code — negligible cost.
- The `http_status` field on `ErrorDefinition` is HTTP-specific. Operations
that aren't HTTP-backed (local, session, from_mcp) leave it as `None`.
This is a pragmatic choice: `from_openapi`/`to_openapi` need it, and it's
optional for everything else. A future non-HTTP adapter that needs a
different error projection field would add it — but `http_status` covers
the immediate use case.
- The TypeScript `mapError()` uses message-string matching to map thrown
errors to codes. The Rust implementation can do better (typed `CallError`
return from handlers), but this means the `Handler` type's return is
`Result<Value, CallError>` rather than `Result<Value, Box<dyn Error>>`.
This is a cleaner API but a slight constraint on handler authors — they
return typed errors, not generic ones. Mitigated: `CallError::internal()`
is available for errors that don't fit a declared code.
## Assumptions
1. **Operations can enumerate their meaningful failure modes at
registration time.** If an operation has failure modes that are only
discoverable at runtime (e.g., a dynamic API that returns novel error
codes), those would be `INTERNAL` with `details` carrying the upstream
error. The assumption is that most operations have a knowable set of
domain errors.
2. **Error codes are stable per operation.** Once an operation declares
`FILE_NOT_FOUND`, clients depend on that code. Changing it (renaming to
`NOT_FOUND_FILE`) is a breaking change for clients that match on it.
This is the same stability property as `input_schema` and
`output_schema` — the operation's interface is its contract. Adding new
error codes is additive (clients that don't know the new code treat it
as `INTERNAL`); removing or renaming codes is breaking.
3. **Protocol-level codes are distinct from operation-level codes.** If an
operation declares a code that collides with a protocol code (e.g., an
operation declares `NOT_FOUND` as a domain error), the protocol code
takes precedence in the dispatch machinery (the registry's `NOT_FOUND`
for "operation not registered" is emitted before the handler runs). The
assumption is that operations use domain-specific codes (`FILE_NOT_FOUND`)
rather than reusing protocol codes (`NOT_FOUND`). This is a naming
convention, not a type-system enforcement.
4. **`details` is optional and backward-compatible.** Existing clients that
ignore `details` continue to work. New clients read `details` for
structured context. The wire format addition is additive.
## References
- ADR-022: Call protocol client and adapter contract (adapter fidelity —
this ADR makes `from_openapi`/`to_openapi` faithful on the error axis)
- ADR-010: Secret material flow (the `details` field must not carry secret
material — same constraint as `metadata`)
- ADR-017: Privilege model (the `FORBIDDEN` protocol code covers ACL
denial; operation-level `PERMISSION_DENIED` is a distinct domain error
for OS-level permission issues)
- docs/reviews/001-pre-implementation-architecture-sanity-check.md
(finding C5, which this ADR resolves)
- ADR-021: Streaming handler for subscriptions (amends this ADR's
protocol-level code list — `INVALID_OPERATION_TYPE` added as the sixth
protocol-level code)
- docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle
of making failure typed and declared)
- TypeScript reference: `/workspace/@alkdev/operations/src/types.ts`
L3847 (`ErrorDefinitionSchema`), L94, L112 (`errorSchemas` on
`OperationSpec`), `error.ts` L2551 (`mapError`)

View File

@@ -0,0 +1,310 @@
# ADR-017: Privilege Model and Authority Context
## Status
Accepted
## Context
The call protocol allows handlers to compose other operations through
`OperationEnv::invoke()`. This creates a call tree: a parent request spawns
children, which may spawn their own children. The `parent_request_id` field
records this tree.
The previous design had a `trusted: bool` flag on `OperationContext`. When a
handler invoked another operation through `OperationEnv`, the nested call was
marked `trusted: true` and **all ACL checks were skipped**. The intent was to
avoid double-checking: if `/agent/chat` is allowed and it internally calls
`/auth/verify`, the auth check is "trusted" because the caller already passed
ACL on `/agent/chat`.
This is a privilege escalation vector. Two concrete attacks:
**Buggy handler**: a handler accidentally calls an operation it shouldn't. With
`trusted: true`, ACL is skipped entirely. A handler with `read` scope that
accidentally calls an operation requiring `admin` succeeds — the caller's `read`
scope effectively triggered an `admin` operation.
**Parameterized dispatch**: a handler takes caller input that determines which
internal operation to call. This is the core agent use case — an LLM picks which
tool to invoke based on the user's prompt. With `trusted: true`, the LLM (and
therefore the user) can invoke any registered operation without ACL checks,
regardless of the caller's scopes. A caller with `chat` scope can invoke
operations requiring `admin` by choosing the right tool name.
The call protocol is a general-purpose cross-boundary RPC mechanism. Every
consumer — NAPI adapter, Python adapter, agent service, future services —
inherits whatever privilege model the protocol defines. The privilege boundary
between external and internal calls, and the authority context switch for
composition, are core protocol semantics. This is not a feature of any single
consumer; it is the protocol's security model.
The agent service is a useful test case because it exercises every edge case
(parameterized dispatch, deep composition, dynamic operations, role-based
escalation), but the decision belongs to the call protocol.
## Mental Models
Two analogies clarify the model:
**Kernel/user mode**: external operations are syscalls — curated entry points
where an unprivileged caller can enter the kernel. Internal operations are
kernel functions — callable only from composition, not from userspace. The
`internal` flag means "this call is in kernel mode." Kernel mode has access
controls — it runs under a different principal, not with no principal.
**Domain/integration events**: external operations are integration events —
they cross a boundary and are visible to external systems. Internal operations
are domain events — they stay within the bounded context. `services/list` is
the integration contract; it only exposes integration events.
**Principal/agent (legal contracting)**: the caller is the principal; the
handler is the agent. The principal delegates scoped authority to the agent.
The agent acts under its own identity (for attribution) but with the principal's
delegated authority (for scope). Liabilities flow upstream (traceable through
`parent_request_id`); privileges flow downstream (the agent gets a subset of the
principal's authority). Role-based escalation: a lower-privileged role can
escalate through a chain of command (agent requests promotion, architect
performs it), not through direct authority.
## Decision
### 1. The `internal` flag switches authority context, not skips ACL
The `internal` flag on `OperationContext` marks calls that originated from
composition (a handler calling another operation via `OperationEnv`), as opposed
to external calls that arrived as `call.requested` from a wire client.
When `internal: true`:
- The ACL check runs against the **handler's identity** (set at registration by
the assembly layer), not the caller's identity and not as a blanket skip.
- The handler's identity has scopes scoped to its composition needs (least
privilege), not blanket root and not the caller's scopes.
When `internal: false` (external call from the wire):
- The ACL check runs against the **caller's identity** (from `AuthContext`,
resolved per-request).
The `internal` flag is set by `OperationEnv`, not by callers. A handler cannot
mark its own call as internal. The field uses module-private construction; only
`pub fn is_internal(&self) -> bool` is exposed for reads.
### 2. Operations have External/Internal visibility
`OperationSpec` has a `visibility: Visibility` field:
```rust
pub enum Visibility {
External, // Callable from the wire (call.requested from a client)
Internal, // Composition-only (env.invoke from a handler)
}
```
The assembly layer declares visibility when registering operations.
When a `call.requested` arrives from a wire client:
- An `Internal` operation returns `call.error` with code `NOT_FOUND` (not
`FORBIDDEN`). This does not leak that the operation exists.
- An `External` operation proceeds to ACL checking.
`services/list` only returns `External` operations to remote callers. Internal
operations are not part of the wire-facing API surface. A remote client cannot
enumerate the internal call tree.
### 3. Handler identity is carried on OperationContext
> **Note**: This decision's `handler_identity: Option<Identity>` type was
> superseded by ADR-018, which replaced `Identity` with
> `CompositionAuthority` — a declared authority bundle that is not a peer
> identity and is not resolvable through `IdentityProvider`. The core
> decision (authority switch, not ACL skip) holds unchanged. See ADR-018
> Decision 2 for the current type.
`OperationContext` carries both the caller's identity (who invoked me) and
the handler's identity (who am I acting as):
```rust
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>, // Caller's identity (inbound)
// Type changed to Option<CompositionAuthority> by ADR-018:
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
// env/scoped_env split by ADR-019:
pub scoped_env: ScopedOperationEnv, // Reachability data (ADR-018, ADR-019)
pub env: Arc<dyn OperationEnv + Send + Sync>, // Dispatch trait (ADR-019)
/// Module-private for writes; read via `is_internal()`. Set only by
/// `OperationEnv::invoke()` (true) or `CallAdapter` dispatch (false).
pub(crate) internal: bool,
}
impl OperationContext {
pub fn is_internal(&self) -> bool { self.internal }
}
```
- `identity`: the authenticated caller (from `AuthContext`). For external calls,
this is who sent the `call.requested`. For internal calls, this is the
*parent handler's* identity (propagated through `OperationEnv::invoke()`).
- `handler_identity`: the identity of the handler processing this call. Set at
registration by the assembly layer. For external calls, this is the handler's
own identity. For internal calls, the ACL check runs against this identity.
The distinction is the principal/agent model: `identity` is the principal (who
delegated), `handler_identity` is the agent (who is acting). Attribution traces
through both — any action can be attributed to the handler that performed it and
the caller that initiated the chain.
### 4. Scoped composition env
The `OperationEnv` given to a handler is scoped — it can only invoke a declared
set of operations. This bounds the parameterized-dispatch attack surface: a
caller (or an LLM) picking which operation to invoke picks from the declared
set, not from the entire registry.
Scoping happens at two levels:
**Static scoping at registration**: the assembly layer declares which operations
a handler may compose. The `OperationEnv` given to that handler is pre-filtered
`invoke("fs", "readFile", ...)` works, `invoke("admin", "deleteUser", ...)`
returns `NOT_FOUND`. This is the reachability control.
**Dynamic scoping at sandbox creation**: when a handler spawns a sandbox
(quickjs), it passes a *further scoped* env to the sandbox — a subset of what
the handler itself can reach. The handler might have `fs:read` and `bash:exec`,
but it only gives the sandbox `fs:read` (not `bash:exec`), because the sandbox
runs untrusted LLM-generated code. This is the "privileges flow downstream"
principle: the principal delegates a subset.
The specific API for declaring the scoped operation set is specified in
ADR-018: `ScopedOperationEnv { allowed_operations: HashSet<String> }`,
operation-level granularity (not just namespace-level). This is finer-grained
than the TypeScript `@alkdev/operations` `buildEnv()` which used
`allowedNamespaces` — operation-level scoping is safer for the
parameterized-dispatch use case.
### 5. The three controls together
The three controls are independent and all are needed:
| Control | What it gates | Without it |
|---------|--------------|-----------|
| Operation visibility | Whether an operation is callable from the wire | Internal operations exposed to external callers |
| Handler identity | What authority composition runs under | ACL skipped or caller's scopes propagated (escalation) |
| Scoped composition env | What operations a handler can reach | Handler can call anything in the registry |
- Visibility alone: internal operations are hidden from the wire, but
composition skips ACL (escalation through buggy handler).
- Handler identity alone: ACL checks against handler scopes, but the handler can
reach any operation (parameterized dispatch unbounded).
- Scoped env alone: handler can only reach declared operations, but ACL is
skipped (if a declared operation requires a scope the handler doesn't have, it
still runs).
All three together: the handler can only reach declared operations (scoped env),
those operations are ACL-checked against the handler's scoped identity (handler
identity), and internal operations are never exposed to the wire (visibility).
Principle of least privilege.
## Consequences
**Positive:**
- No privilege escalation through composition. A handler can only compose
operations its own identity is authorized for, and only from its declared
scope.
- Parameterized dispatch is safe. The agent/LLM tool selection case is bounded
by the scoped env — the LLM picks from the declared tool set, not from the
entire registry. The ACL checks against the handler's identity, not the
caller's.
- Buggy handlers can't accidentally escalate. A handler that tries to call an
operation outside its scoped env gets `NOT_FOUND`; one that calls an operation
its identity lacks scopes for gets `FORBIDDEN`.
- Attribution is complete. Every call carries both the caller's identity (who
initiated the chain) and the handler's identity (who is acting). The
`parent_request_id` chain traces the full agency chain. This supports the
gitea-per-agent pattern where each agent (human or LLM) has its own account.
- Session-scoped operations (OQ-19) are safe by construction. They're always
`Internal`, run under the handler's identity, through the scoped env, in a
locked-down sandbox. The self-improving workflow (agents writing tools) is
bounded.
- Role-based escalation is explicit. An agent requesting promotion (session →
core) is a lower-privileged role asking a higher-privileged role (architect
with `promote` scope) to perform an action. The escalation goes through the
chain of command, not through direct authority.
**Negative:**
- `OperationContext` has two identity fields (`identity` and
`handler_identity`), which is more complex than a single identity. This is
necessary — the principal/agent distinction is real and both are needed for
attribution and ACL.
- The assembly layer has more responsibility: it must declare each handler's
identity (scopes), its scoped composition env (which operations it may
compose), and operation visibility. This is expected — the assembly layer
assembles everything (ADR-008), and forcing explicit declaration of privilege
is a feature, not a bug.
- Adding a new composition to a handler requires updating the assembly layer
(declare the new operation in the scoped env), not just the handler code.
This prevents accidental composition of unauthorized operations.
- The scoped env API is not fully specified here. The one-way constraint
(scoped env exists, is declared at registration, can be further scoped at
runtime) is fixed; the concrete API is a two-way door for implementation.
## Assumptions
1. **Internal calls should run under a different authority than external calls,
not skip ACL entirely.** If internal calls should skip ACL (the old `trusted`
model), this entire ADR is wrong. The assumption is that the escalation
vectors (buggy handler, parameterized dispatch) are real and must be
prevented.
2. **Handler identity is set at registration by the assembly layer.** The
assembly layer is the trust boundary (ADR-008, ADR-010). If the assembly
layer is compromised, all handler identities are compromised. This is the
same trust boundary as capabilities.
3. **The scoped env is declared at registration (static) and can be further
scoped at runtime (dynamic, for sandbox creation).** The static scoping is
the reachability control; the dynamic scoping is the sandbox boundary. If a
use case requires fully dynamic scoping (handler discovers at call time what
it can compose), the model needs extension — but the assumption is that
composition reachability is knowable at registration time.
4. **`services/list` hides internal operations.** If internal operations should
be discoverable by remote callers (e.g., for debugging), the visibility model
needs a third state. The assumption is that internal operations are
implementation details, not part of the external API surface.
5. **Internal operations return `NOT_FOUND`, not `FORBIDDEN`.** This prevents
existence leakage. If a use case requires distinguishing "you can't call
this" from "this doesn't exist" (e.g., for debugging), the error model needs
refinement. The assumption is that not leaking internal operation existence
is more important than debuggability from the wire.
6. **The handler identity is a full `Identity` (with scopes), not a special
principal type.** ~~This reuses the existing `Identity` type and
`IdentityProvider` infrastructure (ADR-003).~~ **Superseded by ADR-018
Decision 2**: composition authority is a declared authority bundle
(`CompositionAuthority`), not a peer `Identity`. It is not resolvable
through `IdentityProvider` and does not represent an inbound caller. The
distinction is necessary because a handler is not a network peer — its
authority is declared by the assembly layer at registration, not resolved
from credentials.
## References
- ADR-003: Auth as shared core (`IdentityProvider`, `Identity`)
- ADR-008: Vault integration (assembly layer is the trust boundary)
- ADR-010: Secret material flow and capability injection (capabilities are
orthogonal — both are set at registration by the assembly layer)
- OQ-15: Call protocol client and adapter contract (adapters produce scoped envs)
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
traces principal → agent)
- OQ-19: Session-scoped registries (session operations are always `Internal`)
- [operation-registry.md](../crates/call/operation-registry.md)
- [call-protocol.md](../crates/call/call-protocol.md)
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
art for scoped composition env
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
the full-registry exposure gap

View File

@@ -0,0 +1,655 @@
# ADR-018: Handler Registration, Provenance, and Composition Authority
## Status
Accepted
## Context
ADR-017 established the privilege model: the `internal` flag marks
composition-originated calls and switches the ACL from the caller's identity
to the handler's identity. This replaces the old `trusted: bool` flag, which
skipped ACL entirely — a privilege escalation vector. The core decision in
ADR-017 is sound: internal calls switch authority, they don't skip ACL.
However, ADR-017 left three things unspecified, which the pre-implementation
review (docs/reviews/001-pre-implementation-architecture-sanity-check.md,
findings C1C4) identified as critical gaps:
1. **`handler_identity` has no registration path.** ADR-017 says the handler's
identity is "set at registration by the assembly layer" (Assumption 2) and
that "ACL check runs against the handler's identity (set at registration)"
(Decision 1). But the registration API shown in operation-registry.md —
`register(spec, handler)` and `OperationRegistryBuilder::with(spec,
handler)` — accepts no identity. Tracing the dispatch path reveals that
`build_root_context` sets `handler_identity: None` for wire calls (correct
for the root), and `OperationEnv::invoke()` propagates
`parent.handler_identity.clone()` to children. Since the root's
`handler_identity` is `None`, every internal call gets `handler_identity:
None` — meaning ADR-017's "ACL runs against `handler_identity` for internal
calls" checks against `None`, which is the privilege-escalation gap ADR-017
was written to close.
2. **The scoped composition env has no registration/construction path.**
ADR-017 says the `OperationEnv` given to a handler is "scoped — it can
only invoke a declared set of operations, set at registration by the
assembly layer" (Decision 4, Assumption 3). But `register(spec, handler)`
takes no scoped-env declaration, `OperationSpec` has no field for it, and
the only `OperationEnv` implementation shown is `LocalOperationEnv` wrapping
the *full* registry — no scoping layer exists.
3. **`Capabilities` lives in two unconnected models.** ADR-010 and
operation-registry.md show two models for how a handler gets outbound
credentials: construction-time capture in the handler closure (Model A) and
per-request on `OperationContext.capabilities` propagated through
composition (Model B). The two don't connect: if the handler closure
captured capabilities at construction, `OperationContext.capabilities` is
either redundant or must be populated from the closure — but the closure
receives the context, it isn't passed it. An implementer would have to
invent the bridge, and the consuming crates (call, agent, napi) could
diverge.
Beyond these wiring gaps, there is a deeper issue with ADR-017's Assumption 6:
"the handler identity is a full `Identity` (with scopes), not a special
principal type." `Identity` was designed for **inbound peer identity** — who
is calling me from the network. A handler is not a peer. Its `id` field would
be something like `"agent-chat-handler"` — a label, not something resolvable
through `IdentityProvider`. Calling it an `Identity` implies it's a peer,
which it isn't. It's an authority bundle.
### The kernel/user analogy
This is structurally the same problem an operating system solves with
kernel/user mode:
- User calls `getaddrinfo()` — the syscall gate (an **External** op). The
kernel checks the user's capabilities at entry.
- `getaddrinfo` internally makes DNS queries, allocates sockets, reads
`/etc/hosts`**Internal** kernel functions. They don't check the user's
`CAP_NET_RAW`. They run under **kernel authority**.
- The user does NOT need `CAP_NET_RAW` to resolve DNS. The kernel does network
access on the user's behalf, under the kernel's own authority.
The key principle: **the user's authority is checked once at the gate. Inside,
the handler runs under its own authority. The user's authority does not
propagate into internal calls.**
This is exactly what ADR-017 specifies. The `internal` flag is the boundary
crossing. When `internal: true`, ACL switches from the caller's identity to
the handler's composition authority. The user's `[chat]` scope got them through
`/agent/chat`'s External ACL. Once inside, it's `/agent/chat`'s composition
authority that authorizes composing `/vastai/listMachines` — not the user's.
### The graph framing
Call trees and operation registries are graph-shaped. The TypeScript
`@alkdev/flowgraph` package models this explicitly with three graphs:
1. **Operation Graph** (static) — nodes are registered operations, edges are
type-compatibility relationships. Built from `OperationSpec`s at startup.
2. **Call Graph** (dynamic) — nodes are call invocations (request IDs), edges
are parent-child relationships (`parent_request_id`). Built from call
protocol events at runtime.
3. **Scoped Operation Subgraph** (per-handler, static) — the declared subset
of the operation graph that a handler may reach. This is what ADR-017 calls
the "scoped env," framed as a subgraph rather than a list of names.
This ADR uses the graph *model* as structural framing but does not mandate a
graph *library*. For v1, the operation graph can be implicit (a
`HashMap<String, OperationNode>`), the call graph can be implicit (the
`PendingRequestMap` indexed by `parent_request_id` *is* a call graph), and the
scoped env can be a `HashSet<String>` of reachable operation names. A
dedicated `alknet-flowgraph` crate (or folding graph structures into
`alknet-call`) is a future enhancement for workflow templates, type
compatibility validation, and call-graph observability — not a prerequisite
for the security model.
## Decision
### 1. Provenance is the primary registration axis
Every registered operation carries a provenance tag that classifies where it
came from. Provenance determines whether the operation can compose, whether it
has composition authority, its default visibility, and its trust model.
```rust
pub enum OperationProvenance {
/// Assembly-written, trusted code, can compose.
Local,
/// HTTP forwarding stub (from_openapi), leaf — cannot compose.
FromOpenAPI,
/// MCP forwarding stub (from_mcp), leaf — cannot compose.
FromMCP,
/// QUIC forwarding stub (from_call). Leaf in the local registry —
/// forwards calls to a remote node; cannot compose locally.
FromCall,
/// HTTP forwarding stub (from_jsonschema, single endpoint), leaf —
/// cannot compose. (ADR-027: was "no handler — schema only"; now a
/// real reqwest-backed forwarding handler in alknet-http.)
FromJsonSchema,
/// Agent-written, sandboxed, can compose within sandbox bounds.
Session,
}
```
| Provenance | Can compose? | Has composition authority? | Default visibility | Trust model |
|-----------|-------------|---------------------------|-------------------|-------------|
| `Local` | Yes | Yes — scopes set by assembly layer | External or Internal (assembly declares) | Trusted code |
| `FromOpenAPI` | No (leaf) | No | Internal | HTTP endpoint trusted; handler is a forwarding stub |
| `FromMCP` | No (leaf) | No | Internal | MCP server trusted; handler is a forwarding stub |
| `FromCall` | No (leaf in local registry) | No | Internal | Remote node trusted; handler is a forwarding stub |
| `FromJsonSchema` | No (leaf) | No | Internal | HTTP endpoint trusted; handler is a forwarding stub (ADR-027) |
| `Session` | Yes (within sandbox) | Yes — scopes set by assembly layer at sandbox creation | Internal always | Untrusted code in sandbox |
> **ADR-027 amendment (2026-07-09).** The `FromJsonSchema` row
> previously read "N/A (no handler) / N/A / N/A" — `from_jsonschema`
> was a schema-only placeholder in `alknet-call` with a
> `NOT_FOUND`-returning handler.
> [ADR-027](027-from-jsonschema-as-http-adapter.md) moved the adapter
> to `alknet-http` as a real HTTP-backed single-endpoint adapter with a
> reqwest forwarding handler. `FromJsonSchema` is now a leaf, same
> trust model as `FromOpenAPI` (HTTP endpoint trusted; handler is a
> forwarding stub). The "schema-only, no handler" concept is removed.
Only `Local` and `Session` ops get composition authority. Leaves
(`FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema`) don't compose, so
they don't get one. The assembly layer does not invent identities for leaves.
### 2. Composition authority replaces `handler_identity: Identity`
ADR-017's Assumption 6 said "the handler identity is a full `Identity` (with
scopes), not a special principal type." This ADR refines that: composition
authority is a declared authority bundle, not a peer `Identity`. It's only set
for ops that can compose (`Local`, `Session`). Leaves don't have one.
```rust
/// Authority under which a handler composes child operations.
///
/// This is NOT a peer `Identity` — it's not resolvable through
/// `IdentityProvider` and doesn't represent an inbound caller. It's the
/// declared authority (scopes + resources + label) that the assembly layer
/// grants a handler for composition. When the handler composes children via
/// `OperationEnv::invoke()`, the child's ACL runs against this authority,
/// not the caller's identity and not as a blanket skip.
///
/// Only ops that can compose (`Local`, `Session`) have one. Leaves
/// (`FromOpenAPI`, `FromMCP`, `FromCall`) have `None`.
pub struct CompositionAuthority {
/// Human-readable label for attribution and logging
/// (e.g., "agent-chat", "fs-handler"). Not a peer id — not resolvable
/// through IdentityProvider.
pub label: String,
/// Scopes the handler operates under for composition. When the handler
/// composes a child via `env.invoke()`, the child's ACL checks against
/// these scopes. Least privilege: the assembly layer grants only the
/// scopes the handler needs for its declared composition.
pub scopes: Vec<String>,
/// Named resource lists, same shape as `Identity.resources`. Optional.
/// e.g., {"service": ["vastai", "github"]} bounds which services the
/// handler can reach in composition.
pub resources: HashMap<String, Vec<String>>,
}
impl CompositionAuthority {
/// `None` — for leaves that don't compose (convenience for
/// `composition_authority: CompositionAuthority::none()`).
pub fn none() -> Option<Self> { None }
/// Construct a composition authority with the given label and scopes.
pub fn new(
label: &str,
scopes: impl IntoIterator<Item = String>,
) -> Self {
Self {
label: label.to_string(),
scopes: scopes.into_iter().collect(),
resources: HashMap::new(),
}
}
/// Convert to a synthetic `Identity` for ACL matching on child calls.
///
/// When a handler composes a child via `env.invoke()`, the child's
/// `identity` (the caller identity for ACL) is set to the parent's
/// composition authority converted to an `Identity`. This constructs
/// a synthetic `Identity { id: label, scopes, resources }` that is
/// **not** resolvable via `IdentityProvider` — it's not a peer
/// identity, it's a declared authority bundle used directly for ACL
/// matching. This creates a second `Identity` construction path (the
/// first is `IdentityProvider::resolve_*`), which is acknowledged and
/// intentional: the composition authority is a declared authority, not
/// a resolved credential.
///
/// Returns `None` when the authority is `None` (leaf case — leaves
/// don't compose, so `as_identity()` is never called on them in
/// practice, but the `Option` makes the types line up).
pub fn as_identity(&self) -> Option<Identity> {
Some(Identity {
id: self.label.clone(),
scopes: self.scopes.clone(),
resources: self.resources.clone(),
})
}
}
```
This supersedes ADR-017's Assumption 6. ADR-017's core decision (authority
switch, not ACL skip) holds unchanged — the only change is *what* the
authority is and which ops have it.
### 3. The scoped env is a declared subgraph (reachability control)
The scoped composition env from ADR-017 is the **reachability control**: it
bounds which operations a handler can reach via `env.invoke()`. ADR-017
specifies it as "a declared set of operations, set at registration by the
assembly layer." This ADR makes the registration path explicit and frames it
as a subgraph of the operation graph.
```rust
/// The set of operations a handler may reach via `env.invoke()`.
///
/// This is the reachability control from ADR-017: a handler (or an LLM
/// picking tools, or a quickjs sandbox) can only compose declared operations,
/// not the entire registry. Set at registration by the assembly layer for
/// composing ops (`Local`, `Session`). `None` for leaves — they don't
/// compose, so they get an empty/no-op env.
///
/// Conceptually a subgraph of the operation graph. For v1, implemented as a
/// set of operation names — the *model* is a subgraph (which nodes this
/// handler can reach), but type-compatibility edges between those nodes are
/// a future enhancement for static validation, not a v1 requirement.
///
/// The `allowed_operations` field is **private** (not `pub`). Construction
/// is via `ScopedOperationEnv::new(ops)` or `ScopedOperationEnv::empty()`.
/// Reachability is queried via `allows(&name)`. This encapsulation makes the
/// future subgraph refactor (from `HashSet<String>` to a typed subgraph) a
/// non-breaking change to construction sites (review #002 W21). The
/// `HashSet<String>` representation does not support type-compatibility
/// validation — session-scoped ops (OQ-19, untrusted code) compose without
/// static type checking until a flowgraph crate is built.
pub struct ScopedOperationEnv {
allowed_operations: HashSet<String>,
}
impl ScopedOperationEnv {
/// Empty set — for leaves that don't compose (no reachable operations).
pub fn empty() -> Self {
Self { allowed_operations: HashSet::new() }
}
/// Construct from an iterable of operation names.
pub fn new(ops: impl IntoIterator<Item = String>) -> Self {
Self { allowed_operations: ops.into_iter().collect() }
}
/// Returns true if the given operation name is reachable.
pub fn allows(&self, name: &str) -> bool {
self.allowed_operations.contains(name)
}
}
```
### 4. The registration bundle carries all three
The three controls from ADR-017 (visibility, composition authority, scoped
env) plus the capability injection from ADR-010 all enter the system at the
same boundary: the assembly layer hands the registry a `(spec, handler)` pair
*plus* the handler's runtime context material. This ADR makes that explicit
as a registration bundle.
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: Handler,
pub provenance: OperationProvenance,
/// Composition authority for this handler. `None` for leaves
/// (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose.
/// `Some(...)` for `Local` and `Session` ops that can compose children.
pub composition_authority: Option<CompositionAuthority>,
/// Scoped composition env. `None` for leaves — they get an empty
/// no-op env. `Some(...)` for composing ops.
pub scoped_env: Option<ScopedOperationEnv>,
/// Outbound credentials the handler may use (decrypted API keys, signing
/// keys, HTTP tokens). Populated by the assembly layer from the vault
/// at handler construction. See ADR-010.
pub capabilities: Capabilities,
}
```
The registry's `register` and builder's `with` accept a `HandlerRegistration`,
not a bare `(OperationSpec, Handler)` pair:
```rust
impl OperationRegistry {
pub fn register(&mut self, registration: HandlerRegistration);
}
impl OperationRegistryBuilder {
pub fn with(mut self, registration: HandlerRegistration) -> Self;
}
```
Adapter convenience methods (`from_openapi`, `from_mcp`, `from_call`)
construct `HandlerRegistration` with `composition_authority: None` and
`scoped_env: None` for the leaf ops they produce — the adapter doesn't grant
composition authority, and the assembly layer doesn't have to invent values
for leaves.
### 5. The dispatch path reads from the registration bundle
The CallAdapter's `build_root_context` and `OperationEnv::invoke()` read
composition authority, scoped env, and capabilities from the registration
bundle, looked up by operation name.
**`build_root_context` (wire-originated call, `internal: false`):**
```rust
fn build_root_context(
&self,
request_id: String,
operation_name: &str, // looked up in registry
identity: Option<Identity>, // resolved per-request from AuthContext/auth_token
) -> OperationContext {
let registration = self.registry.registration(operation_name);
OperationContext {
request_id,
parent_request_id: None,
identity, // caller's identity (inbound — gate credential)
handler_identity: registration.composition_authority, // C1: from bundle, None for leaves
capabilities: registration.capabilities.clone(), // C3: from bundle
metadata: HashMap::new(),
abort_policy: AbortPolicy::default(), // abort-dependents (ADR-020 Decision 6)
// env/scoped_env split by ADR-019: scoped_env is the reachability
// data (from the bundle), env is the dispatch trait object (composed
// per-call by the CallAdapter from active overlays).
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // C2: from bundle, empty for leaves
env: self.compose_root_env(/* connection, session */), // Arc<dyn OperationEnv + Send + Sync> — see ADR-019
internal: false, // wire call — ACL against caller identity
}
}
```
ACL for the root checks against `identity` (the caller's identity, resolved
per-request). `handler_identity` is on the context for *propagation* to
children, not for the root's own ACL.
**`OperationEnv::invoke()` (composition-originated call, `internal: true`):**
```rust
async fn invoke(&self, namespace: &str, operation: &str, input: Value,
parent: &OperationContext) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check (C2): is this op in the parent's scoped env?
// If not, return NOT_FOUND. This is the reachability control.
// (ADR-019: the reachability check consults parent.scoped_env, not
// parent.env — env is now the dispatch trait, scoped_env is the data.)
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
let registration = self.registry.registration(&name);
let context = OperationContext {
request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()),
identity: parent.handler_identity.as_identity(), // parent's authority becomes the caller
handler_identity: registration.composition_authority.clone(), // C1: child's own authority
capabilities: parent.capabilities.clone(), // C3: propagate through composition
metadata: HashMap::new(), // fresh — does NOT propagate (ADR-010)
abort_policy: parent.abort_policy.clone(), // inherit parent's policy (ADR-020 Decision 6, W19)
// env/scoped_env split by ADR-019:
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // C2: child's own scoped env
env: parent.env.clone(), // child inherits parent's composite env (Arc::clone)
internal: true, // composition — ACL against handler_identity
};
self.registry.invoke(&name, input, context).await
}
```
Two things happen here:
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 bounds the parameterized-dispatch attack surface — a
handler (or an LLM picking tools) can only reach declared ops.
2. **Authority propagation**: the child's `identity` is the parent's
`handler_identity` (the parent's composition authority becomes the caller
for the child). 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-017, now wired.
ACL for the child checks against `handler_identity` (the child's composition
authority). For leaves, `handler_identity` is `None` — but leaves don't
compose, so their `handler_identity` is never used for ACL on a grandchild.
Leaves only have ACL checked against *themselves* (as the target of
composition), where the check is: does the parent's composition authority
satisfy the leaf's `AccessControl`?
### 6. Capabilities are per-request, populated from the bundle (Model A reconciled)
This ADR resolves the C3 ambiguity by adopting option (a) from the review:
capabilities are only per-request on `OperationContext`, populated by the
dispatch path from the per-handler capabilities in the registration bundle.
The construction-time "baking" described in ADR-010 L82 populates the
registration bundle's `capabilities` field — the handler closure does not
capture capabilities.
```rust
// Assembly layer: construct registration with capabilities from vault
let google_api_key = vault.decrypt(&google_key_blob)?;
let agent_registration = HandlerRegistration {
spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler), // closure captures nothing
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority {
label: "agent-chat".into(),
scopes: vec!["llm:call".into(), "fs:read".into(), "vastai:query".into()],
resources: HashMap::new(),
}),
scoped_env: Some(ScopedOperationEnv::new(
["fs/readFile", "vastai/listMachines", "llm/generate"])),
capabilities: Capabilities::new()
.with_api_key("google", google_api_key), // C3: in the bundle, not the closure
};
```
The handler reads `context.capabilities` at call time. The dispatch path
populates it from `registration.capabilities`. Composition propagates it via
`parent.capabilities.clone()` in `invoke()`. No circular dependency, no
redundant models.
### 7. The three controls together (ADR-017's model, now wired)
| Control | What it gates | Where it's set | Without it |
|---------|--------------|----------------|-----------|
| Visibility (External/Internal) | Whether the op is callable from the wire | `OperationSpec.visibility` | Internal ops exposed to external callers |
| Composition authority | What authority internal calls run under | `HandlerRegistration.composition_authority` | ACL skipped or caller's scopes propagated (escalation) |
| Scoped env | What ops a handler can reach | `HandlerRegistration.scoped_env` | Handler can call anything in the registry (confused deputy) |
All three enter at registration. All three reach the dispatch path via the
registration bundle. The user's identity is the **gate credential** — checked
once at the External boundary. The composition authority is the **internal
credential** — used for all composition inside. The scoped env is the
**reachability boundary** — what the handler can even attempt to compose.
### 8. No intersection semantics
The user's authority does NOT limit internal calls. If the user has `chat` but
not `vastai:query`, `/agent/chat` composing `/vastai/listMachines` is NOT
denied because the user lacks `vastai:query`. The user's authority was
checked at the gate (`/agent/chat` requires `chat`, user has `chat`). Inside,
the handler runs under its own composition authority. The user's authority
does not propagate into internal calls.
This is the kernel/user model: `getaddrinfo` doesn't require the caller to
have `CAP_NET_RAW` to make DNS queries. The curated entry point exists
*because* it does things the user can't, on the user's behalf, under its own
authority.
If a handler *wants* to act on behalf of the user (e.g., a database proxy
that runs queries under the user's DB identity), that's a **handler-level
decision** — it reads `context.identity` and explicitly narrows its
behavior. That's delegated access, not automatic intersection. The system
shouldn't silently intersect; the handler should explicitly delegate.
## Consequences
**Positive:**
- The privilege model in ADR-017 is now implementable as specified. The
composition authority, scoped env, and capabilities all have registration
paths and dispatch-path wiring. No implementer has to invent the bridge.
- Leaves (`from_openapi`, `from_mcp`, `from_call`) don't get fake identities.
The assembly layer doesn't have to invent `Identity { id:
"vastai-listmachines-handler", scopes: [], resources: {} }` for forwarding
stubs that will never compose. `composition_authority: None` is natural for
leaves, not an oversight.
- External services can't self-grant composition authority. The OpenAPI spec
defines the operation interface (name, schemas, access control). The
*provenance* is set by the assembly layer when it runs `from_openapi`. The
*composition authority* is `None` for imported ops — the external service
can't grant itself scopes to compose into your registry. The assembly layer
is the sole grantor, and only for `Local` and `Session` ops.
- Capabilities have one model: per-request on `OperationContext`, populated
from the registration bundle. No closure-capture vs context duplication
ambiguity. The three consuming crates (call, agent, napi) can't diverge
because there's one wiring path.
- The graph model provides a precise structural framing without mandating a
graph library for v1. The operation graph, scoped subgraph, and call graph
are concepts that guide the API shape; HashMaps and HashSets are the v1
implementation. A future `alknet-flowgraph` crate can reify these as
petgraph structures when workflow templates and type-compatibility
validation are needed.
- The kernel/user analogy makes the security model legible. The user's
authority is the gate credential (checked once at External entry). The
composition authority is the internal credential (used for all
composition inside). The scoped env is the reachability boundary (what the
handler can attempt to compose). This is the same model every OS uses, and
it's been battle-tested.
**Negative:**
- The registration API changes from `register(spec, handler)` to
`register(HandlerRegistration)`. This is a breaking change to the API
surface shown in operation-registry.md, but since no implementation exists
yet, it's a spec edit, not a migration.
- `CompositionAuthority` is a new type, distinct from `Identity`. This adds a
type to alknet-call. It's not a peer identity — it's a declared authority
bundle. The distinction from `Identity` is intentional and necessary (a
handler is not a network peer), but it means the codebase has two
scope-bearing types. Mitigated: they serve different roles and don't
converge — `Identity` is inbound (resolved from credentials via
`IdentityProvider`), `CompositionAuthority` is declared (set by the
assembly layer at registration).
- The assembly layer has more registration-time responsibility: it must
declare each handler's provenance, composition authority, and scoped env.
This is expected — the assembly layer assembles everything (ADR-008), and
forcing explicit declaration of privilege is a feature, not a bug. An
`OperationRegistryBuilder` convenience API can reduce boilerplate for
common cases (e.g., `.with_local(spec, handler, authority, env,
capabilities)` vs `.with_leaf(spec, handler, capabilities)`).
- The dispatch path does a registry lookup per call (to fetch the
registration bundle's composition authority, scoped env, and capabilities).
This is a `HashMap` lookup — negligible cost. The alternative (baking
everything into the handler closure) creates the C3 ambiguity. The lookup
is the right trade.
**Validation strategy:**
The security model should be validated by fuzzing. A fuzzer that generates
call trees (valid and invalid compositions, different provenance mixes, edge
cases around the gate) and asserts "no path through the call graph lets a
user with scope X reach an operation requiring Y without going through a gate
that checks X" would catch the class of privilege-escalation bug this ADR is
designed to prevent. The typebox-rs fake data generator can produce valid and
invalid inputs from JSON Schemas; with minor edits it can output invalid
inputs or a mix of valid/invalid, enabling property-based testing of the ACL
model. This is a downstream concern — the spec needs to be right first, then
the fuzzer validates the implementation against the spec.
## Assumptions
1. **Internal calls should run under a different authority than external
calls, not skip ACL entirely.** Inherited from ADR-017. The escalation
vectors (buggy handler, parameterized dispatch) are real and must be
prevented.
2. **Provenance is knowable at registration time.** The assembly layer knows
whether an op is `Local`, `FromOpenAPI`, `FromMCP`, `FromCall`, or
`Session` when it registers the op — the adapter that produced the
`(OperationSpec, Handler)` pair knows its own type. If a future use case
requires provenance to be discovered at call time, the model needs
extension.
3. **Composition reachability is knowable at registration time.** The
assembly layer can declare which operations a handler may compose when it
registers the handler. If a use case requires fully dynamic scoping
(handler discovers at call time what it can compose), the model needs
extension — but the assumption is that composition reachability is
knowable at registration time for `Local` ops, and at sandbox creation
time for `Session` ops.
4. **The assembly layer is the trust boundary.** The assembly layer declares
provenance, composition authority, and scoped env. If the assembly layer
is compromised, all handler authority is compromised. This is the same
trust boundary as ADR-008 and ADR-010.
5. **Leaves don't compose.** `FromOpenAPI`, `FromMCP`, and `FromCall` ops are
forwarding stubs — they take input, forward it (over HTTP, MCP, or QUIC),
and return output. They don't call `env.invoke()`. If a future use case
requires an imported op to compose (e.g., a `from_call` op that locally
composes other ops before forwarding), its provenance would need to change
to `Local` (it's no longer a pure forwarding stub), or the model needs a
hybrid provenance.
6. **`Session` ops compose under restricted authority.** Session ops
(agent-written, OQ-19) get composition authority scoped down by the parent
handler at sandbox creation (ADR-017's "dynamic scoping at sandbox
creation"). The assembly layer grants the sandbox's parent handler a
composition authority; the parent handler scopes it down further when
creating the sandbox. The session op's composition authority is a subset
of the parent's.
## References
- ADR-010: Secret material flow and capability injection (capabilities are
orthogonal to identity — both set at registration; this ADR specifies the
registration path ADR-010 left as a two-way door)
- ADR-017: Privilege model and authority context (this ADR refines
Assumption 6 — composition authority is not a peer `Identity`; and wires
the three controls that ADR-017 specified but left without registration
paths)
- ADR-020: Abort cascade for nested calls (the call graph is the abort
cascade tree; `parent_request_id` indexes it)
- ADR-022: Call protocol client and adapter contract (adapter-registered
ops are `Internal` by default; this ADR's provenance makes that explicit)
- ADR-019: Operation registry layering (amends this ADR's Decision 5: the
`env` field shown in `build_root_context` and `invoke()` is split into
`scoped_env: ScopedOperationEnv` (reachability data, populated from the
bundle's `scoped_env`) and `env: Arc<dyn OperationEnv + Send + Sync>`
(dispatch trait object). The split is required by ADR-019's overlay model
— the trait-object design is what enables connection and session overlays
to compose. The `HandlerRegistration` bundle shape, provenance model,
composition authority, and capability injection specified by this ADR
are unchanged.)
- ADR-008: Vault integration point (assembly layer is the trust boundary)
- OQ-19: Session-scoped operation registries (session ops are `Session`
provenance, always `Internal`, compose under restricted authority)
- docs/reviews/001-pre-implementation-architecture-sanity-check.md (findings
C1C4, which this ADR resolves)
- docs/reviews/002-pre-implementation-architecture-sanity-check.md (finding
C6, resolved by ADR-019's `env`/`scoped_env` split)
- `/workspace/@alkdev/flowgraph/README.md` — operation graph, call graph, and
scoped subgraph concepts (the graph model this ADR uses as framing)
- `/workspace/@alkdev/alknet-main/docs/architecture/flowgraph.md` — prior
Rust speccing of flowgraph (incomplete; this ADR uses the model, not the
crate)
- Kernel/user mode analogy: `getaddrinfo` runs under kernel authority, not
the caller's `CAP_NET_RAW`; the curated entry point exists to do things
the user can't, on the user's behalf

View File

@@ -0,0 +1,483 @@
# ADR-019: Operation Registry Layering
## Status
Accepted
## Context
The architecture has two registries that the spec documents previously treated
as sharing one immutability argument:
1. **The endpoint's `HandlerRegistry`** (ALPN string → `ProtocolHandler`).
This is what ADR-010 and OQ-04 are about. Its immutability is load-bearing:
ALPN strings are baked into the TLS `ServerConfig` at startup, so adding a
protocol handler at runtime requires rebuilding the TLS config. This is a
genuine one-way door and the rationale is correct.
2. **The call protocol's `OperationRegistry`** (operation name →
`HandlerRegistration`). This lives *inside* the `CallAdapter`, which is one
`ProtocolHandler` behind the single ALPN `alknet/call`. Adding an operation
to the `OperationRegistry` does **not** touch the TLS `ServerConfig` — the
ALPN is already `alknet/call`, registered once at startup.
`operation-registry.md` stated the operation registry "is immutable after
construction… consistent with OQ-04 and ADR-010." That inheritance was by
analogy, not by shared rationale. The TLS argument that justifies
`HandlerRegistry` immutability does not apply to the `OperationRegistry`. The
operation registry's mutability profile is a separate question, and it has been
answered incorrectly by inheriting a constraint that belongs to a different
registry.
### Why `from_call` breaks the inherited constraint
The import adapters have different lifecycle requirements:
- **`from_openapi` / `from_mcp`** can run at startup — the assembly layer reads
a static spec file or queries a known service before the registry is frozen.
Static import, fits immutability.
- **`from_call`** requires a **live connection** to discover operations
(`services/list` + `services/schema`). Connections happen at runtime.
Workers join and leave dynamically in the machine→worker topology. You
cannot pre-freeze a set you discover over a connection you haven't opened
yet.
So `from_call` is structurally incompatible with "frozen at startup, never
touched again." The pre-ADR-019 spec held two contradictory positions: the
registry is immutable (operation-registry.md), and `from_call` imports remote
operations at connection time (ADR-022). An implementer would have to resolve
the contradiction by guessing — likely by either forcing all `from_call`
imports to happen at startup (awkward, doesn't fit worker topologies) or
quietly making the registry mutable (undermining the stated constraint without
acknowledging it).
### Why immutability is not the load-bearing security control for imported ops
Imported operations (`FromOpenAPI`, `FromMCP`, `FromCall`) are leaves — they
cannot compose (ADR-018 Assumption 5). They have no composition authority, no
scoped env, `Internal` visibility by default, and their trust model is "the
remote endpoint is trusted as much as my own handlers" (ADR-022). Their
reachability from a composing handler is bounded by the *parent handler's*
scoped env, not by their registration timing.
The security controls on imported ops are **provenance** and **composition
authority** — both set at registration, both checked at dispatch. Immutability
is redundant here. An imported op registered at runtime is no more or less
privileged than one registered at startup; it's a forwarding stub either way,
and its capacity to do harm is bounded by what the *composing parent*'s
authority and scoped env permit.
Immutability *is* load-bearing for **curated** operations — the `Local` ops
the assembly layer writes at startup, which *can* compose and therefore *can*
escalate privilege under their own authority. For those, the trust boundary is
"the assembly layer declared them at startup," and immutability is what locks
that declaration. But that's a constraint on `Local` provenance specifically,
not on the registry as a whole.
### The trust-boundary principle
The right axis is not visibility (`Internal` vs `External`) or wire-vs-local —
it is **provenance combined with import timing**, which maps to where each
operation's trust decision is made:
| Provenance | Import timing | Trust boundary | Layer | Lifetime |
|-----------|---------------|----------------|-------|----------|
| `Local` | Startup | Assembly layer at startup | 0 (curated) | Process — immutable |
| `Session` | Sandbox creation | Composing handler at sandbox creation | 1 (session) | Session — dynamic |
| `FromCall` | Connection (runtime) | Remote node at connection time | 2 (connection) | Connection — dynamic |
| `FromOpenAPI` / `FromMCP` | Startup | External endpoint, discovered at startup | 0 (curated) | Process — immutable |
| `FromOpenAPI` / `FromMCP` | Runtime (rare) | External endpoint, discovered at runtime | 2 (discovery) | Discovery-scoped — dynamic |
`FromOpenAPI` / `FromMCP` provenance is **layer-polymorphic**: the same
provenance lands in Layer 0 (immutable) or Layer 2 (dynamic) depending on
when the import happens. The common case is startup import into Layer 0
(Decision 6); runtime import into Layer 2 is permitted but rare.
**Immutability follows the trust boundary.** Operations are mutable at the
scope where their trust decision is made. `Local` ops (and startup-imported
`FromOpenAPI`/`FromMCP`) are trusted at startup → immutable. Session ops
are trusted at sandbox creation → session-scoped dynamic. `FromCall` ops
(and runtime-imported `FromOpenAPI`/`FromMCP`) are trusted at
connection/discovery time → connection/runtime dynamic.
Session ops are the edge case that proves the rule: they are `Internal`
visibility and can compose, but their trust boundary is per-session (the
parent handler grants them restricted authority at sandbox creation, per
ADR-018 Assumption 6), not per-startup. Visibility alone would misclassify
them; provenance correctly identifies them as dynamic.
### The precedent: `IdentityProvider`
The structural problem — *N consumers need to resolve something from M
sources, don't globalize the sources into one pot, don't make each consumer
know about all sources* — is the same problem `IdentityProvider` solves for
auth (ADR-003). An `IdentityProvider` is a trait (`Arc<dyn IdentityProvider>`)
that centralizes resolution policy behind a stable interface; source
composition is an impl detail. Handlers consume the result; the trait owns the
routing.
`OperationEnv` is the same problem one layer over: *N handlers need to
dispatch to operations, operations come from M sources (curated local, this
session, this peer connection, that peer connection), don't globalize all
sources into one mutable pot, don't make each handler know about all sources
and pick the right registry.* The solution is the same shape: a trait —
`Arc<dyn OperationEnv>` — that centralizes dispatch routing behind a stable
interface, with overlay composition as an impl detail.
The alternative — a single global `ArcSwap<OperationRegistry>` into which all
imported ops merge with namespace prefixes — is the registry equivalent of
"every handler reads identity from a global env var." It works at one
connection. At many connections it produces: an unbounded pot, namespace
collisions scaling with connection count, disconnect cleanup requiring a
reverse index (op → owning connection), zero source isolation, and
routing-by-naming-convention instead of routing-by-structure. That is the
failure mode the `IdentityProvider` pattern exists to prevent.
## Decision
### 1. The operation registry is layered by trust boundary
The `OperationRegistry` is not a single flat map. It is a layered structure
where each layer corresponds to a trust boundary:
```
Layer 0 — Curated (static, immutable, startup trust boundary)
Local provenance operations from the assembly layer.
Registered once at startup, never mutated for the process lifetime.
This is where immutability is load-bearing: these ops can compose,
therefore can escalate privilege under their own authority. The
startup trust boundary + immutability is the security control.
Layer 1 — Session (dynamic, per-session, sandbox-creation trust boundary)
Session provenance operations, agent-written, sandboxed.
Created and destroyed with each session.
Already specified by OQ-19 as an overlay on Layer 0.
Layer 2 — Imported (dynamic, per-connection, peer trust boundary)
FromCall operations discovered when a peer connects.
FromOpenAPI / FromMCP operations when imported at runtime (rare;
usually at startup into Layer 0, but runtime import is permitted).
Created and destroyed with the connection / discovery event.
```
Layers 1 and 2 are the same shape: **per-scope dynamic overlays on the static
curated base.** The scope is "session" for Layer 1 and "connection" (or
"discovery event") for Layer 2. OQ-19 already specified the overlay mechanism
for Layer 1 (session env wraps global env via `OperationEnv` trait layering).
This ADR generalizes the same mechanism to Layer 2.
### 2. The `OperationEnv` trait is the integration point
`OperationContext.env` is `Arc<dyn OperationEnv + Send + Sync>` — a trait
object, not a concrete struct. This is required by the overlay model: a
composite env (curated base + connection overlay + session overlay) is built
by composing `OperationEnv` impls, not by merging registries.
This resolves review #002 finding C6 (`OperationContext.env` type identity
crisis). The pre-ADR-019 spec had `env: OperationEnv` (a trait, which can't
be a field without `dyn`) and used the same field as both a reachability set
(`parent.env.allows()`) and a dispatch trait (`context.env.invoke()`). One
field cannot be both. The split:
- `scoped_env: ScopedOperationEnv` — reachability data. Populated from the
registration bundle's `scoped_env` (ADR-018). The reachability check in
`invoke()` consults `parent.scoped_env.allows(&name)`.
- `env: Arc<dyn OperationEnv + Send + Sync>` — dispatch trait. The handler
calls `context.env.invoke(...)`; the trait impl routes to the right
overlay.
This is the `IdentityProvider`-shaped integration point: handlers consume
the trait; source composition is an impl detail.
### 3. The `CallAdapter` composes the root env per incoming call
When a `call.requested` arrives over connection C, the `CallAdapter` does
not look up the operation in a single global registry. It composes the root
`OperationContext.env` from the layers active for this call:
```
root env = CompositeOperationEnv {
base: curated_registry_env, // Layer 0 — static
connection: C.imported_operations, // Layer 2 — this connection's overlay
session: active_session_overlay, // Layer 1 — if a session is active
}
```
The composite impl checks overlays in order (session first, then connection,
then curated base) and dispatches to the first match. This is structural
source binding: a handler composing `worker/exec` reaches it via the
connection overlay that contains it, not via a naming convention in a
global pot.
**Env inheritance through composition**: the child's `env` is
`parent.env.clone()` — an `Arc::clone`, not a re-composition. Overlay
composition happens once at the root (in `build_root_context`) and
propagates by `Arc` through the composition tree. A child handler sees the
same active overlays its parent saw. This is deliberate: re-composing per
`invoke()` would re-resolve overlays on every dispatch and would break the
session-overlay case (a session that was active when the parent ran must
still be active for the child, even if the session ended mid-composition —
the child is part of the same call tree the parent started). The root env
is composed per incoming call; nested calls inherit it by `Arc::clone`.
When connection C disconnects, its overlay is dropped. Operations imported
from C vanish from the reachable set with no global mutation and no reverse
index. Handlers that try to compose a now-gone op receive `NOT_FOUND` (if
the overlay was already dropped when `invoke()` runs the reachability
check) or a connection error with code `INTERNAL` (if the call was
dispatched to the forwarding handler and the connection drops mid-flight).
Both cases are clean failures — no stale-handler-binds-to-dead-connection
hazard.
### 4. Curated operations remain immutable; imported and session ops are dynamic
The blanket immutability claim in `operation-registry.md` is replaced by:
- **Layer 0 (curated, `Local`)**: immutable after startup. The
`OperationRegistry` holding curated ops is constructed once by the
assembly layer and never mutated. This is where the security argument for
immutability applies: composing ops are privileged, the startup trust
boundary is where that privilege is granted, immutability locks it.
- **Layer 1 (session, `Session`)**: dynamic, per-session. Created at sandbox
creation, destroyed at session end. Already specified by OQ-19.
- **Layer 2 (imported, `FromCall` etc.)**: dynamic, per-connection. Created
when a peer connection completes `from_call` discovery, destroyed when the
connection closes.
Adding a `Local` op at runtime is not supported — it would require re-entering
the startup trust boundary, which is a deployment (restart), not a runtime
operation. This preserves the security property ADR-010/OQ-04 were concerned
with, scoped to where it actually applies.
### 5. `from_call` imports into the connection's overlay, not the global registry
The `from_call` adapter (ADR-022) discovers operations on a remote peer and
produces `HandlerRegistration` bundles. Under ADR-019, those bundles are
registered into the **connection's overlay**, not a global mutable registry.
```rust
// On CallConnection establishment:
let imported = from_call(&connection, config).await;
connection.imported_operations.extend(imported);
// The connection's env now includes these ops.
```
The handler closures produced by `from_call` capture the `CallConnection`
when the connection drops, the handlers become unreachable (their env is
dropped), and any in-flight calls to them return connection errors. This is
the natural lifecycle; no explicit deregistration is needed.
### 6. `from_openapi` and `from_mcp` default to startup import into Layer 0
For the common case — the assembly layer imports a static OpenAPI spec or
connects to a known MCP server at startup — `from_openapi` / `from_mcp`
register into the curated (Layer 0) registry, which is then frozen. This
preserves the pre-ADR-019 behavior for the case where it was correct.
Runtime `from_openapi` / `from_mcp` import (e.g., discovering an MCP server
at connection time) is permitted and follows the Layer 2 model — the imported
ops live in a connection/discovery-scoped overlay. This is additive and
does not affect the startup-import path.
### 7. OQ-04 scope clarification and OQ-19 generalization
This ADR amends OQ-04 to scope its immutability claim to the
**`HandlerRegistry`** (ALPN-level, ADR-010). The `OperationRegistry`'s
mutability profile is now governed by this ADR: curated (Layer 0) is
immutable; session and imported layers are dynamic at their trust-boundary
scopes. See the OQ-04 amendment in `open-questions.md`.
This ADR generalizes OQ-19's session-overlay mechanism to also cover
connection-scoped remote imports. Both are per-scope dynamic overlays on the
static curated base, composed into the per-call `OperationContext.env` by
the `CallAdapter`. `OperationEnv` being a trait object is what enables
both. See the OQ-19 resolution update in `open-questions.md`.
## Consequences
**Positive:**
- `from_call` has a coherent home. Imported ops live with the connection
that produced them, appear when the connection is established, and
disappear when it closes. No contradiction with immutability, no awkward
"import everything at startup" workaround.
- The immutability argument is now correctly scoped. Layer 0 (curated,
composing ops) is immutable because that's where the security control
applies. Layers 1 and 2 are dynamic because their trust boundaries are
per-scope. An implementer reading the spec sees the right constraint in
the right place, instead of a blanket claim that doesn't fit all cases.
- The `OperationEnv`-as-trait constraint (OQ-19) is now required by the
overlay model, not just by the session-overlay pattern. The same
mechanism (trait layering) supports both session overlays and connection
overlays — one pattern, two scopes. This makes C6's resolution
(`env: Arc<dyn OperationEnv>`) structurally motivated, not just a
type-system cleanup.
- Disconnect handling is structural. A connection drops → its overlay drops
→ its ops vanish from the reachable set. No `ArcSwap` coordination, no
reverse index from op to owning connection, no stale handlers bound to a
dead connection. This is the same lifecycle property session overlays
already have (session ends → session overlay drops).
- Source isolation is structural. Imported ops from peer X are only
reachable from handlers whose `OperationEnv` is wired to X's overlay.
They are not globally callable. A handler that shouldn't be able to
reach peer X's ops simply doesn't have X's overlay in its env. This is
better hygiene than a global registry with namespace prefixes, where
every handler sees every imported op and isolation is a naming
convention.
- The `IdentityProvider` precedent makes the design legible. A future
reader sees "trait-object integration point, source composition as impl
detail" and recognizes the pattern; they don't have to re-derive why
trait-composed overlays were chosen over a global mutable registry.
**Negative:**
- The dispatch path is a composite lookup (session → connection → curated)
rather than a single `HashMap` lookup. This is a small constant cost —
three hash lookups in the worst case instead of one — and the overlays are
small (a session's ops, a connection's imported ops). The common case
(composing a curated op) hits Layer 0 after two empty-overlay misses, which
is a predictable and cache-friendly path. The cost is justified by the
source isolation and lifecycle properties it buys.
- `OperationContext.env` is now `Arc<dyn OperationEnv + Send + Sync>`, which
is a trait object with dynamic dispatch. This is the same cost as
`Arc<dyn IdentityProvider>` — a vtable call per `invoke()`. Negligible
relative to the work an operation does, and the same pattern the codebase
already uses for auth.
- The `CallAdapter` has more responsibility: it composes the root env per
call from the active layers, rather than handing every call the same
global registry. This is expected — the CallAdapter is the integration
point for the call protocol, and per-call env composition is the same
shape as per-call identity resolution (which the CallAdapter already does
via `IdentityProvider`).
- Naming across overlays: if two connections import ops with the same name
(e.g., both peers expose `worker/exec`), the composite env dispatches to
the first overlay that contains the name. This is the same ambiguity
`FromCallConfig`'s namespace prefix (ADR-022) was designed to address —
the caller disambiguates with a prefix at import time. ADR-019 does not
change this; it makes the disambiguation structural (which overlay is in
the env) rather than nominal (which prefix is in the name).
- The blanket immutability claim in `operation-registry.md` and the
cross-references that inherit it (the "Two-way door —
`ArcSwap<OperationRegistry>` can be added later" note, OQ-04's framing)
must be updated. This is a spec edit, not a migration — no implementation
exists yet.
**On review #002 findings resolved by this ADR:**
- **C6** (`OperationContext.env` type identity crisis): resolved by Decision 2.
The field is split into `scoped_env` (reachability data) and `env` (dispatch
trait object). The split is structurally motivated by the overlay model,
not just a type-system cleanup.
- **W4** (hot-swap ↔ registry mutability coupling): localized to the
connection scope. There is no global mutable registry to hot-swap.
Overlays are per-scope and replace naturally with connect/disconnect and
session start/end. The schema-drift hazard (a peer re-runs
`services/list` on reconnect and re-imports with a changed schema) moves
from global to per-connection — it does not vanish. A handler
mid-composition whose peer reconnects with a changed schema sees the old
schema until the overlay is rebuilt. This is a per-connection concern,
not a global one; the guard clause the review asked for becomes a note on
overlay rebuild semantics rather than a global hot-swap protocol.
- **W3** (CallClient registry security dimension): partially addressed. The
*registry-shape* sub-question is resolved by the overlay model — a
`CallClient`'s incoming-call dispatch uses the same overlay composition,
and sharing the curated base with a remote peer is fine (curated ops are
trusted). The *capability-exposure* sub-question (a remote peer calling
`/llm/generate` uses the local node's API key) is **not resolved by this
ADR** — it is a separate concern about what capabilities a remote peer
can trigger, and it is unaffected by the registry shape. That sub-question
remains open for ADR-022 (a guard-clause note: a peer-scoped subset must
filter by capability remote-safety, not just operation name). ADR-019
resolves the dispatch shape; ADR-022 retains the capability-exposure
decision.
## Assumptions
1. **Provenance is knowable at registration time and stable for the
registration's lifetime.** A `Local` op does not become `FromCall` later;
a `FromCall` op does not become `Local`. If a remote-imported op is later
"promoted" to curated, that's a re-registration at the next startup
(deployment), not a runtime mutation. Inherited from ADR-018 Assumption 2.
2. **Layer 0 immutability is the security control for composing ops.** The
pre-ADR-019 blanket immutability claim was overbroad but not wrong about
`Local` ops. Curated composing ops must be immutable because the startup
trust boundary is where their authority is granted. This ADR narrows the
claim, it does not remove it.
3. **Imported and session ops do not need immutability as a security
control for privilege escalation.** Their security against privilege
escalation is bounded by provenance (no composition authority → no
privilege escalation) and by the parent handler's scoped env
(reachability control). This is the central argument; if it's wrong —
if a `from_call` op can escalate in some way provenance + scoped env
don't bound — the model needs revisiting. **Immutability is not the
control for non-escalation threats** (availability, schema drift):
availability is bounded by per-handler timeouts (ADR-020) and the
connection's overlay being drop-on-disconnect; schema drift on
reconnect is a per-connection overlay-rebuild concern (see W4 in
Consequences), not a global-registry-mutation concern. The point of
scoping immutability to Layer 0 is that immutability is the right
control *for composing ops* and the wrong control *for non-composing
ops*; it is not a claim that non-composing ops face no threats.
4. **A connection's overlay is the right scope for `from_call` imports.**
Operations discovered from peer X are reachable from handlers whose env
includes X's overlay. If a use case requires imported ops to be globally
reachable (every handler sees every peer's ops), the composite env can be
built to include all active connection overlays — but the default is
per-connection scoping for isolation.
5. **Disconnect → overlay drop → op vanishes is acceptable behavior.** A
handler composing an op whose peer has disconnected receives `NOT_FOUND`
(or a connection error if the in-flight call was mid-dispatch). This is
the same behavior as a peer that never exposed the op. If a use case
requires disconnected-peer ops to remain reachable (e.g., cached results),
that's a handler-level caching concern, not a registry concern.
6. **The root env is composed per incoming call, not cached per
connection.** The active session overlay can change during a connection's
lifetime (a session starts or ends mid-connection), so the env cannot be
composed once at connection establishment and reused. `build_root_context`
runs per `call.requested` and composes the env from the layers active at
that moment. The cost (constructing an `Arc<CompositeOperationEnv>` per
call) is negligible — it's three `Arc::clone`s, not three registry
traversals.
7. **Session-overlay attachment is an agent-crate concern.** ADR-019
generalizes OQ-19's session overlay to also cover connection overlays,
but the mechanism by which a session overlay attaches to a given wire
call (session ID in metadata, payload field, connection-bound session
state, etc.) is not specified here. The `CallAdapter` is wired with an
optional session-overlay source by the assembly layer; the lookup
mechanism belongs to the agent crate spec (OQ-19: "the agent-specific
mechanism belongs to the agent crate spec"). If a wire call has no
active session, the root env is `curated base + connection overlay`
(no session layer).
## References
- ADR-010: ALPN router and endpoint (the `HandlerRegistry` immutability
argument — this ADR clarifies that it applies to the ALPN registry, not
the operation registry)
- ADR-010: Secret material flow and capability injection (capabilities are
per-`HandlerRegistration` bundle, not per-registry — the overlay model
doesn't change how capabilities flow; an imported op's capabilities come
from its bundle, which for `from_call` is whatever the assembly layer
granted the import)
- ADR-022: Call protocol client and adapter contract (`from_call` adapter;
the `FromCallConfig` namespace prefix is the disambiguation mechanism this
ADR's overlay model uses structurally)
- ADR-018: Handler registration, provenance, and composition authority
(provenance is the axis this ADR's layering is based on; the
`HandlerRegistration` bundle shape is unchanged)
- ADR-003: Auth as shared core (`IdentityProvider` — the precedent for the
trait-object integration point pattern this ADR applies to `OperationEnv`)
- OQ-04: Dynamic handler registration (this ADR amends OQ-04 to scope it to
the `HandlerRegistry`; the operation registry's mutability is now governed
by ADR-019)
- OQ-19: Session-scoped operation registries (this ADR generalizes the
session-overlay mechanism to connection overlays — same pattern, two
scopes)
- docs/reviews/002-pre-implementation-architecture-sanity-check.md
(findings C6, W3, W4 — resolved by this ADR)

View File

@@ -0,0 +1,257 @@
# ADR-020: Abort Cascade for Nested Calls
## Status
Accepted
## Context
The call protocol allows handlers to compose other operations through
`OperationEnv::invoke()`. This creates a call tree: a parent request spawns
children (via `parent_request_id`), which may spawn their own children. The
tree is the agency chain (ADR-017) — principal delegates to agent, agent may
delegate to sub-agent.
When `call.aborted` arrives for a parent request, the current `PendingRequestMap`
removes only that single entry. The children are unaware — they continue running,
consuming resources, and potentially producing side effects. This is the nested
abort problem:
```
Client calls /agent/chat (r1)
agent handler calls /fs/readFile via env.invoke (r1-a)
fs handler calls /db/query via env.invoke (r1-a-1)
agent handler calls /bash/exec via env.invoke (r1-b)
Client aborts r1 (call.aborted { id: "r1" })
→ r1 removed from PendingRequestMap
→ r1-a, r1-a-1, r1-b continue running (ghost work)
→ bash/exec keeps executing (unwanted side effect)
→ db/query keeps running (wasted resources)
→ results produced that nobody consumes
```
The `@alkdev/flowgraph` TypeScript package solved this with a directed graph
that tracks the call tree and a `FailurePolicy` enum:
- `"abort-dependents"`: aborting a node cascades to all non-terminal descendants.
This is the "whole tree should abort" behavior.
- `"continue-running"`: only idle/waiting dependents are aborted; started ones
keep going. New ones don't start because their predecessors failed/aborted.
The agent use case makes this concrete and urgent: an LLM composes deep, dynamic
call trees (parallel tools, sequential tools, sub-agents calling sub-tools).
Aborting a chat should tear down the entire tree — the LLM HTTP stream, all tool
calls, all sub-calls. But this is a protocol-level concern, not an agent feature:
every consumer (NAPI adapter, Python adapter, any service speaking EventEnvelope)
inherits whatever abort model the protocol defines. The call protocol is a
general-purpose cross-boundary RPC mechanism; nested composition is a core
protocol feature, and abort semantics for that composition are protocol semantics.
## Decision
### 1. `call.aborted` cascades to descendants
When `call.aborted` arrives for a request, the protocol cascades the abort to
all non-terminal descendants in the call tree (identified via `parent_request_id`).
Each descendant receives a `call.aborted` event. The `PendingRequestMap` removes
all affected entries.
The cascade is protocol-level: the event schema carries cascade semantics. A
`call.aborted` for a parent implies abort of all descendants. This is not a
client-side convention — the server (CallAdapter) is responsible for discovering
descendants and propagating the abort.
### 2. Default policy: `abort-dependents`
The default policy is `abort-dependents`: aborting a request aborts everything
downstream, regardless of branch. This is the correct default because aborted
parent work has no consumer waiting for results — continuing is wasted work at
best and unwanted side effects at worst (e.g., a `bash/exec` that keeps running
after the caller stopped caring, a DB mutation that completes after the
transaction was aborted).
### 3. Opt-in policy: `continue-running`
An opt-in `continue-running` policy is available for cases where long-running
work should survive a parent's abort. Under `continue-running`:
- Descendants that have already started (status: running) continue to completion.
- Descendants that haven't started yet (status: pending/waiting) are aborted
(their predecessors failed, so they can't proceed).
- No new descendants start (the parent is gone).
Use cases for `continue-running`: a long-running subscription that should keep
streaming after its parent's sibling failed; a background task that was spawned
by a handler and should survive the handler's abort.
The caller or handler specifies the policy at call time. The policy is set
on the `OperationContext` and propagated to children via `OperationEnv::invoke()`
— see Decision 6 below. The default is `abort-dependents`; `continue-running`
is an opt-in for long-running work that should survive a parent's abort.
### 4. Cleanup hooks
When a call is aborted, handlers need a mechanism to clean up resources: cancel
an HTTP stream, cancel a honker queue job, close a file handle, release a lock.
The protocol provides this through the call lifecycle — when a call is aborted,
the handler's task is cancelled (in Rust, the future is dropped). Cleanup is
handled by `Drop` implementations on resource guards, or by explicit
cancellation callbacks if the handler registers them.
This is a handler-level concern, not a protocol-level one. The protocol's job is
to cascade the abort; the handler's job is to clean up when cancelled. The
mechanism (tokio `CancellationToken`, `Drop` guards, explicit callbacks) is a
two-way door for implementation.
### 5. The call tree is tracked via `parent_request_id`
The call tree is already recorded: `OperationContext.parent_request_id` links
each call to its parent. The cascade mechanism walks this tree to find
descendants. No separate graph structure is required at the protocol level —
the `PendingRequestMap` can index entries by `parent_request_id` to enable
efficient descendant lookup.
The `@alkdev/flowgraph` package (directed graph with `descendants()`,
reactive status propagation, `FailurePolicy`) is prior art and may be adapted
as a separate Rust crate for consumers that need richer call-tree visualization
or reactive status tracking. It is not required for the protocol-level cascade
— a parent-indexed map suffices.
### 6. The abort policy is set on `OperationContext`, not on the wire payload
The abort policy (`abort-dependents` vs `continue-running`) is set on
`OperationContext` and propagated to children via `OperationEnv::invoke()`.
It is NOT a field in the `call.requested` wire payload, and it is NOT a
per-operation declaration on `OperationSpec`.
**Why not the wire payload**: the wire caller doesn't know the composition
tree. The caller of `/agent/chat` cannot meaningfully decide whether
`/fs/readFile` (composed internally by the agent handler) should survive an
abort — the handler that composes the child knows that, not the wire caller.
Putting the policy on the wire payload would give the wire caller control
over internal composition behavior it can't see.
**Why not per-operation declaration**: Assumption 5 says the policy
is per-call, not per-operation. The same operation may need
`abort-dependents` in one composition context and `continue-running` in
another. A static property on `OperationSpec` can't express that.
**How it works on `OperationContext`**: the root context
(`build_root_context` in the CallAdapter) gets the default policy
(`abort-dependents`). When a handler composes a child via
`env.invoke()`, it can specify the policy for that child:
```rust
// Default: abort-dependents (child aborts if parent aborts)
context.env.invoke("fs", "readFile", input, &context).await
// Opt-in: continue-running (child survives parent's abort)
context.env.invoke_with_policy(
"fs", "readFile", input, &context, AbortPolicy::ContinueRunning
).await
```
The child's `OperationContext` carries the policy. If the child itself
composes grandchildren, the policy **propagates by inheritance** — the
grandchild inherits the child's policy (which was the parent's policy,
unless the parent overrode it for the child via `invoke_with_policy`).
`ContinueRunning` does auto-propagate to grandchildren: if a parent opts
its child into `ContinueRunning`, and the child composes grandchildren
without explicitly overriding, the grandchildren also get
`ContinueRunning`. This is consistent with the composition authority and
scoped env propagation in ADR-018 — the parent handler decides the
child's runtime context, including abort policy, and that decision
propagates through the composition tree by default.
**Review #002 W19 resolution**: `invoke()` with no explicit policy
argument inherits the parent's current policy (option a). It does **not**
reset to `AbortDependents`. A handler that wants a child to reset to the
default must explicitly call `invoke_with_policy(...,
AbortPolicy::AbortDependents)`. This makes the propagation predictable:
the policy I set for my child applies to my child's children unless they
re-decide. The `invoke()` default in operation-registry.md
(`abort_policy: parent.abort_policy.clone()`) is correct.
The `OperationEnv` trait gains an optional policy parameter. The specific
API shape (a separate `invoke_with_policy` method, a policy field on an
`InvokeOptions` struct, or a builder pattern) is a two-way door for
implementation — but the policy enters through `OperationEnv::invoke()`,
not through the wire and not through `OperationSpec`.
## Consequences
**Positive:**
- No ghost work. Aborting a parent call tears down the entire tree. Resources
are released, side effects are halted, no results are produced for absent
consumers.
- The default (`abort-dependents`) matches the intuitive expectation: if I
stop caring about the parent, I stop caring about everything it spawned.
- The opt-in (`continue-running`) covers the legitimate exception (long-running
work that should survive) without making it the default.
- The protocol carries cascade semantics, so every consumer inherits the
correct behavior — no consumer needs to implement its own abort propagation.
- The `parent_request_id` chain already exists; the cascade mechanism is an
index on it, not a new data structure.
- Cleanup hooks are handled by Rust's async drop semantics — dropping the
handler's future cancels it, and `Drop` guards release resources. This is
idiomatic Rust, not a custom mechanism.
**Negative:**
- The `PendingRequestMap` needs a parent-indexed lookup (a `HashMap<String,
Vec<String>>` from parent_request_id to child request_ids, or a scan). This
is a minor implementation cost, not a protocol change.
- The `call.aborted` event schema carries cascade semantics — clients that
don't understand cascade (future versions, other implementations) would
need to handle it. Mitigated: cascade is server-side (the CallAdapter walks
the tree and sends `call.aborted` per descendant), so clients see individual
abort events regardless of whether they understand the cascade concept.
- The `continue-running` policy adds a parameter to the call lifecycle. The
specific location (payload field, context field, per-operation declaration)
is a two-way door, but the existence of the policy is a one-way commitment.
## Assumptions
1. **Aborting a parent should abort descendants by default.** If the default
should be `continue-running` (descendants survive), this ADR is wrong. The
assumption is that ghost work is worse than premature cancellation — a
cancelled descendant can be retried, but a ghost process consuming
resources and producing unwanted side effects is harder to recover from.
2. **The server (CallAdapter) is responsible for cascade.** The client sends
`call.aborted` for one request ID; the server discovers descendants and
propagates. If the client were responsible for cascading, it would need to
know the full tree — which it may not (server-side composition creates
children the client never saw).
3. **`parent_request_id` is sufficient to discover descendants.** The call tree
is a tree (acyclic, single parent per node). If future composition patterns
create multi-parent relationships (e.g., a shared subcall invoked by two
parents), the cascade model needs extension. The assumption is that
composition creates a tree, not a DAG.
4. **Dropping the handler's future is sufficient for cleanup.** Rust's async
drop semantics cancel the future and run `Drop` guards. If a use case
requires explicit cleanup callbacks (e.g., external systems that need a
signal), the mechanism needs extension. The assumption is that `Drop`
guards cover the common cases (HTTP stream cancellation, file handle
release, lock release).
5. **`continue-running` is per-call, not per-operation.** The policy is
specified at call time via `OperationEnv::invoke()`, not declared at
registration on `OperationSpec` and not set by the wire caller. The
composing handler decides the child's policy based on the specific
context. See Decision 6.
## References
- ADR-015: Call protocol stream model (bidirectional streams, EventEnvelope,
ID-based correlation)
- ADR-017: Privilege model (the call tree is the agency chain —
`parent_request_id` traces principal → agent)
- OQ-17: Abort cascade semantics (resolved by this ADR)
- OQ-19: Session-scoped registries (session-scoped operations are in the call
tree and participate in cascade)
- `@alkdev/flowgraph` TypeScript package — prior art for call-graph tracking
with `descendants()`, `FailurePolicy`, reactive status propagation
- [call-protocol.md](../crates/call/call-protocol.md)
- [operation-registry.md](../crates/call/operation-registry.md)

View File

@@ -0,0 +1,337 @@
# ADR-021: Streaming Handler for Subscription Operations
## Status
Accepted
## Context
The call protocol defines `Subscription` as a first-class operation type
(ADR-015 lists `subscribe` as one of four top-level protocol operations;
`OperationSpec.op_type` includes `Subscription`). The wire protocol supports
streaming: five event types (`call.requested`, `call.responded`,
`call.completed`, `call.aborted`, `call.error`), `PendingRequestMap::Subscribe`
with an mpsc channel, `CallConnection::subscribe()` returning
`impl Stream<Item = ResponseEnvelope>`, and a full streaming-subscribe example
in `call-protocol.md`. The **client side** works — a client can subscribe to a
remote stream and consume `call.responded` events until `call.completed`.
The **server side does not.** The `Handler` type in `alknet-call` is:
```rust
pub type Handler = Arc<
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
```
It returns a single `ResponseEnvelope`. `OperationRegistry::invoke()` returns
one `ResponseEnvelope` and closes. `Dispatcher::handle_stream` calls
`dispatch_requested``registry.invoke()` → writes one `EventEnvelope` frame →
loops. A `Subscription` operation that should produce a *stream* of
`call.responded` events followed by `call.completed` has no way to express that
through this handler signature.
This is a **spec gap that should not have shipped.** The TypeScript predecessor
(`@alkdev/operations`, from which the Rust port was derived) had two distinct
handler types:
```typescript
type OperationHandler<I, O, C> = (input: I, context: C) => Promise<O> | O;
type SubscriptionHandler<I, O, C> = (input: I, context: C) => AsyncGenerator<O, void, unknown>;
```
The TS registry (`registry.ts:21`) stored them as a union, validated at
registration that `SUBSCRIPTION` ops get an `AsyncGeneratorFunction`, and the
server-side dispatch (`call.ts:341-349`, `buildCallHandler`) branched on
`op_type`: `SUBSCRIPTION` → iterate the async generator, `respond()` for each,
then `complete()`; else → `execute()`, single `respond()`. The Rust port
collapsed the union into a single `Handler` returning one `ResponseEnvelope`,
losing the streaming path. The fix is to restore it.
The downstream consequences of the gap:
- **`/subscribe` HTTP endpoint** (`GatewayDispatch::invoke()`
`subscribe_handler`) wraps a single `ResponseEnvelope` in a one-event SSE
stream. A real `Subscription` operation (e.g., `agent/chat` streaming LLM
tokens) cannot stream through it.
- **`from_call` forwarding** for a `Subscription` op calls
`CallConnection::call_with_payload()` (single response), not
`CallConnection::subscribe()` (stream). A `from_call`-imported subscription
truncates to the first value.
- **`from_openapi` forwarding** for a `text/event-stream` response returns one
`ResponseEnvelope` instead of streaming the SSE chunks.
All three are symptoms of the same root cause: the `Handler` type cannot
produce a stream.
## Decision
### 1. `StreamingHandler` type (alongside `Handler`)
Add a streaming handler type that returns a stream of `ResponseEnvelope`s,
mirroring the TS `SubscriptionHandler` / `OperationHandler` split:
```rust
pub type StreamingHandler = Arc<
dyn Fn(Value, OperationContext)
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
```
Each `Ok(value)` in the stream becomes a `call.responded` event. An `Err`
becomes a `call.error` event (terminal — the stream ends). Natural stream end
becomes `call.completed`. The dispatch path converts each `ResponseEnvelope` to
`EventEnvelope` exactly as it does today for the single-response case — no new
wire-format concept is introduced.
A `make_streaming_handler()` helper (analogue of `make_handler()`) wraps an
async generator / stream-producing closure into a `StreamingHandler`.
### 2. `HandlerKind` enum on `HandlerRegistration`
```rust
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
}
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: HandlerKind, // validated against spec.op_type at registration
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>,
pub scoped_env: Option<ScopedPeerEnv>,
pub capabilities: Capabilities,
}
```
Registration validates: `Query` / `Mutation``HandlerKind::Once`;
`Subscription``HandlerKind::Stream`. Mismatch is a startup error (same as
the TS `validateSubscriptionHandler`). The enum makes the "one or the other,
matching `op_type`" invariant type-level rather than two `Option`s validated at
runtime.
### 3. `OperationRegistry::invoke_streaming()`
```rust
impl OperationRegistry {
/// Dispatch a Subscription operation. Returns a stream of
/// ResponseEnvelopes. Errors (not-found, forbidden, invalid operation
/// type) yield a single ResponseEnvelope::error and end the stream.
pub fn invoke_streaming(
&self,
name: &str,
input: Value,
context: OperationContext,
) -> BoxStream<ResponseEnvelope>;
}
```
`invoke_streaming()` performs the same visibility + ACL checks as `invoke()`,
then dispatches to the `StreamingHandler`. Pre-handler errors (not-found,
forbidden) produce a single error `ResponseEnvelope` and end the stream
(matching the single-response path's behavior, just on a stream).
### 4. `OperationRegistry::invoke()` errors on `Subscription`
`invoke()` is the request/response dispatch path. Calling it on a
`Subscription` op is a type mismatch — a streaming operation dispatched through
the request/response path. It returns a `ResponseEnvelope` carrying
`CallError { code: "INVALID_OPERATION_TYPE", ... }` (a new protocol-level
code):
```
INVALID_OPERATION_TYPE
```
(`retryable: false`, `details: None`). This is the wire-format addition: a
sixth protocol-level error code. It signals "you called the wrong dispatch
method for this operation's type" — distinct from `INVALID_INPUT` (schema
mismatch) and `INTERNAL` (handler failure). Clients should treat unknown codes
as `INTERNAL` with `retryable: false` (the existing rule); `INVALID_OPERATION_
TYPE` is a permanent client-side programming error, not a transient failure.
### 5. `OperationEnv::invoke()` errors on `Subscription`
`OperationEnv::invoke()` (composition) stays request/response-only. It returns
a single `ResponseEnvelope`. Calling it on a `Subscription` op produces the
same `INVALID_OPERATION_TYPE` error — composition cannot truncate a stream to
its first value. This is a clean architectural boundary, not a deferral:
- **`OperationEnv` composition** is "call a child operation, get a result"
(the `OperationHandler` model). It is request/response by construction.
- **Stream composition** (filter, map, combine, window, dedupe) is a
handler-level concern. A handler that produces a stream transforms it with
stream operators at the handler level, not through `OperationEnv`. The
`@alkdev/pubsub` `operators.ts` is the prior art for this model: 13 operators
(`filter`, `map`, `take`, `batch`, `dedupe`, `window`, `chain`, `join`, etc.)
that operate on `AsyncIterable<T>`, distinct from the request/response
composition. In Rust, the analogues operate on `BoxStream<T>`.
- No `invoke_streaming()` is added to `OperationEnv`. The protocol composition
surface is request/response; stream manipulation is handler-internal.
### 6. Server-side dispatch branches on `op_type`
`Dispatcher::handle_stream` / `dispatch_requested` gains a branch on
`op_type`:
- `Subscription``registry.invoke_streaming()` → for each `ResponseEnvelope`
in the stream, write `EventEnvelope` to the wire → write `call.completed` on
stream end.
- `Query` / `Mutation``registry.invoke()` → write one `EventEnvelope`
(existing path, unchanged).
The streaming branch sets `deadline: None` for subscriptions (unbounded —
already specced in `call-protocol.md` Timeouts) and wires abort cascade
(ADR-020): if `call.aborted` arrives for a streaming request, the stream is
dropped (Rust `Drop` releases the handler's resources).
### 7. `GatewayDispatch::invoke_streaming()` (alknet-http)
The shared dispatch spine gains a streaming variant:
```rust
impl GatewayDispatch {
pub async fn invoke_streaming(
&self,
identity: Option<Identity>,
op: &str,
input: Value,
) -> BoxStream<ResponseEnvelope>;
}
```
`invoke_streaming()` builds the root `OperationContext` identically to
`invoke()` (same security invariants: `internal: false`, `forwarded_for:
None`, same capabilities, same `scoped_env`), then calls
`registry.invoke_streaming()`. The two gateways (`to_openapi`, `to_mcp`)
diverge only on wire-framing; the security axis is provably identical between
`invoke()` and `invoke_streaming()`.
The HTTP `/subscribe` handler calls `invoke_streaming()` and pipes the
`BoxStream<ResponseEnvelope>` to SSE: each `Ok(value)` → SSE `data:` frame,
`Err` → SSE error event + close, stream end → close. This replaces the current
one-event `subscribe_stream_from_envelope` with the real streaming path.
### 8. `from_call` stream forwarding
The `from_call` forwarding handler construction branches on `op_type` during
discovery:
- `Query` / `Mutation` → existing `make_forwarding_handler()` (calls
`CallConnection::call_with_payload()`, returns single `ResponseEnvelope`),
registered as `HandlerKind::Once`.
- `Subscription` → new `make_streaming_forwarding_handler()` (calls
`CallConnection::subscribe()`, returns `impl Stream<Item =
ResponseEnvelope>`, maps to `BoxStream<ResponseEnvelope>`), registered as
`HandlerKind::Stream`.
A `from_call`-imported `Subscription` op forwards the remote stream end-to-end:
the client-side `CallConnection::subscribe()` (already working) feeds a
`StreamingHandler` that produces the stream. No truncation, no first-value
fallback.
### 9. `from_openapi` SSE forwarding
The `from_openapi` forwarding handler construction branches on `op_type`
(determined by `detectOperationType``text/event-stream` response →
`Subscription`):
- `Query` / `Mutation` → existing forwarding handler (single HTTP request →
single `ResponseEnvelope`), `HandlerKind::Once`.
- `Subscription` → streaming forwarding handler (HTTP request → SSE response
stream → parse SSE chunks → `BoxStream<ResponseEnvelope>`), `HandlerKind::
Stream`.
The SSE parsing reuses the TS `parseSSEFrames` pattern: each SSE `data:` frame
becomes a `ResponseEnvelope::ok()`, SSE stream end becomes stream end (→
`call.completed`).
## Consequences
**Positive:**
- `Subscription` operations work end-to-end: server-side handler →
server-side dispatch → wire → HTTP `/subscribe` SSE → `from_call`
forwarding → `from_openapi` SSE forwarding. No truncation, no broken paths.
- The `Handler` / `StreamingHandler` split mirrors the TS prior art exactly,
making the Rust port faithful to its source.
- `HandlerKind` makes the "one or the other, matching `op_type`" invariant
type-level (a `Once` variant for `Query`/`Mutation`, a `Stream` variant for
`Subscription`) rather than a runtime check on two `Option`s.
- Existing handlers (echo, discovery, from_openapi Query/Mutation, from_mcp,
from_call Query/Mutation) are unchanged — they return a single
`ResponseEnvelope` and register as `HandlerKind::Once`. The streaming path
is additive to the existing handler surface.
- `OperationEnv` composition stays request/response, preserving the
composition model's simplicity. Stream composition is a handler-level
concern, cleanly separated.
- The new `INVALID_OPERATION_TYPE` protocol code catches dispatch-path misuse
(calling `invoke()` on a `Subscription`) at the protocol level instead of
silently producing wrong behavior.
**Negative:**
- `HandlerRegistration.handler` changes type from `Handler` to `HandlerKind`.
Existing code constructing `HandlerRegistration` bundles must wrap in
`HandlerKind::Once(...)`. This is a mechanical change across handler
construction sites (the builder's `.with_local()` / `.with_leaf()` /
`.with()` methods absorb the wrapping internally, so most assembly-layer
code is unaffected; direct `HandlerRegistration::new()` calls need the
wrap).
- A new protocol-level error code (`INVALID_OPERATION_TYPE`) is a wire-format
addition. Existing clients that treat unknown codes as `INTERNAL` with
`retryable: false` (the existing rule) handle it correctly — they just
don't distinguish it from `INTERNAL` until updated. The code is distinct
from all existing codes and from operation-level domain codes (no
`HTTP_` prefix, no collision with the five existing protocol codes).
- The `Dispatcher::handle_stream` streaming branch adds a stream-to-wire
pump (read stream → write frames → write `call.completed`). This is new
code in the hot dispatch path, but it is a straightforward `while let
Some(envelope) = stream.next().await` loop, not a complex abstraction.
## Door type
**One-way.** The `Handler` / `StreamingHandler` / `HandlerKind` API surface
is what handlers are written against across crates (`alknet-call`,
`alknet-http`, downstream consumers). Changing it after handlers exist is a
rewrite. The `INVALID_OPERATION_TYPE` wire code is also one-way — once
emitted, clients may handle it, and removing it would break those handlers.
The `HandlerKind` enum shape (`Once(Handler) | Stream(StreamingHandler)`) is
the one-way commitment: two handler variants, validated against `op_type`.
The concrete `BoxStream` library choice (`futures::stream::BoxStream` vs a
custom type) is a two-way-door implementation detail within the one-way
decision.
## References
- ADR-015: Call Protocol Stream Model (defines `subscribe` as a top-level
protocol operation; the streaming path this ADR implements)
- ADR-022: Call Protocol Client and Adapter Contract (the adapter contract
this ADR extends with the `StreamingHandler` variant)
- ADR-018: Handler Registration, Provenance, and Composition Authority
(`HandlerRegistration` gains `HandlerKind`)
- ADR-017: Privilege Model and Authority Context (visibility/ACL checks run
identically in `invoke_streaming()` as in `invoke()`)
- ADR-020: Abort Cascade for Nested Calls (stream drop on abort; cascade
through streaming handlers)
- ADR-016: Operation Error Schemas (`INVALID_OPERATION_TYPE` is a
protocol-level code, distinct from operation-level domain codes)
- ADR-024: Peer-Graph Routing Model (`from_call` forwarding handlers gain the
streaming variant)
- ADR-032: One-Way Door Decision Framework (the `Handler` / `StreamingHandler`
split is a one-way door — handler API surface)
- `@alkdev/operations/src/types.ts:62-78` — TS prior art
(`OperationHandler` / `SubscriptionHandler` split)
- `@alkdev/operations/src/registry.ts:65-75` — TS prior art
(`validateSubscriptionHandler` — runtime validation against `op_type`)
- `@alkdev/operations/src/call.ts:341-349` — TS prior art (`buildCallHandler`
branches on `op_type`: `SUBSCRIPTION` → iterate + complete; else → execute)
- `@alkdev/pubsub/src/operators.ts` — stream operators prior art (filter,
map, batch, dedupe, window, chain, join — handler-level stream
composition, distinct from `OperationEnv` request/response composition)
- Spec documents amended: `operation-registry.md`, `call-protocol.md`,
`http-server.md`, `http-adapters.md`, `http-mcp.md`, `client-and-adapters.md`

View File

@@ -0,0 +1,488 @@
# ADR-022: Call Protocol Client and Adapter Contract
## Status
Accepted (amended 2026-06-26, 2026-07-13, and 2026-07-16 — see "Amendments" below; the 2026-07-16 amendment per ADR-045 §5 removes `CallClient::connect`)
## Context
The call protocol spec (ADR-015) defined the stream model as bidirectional —
"both sides can initiate calls." But the spec only described the server side:
`CallAdapter` implements `ProtocolHandler`, accepts incoming QUIC connections,
and dispatches to the operation registry. The client side — who opens the
connection, how calls are sent, how remote operations are discovered and
imported — was left as OQ-15.
The need for the client side is concrete and immediate:
- **Head/worker dispatch**: a head node manages worker nodes (Vast.ai, RunPod,
local Docker). The head needs to call operations on workers (exec, sync,
status) and workers need to call back (report status, request work). The
POC at `/workspace/@alkdev/dispatch` demonstrated this over SSH+axum; under
the call protocol, it's cross-node composition.
- **NAPI/Python adapters**: Node.js and Python clients need to call operations
on an alknet node. They speak the EventEnvelope wire format over a QUIC
connection.
- **Agent tool dispatch**: an agent handler needs to call operations on remote
nodes (tools, services) the same way it calls local operations — through
`OperationEnv::invoke()`. The `from_call` adapter makes remote operations
appear in the local registry.
- **Cross-protocol interop**: external systems (HTTP APIs, MCP servers) are
imported via `from_openapi` and `from_mcp`. The reverse direction —
exposing local operations to external systems — needs `to_openapi` and
`to_mcp`.
The `@alkdev/operations` TypeScript package demonstrated the adapter patterns
(`from_openapi`, `from_mcp`) and the `buildEnv` composition mechanism. The Rust
implementation defines the canonical traits (ADR-033).
OQ-15 was constrained by ADR-010 (adapters take credential sources, not static
tokens) and ADR-017 (adapter-registered operations are `Internal` by default).
This ADR locks the remaining one-way door: the client/adapter contract
architecture.
## Decision
### 1. `CallClient` opens connections and shares the dispatch loop
`CallClient` opens a QUIC connection to a remote node with ALPN `alknet/call`.
Once connected, the connection is symmetric — both sides can send and receive
`call.requested`. The `CallClient` is not just a caller; it is also a callee.
It has its own operation registry to dispatch incoming calls from the remote
side.
```rust
pub struct CallClient {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl CallClient {
pub async fn connect(&self, addr: SocketAddr, credentials: CallCredentials) -> Result<CallConnection>;
}
```
The dispatch loop is shared between `CallAdapter` and `CallClient`. Once a
connection is established (whether accepted by the adapter or opened by the
client), the same logic applies: read `EventEnvelope` frames, dispatch to the
operation registry, write responses, and send outgoing `call.requested` events
for calls initiated on this side. The only difference is who opened the
connection.
`CallConnection` provides:
- `call(operation_id, input) -> ResponseEnvelope` — send `call.requested`,
await `call.responded` (one result)
- `subscribe(operation_id, input) -> Stream<ResponseEnvelope>` — send
`call.requested`, yield each `call.responded` until `call.completed` or
`call.aborted`
- `abort(request_id)` — send `call.aborted`, cascade to descendants (ADR-020)
- `services_list() -> Vec<OperationSpec>` — call `services/list`
- `services_schema(name) -> OperationSpec` — call `services/schema`
### 2. Connection direction is independent of call direction
Who opens the QUIC connection (who has the public IP, who uses a relay, who
connects out reverse-runner style) is a connection-layer concern, not a
protocol-layer concern. Once connected, both sides can call each other.
| Topology | Who advertises | Who opens connection | Who can call whom |
|----------|---------------|----------------------|-------------------|
| Public service | Server (public IP/domain) | Client | Both directions |
| P2P (iroh relay) | Both (relay-assisted) | Either | Both directions |
| Reverse (runner pattern) | Head (public IP) | Worker connects out | Both directions |
| Reverse (dispatch pattern) | Worker (public SSH port) | Head connects out | Both directions |
The protocol does not distinguish "server" and "client" after connection
establishment. The `CallAdapter` accepts connections; the `CallClient` opens
connections. Both dispatch incoming and outgoing calls through the same
mechanism.
### 3. `from_call` adapter imports remote operations
`from_call` does for call protocol endpoints what `from_openapi` does for HTTP
APIs: discovers operations and registers them in the local registry with
forwarding handlers.
```rust
pub async fn from_call(
connection: &CallConnection,
config: FromCallConfig,
) -> Vec<HandlerRegistration>
```
The adapter:
1. Calls `services/list` on the remote node → gets the list of `External`
operations
2. Calls `services/schema` for each → gets the input/output JSON Schemas and
declared error_schemas (ADR-016)
3. For each discovered operation, constructs a `HandlerRegistration` bundle:
- The spec mirrors the remote operation's name, namespace, type, schemas
(input, output, and error_schemas — ADR-016), and access control
- The handler sends `call.requested` through the `CallConnection` and awaits
`call.responded` (or streams for subscriptions)
- `provenance: FromCall`, `composition_authority: None`, `scoped_env: None`
(leaves — ADR-018)
4. The caller registers these bundles in their local registry (into the
connection's overlay — ADR-019)
`from_call`-registered operations are `Internal` by default (ADR-017) — they
are composition material, not directly callable from the wire. The handler
that composes them is `External`.
The `FromCallConfig` includes:
- The credential source for the outbound connection (ADR-010) — TLS identity,
auth token, or capability-provided credentials
- An optional namespace prefix (to avoid collisions when importing from
multiple remote nodes)
- An optional operation filter (to import only specific operations)
### 4. `to_openapi` and `to_mcp` adapters export local operations
The reverse direction — exposing local operations to external systems:
- **`to_openapi`**: generates an OpenAPI spec from the local registry's
`External` operations. External systems (HTTP clients, API gateways) can
discover and call alknet operations through a standard HTTP interface.
- **`to_mcp`**: exposes local operations as MCP tools. MCP clients (editors,
AI tools) can discover and call alknet operations through the MCP protocol.
These adapters are outbound bridges — they translate the call protocol's
operation model into external protocol formats. They do not modify the local
registry; they project it.
### 5. The adapter contract trait
The adapter patterns share a common shape: they produce
`HandlerRegistration` bundles that register in the local registry. The
trait:
```rust
#[async_trait]
pub trait OperationAdapter: Send + Sync {
async fn import(&self) -> Vec<HandlerRegistration>;
}
```
The return type is `Vec<HandlerRegistration>` (not `(OperationSpec,
Handler)` pairs) — ADR-018 changed the registration API to the bundle
shape, and adapters must produce bundles. Adapter convenience methods
construct bundles with `composition_authority: None` and `scoped_env: None`
for the leaf ops they produce.
The trait is **async** because `from_call` requires async discovery
(`services/list` + `services/schema` over a QUIC connection). A synchronous
trait cannot accommodate `from_call` without a separate async pre-step that
populates a cache. The sync adapters (`from_openapi`, `from_mcp` reading a
static spec) trivially satisfy an async trait — their `import()` bodies
contain no `.await` points. The async/sync question is decided: the trait
is async.
Implementations:
- `FromOpenAPI` — imports from an OpenAPI spec (HTTP-backed handlers)
- `FromMCP` — imports from an MCP server (MCP-backed handlers)
- `FromCall` — imports from a remote call protocol endpoint
(call-protocol-backed handlers)
- ~~`FromJsonSchema` — imports from a JSON Schema definition (schema-only, no
handler — used for validation or client generation)~~ — **superseded by
[ADR-027](027-from-jsonschema-as-http-adapter.md)**: `from_jsonschema` is
now an HTTP-backed single-endpoint adapter in `alknet-http` (reqwest
forwarding handler, not a schema-only placeholder); `FromJsonSchema`
provenance stays in `alknet-call` as a handler-bearing leaf.
The `to_*` adapters are outbound projections, not `OperationAdapter`
implementations — they consume the registry, they don't produce entries for it.
The specific trait signatures (error types, configuration parameters) are
two-way doors for implementation. The one-way doors are the architectural
commitments: adapters produce `HandlerRegistration` bundles (ADR-018), the
trait is async (required by `from_call`), and the adapter *trait* lives in
`alknet-call` while adapter *implementations* live with their transport
(HTTP-backed adapters in `alknet-http` per ADR-027; QUIC-backed `from_call`
in `alknet-call`). See `client-and-adapters.md` §"Adapter Location Map."
### 6. Cross-node call tree and abort cascade
When a `from_call` handler sends `call.requested` to a remote node, the call
participates in the local call tree via `parent_request_id`. If the parent is
aborted, the cascade (ADR-020) reaches the `from_call` handler, which sends
`call.aborted` to the remote node. The remote node cascades to its own
descendants. The abort crosses the node boundary transparently.
```
Head node Worker node
r1: /dispatch/run_training
r1-a: worker/exec (from_call handler)
→ call.requested { id: r1-a } ────────→ receives, dispatches to exec
r1-a-1: exec spawns child
user aborts r1
cascade to r1-a
from_call handler sends:
call.aborted { id: r1-a } ───────────→ receives, cascades to r1-a-1
aborts exec and children
```
### 7. Credential sources for connections
The `CallClient` needs credentials to authenticate to the remote node. These
come from capabilities (ADR-010), not environment variables. The credential
types:
- **TLS identity**: the local node's Ed25519 key (RFC 7250 raw key) or X.509
cert, derived from the vault at startup
- **Auth token**: an opaque token for call-protocol-level authentication,
decrypted from the vault or derived from a shared secret
- **Remote identity verification**: the expected fingerprint or cert of the
remote node, stored as a capability (not an env var or config file)
The `from_call` adapter receives these credentials at registration time,
same as `from_openapi` receives HTTP credentials.
## Consequences
**Positive:**
- Cross-node composition works the same as local composition. A handler calls
`env.invoke("worker", "exec", ...)` and doesn't know (or care) whether
`worker/exec` is a local operation or a `from_call`-imported remote
operation. The composition is transparent.
- The head/worker pattern (dispatch, runners) is a connection topology, not a
protocol feature. Workers can connect to heads (runner pattern) or heads can
connect to workers (dispatch pattern) — the protocol handles both.
- `from_call` is the same pattern as `from_openapi` and `from_mcp`: discover,
register, forward. The adapter contract is unified.
- `to_openapi` and `to_mcp` enable interop with non-alknet systems without
those systems needing to speak EventEnvelope.
- The abort cascade (ADR-020) crosses node boundaries transparently. No
consumer needs to implement cross-node abort propagation.
- The NAPI and Python adapters can use `CallClient` directly to call remote
operations — they don't need a separate client implementation.
**Negative:**
- `CallClient` has its own operation registry (for dispatching incoming calls
from the remote side). This is a second registry instance, not the global
one — it needs to be populated with the operations this node wants to expose
to that specific remote peer. The specific mechanism (sharing the global
registry, a peer-scoped subset, or a separate registry) is a two-way door.
- `from_call`-registered operations have a latency cost: each invocation sends
a `call.requested` over QUIC and awaits a `call.responded`. This is
inherent to remote calls and not specific to the adapter pattern. Caching
or batching strategies are consumer concerns.
- The `to_*` adapters need to translate the call protocol's operation model
(JSON Schema, EventEnvelope, subscribe/stream) into external formats
(OpenAPI paths, MCP tools). Some semantics don't map cleanly (e.g.,
subscriptions in OpenAPI, bidirectional calls in MCP). The adapters handle
these with best-effort mappings and document the gaps.
- **Published `to_*` specs are compatibility contracts.** The "best-effort"
mapping label is internal framing. Once a generated spec is published and
external clients build against it, the mapping semantics (e.g.,
subscriptions → SSE long-poll) become a de facto contract. Changing the
mapping later breaks every client. `to_*` mapping choices are two-way
*before* first publication but one-way *after*. Version the generated
specs (e.g., OpenAPI spec version tied to the registry's External
operation set version) and emit a spec version marker so consumers can
detect mapping changes. This is the "published artifact is a contract"
blind spot in ADR-032's framework: it classifies doors by reversal cost
in the codebase, not by compatibility cost for external consumers.
- **Sharing the global registry with a `CallClient` exposes local
capabilities to the remote peer.** Each `HandlerRegistration` carries
`Capabilities` with secret material. If the `CallClient` shares the
global registry, a remote peer calling an External operation triggers
dispatch that populates `OperationContext.capabilities` from the local
registration bundle — meaning the local node's API keys and signing keys
are used for the remote peer's call. A peer-scoped subset must filter by
capability remote-safety (is this operation's capability safe to expose
to this peer?), not just operation name. The registry-mechanism choice
(share global vs subset vs separate) is two-way mechanically but has a
security dimension post-ADR-018: the "share global" option is a
capability-exposure decision, not just a dispatch decision.
- The `CallConnection` abstraction adds a layer between the handler and the
raw QUIC stream. This is necessary for the `from_call` handler to be
transparent — it shouldn't know about QUIC streams, only about call/request
semantics.
## Assumptions
1. **The connection is symmetric after establishment.** Both sides can send
and receive `call.requested`. If a future use case requires one-directional
connections (e.g., a fire-and-forget notification where the receiver can't
call back), the model needs extension. The assumption is that bidirectional
is the correct default.
2. **`services/list` and `services/schema` are the discovery mechanism for
`from_call`.** The remote node exposes its `External` operations through
these built-in operations. If a remote node doesn't support service
discovery (e.g., a minimal worker that only accepts specific calls),
`from_call` needs an alternative discovery mechanism (static config, manual
spec). The assumption is that nodes participating in cross-node composition
support service discovery.
3. **The `from_call` handler is transparent to composition.** A handler that
calls `env.invoke("worker", "exec", ...)` doesn't know it's a remote call.
If the remote node is unreachable or the connection drops, the handler gets
a `call.error` (same as a local handler error). The assumption is that
remote call failures are handled the same as local handler failures.
4. **`from_call`-registered operations mirror the remote spec.** The imported
`OperationSpec` has the same name, namespace, type, schemas (input, output,
and error_schemas per ADR-016), and access control as the remote operation. If the remote operation changes (new
schema, renamed), the imported spec is stale until re-import. The
assumption is that re-import happens on reconnection or is triggered
explicitly. Hot-swapping imported specs is a two-way door.
5. **The `to_*` adapters are projections, not live bridges.** `to_openapi`
generates a spec; it doesn't proxy HTTP requests. An external HTTP client
calling the generated OpenAPI endpoints needs an HTTP handler (alknet-http)
that translates HTTP requests into call protocol operations. The assumption
is that `to_*` generates specs/tools, and a separate HTTP/MCP handler
bridges the actual traffic.
## References
- ADR-013: irpc as call protocol foundation
- ADR-015: Call protocol stream model (bidirectional streams)
- ADR-033: Rust as canonical implementation language (adapter traits in Rust)
- ADR-010: Secret material flow (credential sources, not static tokens)
- ADR-017: Privilege model (adapter ops are Internal by default)
- ADR-020: Abort cascade (cross-node abort propagation)
- ADR-023: Peer-Scoped Registry Filtering for CallClient Inbound Dispatch
(resolves the §1 Consequences security dimension flagged as a two-way door)
- OQ-15: Call protocol client and adapter contract (resolved by this ADR)
- OQ-25..28: Two-way-door remainders from the call-completion gap analysis
(DC-1 shape, DC-4 error type, DC-2 re-import trigger, DC-3 namespace
collision — see [open-questions.md](../open-questions.md))
- [call-protocol.md](../crates/call/call-protocol.md)
- [operation-registry.md](../crates/call/operation-registry.md)
- [client-and-adapters.md](../crates/call/client-and-adapters.md) — the spec
that operationally fills the gap this ADR left to implementation
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1..4, the
decisions that needed resolution before implementation
- TypeScript `@alkdev/operations``from_openapi`, `from_mcp`, `buildEnv`
prior art
- POC at `/workspace/@alkdev/dispatch` — head/worker dispatch over SSH+axum
## Amendments (2026-06-26)
This ADR left four decisions as two-way doors (§1 Consequences flagged DC-1's
security dimension; §5 noted trait signatures are two-way doors; Assumption 4
noted re-import hot-swap is a two-way door; §3 mentioned the namespace prefix).
The call-completion gap analysis (`docs/research/alknet-call-completion/gap-analysis.md`
DC-1..4) resolved them. The resolutions:
### DC-1 — CallClient registry scope: resolved by ADR-023, superseded by ADR-024
The §1 Consequences security dimension was originally resolved by ADR-023
(default-deny `remote_safe: bool` + `trusted_peer` opt-in). **ADR-023 is now
superseded by [ADR-024](024-peer-graph-routing-model.md)** (2026-06-27):
the flat-namespace single-peer model ADR-023 built on cannot express the
head→N-workers pattern, and the `remote_safe`/`trusted_peer` gate duplicates
the existing `AccessControl`/`Identity` machinery while reintroducing the
blanket-bypass anti-pattern ADR-017 killed. ADR-024 replaces the flat overlay
with peer-keyed overlays + `PeerRef` routing, and retires `remote_safe`/
`trusted_peer` in favor of `AccessControl::check(peer_identity)` — the
existing authorization path that was already in the dispatch path. The peer-
scoping question this section flagged is now answered structurally (peer-keyed
overlays), not by a parallel boolean gate.
### DC-4 — OperationAdapter trait error type: resolved
§5 showed `async fn import(&self) -> Vec<HandlerRegistration>` with no error
type. The trait returns `Result<Vec<HandlerRegistration>, AdapterError>`
where `AdapterError` is a crate-level enum. The *presence* of the error type
is recorded in [client-and-adapters.md](../crates/call/client-and-adapters.md);
the exact variants are the two-way-door remainder, tracked as OQ-26.
### DC-2 — from_call re-import on reconnection: manual free function
Assumption 4 noted re-import "happens on reconnection or is triggered
explicitly." The decision is **manual**: `from_call` is a free function; the
assembly layer calls it after `connect()`. The overlay is per-connection
(Layer 2, ADR-019), so re-import on reconnect is naturally scoped; a stale
overlay dies with the connection. A `CallConnection::refresh()` method for
mid-connection re-discovery is a genuine feature addition — non-breaking,
additive — if a deployment needs manual re-discovery without
drop-and-reconnect. Two-way door; recorded in
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
OQ-27. See [ADR-028](028-from-call-manual-free-function.md) for the full
rationale.
### DC-3 — from_call namespace collision: default set
§3's `FromCallConfig` namespace prefix is **optional, default no prefix,
collision = error**. A node importing from two remotes that both expose the
same unprefixed op name should fail loudly. The operator adds prefixes when
importing from multiple sources. Two-way door; recorded in
[client-and-adapters.md](../crates/call/client-and-adapters.md); tracked as
OQ-28.
### Operational spec
The gap this ADR left to implementation — the `CallClient` API, the
`from_call` flow, the trait signature, the adapter location map, the
no-env-vars invariant, and the exchange-of-operations pattern — is
specified in
[client-and-adapters.md](../crates/call/client-and-adapters.md). That document
is the operational complement to this ADR; this ADR remains the architectural
authority.
## Amendments (2026-07-09)
### `from_jsonschema` clause superseded by ADR-027
The §5 `FromJsonSchema` implementation listing ("schema-only, no handler")
is **superseded by [ADR-027](027-from-jsonschema-as-http-adapter.md)**.
`from_jsonschema` is now an HTTP-backed single-endpoint adapter in
`alknet-http` (reqwest forwarding handler, same shape as `from_openapi`),
not a schema-only placeholder in `alknet-call`. The `FromJsonSchema`
provenance variant stays in `alknet-call` (`OperationProvenance`) but is
now a handler-bearing leaf, not a "no handler" entry. The "schema-only,
no handler" concept is removed — schema validation without a handler is
served by consuming `OperationSpec` directly. The §5 "adapters live in
alknet-call" one-way-door statement is corrected above to "the adapter
trait lives in `alknet-call`; implementations live with their transport."
See [ADR-027](027-from-jsonschema-as-http-adapter.md) and
[client-and-adapters.md](../crates/call/client-and-adapters.md) §"from_jsonschema".
## Amendments (2026-07-13)
### `CallClient` transport-agnostic API (mirrors ADR-043's amendment)
The §1 Decision framed `CallClient::connect(addr: SocketAddr,
credentials)` as the primary constructor and described it as "opens a
QUIC connection." The operational spec
([client-and-adapters.md](../crates/call/client-and-adapters.md))
framed `spawn_dispatch(connection)` as the "lower-level API" that
`connect()` uses after the QUIC dial. That framing welded the
client-side one-way-door API to QUIC — the same welding ADR-007
unwound on the server side and ADR-043 corrected for `ChannelClient`.
The call protocol is transport-agnostic (ADR-015 EventEnvelope framing;
ADR-007 `Connection::from_stream`/`from_bidi` accept any
`AsyncRead + AsyncWrite`). The client side is half of that protocol
and must not be coupled to a transport. This amendment reframes the
existing code (which already has the right structure —
`spawn_dispatch` is not feature-gated, `connect` is
`#[cfg(feature = "quinn")]`):
- **`CallClient::spawn_dispatch(connection: Connection)`** — the
transport-agnostic primary constructor and the one-way-door API.
Takes a pre-established `Connection` (any transport), spawns the
shared dispatch loop, returns a live `CallConnection`. Mirrors the
server-side `CallAdapter::handle(Connection)` and
`ChannelClient::from_connection` (ADR-043).
- **`CallClient::connect(addr, credentials)`** — ~~a QUIC convenience
constructor~~ **REMOVED per ADR-045 §5 (2026-07-16)**. The dial is
centralized in `AlknetClient` (`alknet-client`); `connect` is
deleted, not retained as a two-way-door convenience, to avoid
`alknet-call` depending on `alknet-client` and to let `alknet-call`
shed its TLS/transport deps. Callers compose
`AlknetClient::dial_quic(...).await?` +
`CallClient::new(...).spawn_dispatch(conn)`.
The door-type classification is updated: `spawn_dispatch` is one-way
(the handler-facing surface); ~~`connect` is two-way (additive
convenience)~~ `connect` is **removed** (ADR-045 §5). The
`AlknetClient` extraction (OQ-55) is **resolved** by ADR-045 — the
shared dial seam is `alknet-client`; `spawn_dispatch` is the
protocol-crate take-over that consumes the dial's `Connection`.
See [client-and-adapters.md](../crates/call/client-and-adapters.md)
§"CallClient" for the reframed operational spec.

View File

@@ -0,0 +1,228 @@
# ADR-023: Peer-Scoped Registry Filtering for CallClient Inbound Dispatch
## Status
**Superseded** by [ADR-024](024-peer-graph-routing-model.md) (2026-06-27).
ADR-023 introduced `remote_safe: bool` and `trusted_peer: bool` as a parallel
authorization system for peer-scoped dispatch. This was a structural miss: the
flat-namespace single-peer model it built on cannot express the head→N-workers
pattern (the primary use case), and the parallel `remote_safe`/`trusted_peer`
gate duplicates the existing `AccessControl`/`Identity` machinery (which
already authorizes peer calls) while reintroducing the blanket-bypass
anti-pattern ADR-017 was written to kill. ADR-024 replaces the flat overlay
with peer-keyed overlays + `PeerRef` routing, and retires `remote_safe`/
`trusted_peer` in favor of the existing `AccessControl::check(peer_identity)`.
See ADR-024 for the design that replaces this one; see
`docs/research/alknet-call-peer-routing/findings.md` for the research that
identified the gap.
## Context
ADR-022 §1 established that a `CallClient` — which opens an outbound
`alknet/call` connection — "has its own operation registry to dispatch incoming
calls from the remote side." The ADR left the *registry scope* as an explicit
two-way door in its Consequences:
> Sharing the global registry with a `CallClient` exposes local capabilities to
> the remote peer… A peer-scoped subset must filter by capability
> remote-safety, not just operation name. The registry-mechanism choice
> (share global vs subset vs separate) is two-way mechanically but has a
> security dimension post-ADR-018: the "share global" option is a
> capability-exposure decision, not just a dispatch decision.
This is the one decision identified in
`docs/research/alknet-call-completion/gap-analysis.md` (DC-1) that must be
locked before `CallClient` can be implemented correctly. It is a **one-way door
on the security dimension**: the choice of default determines what a remote peer
can reach, and a wrong default silently exposes outbound credentials.
### Why this is a one-way door, not a two-way door
The gap analysis framed the *mechanism* (share-global vs subset vs separate
registry instance) as a two-way door, and that framing holds. But the
**existence of peer-scoped filtering as the v1 default** is one-way, because:
1. Once a downstream consumer (the runner pattern, the container service, the
NAPI projection) is written against the "remote peer can call any
`External` op and the local node's capabilities will be populated for it"
semantics, switching the default to default-deny is a breaking change for
every consumer. The container-service rewrite at `/workspace/@alkdev/dispatch`
and the dev/runner patterns are the first consumers; the default is set
before they're written, so it's still cheap to set correctly — but only now.
2. The security dimension is asymmetric in ADR-032 terms. "Share global" leaks
silently: there is no error, no log line, no test that fails — the remote
peer simply receives a populated `OperationContext.capabilities` drawn from
the local `HandlerRegistration.capabilities`, and the local node's API keys
get used for the remote peer's call. The reversal cost is "discover which
consumers quietly depend on the leak and re-audit." Default-deny fails
loudly (the remote peer's call to an unexposed op returns `NOT_FOUND`),
which is the cheaper failure mode.
3. ADR-010's invariant — "no handler reads outbound credentials from any
source other than `OperationContext.capabilities`" — combined with
ADR-018's dispatch path (which populates `capabilities` from the
registration bundle) means the registration bundle *is* the exposure
boundary. Whatever the `CallClient` dispatches determines which
`Capabilities` objects cross to the remote peer's call context. Filtering
the registry is filtering capability exposure.
### The runner/dispatch pattern is the primary use case, and it is semi-trusted
The canonical consumer (gap analysis §"Exchange of Operations"): a container
service / dev runner connects *outward* to a hub and exposes `/container/exec`,
`/container/list`, etc. The hub then calls back into the runner. Both sides
are semi-trusted peers, not extensions of self. Exposing every `External`
operation on the runner — including any operation that carries an outbound
API key the runner happens to hold — is wrong by default. The operator who
*does* want full bilateral sharing is making an explicit trust decision.
## Decision
### 1. Default-deny: a CallClient exposes no operations to the remote peer unless explicitly marked remote-safe
The `CallClient` does not share the global `OperationRegistry` by default. It
holds a **peer-scoped subset**: a filtered view containing only
`HandlerRegistration`s that are explicitly marked as remote-safe for this peer.
The *existence* of filtering is the one-way door; this ADR locks it.
### 2. The remote-safe marking lives on the registration bundle, not on capabilities
The marking is added to `HandlerRegistration` (per ADR-018, the registration
bundle) as a peer-exposure field. It is not placed on `Capabilities` entries,
because:
- `Capabilities` is a flat credential bag; marking individual entries
remote-safe conflates "this credential is safe to send over the wire" with
"this operation may be dispatched on behalf of a remote peer." Those are
different questions — an operation may be remote-safe while using a
credential that must never leave the node, and the dispatch path already
keeps `Capabilities` off the wire (ADR-010). The exposure question is about
*which ops dispatch*, not *which credentials are serializable*.
- The registration bundle is already the integration point for provenance,
composition authority, scoped env, and visibility (ADR-018). Peer-exposure is
a property of the same shape: a dispatch-path concern set at registration.
The exact shape of the marking (a boolean, a per-peer allowlist, a
capability-class tag) is the two-way-door remainder — tracked as OQ-25, not
decided here. v1 uses the simplest shape that supports default-deny: a boolean
`remote_safe: bool` on `HandlerRegistration`, defaulting to `false`.
### 3. "Share the global registry" remains available as an explicit opt-in
A `CallClient` may be constructed in "trusted-peer" mode that exposes all
`External` operations from the global registry regardless of the remote-safe
marking. This is the explicit-allow path for operators who have made the trust
decision (e.g., two nodes under single administrative control, a test harness).
It is opt-in, never the default.
### 4. Provenance-based defaults
The remote-safe marking has a provenance-aware default at registration time,
before the operator's explicit choice:
| Provenance | Default `remote_safe` |
|-----------|----------------------|
| `Local` | `false` — assembly-written ops are not remote-callable unless the operator says so |
| `Session` | `false` — agent-written ops are sandboxed (ADR-017); exposing them to a remote peer would widen the sandbox |
| `FromOpenAPI`, `FromMCP`, `FromCall`, `FromJsonSchema` | `false` — leaves are composition material, not wire-callable (ADR-017) |
`false` across the board as the default. The operator flips specific
operations to `true` when they want this peer to reach them. This is the same
default-deny posture as ADR-017's visibility (`Internal` by default) and
ADR-018's composition authority (`None` for leaves by default).
### 5. The filtering is a dispatch-time read, not a copy
The `CallClient`'s peer-scoped view is not a second copy of the registry. It
is a dispatch-time read against the global registry, gated by the remote-safe
marking (and the trusted-peer flag). This keeps the curated layer (Layer 0,
ADR-019) single-source — the global registry is still the one Layer-0 store
built by the assembly layer at startup. Only the *visibility* to the remote
peer is filtered.
This avoids a third registry instance (the "separate registry per CallClient"
option from DC-1) and avoids the staleness problem a copied subset would
introduce: if the assembly layer reloads a curated op's spec, the peer-scoped
view reflects it on the next dispatch, not on the next copy.
## Consequences
**Positive:**
- The default is safe-by-construction for the runner/dispatch pattern. A
container service that connects outward to a hub cannot accidentally expose
its local vault-derived API keys to the hub's calls.
- The one-way security door is locked before any consumer is written against
the leaky default. The container-service rewrite and the dev/runner patterns
implement against default-deny from day one.
- Failure mode is loud: a remote peer calling an unexposed op gets
`NOT_FOUND`, not silent credential exposure.
- The mechanism is additive. Trusted-peer opt-in preserves the "share global"
path for operators who want it, without making it the default.
- Single-source Layer 0: no copied registry, no staleness.
**Negative:**
- Adds one field (`remote_safe: bool`) to `HandlerRegistration` (ADR-018).
The registration bundle grows. This is the smallest shape that supports
default-deny; OQ-25 may replace it with a richer mechanism (per-peer
allowlists, capability-class tags).
- Operators must explicitly mark operations remote-safe for bilateral
exchange. This is friction, deliberately: the bilateral container-service
pattern requires the operator to declare which of the runner's ops the hub
may call back into.
- The remote-safe marking is a v1 mechanism and may be superseded. OQ-25
tracks the shape; a future ADR may amend or supersede this one without
revisiting the *existence* of filtering.
- The trusted-peer opt-in is a sharp tool. An operator who enables it for the
wrong peer gets the "share global" exposure this ADR exists to prevent.
The opt-in is documented as a trust decision, not as a convenience.
## Assumptions
1. **The remote-safe marking is set at registration time, not at connection
time.** The marking is a property of the operation (per-peer in a richer
shape, but at least a boolean in v1), set by the assembly layer when it
builds the registry. Per-connection overrides are not part of v1; if a
deployment needs different exposure per peer, it uses the richer shape
(OQ-25) or multiple `CallClient`s with different filtered views.
2. **The peer-scoped view filters dispatch, not `services/list` semantics.**
The remote peer discovers operations via `services/list` (ADR-022 §3),
which already filters by `Visibility::External` (ADR-017). The remote-safe
marking is an *additional* filter for the dispatch path: an op may be
`External` yet not remote-safe. In v1, `services/list` served to a
`CallClient` peer **hides** non-remote-safe ops — a peer should not see
ops it cannot call, so discovery and dispatch filters agree. (The
pre-filter mental model — "`External` appears in `services/list`, then
the dispatch path returns `NOT_FOUND` for non-remote-safe" — is *not* the
v1 behavior; v1 hides them from listing too.) Whether a richer shape
(OQ-25) should expose-but-deny instead of hide is a two-way-door detail
tracked in OQ-25.
3. **Filtering is per-`CallClient`, not global.** A node with multiple
outbound connections may expose different subsets to different peers. The
v1 boolean marking limits this to "remote-safe for any peer" vs "not"; the
richer OQ-25 shape is what enables per-peer differentiation. v1's
limitation is acceptable because the runner/dispatch pattern has one
remote peer per `CallClient`.
## References
- ADR-032: One-Way Door Decision Framework (the door-type framing this ADR
relies on)
- ADR-010: Secret Material Flow and Capability Injection (the no-env-vars
invariant this ADR's security argument rests on)
- ADR-017: Privilege Model and Authority Context (the default-`Internal`,
default-deny posture this ADR mirrors)
- ADR-022: Call Protocol Client and Adapter Contract (§1 Consequences flagged
this decision; §1 is amended by this ADR)
- ADR-018: Handler Registration, Provenance, and Composition Authority (the
registration bundle this ADR adds a field to)
- ADR-019: Operation Registry Layering (Layer 0 single-source; the peer-scoped
view is a dispatch-time read, not a copy)
- OQ-25: Remote-safe marking shape (the two-way-door remainder)
- `docs/research/alknet-call-completion/gap-analysis.md` — DC-1
- `docs/architecture/crates/call/client-and-adapters.md` — the spec this ADR
informs

View File

@@ -0,0 +1,311 @@
# ADR-024: Peer-Graph Routing Model for alknet-call Composition
## Status
Accepted (supersedes ADR-023; Assumption 1's `PeerId` source is superseded
by ADR-025 on the source dimension — the one-way door is preserved)
## Context
The call protocol's composition model is **flat per overlay and single-peer**.
`CompositeOperationEnv` holds one `connection: Option<Arc<dyn OperationEnv>>`
overlay; the Layer 2 imported-ops overlay on `CallConnection` is a flat
`HashMap<String, HandlerRegistration>` keyed by operation name. This works for
one remote peer. The head→many-workers / hub→spoke pattern (the ray.io model,
and the primary downstream use case — the container-service rewrite this
completion was supposed to unblock) cannot be expressed:
1. **Overlay collision.** A head importing from worker A and worker B, both
exposing `/container/exec`, has no way to route
`invoke("container", "exec")` to the right peer. The composite env holds
one connection overlay; even with two, `contains("container/exec")` is
true for both with no disambiguation.
2. **`from_call` namespace prefix is a naming-convention hack.** DC-3 / OQ-28
made `FromCallConfig::namespace_prefix` the disambiguation mechanism — the
operator prefixes imported op names so two peers' ops don't collide in a
flat map. This pushes disambiguation to the caller and into the
`ScopedOperationEnv { allowed: HashSet<String> }` reachability list. It is
bolted onto a flat map instead of being structural routing.
3. **ADR-023's `remote_safe: bool` + `trusted_peer: bool` is a second,
parallel, weaker authorization system.** ADR-023 introduced a
`RemoteFilter { trusted_peer: bool }` gate in `protocol/dispatch.rs` that
runs *before* the existing `AccessControl::check`.
`trusted_peer: true` is a blanket security-bypass flag — the exact
anti-pattern ADR-017 was written to kill (it replaced `trusted: true` with
the authority-switch model). ADR-023 reintroduced it at the peer boundary.
The existing authorization machinery in core (`Identity` with scopes and
resources, `IdentityProvider`, `AccessControl::check`) is real, grounded,
and already wired into the dispatch path — ADR-023 should have *used* it for
peer authorization, not invented a parallel system.
This is a blocking structural fix, not a "v1/later" refinement. The research
at `docs/research/alknet-call-peer-routing/findings.md` validates the design
through a POC that type-checks against the real types (since removed; the
shapes are recorded in the research doc). ADR-023 is superseded by this ADR.
## Decision
### 1. Peer-keyed overlays
The Layer 2 overlay becomes peer-keyed at the composition-env level.
`CompositeOperationEnv`'s singular `connection: Option<Arc<dyn OperationEnv>>`
is replaced by `PeerCompositeEnv` with peer-keyed connections:
```rust
pub struct PeerCompositeEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
```
The per-`CallConnection` overlay stays flat (one connection = one peer — a
flat `HashMap<String, HandlerRegistration>` per connection is correct). The
peer-keying is at the *aggregation* layer: the head node's composition env
holds a `HashMap<PeerId, connection_overlay>`, not one overlay. `PeerId` is
the peer's `Identity.id` — the same field `Connection::identity()` already
exposes, already resolved in the dispatch path, and already unique per peer.
### 2. `PeerRef` routing selector
`OperationEnv` gains a peer-routing method with a `PeerRef` selector. The
default-impl preserves back-compat (existing impls that don't override it
delegate to `invoke_with_policy`, preserving current behavior):
```rust
pub enum PeerRef {
Specific(PeerId), // route to this peer; NOT_FOUND if it doesn't serve the op
Any, // first peer (insertion order) that serves it
}
pub type PeerId = String; // logical id, NOT Identity.id — see OQ-33
async fn invoke_peer(&self, peer: &PeerRef, namespace: &str, operation: &str,
input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
// default: ignore peer selector, dispatch via invoke_with_policy
self.invoke_with_policy(namespace, operation, input, parent, policy).await
}
fn peer_contains(&self, _peer: &PeerId, name: &str) -> bool { self.contains(name) }
```
`PeerRef::Specific(PeerId)` routes to the named peer's overlay; if that peer
doesn't serve the op, `NOT_FOUND` (no silent fallthrough — explicit routing
must be honored or fail loudly). `PeerRef::Any` routes to the first peer
(insertion order) whose overlay contains the op — the "any worker that serves
this name" fan-out primitive. A richer `RoutingPolicy` (round-robin,
least-loaded) is the two-way-door remainder tracked as OQ-30; the `PeerRef`
enum is designed to compose with it without breaking the signature.
The existing `invoke()` / `invoke_with_policy()` methods stay as the
`PeerRef::Any` equivalent for code that doesn't care about peer selection.
### 3. `AccessControl`-based peer authorization; retire `remote_safe`/`trusted_peer`
`RemoteFilter`, `HandlerRegistration::remote_safe`,
`CallClient::trusted_peer`, `OperationRegistry::list_operations_peer_scoped`,
and `services_list_handler_peer_scoped` are **removed**. Peer authorization
flows through the existing `AccessControl::check` against the peer's resolved
`Identity`:
- A remote peer's call arrives → `dispatch_requested` resolves the peer's
`Identity` (already does, from the connection's TLS fingerprint or the
`auth_token` payload) → `OperationRegistry::invoke` runs
`AccessControl::check(peer_identity)`.
- If the op's `AccessControl` is satisfied → dispatch (capabilities populated
from the bundle, same as today).
- If not → `FORBIDDEN` (capabilities never populated — the security property
ADR-023 wanted, achieved by the existing ACL, not a parallel gate).
- If the op is `Visibility::Internal``NOT_FOUND` before ACL (existing
behavior). This is the "never callable from wire" case.
The three cases `remote_safe` was meant to handle map to existing mechanisms:
| `remote_safe` case | Replacement |
|---|---|
| Op callable by any peer (was `remote_safe: true`) | `AccessControl::default()` — no restrictions; implicitly "remote-safe" because it requires no privileged scope. |
| Op callable only by some peers | `AccessControl { required_scopes: [...] }` — only peers whose `Identity.scopes` satisfy the AND-gate may call. Per-peer differentiation via `IdentityProvider` config. |
| Op never callable from wire | `Visibility::Internal``NOT_FOUND` before ACL. Existing mechanism, unchanged. |
**The op's `AccessControl` *is* the peer-authorization policy.** There is no
separate exposure decision. If the peer's `Identity` satisfies the op's
`AccessControl`, the op dispatches and capabilities populate (same as for any
authorized caller). If not, `FORBIDDEN` before the handler — capabilities
never populate. The exposure decision and the authorization decision are the
same decision, made through one mechanism, not two.
### 4. Peer-qualified reachability (`ScopedPeerEnv`)
`ScopedOperationEnv { allowed: HashSet<String> }` is extended with an optional
peer-pinned allowlist. Unqualified reachability (peer-agnostic composition —
"I want to call `container/exec` on whichever worker serves it") stays the
common case; peer-pinning is opt-in for the disambiguation case that replaces
`FromCallConfig::namespace_prefix`:
```rust
pub struct ScopedPeerEnv {
pub allowed_ops: HashSet<String>, // peer-agnostic — reachable via PeerRef::Any
pub peer_pinned: HashSet<String>, // "peer-id/op-name" — reachable only via PeerRef::Specific(that peer)
}
```
Instead of prefixing the *op name* (the flat-namespace hack), you pin the
*peer* in the reachability set. The existing `ScopedOperationEnv.allowed`
becomes the `allowed_ops` field; peer-pinning is additive.
### 5. `from_call` peer-keyed registration; collision rule change
`from_call` registers into the specific peer's sub-overlay, not a flat
overlay. Cross-peer collision dissolves: same name on different peers is fine
(separate sub-overlays, no collision, no prefix needed). Same-peer collision
stays an error (a peer shouldn't expose two ops with the same name).
`FromCallConfig::namespace_prefix` becomes optional local-naming sugar for
the case where the importing node wants to expose a peer's ops under a
different name *locally* — a local-naming concern, not a disambiguation
concern. It defaults to `None`.
### 6. `services/list` `AccessControl`-filtered; `services/list-peers` opt-in
`services/list` filters by `AccessControl::check(calling_peer_identity)` — the
calling peer sees only ops it is authorized to call. The
`services_list_handler` / `services_list_handler_peer_scoped` split collapses
to a single `AccessControl`-filtered handler. `services/list-peers` is the
opt-in for peer-attributed re-export listing (each peer's sub-overlay listed
with attribution, filtered by the calling peer's authorization).
## Consequences
**Positive:**
- The head→N-workers pattern works. A head with multiple worker connections
routes `invoke()` to the right peer via `PeerRef`. This is the primary use
case the previous model couldn't express.
- One authorization system, not two. Peer authorization flows through the
existing `AccessControl`/`Identity` machinery — the same mechanism that
gates every other call. No parallel `remote_safe` gate, no blanket-bypass
`trusted_peer` flag. Per-peer differentiation is via `IdentityProvider`
config (different peers get different scopes), which is a real
authorization decision, not a boolean.
- Structural disconnect cleanup. When a peer disconnects, its sub-overlay
drops (the `PeerId` key is removed from `connections`). No stale overlay,
no explicit deregistration. An in-flight `PeerRef::Specific(that_peer)` gets
`NOT_FOUND` — the correct failure mode.
- `from_call` collision dissolves across peers. Two workers exposing
`/container/exec` coexist; the prefix is no longer the disambiguation
mechanism.
- The `OperationEnv` trait gains a method with a default-impl, preserving
back-compat. Existing impls (`LocalOperationEnv`, `OverlayOperationEnv`)
work unchanged; `PeerCompositeEnv` overrides with real peer routing.
- The peer-keyed overlay model extends naturally to multi-hop federation (a
chain of `PeerRef::Specific` routing decisions) without redesign. Petgraph
is not needed for v1 (one-hop, shallow); it pays off if multi-hop
path-finding becomes real (OQ-32).
**Negative:**
- `CompositeOperationEnv``PeerCompositeEnv` is a migration. Existing call
sites that construct `CompositeOperationEnv::new(base, Some(conn), session)`
migrate to `PeerCompositeEnv::new(base).with_session(session).attach_peer(peer_id, conn)`.
The singular-connection case (one peer) is the degenerate case
(`connections` with one entry).
- `OperationEnv` trait gains a method. The default-impl preserves back-compat,
but it's a trait surface change; downstream impls (`alknet-http`,
`alknet-agent`) gain the method with the default delegation.
- `services/list` semantics change: the filter is `AccessControl`-based, not
`remote_safe`-based. An op with `AccessControl::default()` (no restrictions)
is now listed to any peer — this is correct (it's implicitly callable by
any authenticated peer), but operators who relied on `remote_safe: false` to
hide ops from peers must instead set `required_scopes` or `Visibility::Internal`.
- ADR-023 is superseded. The `remote_safe` field, `trusted_peer` flag,
`RemoteFilter`, `list_operations_peer_scoped`, and
`services_list_handler_peer_scoped` are removed. Code that references them
(the `CallClient`, `Dispatcher`, `HandlerRegistration`, `discovery.rs`)
changes. This is the cost of fixing a one-way-door miss — the previous model
shipped and was reviewed before the structural gap was caught.
- `PeerId` is a logical identifier, **not** `Identity.id` (the fingerprint or
API-key prefix). Coupling `PeerId` to the crypto material would break every
in-flight `PeerRef::Specific` and every ACL entry referencing that peer on
key rotation. v1 uses a connection-assigned UUID; a configured node name is
the future shape. See OQ-33 for the full decision and the key-rotation/ACL
rationale.
## Assumptions
1. **`PeerId` is a logical identifier, not `Identity.id`.** v1 source is a
connection-assigned UUID (v4) — stable for the connection's lifetime,
changes on reconnect. This is a no-storage workaround: the core crates are
deliberately DB-free (smaller, fewer deps), which works for local-only
state but not for cross-node peer identity that wants to persist across
restarts and key rotations. An in-flight `PeerRef::Specific(stale_uuid)`
gets `NOT_FOUND` on reconnect — the correct failure mode (the peer is
gone); re-`from_call` produces a fresh `PeerRef`. The real solution (a
persistent peer registry that maps a stable logical name to current crypto
material, surviving key rotation) is tracked as OQ-34, not a v1 blocker.
The one-way door: `PeerId` is logical, not crypto — this determines the
`PeerCompositeEnv` key type and `PeerRef::Specific` payload. See OQ-33.
> **Superseded by ADR-025 on the `PeerId` source dimension.** The
> one-way door (`PeerId` is logical, not crypto) is preserved. The v1
> UUID source is replaced by `Identity.id` from `PeerEntry.peer_id`
> (stable across key rotation). The "no-storage workaround" framing is
> no longer accurate — the storage boundary is now `config + in-memory
> adapter` (ADR-025 + ADR-033), with persistence adapters additive. See
> ADR-025 and OQ-33 (resolved).
2. **`PeerRef::Any` = insertion-order first-match.** Deterministic but
order-dependent (worker A connects before worker B → `Any` routes to A
until A disconnects). This is the simplest routing policy and is correct for
the immediate use case (the head picks the first worker that serves the
op). A richer `RoutingPolicy` (round-robin, least-loaded, affinity) is OQ-30;
the `PeerRef` enum composes with it without breaking the signature.
3. **`services/list` defaults to "own ops only" (unchanged from today).**
Re-exported peer ops are not listed unless the calling peer invokes
`services/list-peers` (the opt-in). The re-export policy (which peers' ops a
given peer sees) is an `AccessControl` decision on the listing op.
4. **Capability exposure under `PeerRef::Any`.** When a handler composes via
`Any` and routing picks worker A, the handler's `Capabilities` propagate to
worker A's call (same as today's `from_call` forwarding). This is correct:
the handler declared the op in its scoped env, so it authorized the
composition; the peer selection is a routing detail. If a handler needs
per-peer capability scoping, it uses `PeerRef::Specific` and peer-pinned
reachability.
5. **Multi-hop federation is out of scope for v1.** Worker A does not
transitively see worker B's ops through the head unless the head explicitly
re-exports them. The peer-keyed overlay model extends to multi-hop without
redesign (a chain of `PeerRef::Specific` decisions), but path-finding
(which peer reaches which op transitively) is where petgraph would pay off
(OQ-32, not designed).
## References
- ADR-017: Privilege Model and Authority Context (the authority-switch pattern
ADR-023 violated by reintroducing a blanket-bypass flag)
- ADR-022: Call Protocol Client and Adapter Contract (amended: `CallClient`
no longer has `trusted_peer`; the client/adapter spec updates)
- ADR-018: Handler Registration, Provenance, and Composition Authority
(`remote_safe` field removed from the registration bundle)
- ADR-019: Operation Registry Layering (Layer 2 becomes peer-keyed at the
composition-env aggregation level)
- ADR-023: Peer-Scoped Registry Filtering for CallClient Inbound Dispatch
(superseded)
- OQ-25: dissolved (no `remote_safe` marking — `AccessControl` is the policy)
- OQ-26: resolved (`AdapterError` variants — `SamePeerCollision` replaces
the flat `Conflict` variant; `#[non_exhaustive]`)
- OQ-27: stays (re-import trigger — unchanged; the overlay is now peer-scoped)
- OQ-28: dissolved cross-peer (same name on different peers is fine); stays
same-peer
- OQ-29: stays (TLS client-auth — orthogonal to the routing model)
- OQ-30: `PeerRef::Any` routing policy (new — round-robin/least-loaded)
- OQ-31: `services/list-peers` re-export semantics (new)
- OQ-32: Multi-hop federation (new — petgraph candidate)
- OQ-33: resolved — `PeerId` is a logical id (UUID v1), not `Identity.id`;
decoupling from crypto material keeps the door open for key-rotation-safe ACLs
- OQ-34: persistent peer registry (new — the storage dimension OQ-33 surfaced;
not a v1 blocker, tracked so the no-DB posture's limit is deliberate)
- Research: `docs/research/alknet-call-peer-routing/findings.md`
- Prior art: Ray.io actors (`ActorHandle` = `PeerRef::Specific`), Dapr service
invocation (app-ID routing = `PeerRef::Specific`, access-control allowlist =
`AccessControl`-based peer authorization)

View File

@@ -0,0 +1,435 @@
# ADR-025: PeerEntry and Identity.id Decoupling
## Status
Accepted (supersedes the "v1 UUID" source in ADR-024 Assumption 1; resolves
the "real solution" half of OQ-33 and the storage-boundary half of OQ-34)
## Context
`Identity.id` is the string that keys authorization decisions across the
alknet crate graph. Today it is **coupled to the cryptographic material**:
```rust
// crates/alknet-core/src/config.rs — current implementation
pub struct AuthPolicy {
pub authorized_fingerprints: HashSet<String>, // just strings, no stable id
pub api_keys: Vec<ApiKeyEntry>,
}
impl AuthPolicy {
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
if self.authorized_fingerprints.contains(fingerprint) {
Some(Identity {
id: fingerprint.to_string(), // ← identity IS the crypto material
scopes: vec!["relay:connect".to_string()],
...
})
}
}
}
```
This coupling is a latent bug for any cross-node authorization decision:
- A TLS fingerprint or raw-key identity changes when the node rotates its key.
- When it changes, every ACL entry that references the old fingerprint stops
matching — the peer "disappears" from the authorization system even though
it is the same logical node.
- `PeerRef::Specific(PeerId)` (ADR-024) routes by `Identity.id`; a key
rotation would break in-flight routing references the same way.
- The hub's `authorized_fingerprints` set has to be manually updated on every
rotation on the *remote* side, which is exactly the operational pain the
vault's local key rotation (ADR-021) was meant to remove.
ADR-024 §1 set `PeerId = Identity.id` and made `PeerId` a logical identifier
"NOT `Identity.id` (the fingerprint)" — but left the *source* of that logical
identifier as a connection-assigned UUID (OQ-33's v1 workaround). The UUID
is ephemeral: it survives only for the connection's lifetime, changes on
reconnect, and cannot persist across restarts or key rotations. It is a
no-storage workaround, not a real identity.
The research at `docs/research/alknet-storage-strategy/findings.md` §4
established the real fix: introduce a `PeerEntry` config model that maps a
**stable logical peer id** to its current cryptographic material and
authorization scopes, and have `ConfigIdentityProvider` resolve
fingerprint → `PeerEntry``Identity { id: peer_entry.peer_id, scopes:
peer_entry.scopes, ... }`. The `Identity.id` becomes the stable `peer_id`,
decoupled from the fingerprint. Key rotation is a single field update in the
peer entry; the `peer_id` and every ACL / routing reference to it stay
stable.
This is the storage-boundary question OQ-34 tracks. With ADR-033 (the
repo/adapter pattern) establishing that core defines repo traits and the
default in-memory adapter lives alongside the trait, the answer is: core
gets the `PeerEntry` config model and the
`ConfigIdentityProvider::resolve_from_fingerprint → Identity { id: peer_id
}` resolution path now, with no SQLite dependency in core. A future
`alknet-peer-store-sqlite` adapter that persists `PeerEntry` records is
additive — it implements the same `IdentityProvider` trait against a `peers`
table instead of config. The trait is the one-way door; the adapter is the
two-way door.
## Decision
### 1. Add `PeerEntry` to `AuthPolicy`, replacing `authorized_fingerprints`
```rust
pub struct PeerEntry {
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
/// key rotation. This becomes Identity.id on resolution, regardless of
/// which credential path resolved the identity.
pub peer_id: String,
/// TLS fingerprints for this peer — one or more. A peer may have
/// multiple keys (e.g., an Ed25519 raw key for P2P and an X.509 cert
/// for domain-facing). Resolution matches against any entry.
/// Format: "ed25519:<hex of 32-byte pub key>" for RFC 7250 raw keys
/// (normalized across quinn and iroh — see §6), "SHA256:<hex>" for
/// X.509 certs (DER hash). Changes on key rotation.
pub fingerprints: Vec<String>,
/// Optional: bearer-token authentication for this peer. A peer that
/// also authenticates via auth_token (e.g., HTTP clients that can't
/// do TLS client-auth) stores the SHA-256 hash of the token here.
/// Resolution via resolve_from_token matches this field and returns
/// the same Identity { id: peer_id, ... } as the fingerprint path.
pub auth_token_hash: Option<String>,
/// Authorization scopes granted to this peer. Resolved into
/// Identity.scopes.
pub scopes: Vec<String>,
/// Named resource lists granted to this peer. Resolved into
/// Identity.resources.
pub resources: HashMap<String, Vec<String>>,
/// Human-readable display name for logs / UIs. Optional.
pub display_name: Option<String>,
/// Whether this peer is authorized at all. false = recognized but
/// disabled (revoked). Resolution returns None.
pub enabled: bool,
}
pub struct AuthPolicy {
/// Replaces authorized_fingerprints: HashSet<String>. Each entry maps
/// a stable logical peer_id to its credential paths (fingerprints,
/// optional auth_token_hash) + scopes + resources. The list is keyed
/// by peer_id; resolution looks up by fingerprint OR auth_token.
pub peers: Vec<PeerEntry>,
/// API keys for bearer-token auth where the token IS the identity
/// (rotation = new identity). Peers that need a stable logical id
/// across credential rotation use PeerEntry.auth_token_hash instead.
/// See "Bearer tokens" below.
pub api_keys: Vec<ApiKeyEntry>,
}
```
### 2. `Identity.id` becomes `PeerEntry.peer_id` on resolution (any credential path)
`ConfigIdentityProvider::resolve_from_fingerprint` resolves fingerprint →
matching `PeerEntry` (by any entry in `fingerprints`) → `Identity { id:
peer_entry.peer_id, ... }`. `ConfigIdentityProvider::resolve_from_token`
resolves token → matching `PeerEntry` (by `auth_token_hash`) → the same
`Identity { id: peer_entry.peer_id, ... }`. Both paths produce the same
`Identity` — the `peer_id` is the stable logical id regardless of how the
peer authenticated.
```rust
impl AuthPolicy {
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
self.peers.iter()
.find(|p| p.enabled && p.fingerprints.iter().any(|f| f == fingerprint))
.map(|p| Identity {
id: p.peer_id.clone(),
scopes: p.scopes.clone(),
resources: p.resources.clone(),
})
}
pub fn resolve_identity_from_token(&self, token: &str) -> Option<Identity> {
let token_hash = sha256(token);
self.peers.iter()
.find(|p| p.enabled && p.auth_token_hash.as_deref() == Some(&token_hash))
.map(|p| Identity {
id: p.peer_id.clone(),
scopes: p.scopes.clone(),
resources: p.resources.clone(),
})
.or_else(|| self.resolve_api_key(token)) // fall through to ApiKeyEntry
}
}
```
If the token doesn't match any `PeerEntry.auth_token_hash`, resolution falls
through to `resolve_api_key` (the `ApiKeyEntry` path, where `Identity.id =
prefix`). This preserves the existing API-key path for bearer tokens that
ARE the identity, while adding the `PeerEntry` token path for tokens that
are one credential path among several for a stable logical peer.
This removes the pre-ADR-025 limitation in `auth.md`
§"Resource-scoped ACLs and external identities" — resolved identities now
carry `resources` from the `PeerEntry`, not just from the composition path.
### 3. Key rotation is a `PeerEntry` field update (no `peer_id` change)
Rotating a peer's TLS key:
- The vault derives the new key locally (ADR-020/021).
- The remote side's config updates the `PeerEntry.fingerprints` entry for
that `peer_id`. The `peer_id`, `scopes`, `resources`, ACL entries, and
any `PeerRef::Specific(peer_id)` references stay stable.
- A config reload (`ConfigReloadHandle::reload`) makes the change live.
Rotating a peer's auth token:
- Update `PeerEntry.auth_token_hash` for that `peer_id`. The `peer_id`
and everything that references it stays stable.
No ACL update, no routing reference invalidation, no peer "disappears."
The vault's local rotation + a remote-side config edit is the full key
rotation story across nodes, for any credential path.
### 4. `PeerId` source changes from UUID to `Identity.id` from `PeerEntry`
ADR-024 Assumption 1 said `PeerId` is a connection-assigned UUID (v4). With
`Identity.id` now stable (`peer_id`), the UUID workaround is no longer
needed: `PeerId = Identity.id` from `IdentityProvider` resolution. This is
the one-way-door tightening — `PeerId` was always specified as logical-not-
crypto (ADR-024), the UUID was the *source*; the source now becomes the
auth system.
```rust
// ADR-024 §1, updated by this ADR:
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material)
```
ADR-024 §2's `invoke_peer` / `PeerRef::Specific(PeerId)` signatures are
unchanged. The `PeerId` payload is now stable across reconnects and key
rotations, instead of ephemeral. An in-flight `PeerRef::Specific` that
survives a reconnect now keeps resolving (the `peer_id` is unchanged), which
is the property the UUID workaround could not provide.
### 5. The `PeerId` for a connection comes from `IdentityProvider` resolution
The dispatch path that builds a `CallConnection` and assigns a `PeerId` to
the peer-keyed overlay (`PeerCompositeEnv::attach_peer`) reads
`connection.identity().id` — the resolved `Identity.id` from the
`IdentityProvider`. If identity resolution returns `None` (no client cert,
unrecognized fingerprint), the peer has no `PeerId` and the connection
cannot be added to the peer-keyed overlay. The handler either rejects the
connection or falls back to a connection-without-peer-identity path (the
caller-id-is-the-connection case, e.g., anonymous dial-in).
The UUID fallback is removed. A connection with no resolved identity has no
`PeerId`, not a random one.
### 6. Fingerprint format normalization: `ed25519:` for raw keys
Ed25519 raw keys (RFC 7250) produce different fingerprint formats depending
on the transport:
- **iroh** (direct or relay): `ed25519:<hex of 32-byte public key>`
extracted from `connection.remote_node_id()`, which returns the NodeId
(the raw Ed25519 public key). Already implemented.
- **quinn RawKey**: currently `SHA256:<hex of cert DER>` — because
`fingerprint_from_cert_der` hashes the SPKI DER bytes. The DER encoding
of the SPKI is not the raw 32-byte public key; it's an ASN.1 wrapper.
So the same Ed25519 key produces `ed25519:abc...` on iroh and
`SHA256:def...` on quinn — two different fingerprints for the same key.
This is normalized: the quinn path extracts the Ed25519 public key from the
cert DER (the `RawKeyCertResolver` already has the raw key bytes via
`Ed25519SecretKey::public()`) and formats it as `ed25519:<hex>`, matching
iroh. A peer that connects via quinn direct and via iroh has the same
fingerprint in `PeerEntry.fingerprints` — one entry, both transports.
The normalization is in `extract_quinn_client_fingerprint`: when the
presented cert is an RFC 7250 raw public key (SPKI with Ed25519 algorithm
identifier), extract the raw 32-byte public key and format as
`ed25519:<hex>`. When the cert is X.509, keep the `SHA256:<hex of DER>`
format (X.509 certs don't have a "raw public key" form — the DER hash is
the fingerprint).
This also simplifies the coming WebTransport relay work: a WebTransport
relay acts as a proxy, and the proxied connection's Ed25519 identity
should be the same `ed25519:<hex>` whether the client connected directly
or through the relay. Normalizing on the iroh pattern means the relay
doesn't need a separate fingerprint format.
## Bearer tokens
There are three credential types in the alknet auth model:
1. **Ed25519 raw key** (RFC 7250) — the most common. Same key type as SSH
keys, native to iroh's `NodeId`. Fingerprint format: `ed25519:<hex>`.
Used for direct quinn, iroh direct, and iroh relay connections. The
fingerprint IS the trust anchor (no CA needed).
2. **X.509 cert** — for domain-facing endpoints (`api.alk.dev`, relays,
ACME/Let's Encrypt). Fingerprint format: `SHA256:<hex of DER>`. Requires
CA verification on the client side. The outgoing-only case (a client
connects to a public X.509 endpoint) is tracked as OQ-37.
3. **Bearer token** (auth_token) — for HTTP clients that can't do TLS
client-auth (browsers, curl), or as a secondary credential path. Carried
in the call-protocol `auth_token` payload field.
A `PeerEntry` can have any combination of these: `fingerprints: Vec<String>`
for one or more TLS keys (Ed25519 and/or X.509), `auth_token_hash:
Option<String>` for an optional bearer-token path. All resolve to the same
`peer_id`. A peer that authenticates via Ed25519 today and via auth_token
tomorrow gets the same `PeerId` — the logical identity is stable across
credential paths.
`ApiKeyEntry` stays as a separate path for bearer tokens where the token IS
the identity (rotation = new identity, no stable logical id needed). When a
bearer token is one credential path among several for a stable peer, it
goes in `PeerEntry.auth_token_hash`. The distinction is not "peer bearer vs
auth bearer" — it's whether the token needs a stable logical id across
rotation (`PeerEntry`) or not (`ApiKeyEntry`).
| Credential type | `PeerEntry` field | `Identity.id` | Rotation |
|-----------------|-------------------|---------------|----------|
| Ed25519 raw key | `fingerprints[i]` (`ed25519:...`) | `peer_id` (stable) | Update `fingerprints` entry |
| X.509 cert | `fingerprints[i]` (`SHA256:...`) | `peer_id` (stable) | Update `fingerprints` entry |
| Bearer token (peer) | `auth_token_hash` | `peer_id` (stable) | Update `auth_token_hash` |
| Bearer token (identity) | `ApiKeyEntry` (separate) | `prefix` (changes with key) | New `ApiKeyEntry` |
## What this does NOT change
- **`Identity` struct shape** — `id: String`, `scopes: Vec<String>`,
`resources: HashMap<String, Vec<String>>` are unchanged. Only the
*meaning* of `id` on the fingerprint path changes (fingerprint →
peer_id).
- **`IdentityProvider` trait** — unchanged. The adapter's resolution
semantics change, not the trait.
- **`AccessControl::check`** — unchanged. Still a flat scope/resource match
against `Identity`. The `Identity` it checks now has a stable `id` on the
fingerprint path, but `check` doesn't key on `id` (it checks scopes and
resources).
- **`AuthToken`, `AuthContext`** — unchanged.
- **`PeerRef::Specific(PeerId)` signature** — unchanged. The payload is now
stable.
- **`CompositeOperationEnv``PeerCompositeEnv` migration** — unchanged.
This ADR provides the stable `PeerId` source; ADR-024 still owns the
overlay-keying model.
## Consequences
**Positive:**
- Key rotation no longer breaks ACL entries or routing references on the
remote side — for any credential path (TLS key or auth token). The
vault's local rotation story (ADR-021) is now the complete story.
- `PeerRef::Specific` survives reconnects. An in-flight routing reference
to "worker-a" keeps resolving after worker-a's TLS key rotates and after
worker-a reconnects.
- OQ-33's UUID workaround is removed — the stable logical id is the real
thing, not an ephemeral stand-in.
- OQ-34's storage-boundary question is resolved: core has the config model
(`PeerEntry`) + the in-memory adapter (`ConfigIdentityProvider`); a
future `alknet-peer-store-sqlite` adapter that persists `PeerEntry`
records is additive, implementing the same `IdentityProvider` trait
against a `peers` table. See ADR-033.
- Resolved identities now carry `resources` (the pre-ADR-025 limitation is
lifted) — `AccessControl::check` against `resource_type`/
`resource_action` works for external authenticated callers when
configured, regardless of credential path.
- A peer can authenticate via Ed25519 today and via auth_token tomorrow,
getting the same `PeerId` — the logical identity is stable across
credential paths.
- Fingerprint normalization (`ed25519:<hex>` for raw keys across quinn and
iroh) means the same key has the same fingerprint regardless of transport.
This also simplifies the coming WebTransport relay work.
**Negative:**
- `AuthPolicy.authorized_fingerprints: HashSet<String>` is replaced with
`AuthPolicy.peers: Vec<PeerEntry>`. This is a breaking config change —
existing config files with `authorized_fingerprints` migrate to `peers`
entries. The migration is mechanical (each fingerprint becomes a
`PeerEntry { peer_id: <chosen name>, fingerprints: vec![<old value>], ... }`),
and operators must choose a `peer_id` per peer, but it is a config break.
- `Identity.id` for resolved identities changes from the fingerprint to
the `peer_id`. Code that logs or compares `Identity.id` and assumed it
was the fingerprint string will see the `peer_id` instead. This is the
correct behavior (logs should show the logical name, not the rotating
crypto material), but it's a behavior change in log output.
- The quinn fingerprint extraction changes from `SHA256:<hex of DER>` to
`ed25519:<hex of raw key>` for raw-key certs. Existing configs with
`SHA256:` fingerprints for Ed25519 keys migrate to `ed25519:` format.
X.509 fingerprints stay as `SHA256:<hex of DER>`.
- ADR-024 Assumption 1 is superseded on the `PeerId` source dimension:
the one-way door (`PeerId` is logical, not crypto) is preserved, but the
UUID source is replaced by `Identity.id` from `PeerEntry`. The
Assumption's framing of "no-storage workaround" is no longer accurate —
the storage boundary is now explicitly `config + in-memory adapter`
(this ADR + ADR-033), with the SQLite adapter additive.
## Assumptions
1. **The dispatch path can require identity resolution for peer-keyed
overlay membership.** A connection that fails `IdentityProvider`
resolution has no `PeerId` and is not added to `PeerCompositeEnv`. The
caller either authenticates with a recognized fingerprint (and gets a
`peer_id`) or is rejected / falls back to a no-peer-identity path. The
v1 UUID fallback is removed deliberately — anonymous dial-in to a
peer-keyed composition env is a contradiction.
2. **`PeerEntry.peer_id` is operator-chosen and unique within a config.**
Config validation enforces uniqueness; duplicate `peer_id` values in
`AuthPolicy.peers` are a config error.
3. **Bearer tokens have two paths.** `PeerEntry.auth_token_hash` is for
tokens that are one credential path among several for a stable logical
peer (the token rotates, the `peer_id` stays). `ApiKeyEntry` is for
tokens that ARE the identity (rotation = new identity, no stable
logical id needed). See "Bearer tokens" above. The distinction is not
"peer bearer vs auth bearer" — it's whether the token needs a stable
logical id across rotation.
4. **The `peers` list resolution is O(peers) per fingerprint lookup.** The
expected peer count per node is small (10s100s); a linear scan with a
side index is fine. A `HashMap<fingerprint, &PeerEntry>` index is an
implementation-detail two-way door.
5. **Adapter crates that persist `PeerEntry` records are additive and not
specified here.** ADR-033 establishes the pattern (core trait + in-memory
default; persistence adapters are separate crates); the concrete adapter
shapes are deferred for exploration per the user's note. This ADR's
commitment is to the `PeerEntry` config model + the resolution
semantics + the `PeerId` source, not to any specific backend.
## References
- ADR-003: Auth as Shared Core (`IdentityProvider` in core)
- ADR-017: Privilege Model and Authority Context (`AccessControl::check`
against `Identity`)
- ADR-021: Key Rotation via Version-Indexed Paths (the local rotation half
this ADR completes across nodes)
- ADR-018: Handler Registration, Provenance, and Composition Authority
(the registration bundle's `composition_authority` path produces its own
`Identity`; this ADR's `PeerEntry.resources` populates the external-auth
path's `Identity.resources`)
- ADR-024: Peer-Graph Routing Model (the `PeerId = Identity.id` model;
Assumption 1's UUID source is superseded by this ADR's `PeerEntry.peer_id`
source — the one-way door is preserved)
- ADR-033: Storage Boundary and Repo/Adapter Pattern (the overarching pattern
this ADR's `PeerEntry` + `ConfigIdentityProvider` follows)
- OQ-33: PeerId — Cryptographic Identity vs Stable Logical Identifier
(resolved by this ADR — the "real solution" half, replacing the UUID
workaround)
- OQ-34: Persistent Peer Registry (resolved by this ADR + ADR-033 — the
storage boundary is `config + in-memory adapter` now, SQLite adapter
additive)
- ~~OQ-35: API Key Identity vs Peer Identity~~ (dissolved — the
"asymmetry" framing was wrong; `PeerEntry` supports multiple credential
paths, and `ApiKeyEntry` is for tokens that ARE the identity)
- OQ-29: CallClient TLS Client-Auth (resolved by this ADR's §6 fingerprint
normalization + the client-auth wiring decision recorded in OQ-29)
- OQ-37: X.509 outgoing-only case (the three auth types and how X.509
server identity fits — see OQ-37 in open-questions.md)
- `docs/research/alknet-storage-strategy/findings.md` §4 (the `PeerEntry`
model and resolution path)
- `docs/architecture/crates/core/auth.md` (the spec amended by this ADR)
- `docs/architecture/crates/core/config.md` (the `AuthPolicy` change)

View File

@@ -0,0 +1,221 @@
# ADR-026: Forwarded-For Identity (Metadata, Not Authority)
## Status
Accepted (adds a wire-format field and an `OperationContext` field;
included with the ADR-024 migration or a companion task immediately after,
since `OperationContext` and the `from_call` handler are being rewritten)
## Context
When a hub forwards a call to a spoke (via `from_call`, ADR-022), the spoke
authenticates the hub (resolves the hub's identity from the connection)
and checks its ACL: "is the hub authorized to call this operation?" The
spoke's ACL answers yes/no based on the hub's identity. This is per-node
ACL (ADR-024 §3) — the correct authorization model, no "trusted" bypass.
But the spoke is **blind to the originator**. It knows "the hub called me"
but not "alice asked the hub to call me." The hub's `OperationContext.identity`
holds alice's identity (the hub authenticated alice), but the `from_call`
forwarding handler authenticates as the hub (its own `auth_token`), so the
spoke sees the hub's identity, not alice's. The originator information is
at the hub, not at the spoke.
This matters for three use cases the research at
`docs/research/alknet-storage-strategy/findings.md` §6 identified:
1. **Audit trail.** A cross-node call chain is untraceable at the leaf
without the originator. The spoke logs "the hub called `/docker/start`"
but can't log "alice asked the hub to call `/docker/start`." For
debugging, billing, and abuse investigation, the originator matters.
2. **Per-user rate limiting at the leaf.** If the spoke wants to rate-limit
per-user (not per-hub), or apply per-user quotas, it can't — it only
sees the hub. The hub would have to proxy and track everything, which
defeats the point of direct service composition.
3. **Handler context.** A handler may want the originator's identity for
application logic (per-user views, per-user data isolation, attribution
in logs).
The question is whether to include the originator's identity in the
forwarded call. The wire format is the constraint: a field is either in the
`call.requested` payload or it isn't — it can't be bolted on later without
a protocol change. This is a wire-format one-way door.
## Decision
### 1. Add `forwarded_for` to the `call.requested` payload
```json
{
"operationId": "/docker/start",
"input": { ... },
"auth_token": "alk_...", // the direct caller's token (the hub's)
"forwarded_for": { // the original caller (the end user's)
"id": "alice",
"scopes": ["fs:read", "docker:start"],
"resources": {}
}
}
```
`forwarded_for` is optional (`None` when the call is not forwarded, or
when the forwarder chooses not to propagate it). It carries a serialized
`Identity` (id, scopes, resources) — the originator's resolved identity at
the forwarding node.
### 2. Add `forwarded_for` to `OperationContext`
```rust
pub struct OperationContext {
// ... existing fields ...
/// The original caller when this call was forwarded (ADR-026).
/// Metadata only — NOT used by `AccessControl::check`. The dispatch
/// path populates it from the `call.requested.forwarded_for` field;
/// the `from_call` handler sets it when constructing the forwarded
/// payload (see §3). Handlers may read it for logging, auditing,
/// per-user rate limiting, or application context. The ACL check
/// always runs against `identity` (the direct caller), never against
/// `forwarded_for`.
pub forwarded_for: Option<Identity>,
}
```
`identity` is the direct caller (authorized by ACL). `forwarded_for` is
the original caller (metadata only). The ACL check signature is
`AccessControl::check(identity.as_ref())` — unchanged. The
`forwarded_for` field is a **separate** field, not a parameter to `check`.
### 3. The `from_call` handler populates `forwarded_for`
The hub's `from_call` forwarding handler constructs the `call.requested`
payload to send to the spoke. It populates `forwarded_for` with the end
user's identity — read from the hub's `OperationContext.identity` (the
caller the hub authenticated) when the hub forwards the call. The hub
authenticates as itself (its own `auth_token`); the `forwarded_for` field
carries the originator's identity as context.
This is the hub's responsibility, not the protocol's. The protocol carries
the field; the `from_call` handler chooses to populate it. A forwarder that
doesn't want to disclose the originator can set `forwarded_for: None` (the
spoke sees only the hub). A forwarder that wants to propagate it sets it.
### 4. `AccessControl::check` never reads `forwarded_for`
The security property: `forwarded_for` is metadata, not authority. The
spoke's dispatch path makes it available on `OperationContext` for handlers,
but `AccessControl::check(identity.as_ref())` — the ACL check — always
authorizes the **direct caller's** identity, never the forwarded-for
identity. There is no path through which `forwarded_for` becomes an
authorization input.
This is enforced structurally, not by convention: `AccessControl::check`
takes `Option<&Identity>` (the direct caller's identity). The
`forwarded_for` field is `Option<Identity>` on `OperationContext`, but
the check signature doesn't accept it. If someone wants to ACL on the
forwarded-for identity, they would have to change the
`AccessControl::check` signature — a visible, reviewable change, not a
quiet flag flip. The type system prevents accidental misuse.
## Why include it now
The window is the ADR-024 migration. The `from_call` handler is being
rewritten (peer-keyed overlays, `AccessControl`-based peer authorization,
removal of `remote_safe`/`trusted_peer`), and `OperationContext` is being
touched (the `PeerCompositeEnv` aggregation changes how the context is
built). Adding a field to the `call.requested` payload and to
`OperationContext` now is the cheapest point — the structures are already
under edit. After the protocol ships without it, adding it is a breaking
wire-format change (every client and server must learn the new field) and
an `OperationContext` break (every handler that pattern-matches the struct
must update).
## Consequences
**Positive:**
- The spoke can audit cross-node call chains. The leaf knows who actually
initiated the call, not just who forwarded it.
- Per-user rate limiting at the leaf becomes possible. The spoke can key
rate-limit state on `forwarded_for.id` instead of only on the hub's
identity.
- Handler application logic can use the originator's identity for per-user
views, per-user data isolation, or attribution.
- The security model is unchanged: the spoke authorizes the hub (its
direct caller), not the end user. The `forwarded_for` field is metadata,
not authority. The type-system separation (`check` takes `identity`, not
`forwarded_for`) prevents misuse.
- The forwarder decides. A hub that doesn't want to disclose the
originator (e.g., for privacy, or because the originator's identity is
not meaningful to the spoke) sets `forwarded_for: None`. The field is
opt-in by the forwarder, not mandatory.
**Negative:**
- The `call.requested` payload gains a field. Wire-format addition — old
servers that don't recognize `forwarded_for` ignore it (JSON
deserialization into a struct without the field drops it); old clients
that don't send it produce `forwarded_for: None` on the server. This is
forward-compatible, but a server that wants to *use* `forwarded_for`
must be new enough to deserialize it.
- `OperationContext` gains a field. Handlers that construct
`OperationContext` literals (tests, custom dispatch paths) must add the
field. The composition path (`OperationEnv::invoke`) sets it to `None`
for composed children — `forwarded_for` is a wire-ingress field, not a
composition-ingress field.
- The `Identity` in `forwarded_for` is a serialized value on the wire,
not a server-resolved identity. The spoke receives the hub's *claim*
about the originator's identity. A malicious hub could lie — set
`forwarded_for` to a fake identity. The spoke must not treat
`forwarded_for` as authoritative for anything security-relevant; it's
the hub's assertion, useful for audit/attribution when the hub is
trusted, but not a verified identity. This is the inherent property of
forwarded-for metadata (same as HTTP `X-Forwarded-For` — it's a claim by
the forwarder, not a verified value).
- One more field for the `from_call` handler to populate correctly. The
handler must read the hub's `OperationContext.identity` and decide
whether to propagate it. This is a small implementation cost, but it's a
handler-responsibility increase.
## Assumptions
1. **`forwarded_for` is a claim by the forwarder, not a verified
identity.** The spoke receives the hub's assertion about the
originator. A malicious hub can lie. The spoke must not use
`forwarded_for` as authoritative for security decisions — only for
audit, logging, and application-context purposes when the hub is
trusted. This is the same property as HTTP `X-Forwarded-For`.
2. **`AccessControl::check` never reads `forwarded_for`.** The security
property is structural (the check signature doesn't accept it), not
conventional. Adding `forwarded_for` to the ACL path would require a
signature change to `AccessControl::check` — a visible, reviewable
change.
3. **`forwarded_for` is wire-ingress only.** Composed children (calls via
`OperationEnv::invoke`) do not inherit `forwarded_for` — they get
`None`. The field is populated from `call.requested.forwarded_for` by
the dispatch path, and the `from_call` forwarding handler sets it when
constructing the forwarded payload. Composition-propagation of
`forwarded_for` would be a separate decision (not in this ADR).
4. **The `Identity` shape in `forwarded_for` is the same as `Identity`
on `OperationContext`.** Both carry `id`, `scopes`, `resources`. The
`forwarded_for` value is a serialized `Identity` from the forwarding
node's resolution — the same `Identity` the hub resolved for the end
user, just passed along as metadata.
## References
- ADR-010: Secret Material Flow and Capability Injection (`forwarded_for`
carries an `Identity` with scopes/resources, not secret material — the
no-secret-material-on-the-wire invariant is preserved)
- ADR-017: Privilege Model and Authority Context (the authority-switch
model — `forwarded_for` does not participate; the direct caller's
identity is the authority)
- ADR-022: Call Protocol Client and Adapter Contract (the `from_call`
forwarding handler that populates `forwarded_for`)
- ADR-024: Peer-Graph Routing Model (the migration window —
`OperationContext` and the `from_call` handler are being rewritten)
- `docs/research/alknet-storage-strategy/findings.md` §6 (the
forwarded-for identity decision and rationale)

View File

@@ -0,0 +1,180 @@
# ADR-027: `from_jsonschema` as an HTTP-Backed Single-Endpoint Adapter in alknet-http
## Status
Accepted (supersedes the `from_jsonschema` clause of ADR-022 §5 and the
`FromJsonSchema` provenance row of ADR-018 — both described a schema-only,
no-handler adapter in `alknet-call`)
## Context
`from_jsonschema` was originally specified (ADR-022 §5) as a schema-only
adapter living in `alknet-call`: it produced `HandlerRegistration` bundles
with a `NOT_FOUND`-returning placeholder handler and `FromJsonSchema`
provenance. The stated use case was validation, discovery, and
composition-graph construction without a runtime — type-checking a
composition plan without executing it, building a UI of available
operations without standing up the transports.
This is broken. An operation in the `OperationRegistry` needs a real
handler. A placeholder that returns `NOT_FOUND` does not work with how
the registry is supposed to function: an `Internal` op registered with
a dead handler is a trap, not a feature. The "schema-only, no handler"
concept conflated two things — schema *validation* (a compile-time /
planning activity that doesn't need a registry entry at all) and
operation *registration* (which always needs a handler). Validation
against a JSON Schema does not require a `HandlerRegistration`; it
requires the schema and a validator. Registering an operation requires
a handler. The old `from_jsonschema` tried to do the former by abusing
the latter, and produced something that works for neither.
The misplacement was compounded by a location error: the adapter lived
in `alknet-call` (which is supposed to stay lean — no HTTP client), but
a `from_jsonschema` that is actually useful for calling non-standard
endpoints needs reqwest, exactly like `from_openapi` and `from_mcp`.
The adapter location map in ADR-022 / `client-and-adapters.md` already
establishes that HTTP-backed adapters live in `alknet-http`; the old
`from_jsonschema` violated its own stated principle by living in
`alknet-call`.
A concrete use case now forces the decision: composing a non-standard,
non-OpenAPI, basic REST endpoint that does not have a full OpenAPI
document. The endpoint has a method, a URL, an input/output JSON Schema,
and an auth scheme — but no `paths` object, no `operationId`, no
`components`. `from_openapi` requires an OpenAPI document; this endpoint
doesn't have one. The gap is: register a single HTTP endpoint as a
call-protocol operation, one at a time, with the caller supplying the
schema directly.
## Decision
`from_jsonschema` becomes an HTTP-backed single-endpoint adapter in
`alknet-http`, functionally similar to `from_openapi` but registering
one endpoint at a time instead of parsing a full OpenAPI document:
1. **Move the adapter implementation to `alknet-http`**
(`crates/alknet-http/src/adapters/from_jsonschema.rs`). The
forwarding handler uses the same reqwest-backed `SharedHttpClient`
and the same no-env-vars credential injection as `from_openapi`. The
adapter implements `OperationAdapter` (the trait from `alknet-call`,
ADR-022 §5 — unchanged).
2. **Give it a real forwarding handler.** A `from_jsonschema`-imported
operation is a leaf with a reqwest forwarding handler, identical in
shape to a `from_openapi`-imported operation — it builds an HTTP
request from the input (path/query/body split per a path template),
injects credentials from `context.capabilities`, sends via the shared
HTTP client, and parses the response (JSON, text, or binary — same
content-type branching as `from_openapi`). For a `Subscription`
op type with `text/event-stream` response, it registers a
`StreamingHandler` (ADR-021), same as `from_openapi`.
3. **Single-endpoint registration.** The caller supplies:
- An `OperationSpec` (name, op type, input/output JSON Schema,
`error_schemas`, `access_control`, `visibility`).
- An `HttpServiceConfig` (base URL, auth scheme, default headers —
the same config type `from_openapi` uses).
- A path template + HTTP method (the one endpoint).
The adapter builds one `HandlerRegistration` with `FromJsonSchema`
provenance and a real forwarding handler. The caller registers it in
the `OperationRegistry`. This is the "one endpoint at a time" shape:
no `paths` object to iterate, no `operationId` to normalize.
4. **`FromJsonSchema` provenance stays in `alknet-call`** (in the
`OperationProvenance` enum, `registration.rs`). The provenance type
lives where the registry types live; only the adapter implementation
moves. `FromJsonSchema` is now a leaf provenance — it has a handler
(a reqwest forwarding handler), same trust model as `FromOpenAPI`
(HTTP endpoint trusted; handler is a forwarding stub).
5. **Remove the "schema-only, no handler" concept.** The placeholder
handler and the "schema-only ops are `Internal`, so dispatch should
never reach them" rationale are removed. An op registered with
`FromJsonSchema` provenance is a real, callable, HTTP-forwarding
operation — `Internal` by default (adapter-registered ops are
composition material, ADR-017), but it actually forwards if invoked.
The schema-validation-without-a-handler use case (type-checking a
composition plan, building a UI) does not require a
`HandlerRegistration` at all. That use case is served by consuming
the `OperationSpec` directly (the spec already carries the input/
output JSON Schemas); no adapter, no registry entry, no handler is
needed. If a future use case requires registering a schema-only op
for discovery purposes, that is a separate feature and would warrant
its own ADR — it is not what `from_jsonschema` is.
### Relationship to `from_openapi`
| | `from_openapi` | `from_jsonschema` |
|---|---|---|
| Input | A full OpenAPI 3.x document (JSON or YAML) | A single endpoint: `OperationSpec` + `HttpServiceConfig` + path template + method |
| Granularity | One `HandlerRegistration` per `(path, method)` in the doc | One `HandlerRegistration` per call |
| Schema source | Parsed from the OpenAPI doc (parameters, request body, responses) | Supplied directly by the caller |
| Handler | reqwest forwarding handler (shared HTTP client) | Same reqwest forwarding handler |
| Provenance | `FromOpenAPI` | `FromJsonSchema` |
| Location | `alknet-http` | `alknet-http` |
| Use case | Standard OpenAPI APIs (GitHub, OpenAI, Anthropic) | Non-standard, non-OpenAPI, or basic REST endpoints without a full spec |
The two adapters share the forwarding-handler implementation, the
credential injection path, the error-fidelity rule (`HTTP_<status>`
prefix, ADR-016), and the no-env-vars invariant (ADR-010). The
difference is purely the input shape: a full document vs. a single
endpoint.
## Consequences
**Positive**:
- `from_jsonschema` actually works — it has a real handler, not a
placeholder. A concrete use case (non-standard REST endpoints) is
served.
- The adapter location is consistent: all HTTP-backed adapters
(`from_openapi`, `from_mcp`, `from_jsonschema`) live in `alknet-http`,
where reqwest is. `alknet-call` stays lean.
- The "schema-only, no handler" trap is removed. An op in the registry
is always callable.
- `FromJsonSchema` provenance becomes a real leaf, consistent with
`FromOpenAPI`/`FromMCP`/`FromCall`.
**Negative**:
- The schema-validation-without-a-handler use case (the original stated
purpose) is no longer served by `from_jsonschema`. That use case is
served by consuming `OperationSpec` directly, but any code that relied
on the placeholder handler returning `NOT_FOUND` breaks. The only
existing consumer is the call crate's own tests; no downstream consumer
depended on this — the placeholder was a trap, not a contract.
- `alknet-call` loses a public export (`from_jsonschema`, `FromJsonSchema`
the adapter struct). The `FromJsonSchema` provenance variant stays;
the adapter struct moves. Downstream consumers that referenced the
adapter (none currently) would need to use `alknet-http`'s re-export.
**Neutral**:
- `FromJsonSchema` provenance is now a leaf (handler-bearing), not a
"no handler" provenance. The ADR-018 table row updates: it can compose?
No. Has composition authority? No. Default visibility? Internal. Trust
model? HTTP endpoint trusted; handler is a forwarding stub. This
aligns with the other leaves. ADR-022 §5 and ADR-018's provenance
table/enum-doc are amended (2026-07-09) to point here — the
supersession is recorded in the superseded ADRs, not only in this one.
## References
- Supersedes the `from_jsonschema` clause of
[ADR-022](022-call-protocol-client-and-adapter-contract.md) §5
("`FromJsonSchema` — imports from a JSON Schema definition (schema-only,
no handler)") and the operational spec in
`docs/architecture/crates/call/client-and-adapters.md` §"from_jsonschema".
- Supersedes the `FromJsonSchema` row of
[ADR-018](018-handler-registration-provenance-and-composition-authority.md)
(the "no handler — schema only" framing).
- Aligns with the adapter location principle in
[ADR-022](022-call-protocol-client-and-adapter-contract.md) §5 and
`client-and-adapters.md` §"Adapter Location Map": HTTP-backed adapters
live in `alknet-http`.
- Reuses the forwarding handler, credential injection, error fidelity
(`HTTP_<status>` prefix, [ADR-016](016-operation-error-schemas.md)),
streaming shape ([ADR-021](021-streaming-handler-for-subscriptions.md)),
and no-env-vars invariant ([ADR-010](010-secret-material-flow-and-capability-injection.md))
established by `from_openapi`.
- Reuses `HttpServiceConfig` and `SharedHttpClient` from
`from_openapi` (in `alknet-http`).

View File

@@ -0,0 +1,126 @@
# ADR-028: from_call Is a Manual Free Function, Not Auto-Wired
## Status
Proposed (amended 2026-07-16 by ADR-045 §5: `CallClient::connect()` is
removed; `from_call` is now called by the assembly layer after
`AlknetClient::dial_*` + `CallClient::new(...).spawn_dispatch(conn)`,
not after `connect()`. The manual-free-function decision stands; the
`connect()` references in the body are the pre-ADR-045 shape.)
## Context
OQ-27 resolved (2026-06-27): "The decision is **auto-re-import on connection
establishment**. The overlay is per-connection (Layer 2, ADR-019), so a stale
overlay dies with the connection; re-import on reconnect is naturally scoped to
the new connection."
The spec in `client-and-adapters.md` §"from_call" (line 358) states: "This is
the v1 default; explicit re-import via a future `CallConnection::refresh()` is
additive."
The implementation does not match. `from_call` is a standalone free function
(`client/from_call.rs:80`). `CallClient::connect()` does not call it. The
assembly layer must call `from_call()` + `register_imported_all()` explicitly
after every `connect()`. There is no `CallConnection::refresh()`.
The "v1 default" language is hedging — it makes a committed-but-not-implemented
feature sound like a deliberate phase. The spec says "auto-re-import on
connection establishment" but the code says "the assembly layer calls
`from_call` immediately after `connect()`" (the doc comment on `from_call`,
line 76). These are different things: auto-wiring means `connect()` calls
`from_call()` internally; manual means the caller does it.
The alkapi project identified this as gap G.4: the hedging language in the
spec, and the question of whether `from_call` should be auto-wired into
`connect()`.
## Decision
**`from_call` is a manual free function. The assembly layer calls it after
`connect()`. It is not auto-wired into `CallClient::connect()`.**
### Why manual is correct
1. **The hub controls discovery timing.** A hub may want to verify the
connection, resolve the peer's identity, check authorization, and *then*
discover operations. Auto-wiring `from_call` into `connect()` would run
discovery before the assembly layer has a chance to inspect the connection.
2. **Discovery is not always wanted.** A pure-client connection to a public
X.509 endpoint (ADR-034) has no `PeerEntry` and no `PeerId` — the remote
is not in the peer graph. Auto-discovering ops on such a connection would
register them in a connection overlay that has no peer key, making them
unreachable via `PeerRef`. The assembly layer decides whether to run
`from_call` based on whether the remote is a known peer.
3. **The `from_call` function is already the right API.** It takes a
`&CallConnection` and a `FromCallConfig`, returns
`Result<Vec<HandlerRegistration>, AdapterError>`, and the caller registers
the bundles. This is a clean separation: connect, discover, register. Each
step is independently testable and independently controllable.
4. **Auto-wiring would require `from_call` to know about the registry.**
`CallClient` holds an `Arc<OperationRegistry>`, but `from_call` produces
`HandlerRegistration` bundles that the caller registers — the caller
decides *where* to register them (the connection's overlay, a session
overlay, or not at all). Auto-wiring would hardcode the registration
target.
### What changes in the spec
The "v1 default" language in `client-and-adapters.md` and ADR-022 is replaced
with an honest statement: `from_call` is a free function; the assembly layer
calls it after `connect()`; there is no `CallConnection::refresh()` for
mid-connection re-discovery. A `CallConnection::refresh()` method is a
genuine feature addition — non-breaking, additive — if a deployment needs
manual re-discovery without drop-and-reconnect.
OQ-27 is updated: the resolution changes from "auto-re-import on connection
establishment" to "manual — the assembly layer calls `from_call` after
`connect()`." The door type remains two-way (auto-wiring is additive).
### What does NOT change
- The `from_call` function signature, behavior, and tests are unchanged.
- `CallClient::connect()` is unchanged.
- The re-import-on-reconnect pattern is unchanged: the assembly layer's
supervision loop calls `from_call` after each `connect()`. The overlay is
per-connection, so a stale overlay dies with the connection; re-import on
reconnect is naturally scoped. This is the correct behavior — it just
isn't automatic.
## Consequences
**Positive:**
- The spec matches the implementation. No hedging language.
- The assembly layer has full control over discovery timing and registration
target.
- The separation of concerns (connect / discover / register) is clean and
testable.
- No code changes needed — this is a spec correction, not an implementation
change.
**Negative:**
- The assembly layer must remember to call `from_call` after `connect()`.
This is a documentation concern, not a correctness concern — forgetting to
call `from_call` means the peer's ops are not imported, which is immediately
visible (calls to those ops return `NOT_FOUND`).
- The "auto-re-import on connection establishment" resolution of OQ-27 was
aspirational and is now corrected. The resolution was written before the
implementation existed; the implementation made the right call (manual),
and the spec is catching up.
## References
- ADR-022 §3: `from_call` adapter specification
- ADR-022 Amendments (DC-2): the amended `from_call` re-import resolution
(manual free function)
- ADR-029: Aggregated Peer-Environment Wiring (sibling hub-wiring decision)
- ADR-030: PeerCompositeEnv::peer_operations Override (sibling hub-wiring decision)
- OQ-27: from_call re-import trigger (amended 2026-07-09)
- `client-and-adapters.md` §"from_call" (updated)
- `crates/alknet-call/src/client/from_call.rs:80``from_call` free function
- `crates/alknet-call/src/client/call_client.rs:142-168``connect()` does
not call `from_call`
- alkapi gap G.4: `from_call` wiring + "v1 default" hedging cleanup

View File

@@ -0,0 +1,213 @@
# ADR-029: Aggregated Peer-Environment Wiring for Hub Deployments
## Status
Proposed
## Context
`Dispatcher::compose_root_env` (`protocol/dispatch.rs:134-154`) constructs a
**fresh** `PeerCompositeEnv` per call and attaches **only the current call's
own connection** as a peer overlay. It does not aggregate the hub's other live
worker connections into that call's environment.
Consequence: on a hub with N connected workers, a handler composing
`env.invoke_peer(&PeerRef::Specific("dev1"), "docker", "container/exec",
input, &ctx, policy)` from a call that arrived on an HTTP connection (or on a
*different* worker's connection) will **not** find dev1's overlay — the routing
falls through to the curated base and returns `NOT_FOUND`.
The `PeerCompositeEnv` *type* and the `invoke_peer` routing logic are built for
multi-peer aggregation (`attach_peer`/`detach_peer` with insertion-order
preservation, `PeerRef::Specific`/`Any` routing — `registry/env.rs:155-301`).
The per-call `compose_root_env` does not use that capability. The ADR-024
*model* is committed; the implementation is incomplete for the head→N-workers
case.
This is the single highest-impact gap a first hub consumer (alkapi) surfaces.
A hub is *defined* by composing ops across its connected workers. Without an
aggregated env shared across all calls, the hub pattern does not work: a call
arriving on one transport cannot reach a worker connected on another.
The alkapi project identified this as OQ-08 and committed to the aggregation
decision in their ADR-006. The decision to aggregate is made; the question is
where the wiring lives — alknet (reusable by any hub) or a hub-side wrapper
(alkapi-only). This ADR resolves that question: the wiring lives in alknet.
## Decision
### 1. `Dispatcher` gains a `with_aggregated_env` builder method
A new optional field on `Dispatcher` holds a shared aggregated
`PeerCompositeEnv`:
```rust
pub struct Dispatcher {
pub registry: Arc<OperationRegistry>,
pub identity_provider: Arc<dyn IdentityProvider>,
pub session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
pub ownership_provider: Option<Arc<dyn OwnershipProvider>>,
pub aggregated_env: Option<Arc<std::sync::RwLock<PeerCompositeEnv>>>,
pub default_timeout: Duration,
}
impl Dispatcher {
pub fn with_aggregated_env(
mut self,
env: Arc<std::sync::RwLock<PeerCompositeEnv>>,
) -> Self {
self.aggregated_env = Some(env);
self
}
}
```
The builder method mirrors `with_session_source` and `with_ownership_provider`
— an optional hook the assembly layer wires at construction time. A deployment
that does not set an aggregated env gets today's `compose_root_env` behavior
unchanged.
### 2. `CallAdapter` gains a matching `with_aggregated_env` builder method
`CallAdapter` delegates to `Dispatcher`:
```rust
impl CallAdapter {
pub fn with_aggregated_env(
mut self,
env: Arc<std::sync::RwLock<PeerCompositeEnv>>,
) -> Self {
self.dispatcher = self.dispatcher.with_aggregated_env(env);
self
}
}
```
### 3. `compose_root_env` reads the aggregated env when set
When `aggregated_env` is `Some`, `compose_root_env` reads the shared env,
attaches the current connection's overlay as an override for the current call
only, and returns the result. When `None`, the existing per-call behavior is
preserved:
```rust
pub fn compose_root_env(
&self,
connection: &CallConnection,
context: &OperationContext,
) -> Arc<dyn OperationEnv + Send + Sync> {
let base: Arc<dyn OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
let session = self
.session_source
.as_ref()
.and_then(|s| s.overlay_for(context));
if let Some(aggregated) = &self.aggregated_env {
// Acquire read lock on the shared aggregated env, clone it (cheap —
// all fields are Arc), and release the lock. The clone is the
// per-call snapshot; the lock is not held for the call duration.
let mut env = aggregated
.read()
.expect("aggregated env lock poisoned")
.clone();
// Attach the current connection's overlay as an override for this
// call only. The current connection's overlay is the authoritative
// view of *that* peer; the aggregated env is the authoritative view
// of *all other* peers. This avoids a race where the aggregated env
// has not yet picked up a new op the current peer just registered.
if let Some(peer_id) = connection.identity().map(|identity| identity.id.clone()) {
env.attach_peer(peer_id, connection.overlay_env());
}
Arc::new(env)
} else {
let mut env = PeerCompositeEnv::new(base);
if let Some(session) = session {
env = env.with_session(session);
}
if let Some(peer_id) = connection.identity().map(|identity| identity.id.clone()) {
env.attach_peer(peer_id, connection.overlay_env());
}
Arc::new(env)
}
}
```
The clone of the aggregated env is cheap: `PeerCompositeEnv`'s fields are all
`Arc` (the `HashMap` values are `Arc<dyn OperationEnv>`, the `Vec` is
`Vec<PeerId>` which is a `String` clone). The `RwLock::read()` is held only
for the clone, not for the duration of the call.
### 4. The hub owns the aggregated env lifecycle
The hub (assembly layer) constructs the aggregated env once at startup:
```rust
let base: Arc<dyn OperationEnv + Send + Sync> =
Arc::new(LocalOperationEnv::new(Arc::clone(&registry)));
let aggregated = Arc::new(RwLock::new(PeerCompositeEnv::new(base)));
let adapter = CallAdapter::new(registry, identity_provider)
.with_aggregated_env(Arc::clone(&aggregated));
```
The hub calls `attach_peer(peer_id, overlay)` on the aggregated env on every
worker connection-establish (after `from_call` populates the overlay) and
`detach_peer(&peer_id)` on every disconnect. The write lock is held only for
the `HashMap` insert/remove — connection-rate, not call-rate.
### 5. `PeerCompositeEnv` gains `Clone`
`PeerCompositeEnv` is made `Clone` (all fields are `Arc` or `Clone` already).
This is a one-line derive addition.
## Consequences
**Positive:**
- The hub pattern works. A call arriving on any transport can reach any
connected worker's ops via `PeerRef::Specific` or `PeerRef::Any`.
- The existing single-connection behavior is preserved. A deployment that does
not set an aggregated env gets today's `compose_root_env` unchanged.
- The hook is additive — a new optional field, a new builder method, a branch
in `compose_root_env`. No existing code path changes.
- The capability is reusable by any future hub, not just alkapi. The alkapi
project's ADR-006 fallback (hub-side wrapper) is no longer needed.
**Negative:**
- A `RwLock<PeerCompositeEnv>` on the read hot path of every dispatch. The
lock is held only for a clone (all `Arc` fields — cheap). An `ArcSwap`
copy-on-write variant could avoid the lock on reads entirely, at the cost
of a clone on `attach_peer`/`detach_peer` (infrequent). The `RwLock` is the
simpler starting point; `ArcSwap` is an additive optimization.
- `PeerCompositeEnv` gains `Clone`. The derive is mechanical; all fields are
already `Clone`.
- The hub must manage the aggregated env lifecycle (`attach_peer`/`detach_peer`
on connection events). This is assembly-layer code, not alknet-call code.
The hooks exist; the hub wires them.
## Assumptions
1. **`PeerCompositeEnv` clone is cheap.** All fields are `Arc` or `Clone` of
small types (`String`, `Vec<String>`). The clone does not copy the
operation registries or the connection overlays — it copies `Arc` pointers.
2. **The current connection's overlay is authoritative for that peer.** A call
arriving on worker-a uses worker-a's live overlay as the view of worker-a
(not the aggregated env's possibly-stale snapshot), and the aggregated env
for all other peers. This avoids a race where the aggregated env has not
yet picked up a new op worker-a just registered.
3. **The `RwLock` is not a contention point.** Reads (clones) are call-rate
but the lock is held only for the clone duration (microseconds). Writes
(`attach_peer`/`detach_peer`) are connection-rate (seconds to minutes). If
profiling shows contention, `ArcSwap` is the additive optimization.
## References
- ADR-024: Peer-Graph Routing Model (the model this wiring completes)
- ADR-025: PeerEntry and Identity.id Decoupling (the `PeerId` source)
- ADR-030: PeerCompositeEnv::peer_operations Override (sibling hub-wiring decision)
- ADR-028: from_call Is a Manual Free Function (sibling hub-wiring decision)
- alkapi ADR-006: Aggregated Peer Environment (the downstream commitment)
- alkapi OQ-08: alknet aggregated peer-env wiring (the blocking question)
- `crates/alknet-call/src/protocol/dispatch.rs:134-154` — current
`compose_root_env`
- `crates/alknet-call/src/registry/env.rs:155-301``PeerCompositeEnv`

View File

@@ -0,0 +1,154 @@
# ADR-030: PeerCompositeEnv::peer_operations Override
## Status
Proposed
## Context
`OperationEnv::peer_operations` (defined in `registry/env.rs:63-65`) has a
default implementation returning `Vec::new()`. `PeerCompositeEnv` overrides
`invoke_with_policy`, `contains`, `invoke_peer`, `peer_contains`, and
`peer_ids` — but does **not** override `peer_operations`. This means
`peer_operations` on a `PeerCompositeEnv` always returns an empty `Vec`.
The `services/list-peers` handler (`registry/discovery.rs:245-296`) calls
`ctx.env.peer_operations(&peer_id)` to discover what operations each peer
serves. Since `PeerCompositeEnv` does not override this, non-local peers
always show empty operation lists in the `list-peers` response. The
`peer_ids()` method correctly returns the peer IDs, but the operations for
each peer are always empty.
This is a pure gap — the `services/list-peers` handler is specced to enumerate
each peer's operations (ADR-024 §6), and the `PeerCompositeEnv` type has all
the data needed to implement it (each peer's `OverlayOperationEnv` holds a
`HashMap<String, HandlerRegistration>`). The override is one method collecting
each peer overlay's registered op names.
The alkapi project identified this as gap G.6: a hub consumer calling
`services/list-peers` gets `peers: [{peer_id: "dev1", operations: []}]` until
this is fixed.
## Decision
`PeerCompositeEnv` overrides `peer_operations` to collect the operation names
from each peer's connection overlay:
```rust
fn peer_operations(&self, peer: &PeerId) -> Vec<String> {
match self.connections.get(peer) {
Some(overlay) => {
// The overlay is an OverlayOperationEnv wrapping a
// HashMap<String, HandlerRegistration>. We need the op names.
// Rather than adding a method to OperationEnv (which would
// require every impl to add it), we use the existing `contains`
// method — but that requires knowing the name to check.
//
// The correct approach: iterate the overlay's known names.
// OverlayOperationEnv already has the data (the HashMap keys).
// We add a `list_operation_names(&self) -> Vec<String>` method
// to OperationEnv with a default returning Vec::new(), and
// OverlayOperationEnv overrides it to return the keys.
overlay.list_operation_names()
}
None => Vec::new(),
}
}
```
### 1. `OperationEnv` gains `list_operation_names` with a default impl
```rust
fn list_operation_names(&self) -> Vec<String> {
Vec::new()
}
```
The default returns empty — existing impls (`LocalOperationEnv`, test-only
envs) don't need to change. Only `OverlayOperationEnv` overrides it.
### 2. `OverlayOperationEnv` overrides `list_operation_names`
```rust
impl OperationEnv for OverlayOperationEnv {
fn list_operation_names(&self) -> Vec<String> {
self.overlay.read().keys().cloned().collect()
}
// ... existing impl unchanged
}
```
### 3. `PeerCompositeEnv::peer_operations` uses `list_operation_names`
The override delegates to each peer's overlay:
```rust
fn peer_operations(&self, peer: &PeerId) -> Vec<String> {
self.connections
.get(peer)
.map(|overlay| overlay.list_operation_names())
.unwrap_or_default()
}
```
### Why a new trait method instead of a different approach
Alternatives considered:
- **Add `fn operations(&self) -> Vec<String>` to `OperationEnv`**: Same
concept, different name. `list_operation_names` is chosen to match the
existing `list_operations` naming on `OperationRegistry`.
- **Make `peer_operations` on `PeerCompositeEnv` reach into
`OverlayOperationEnv`'s internals**: Requires `OverlayOperationEnv` to
expose its `HashMap` or a method. The trait method is cleaner — it keeps
the abstraction boundary intact.
- **Have `services/list-peers` iterate `ctx.env.peer_ids()` and call
`contains` for every known op name**: Requires knowing all possible op
names (from the registry), which is a cross-layer coupling. The trait
method keeps the data where it lives.
The trait method is the smallest surface change: one new method with a
default impl, one override on `OverlayOperationEnv`, one override on
`PeerCompositeEnv`. No existing code changes.
## Consequences
**Positive:**
- `services/list-peers` returns actual operation lists for each peer. A hub
consumer calling `services/list-peers` gets `peers: [{peer_id: "dev1",
operations: [{name: "docker/container/exec", ...}, ...]}]` — the specced
behavior.
- The fix is small: one trait method, two overrides. No existing code paths
change.
- The `list_operation_names` method is generally useful — any future code
that needs to enumerate an env's operations can use it.
**Negative:**
- `OperationEnv` gains a method. The default impl preserves back-compat for
all existing implementors. Only `OverlayOperationEnv` and
`PeerCompositeEnv` override it.
- The `OverlayOperationEnv` override holds the `RwLock` read for the
duration of the `keys().cloned().collect()`. This is a `Vec<String>`
allocation — cheap for typical peer operation counts (tens, not thousands).
## Assumptions
1. **`OverlayOperationEnv`'s `RwLock<HashMap<String, HandlerRegistration>>`
read is cheap.** The lock is held only for the `keys()` iteration and
`collect()`. Typical peer operation counts are small (tens of ops).
2. **`list_operation_names` is the right name.** It matches the existing
`list_operations` naming on `OperationRegistry` and avoids confusion with
`peer_operations` (which takes a `PeerId` parameter).
## References
- ADR-024 §6: `services/list-peers` opt-in peer-attributed re-export listing
- ADR-029: Aggregated Peer-Environment Wiring (sibling hub-wiring decision)
- ADR-028: from_call Is a Manual Free Function (sibling hub-wiring decision)
- `crates/alknet-call/src/registry/env.rs:63-65` — default `peer_operations`
- `crates/alknet-call/src/registry/env.rs:155-301``PeerCompositeEnv`
- `crates/alknet-call/src/protocol/connection.rs:305-397`
`OverlayOperationEnv`
- `crates/alknet-call/src/registry/discovery.rs:245-296`
`services_list_peers_handler`
- alkapi gap G.6: `PeerCompositeEnv::peer_operations` unimplemented

View File

@@ -0,0 +1,143 @@
# ADR-031: Crate Decomposition
## Status
Accepted
## Context
The previous alknet-core crate was a monolith containing transport, interface, server, client, call, auth, config, socks5, credentials, and HTTP — all in one crate with interdependent modules. This created coupling (interface types depended on auth, server depended on call, everything depended on config) and made it impossible to use individual components independently.
The new ALPN dispatch model eliminates the need for a shared interface layer. Each handler is self-contained — it receives a byte stream and manages its own protocol. This naturally decomposes into separate crates.
Key constraints:
- Protocol crates must depend on alknet-core for auth/identity/config — but not on each other
- alknet-vault is already standalone (no alknet-core dependency) and must remain so (see ADR-008)
- The CLI binary assembles everything — it's the only crate that depends on all handler crates
- Handlers with protocol-agnostic cores (SFTP, call protocol) preserve the WASM door — browser clients can implement the wire format over WebTransport (see ADR-032, ADR-033)
- alknet-call includes the call protocol client and adapter traits, not just the server side — this enables alknet-agent and alknet-napi to use it for remote invocation
- Rust is the canonical implementation language. TypeScript is a reference/browser adaptation, not a parallel implementation (see ADR-033)
## Decision
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, 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 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 |
| `alknet-msg` | MessageAdapter (E2E encryption, mixnet) | alknet-core |
| `alknet-http` | HttpAdapter (axum, REST API, MCP endpoint) | alknet-core, axum |
| `alknet-dns` | DnsAdapter (hickory-proto, pkarr, service discovery) | alknet-core, hickory-proto |
| `alknet-napi` | Node.js native addon — thin NAPI projection of the call protocol client | alknet-call, napi-rs |
| `alknet` | CLI binary — registers handlers, starts endpoint | all handler crates, alknet-vault |
Dependency flow:
```
alknet-vault (standalone)
alknet-core ← all handler crates ← alknet (CLI)
alknet-call ← alknet-agent
alknet-call ← alknet-napi
```
No handler crate depends on another handler crate. Cross-handler communication goes through the call protocol (alknet-call) or through alknet-core's endpoint.
alknet-agent depends on alknet-call (not alknet-core directly) because it uses the call protocol client for tool dispatch and the operation registry for tool registration. It receives LLM provider keys through capabilities injected at the assembly layer (from alknet-vault), never from environment variables and never over the call protocol. See ADR-008 and ADR-010.
alknet-napi is a thin projection layer — it exposes the Rust call protocol client to Node.js via NAPI. It does not contain business logic or adapter implementations. See ADR-033.
## Consequences
**Positive:**
- Each handler can be developed, tested, and versioned independently
- WASM-compatible handlers (sftp, call) don't pull in heavy dependencies (russh, axum)
- alknet-vault remains standalone — no circular dependency risk
- New handlers are added by creating a crate and registering it with the endpoint
- Clean separation of concerns — each crate has one job
**Negative:**
- More crates to manage in the workspace — workspace Cargo.toml and version coordination
- Shared types (AuthContext, BiStream) must live in alknet-core — if they change, all handlers recompile
- The CLI binary has a large dependency tree (all handlers) — but this is expected for a binary that assembles everything
- Testing cross-handler behavior requires integration tests in the CLI or a test utility crate
## References
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- ADR-001: ALPN-based protocol dispatch
- ADR-002: ProtocolHandler trait
- ADR-003: Auth as shared core (IdentityProvider)
- ADR-013: irpc as call protocol foundation (superseded by ADR-014)
## Amendments
### Amendment 1 (2026-06-29): `alknet-call` is a protocol-foundation crate
The Decision table lists `alknet-call` as a handler crate that "depends
on alknet-core, irpc." The dependency-flow diagram and the "No handler
crate depends on another handler crate" rule were written before
`alknet-http` (which implements `from_openapi`/`from_mcp`/`to_openapi`/
`to_mcp` and therefore needs `alknet-call`'s `OperationSpec`, `Handler`,
`HandlerRegistration`, and `OperationAdapter` trait) was specced.
**Clarification:** `alknet-call` is both a handler crate (it implements
`ProtocolHandler` on ALPN `alknet/call`) *and* the protocol-foundation
crate that `alknet-agent`, `alknet-napi`, and `alknet-http` consume for
the operation registry, adapter contract, and call client. The "no
handler crate depends on another handler crate" rule applies to peer
handler crates (e.g., `alknet-http` does not depend on `alknet-ssh`);
`alknet-call` is a protocol-foundation crate in the same spirit that
`alknet-core` is, just at a different layer (operations/RPC vs.
transport/auth/config).
`alknet-http` depending on `alknet-call` is "HTTP uses the call protocol
types," not "HTTP depends on SSH." This is within the spirit of this
ADR's decomposition. The `alknet-call``alknet-http` edge is recorded
in the `alknet-http` spec (`crates/http/overview.md`) and in the adapter
location map (`crates/call/client-and-adapters.md`).
### Amendment 2 (2026-07-07): alknet-tty does not depend on alknet-call
Amendment 1's protocol-foundation framing was extended to alknet-tty in
an earlier draft ("alknet-tty depends on alknet-call for the
`FrameFramedReader`/`FrameFramedWriter` framing utility"). A
pre-implementation sanity check found this was unsound:
`FrameFramedReader::read_frame()` is hardcoded to deserialize
`EventEnvelope` — the length-prefix read and the type-specific
deserialize are one entangled call, not a separable "framing utility."
alknet-tty's negotiation frame is a `NegotiateRequest`, not an
`EventEnvelope`, so `read_frame()` cannot return what alknet-tty needs;
the claimed reuse did not exist in a usable form.
**Clarification:** alknet-tty does **not** depend on alknet-call.
alknet-tty implements its own length-prefixed framing (~30 lines: 4-byte
big-endian length + UTF-8 JSON body) directly on tokio's
`AsyncRead`/`AsyncWrite`. The format coincides with alknet-call's
framing by convention (both are length-prefixed JSON); the
implementations are independent. The Amendment 1 protocol-foundation
exception remains for alknet-http/agent/napi (which use alknet-call's
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type reuse,
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).
### 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-033), not from irpc.
The dead `irpc` / `irpc-derive` workspace deps and the `alknet-call` consumer
dep were removed in commit `668d777`. See
[ADR-014](014-irpc-never-integrated-hand-rolled-framing.md) for the full
record (ADR-013, which accepted "irpc as the call protocol foundation," is
superseded).

View File

@@ -0,0 +1,72 @@
# ADR-032: One-Way Door Decision Framework
## Status
Accepted
## Context
Not all architectural decisions carry the same reversal cost. Some decisions are easy to change later — if you pick the wrong data structure, you refactor. Other decisions are nearly impossible to reverse — if you build a type hierarchy that forecloses WASM compatibility, every handler written against that hierarchy must be rewritten.
This distinction matters especially during Phase 0 (exploration) and early Phase 1 (architecture). The project is post-pivot with foundational ADRs in place but no implementation code yet (except alknet-vault). Decisions made now shape the API surface that every handler depends on.
Without an explicit framework, one-way doors can be treated as casually as two-way doors, leading to costly rework. Or conversely, two-way doors can be over-analyzed, blocking progress on decisions that are cheap to reverse.
## Decision
### Classification
Every architectural decision is classified by **reversal cost** — how expensive it is to undo if you got it wrong:
**One-way door** — Reversing this decision requires rewriting significant code across multiple crates or permanently closes a capability door. Getting it wrong is expensive. Examples:
- BiStream as a concrete quinn type (closes WASM door permanently)
- alknet-vault pulled into alknet-core as a dependency (loses standalone property permanently)
- ProtocolHandler signature changes (every handler must be rewritten)
**Two-way door** — Reversing this decision is cheap or additive. Getting it wrong is recoverable. Examples:
- Static vs dynamic handler registration (can add ArcSwap later)
- Single transport vs multi-transport endpoint (can add transport trait later)
- Call protocol stream model (can add multiplexing later)
### Process
- **One-way doors** require an ADR before implementation. If the right choice is unclear, validate with a POC before writing the ADR. If a POC can't resolve the uncertainty within a reasonable timebox, default to the option that keeps more doors open. One-way doors get the deliberation they deserve because getting them wrong is expensive.
- **Two-way doors** still require a decision — pick the simplest option that works, implement it, and move on. If it turns out wrong, revert and try the alternative. The decision is made; what's cheap is the reversal. Note the decision in a commit message or a brief ADR if the context is worth capturing, but don't block on it.
- When in doubt about which classification applies, classify up. If it's unclear whether a door is one-way or two-way, treat it as one-way until proven otherwise.
### What this framework is NOT
This framework classifies decisions by **reversal cost**, not by **urgency**. It does not say "two-way doors can be deferred." A two-way door is a decision you make now and can revert later if needed — it's not a license to leave the decision unmade.
- **Deferral** is a separate concept: sometimes a decision genuinely doesn't need to be made yet because the use case isn't concrete (scope management). That's valid, but it's a scoping judgment, not a door-type classification.
- **Conflating the two** — using "it's a two-way door" as a reason to defer an architectural decision — leads to decisions that compound into a mess. The decision gets made by default (the implementation picks something), and downstream code builds on it, making the "cheap reversal" expensive.
- **The architect's role**: architecture decisions (one-way OR two-way) are for the architect to make, not the implementation agent. The implementation agent makes implementation decisions (variable names, loop order, which library to use for a parsed task). If a decision affects the system's structure, constraints, or API surface, it's an architecture decision regardless of its door type.
### WASM as a design constraint
WASM compatibility is not an immediate implementation goal, but it is a **design constraint on one-way doors**. Decisions that would permanently prevent WASM targets from participating as peers require explicit justification. This means:
- Core types (BiStream, ProtocolHandler, AuthContext) must not assume tokio or quinn
- Protocol parsers that are pure data transformations should remain transport-agnostic
- The cost of keeping the WASM door open is low (trait vs concrete type, abstracted I/O) and the cost of closing it is high (impossible to reverse without rewriting every handler)
This is not "WASM support now." It's "don't close the WASM door accidentally."
## Consequences
**Positive:**
- One-way doors get the deliberation they deserve — ADRs, POCs, explicit justification — because getting them wrong is expensive
- Two-way doors don't block progress — decide, implement, revert if needed — because getting them wrong is recoverable
- WASM compatibility is preserved as a constraint, not treated as an active deliverable
- The framework creates a shared vocabulary for discussing reversal cost ("is this a one-way door?")
**Negative:**
- Classification requires judgment — some decisions are genuinely ambiguous (mitigated: classify up when in doubt)
- POC timeboxing can feel constraining on genuine hard problems (mitigated: the timebox is "reasonable," not "arbitrary")
- The framework adds a step to every architectural discussion ("is this one-way or two-way?") — but this step is fast and prevents expensive mistakes
## References
- ADR-005: BiStream type definition (one-way door: WASM compatibility)
- ADR-008: Secret service integration point (one-way door: standalone crate independence)
- SDD process: `docs/sdd_process.md` (Phase 0 exploration, POC specialist)
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`

View File

@@ -0,0 +1,72 @@
# ADR-033: Rust as Canonical Implementation Language
## Status
Accepted
## Context
alknet's core crates (alknet-core, alknet-call, alknet-vault) and all handler crates are implemented in Rust. A previous TypeScript implementation (`@alkdev/operations`, `@alkdev/pubsub`) informed the design of the call protocol — its operation registry, EventEnvelope framing, adapter patterns (from_openapi, from_mcp, from_call), and bidirectional composition.
The question is: what is the relationship between the TypeScript implementation and the Rust implementation? Is TypeScript a parallel implementation that must be maintained in lockstep, or is Rust the canonical implementation with TypeScript serving a specific role?
Five factors make Rust the canonical choice:
1. **Memory safety eliminates an entire vulnerability class.** Rust's ownership model prevents buffer overflows, use-after-free, and other memory corruption bugs that are endemic in C/C++ and impossible to audit away in JavaScript runtimes.
2. **LLM code generation quality is comparable across Rust and TypeScript.** Agents "grok" both languages roughly equally, so there is no productivity argument for TypeScript.
3. **NPM supply chain attacks are growing rapidly.** The JavaScript ecosystem's dependency density makes supply chain attacks a persistent and increasing risk. NPM is dropping features like post-install scripts in response. This trend makes JavaScript an unreliable foundation for security-critical infrastructure.
4. **Rust is significantly faster.** For networking, encryption, and protocol handling, the performance difference is material — not marginal.
5. **The only legitimate JavaScript use case is the browser.** WASM/WebTransport clients need a JavaScript SDK, and the existing `@alkdev/operations` TypeScript code can be adapted for browser use cases where users want to expose operations to web applications. This is a consumer SDK, not a parallel implementation.
## Decision
**Rust is the canonical implementation language.** All alknet crates are implemented in Rust. The TypeScript `@alkdev/operations` and `@alkdev/pubsub` libraries are reference implementations that informed the design; they are not maintained as parallel implementations.
The relationship between the TypeScript and Rust implementations:
| Aspect | Rust (canonical) | TypeScript (reference/browser) |
|--------|-----------------|-------------------------------|
| OperationSpec, OperationRegistry | alknet-call owns canonical types | `@alkdev/operations` projects canonical types into TS |
| Wire protocol (EventEnvelope) | alknet-call owns canonical framing | `@alkdev/pubsub` implements the same wire format for browser |
| Adapter patterns (from_*, to_*) | alknet-call defines adapter traits and Rust implementations | Browser-adapted implementations where needed |
| Call protocol client | alknet-call (QUIC) | alknet-napi (QUIC via NAPI) or browser SDK (WebTransport) |
| LLM provider integration | alknet-agent (forked aisdk, simplified) | Not applicable |
| Provider key management | alknet-vault via assembly-layer capabilities (no env vars) | Not applicable |
**The adapter contract (from_openapi, from_mcp, from_call, to_openapi, to_mcp) lives in Rust.** These patterns convert external specifications or protocols into `OperationSpec + Handler` pairs that register in the local `OperationRegistry`. The TypeScript implementations serve as reference for browser adaptations, not as the source of truth.
**alknet-napi is a thin projection layer.** It exposes the Rust call protocol client to Node.js via NAPI. It does not contain business logic or adapter implementations. TypeScript consumers who want to use alknet from Node.js use alknet-napi to access the Rust implementation.
**The browser SDK is a future adaptation.** When WASM/WebTransport support is needed, the existing TypeScript code can be adapted to run in browsers, speaking the same EventEnvelope wire format over WebTransport streams. This preserves the WASM door (ADR-032) without requiring Rust-to-WASM compilation of the full stack.
## Consequences
**Positive:**
- Single implementation to maintain, test, and secure
- Memory safety eliminates a whole class of vulnerabilities
- Provider key management through alknet-vault (call protocol) instead of env vars
- No NPM dependency chain for security-critical infrastructure
- The existing TypeScript code informs the Rust design — its patterns are preserved, not its implementation
- Browser clients get a thin, adapted SDK rather than the full operations library
**Negative:**
- Browser support requires a separate JavaScript SDK (adapted from existing TS code) rather than a shared implementation
- Contributors who only know JavaScript cannot contribute to core alknet crates
- The `@alkdev/operations` TypeScript library may drift from the canonical Rust types if not kept in sync during the transition period
**Risks mitigated:**
- WASM door preserved: The `@alkdev/operations` TypeScript code can be adapted for browser use without recompiling Rust to WASM. The wire format is JSON, which any runtime can produce and consume.
- NAPI consumers: alknet-napi provides the call protocol client to Node.js without reimplementing in JavaScript.
## References
- ADR-031: Crate decomposition
- ADR-013: irpc as call protocol foundation
- ADR-032: One-way door decision framework (WASM door)
- Reference TypeScript implementation: `/workspace/@alkdev/operations`
- Reference TypeScript pubsub: `/workspace/@alkdev/pubsub`
- aisdk (Rust port to be forked): `/workspace/aisdk`

View File

@@ -0,0 +1,311 @@
# ADR-034: alknet-channels Wire Format — 8-Byte Chunk Header
## Status
Accepted (revised 2026-07-12: substrate simplification + stream_type
decomposition; **amended 2026-07-18 by ADR-035: wire format is 8 bytes,
not 9; `stream_type` removed from the channels header — see "Amendment
(ADR-035, 2026-07-18)" below**)
## Amendment (ADR-035, 2026-07-18)
The 9-byte chunk header is **amended to 8 bytes**:
`[channel_id:u32 BE][length:u32 BE][payload]`. The `stream_type` byte is
**removed** from the channels header — the channels layer has no
`stream_type` concept, not in its header, not in its code, not in its
mental model. The handler owns its sub-stream multiplexing on the
`BiStream` the channels layer gives it (per ADR-035, the channels-layer
consequence of ADR-009's `BiStream` handler leaf). What was the channels
header's `stream_type` byte is now the first byte of the payload, owned
by the handler's framing (TTY's 5-byte format, call's length-prefixed
JSON, tunnel's raw bytes, SSH's channel protocol).
The stream_type decomposition (unidirectional halves, mod 3 formula, 85
groups) is **removed from the channels layer**. The stream_type concept
survives in TTY's 5-byte format (ADR-052, amended by Phase 7), which the
channels layer carries transparently in its payload. The total header
for a TTY chunk inside channels is 13 bytes (8 channels + 5 TTY), not
9; the two length fields are close but not identical
(`ch_len = tty_len + 5`). This is the documented cost of clean
separation of concerns — see ADR-035 §"Consequences" for the full
cost/benefit.
The body below describes the **current** (9-byte) shape; the amendment
above is the operative decision. The 9-byte description is kept as the
historical context for the amendment. See ADR-035 for the resolution
rationale and the cross-ADR impacts.
## Context
`alknet-channels` is a multiplexing proxy: a `ProtocolHandler` on
`alknet/channels` that carries N logical channels, each with a different
ALPN, over transport stream(s). The wire format is the substrate that makes
this work.
Two prior formats inform this design:
1. **SSH's channel multiplexer (RFC 4254)**`ChannelId(u32)` with
string-named types negotiated per channel, all traffic interleaved on one
encrypted transport stream.
2. **alknet-tty's chunk format (ADR-052)** — `[stream_type: u8][length: u32 be]
[payload]`, a fixed set of four sub-streams (stdin/stdout/stderr/control)
within one bidi stream. Validated by two POCs and in production code.
The channels format is the generalization: add a `channel_id: u32` prefix to
TTY's 5-byte header, turning a fixed 4-channel multiplexer into an arbitrary
N-channel multiplexer. The de-risk POC (28 tests) validated the core
mechanics.
### The substrate simplification
Different transports have different native multiplexing capabilities. QUIC
has native bidi streams; TCP+TLS does not. The initial framing of this ADR
treated the 9-byte header as "the in-line substrate format" — used when the
transport has no native multiplexing, with a separate substrate for QUIC
native streams. That framing adds complexity (two substrates, a
`channel_id`↔stream-ID mapping) for no benefit.
The simplification: **the 9-byte header is used in all substrates, on every
bidi stream that carries channels.** The `channel_id` in the header is the
logical correlation key. The transport's native multiplexing (when present)
is a performance optimization (independent flow-control windows per
stream), not a protocol change. The `ChannelsAdapter` reads the 9-byte
header off every bidi stream it accepts — on a transport with one stream
(in-line), the header demuxes N channels from that stream; on a transport
with N streams (QUIC native), each stream carries one logical channel and
the header provides `stream_type` and `channel_id` correlation. Same code
path, same wire format, same handler experience.
### The stream_type decomposition
The initial framing had `stream_type` 3 as "bidirectional" (control
messages). This is a design flaw: one stream_type both sides write to is not
properly multiplexed — it loses independent flow control, independent EOF,
and clean separation of concerns. The TTY crate's control channel already
exhibits this problem ("the control channel isn't actually bidirectional…
the adapter ignores Exit from the client" — phase-0 findings §Less
Straightforward Parts).
The fix: **every stream_type is unidirectional.** Bidirectionality is
achieved by having two stream_types (one write, one read), the same way
QUIC bidi streams are two unidirectional halves. Control becomes 3 (write,
client→server) and 4 (read, server→client), not one "bidirectional" 3.
## Decision
### Chunk header
```
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
```
9 bytes of header. The `channel_id` is the addition over TTY's 5-byte
format; `stream_type` and `length` are identical to TTY's fields (ADR-052),
preserving the framing-disambiguation soundness property.
| field | width | meaning |
|-------|-------|---------|
| `channel_id` | u32 BE | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open`. |
| `stream_type` | u8 | The unidirectional sub-stream within the channel. See "Stream types" below. |
| `length` | u32 BE | The payload length in bytes. 0 = EOF sentinel (same convention as TTY — ADR-052 §Sentinels). |
### `MAX_CHUNK_LEN`
`16 * 1024 * 1024` (16 MiB), matching TTY's cap (ADR-052 §5). A chunk with
`length > MAX_CHUNK_LEN` returns `ChunkTooLarge` and does not corrupt the
stream — the demux drops the chunk and continues. The header is always
exactly 9 bytes, so the demux can always resync by reading the next 9-byte
header.
### Stream types — unidirectional, grouped in threes
**Every stream_type is unidirectional.** Bidirectionality is two
stream_types (write + read), not one "bidirectional" stream_type. The
stream_types are grouped in threes:
| Group | stream_type | direction | purpose |
|-------|-------------|-----------|---------|
| Data | 0 | write (client→server) | data in (stdin equivalent) |
| | 1 | read (server→client) | data out (stdout equivalent) |
| | 2 | read (server→client) | data err (stderr equivalent, optional) |
| Control | 3 | write (client→server) | control in (ALPN-specific format) |
| | 4 | read (server→client) | control out (ALPN-specific format) |
| | 5 | read (server→client) | control err (optional) |
| Future | 6/7/8 | write/read/read | next group, same pattern |
| | 9/10/11 | write/read/read | next group |
| | ... | | |
**Formula:** `stream_type % 3 == 0` → write half (in), `stream_type % 3 ==
1` → read half (out), `stream_type % 3 == 2` → diagnostic read half (err).
256 values / 3 = 85 groups. The `u32` channel_id space (~4 billion channels
before wrap, ADR-040) combined with 85 stream_type groups is effectively
unlimited for the intended use cases.
**Why unidirectional:** each stream_type gets its own reassembly buffer, its
own flow control, its own EOF. The TTY control channel becomes *actually*
bidirectional because there are two unidirectional streams (3 in, 4 out),
not one stream both sides write to. This resolves the "control channel
isn't actually bidirectional" problem the TTY crate has today. The same
principle applies to any future channel type — control is two halves, not
one shared stream.
**Control payload format is ALPN-specific, not channels-enforced.** The
channels layer is blind to what stream_types 3/4/5 carry — it reassembles
bytes and delivers them to the handler. The TTY crate happens to use JSON
for its control channel (resize, signal, eof, exit) because its control
messages map cleanly to JSON; another ALPN might use a binary control
format. The channels layer does not mandate JSON on control stream_types.
This is the same ALPN-blindness principle that applies to the data
stream_types: the channels layer routes bytes, the handler interprets them.
### Per-ALPN stream_type sets
| ALPN | Active stream_types | Why |
|------|---------------------|-----|
| `alknet/call` (channel 0) | [0, 1] | call frames bidirectional via 0=in, 1=out |
| `alknet/tty` | [0, 1, 2, 3, 4] | data in/out/err + control in/out |
| `alknet/tunnel` | [0, 1] | data in/out only (no channels-layer control needed) |
| `alknet/ssh` | [0, 1] | SSH multiplexes internally, including its own control |
The active set is declared at `channel/open` time (ADR-037 `stream_types`
field) and fixed for the channel's lifetime. A tunnel that wants keepalive
could declare [0, 1, 3, 4].
### Substrate modes — same wire format, different stream counts
The 9-byte header is used in all substrates, on every bidi stream. The
difference between substrates is only **how many bidi streams the transport
yields**:
| Substrate | Transport | Streams | Header role |
|-----------|-----------|---------|--------------|
| In-line | TCP+TLS, WebTransport session, SSH `direct-tcpip` | 1 | Header demuxes N channels from that 1 stream |
| Native | QUIC (quinn/iroh) | N | Each stream carries 1 logical channel; header provides `stream_type` + `channel_id` correlation |
| Multi-connection | Any, N connections | N × M | Each connection is self-contained (own channel 0, own demux); header is per-connection |
The `ChannelsAdapter::handle` loop: `accept_bi()` → for each stream, read
the 9-byte header → route by `(channel_id, stream_type)` → reassemble. On
an in-line transport, `accept_bi()` yields once then `ConnectionClosed` —
the header does all the demux. On QUIC, `accept_bi()` yields repeatedly —
each stream is a channel, and the header provides `stream_type` and
`channel_id` correlation. Same code path, same wire format, same handler
experience.
**Why keep the header on QUIC when the stream already separates channels:**
- **`stream_type` decomposition.** TTY needs 5 sub-streams. On QUIC, one
stream per channel + the header carrying `stream_type` is simpler than 5
streams per channel and matches the in-line case's shape. Per-stream-type
flow control is handled at the reassembly buffer level (ADR-040)
regardless of substrate.
- **`channel_id` correlation.** The hub relay forwards a channel from the
browser leg to the spoke leg, mapping `browser_id ↔ spoke_id`. If
`channel_id` is in the header on every stream, the relay correlates by
reading the header — regardless of substrate. If `channel_id` lived only
in the `channel/open` response, the relay would need a stream-ID-to-
`channel_id` mapping per leg.
- **Uniform handler experience.** The handler receives a `Connection` and
calls `accept_bi()` or `into_sub_streams()`. It doesn't know or care
whether the substrate is in-line or native — the
`ChannelBidiStreamSource` wraps the reassembled stream either way.
The "bloat" (9 bytes/chunk on transports that have native multiplexing) is
the cost of uniformity. For the intended use cases (TTY, SSH, tunnels, call
operations), this is noise. The high-throughput escape hatch is
multi-connection, not stripping the header.
### Framing disambiguation (carried from ADR-052 §5)
Channel 0 is just another channel — its chunks have `channel_id=0` in the
header. Disambiguation between channel 0 (call protocol) and data channels
is by `channel_id`, not by a special first-byte trick. Within a channel,
`stream_type` 0 (write half) from the server is invalid, so `0x00` as the
first byte of a chunk payload from the server is unambiguous.
### Zero-length sentinel = EOF
A zero-length chunk is delivered as an empty `Bytes`, which the reassembled
stream interprets as EOF (same convention as TTY — ADR-052 §Sentinels). This
is the clean-shutdown signal for a `(channel_id, stream_type)` pair.
### Sync core / async shell split
The wire format's core is pure byte manipulation — `parse_header(&[u8; 9])
-> ChunkHeader` and `write_header(channel_id, stream_type, length, &mut
[u8; 9])`. No async, no platform dependencies. Compiles under
`wasm32-unknown-unknown` (validated by the POC). The async shell (demux/mux)
wraps this core with `read_exact`/`write_all` on the transport and `mpsc`
routing. This split keeps the WASM-compatible core separate from the
tokio-dependent shell.
## Consequences
**Positive:**
- One wire format across all substrates. The `ChannelsAdapter` code path is
the same regardless of transport; the transport's native multiplexing is a
performance optimization, not a protocol change.
- Every stream_type is unidirectional — proper multiplexing with independent
flow control and EOF per half. The TTY control channel becomes actually
bidirectional (3 in, 4 out), resolving the "not actually bidirectional"
flaw.
- The hub relay correlates by `channel_id` in the header, uniformly across
substrates — no stream-ID-to-`channel_id` mapping per leg.
- WASM-compatible by construction — the pure core has no platform deps.
- The framing-disambiguation property from ADR-052 carries forward unchanged.
**Negative:**
- 9 bytes per chunk on all substrates, including QUIC where the transport
already separates streams. This is 4 bytes more than using the QUIC stream
ID directly as the `channel_id`. For the intended use cases this is noise;
for high-throughput bulk transfer, the answer is multi-connection, not
stripping the header.
- All channels on one in-line connection share one transport stream's
flow-control window. A slow consumer on one channel can backpressure
others. Mitigated by bounded-buffer backpressure (ADR-040), not
eliminated. The native substrate (QUIC streams) avoids this — each
channel gets its own flow-control window. For high-throughput, use native
or multi-connection.
## Door type
**One-way.** The chunk header layout (`channel_id:u32 + stream_type:u8 +
length:u32`, 9 bytes) and the stream_type group assignments (0/1/2 = data, 3/4/5 =
control, `% 3` formula) are wire-format commitments. Changing them after
deployments exist requires a version migration.
**Amended by ADR-035 (2026-07-18):** the header layout is now
`channel_id:u32 + length:u32` (8 bytes); the `stream_type` byte and its
decomposition are removed from the channels layer. The one-way door is
re-cast (the channels crate is not yet implemented, so this is the right
time to cast it). See ADR-035 for the amended door-type discussion.
The `MAX_CHUNK_LEN` value (16 MiB) is a two-way-door implementation detail
within the one-way format.
## References
- **ADR-035**: channels pure channel multiplexing (amends this ADR —
wire format is 8 bytes, not 9; `stream_type` removed from the channels
header; the stream_type decomposition is removed from the channels
layer; the handler owns its sub-stream multiplexing on the `BiStream`)
- ADR-052: alknet-tty wire format (the 5-byte format this generalizes;
amended by ADR-077 — scoped to direct TTY; **re-amended by ADR-035 —
TTY always uses its 5-byte format, carried transparently in the
channels payload**)
- ADR-007: `Connection::from_stream` (the transport-agnostic Connection)
- ADR-008: `BidiStreamSource` trait (the extension point the channels
connection implements; its docstring already anticipated per-channel
streams)
- ADR-036: channel 0 pre-negotiated (now uses stream_types [0, 1])
- ADR-037: channel lifecycle operations (stream_types field examples
updated)
- ADR-038: ChannelBidiStreamSource (into_sub_streams returns unidirectional
handles)
- ADR-040: backpressure (bounded-buffer applies at reassembly regardless of
substrate)
- ADR-077: TTY inside channels (5 sub-streams; control properly
bidirectional via 3/4)
- `docs/research/alknet-channels/poc-summary.md` — the POC that validated
the format (28 tests, WASM compile check)
- `docs/research/alknet-channels/phase-0-findings.md` §The Wire Format, §Less
Straightforward Parts (the control-channel bidirectionality problem)

View File

@@ -0,0 +1,485 @@
# ADR-035: alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`)
## Status
Accepted (amends ADR-034 — wire format is 8 bytes, not 9, and the channels
layer has no `stream_type` concept; amends ADR-038 — `into_sub_streams()`
removed, `accept_bi` is the only accessor and yields one `BiStream` per
channel; reverses ADR-077 — TTY always uses its 5-byte format, the channels
layer carries it transparently in the payload)
## Context
ADR-034 committed the channels wire format as a 9-byte chunk header
(`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte extension of
TTY's 5-byte format, with `stream_type` carried in the channels header
and decomposed into unidirectional halves (0/1/2 = data write/read/err,
3/4/5 = control write/read/err, `% 3` formula). ADR-038 added a second
accessor (`into_sub_streams()`) alongside `accept_bi` for handlers that
need typed sub-streams (TTY's stdin/stdout/stderr/ctrl-in/ctrl-out).
ADR-077 split TTY's wire format into two modes — direct (5-byte) and
inside-channels (the channels layer de-chunks and the adapter destructures
via `into_sub_streams()`).
The stream-unification research
(`docs/research/stream-unification/findings.md`, 2026-07-18) surfaced that
these three decisions share one root: the channels layer carries a
concept (`stream_type`) it doesn't own. The 9-byte header bakes TTY's
sub-stream multiplexing into the channels wire format. The
`into_sub_streams()` accessor exists because the channels layer reassembles
per-`stream_type` and needs to expose the result. The two-mode TTY design
exists because the channels layer's `stream_type` overlaps with TTY's own
`stream_type`. The mod 2/mod 3/mod 4 numbering question (settled as mod 3
in ADR-034 revised) was a symptom of this overlap — a numbering convention
for a concept the channels layer shouldn't carry.
### The structural question
The channels layer has two objectives in tension:
1. **"Pass a stream to/from any ALPN"** — every channel is a `BiStream`;
any handler gets `accept_bi()` and treats the channel as a duplex
stream. Uniform, transport-agnostic, recursive-composition-friendly.
2. **"Channels carry N sub-streams"** — a TTY channel carries
stdin/stdout/stderr/control; the handler destructures via
`into_sub_streams()`. Carries what the source produces.
The tension is real when a sub-stream is *unidirectional* (stderr). You
can't represent stderr as a `BiStream` without wasting the write half;
you can't make it a "third half" (mod 3) without breaking pair symmetry;
you can't make the channel a single `BiStream` without losing the
stdout/stderr distinction.
ADR-038's two-accessor design resolves this by making the "pass a stream
to/from any ALPN" objective *qualified* — it applies to single-stream
channels (tunnel, SSH, call), not multi-stream channels (TTY). The mod
2/mod 3/mod 4 numbering was a symptom of that qualified design.
### The resolution: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed
by `channel_id`." It does not know about TTY's sub-streams, SSH's channel
protocol, or how call frames its JSON. Handlers own their sub-multiplexing
on the `BiStream` the channels layer gives them.
- **Every channel is a `BiStream`.** `accept_bi()` yields one `BiStream`
per channel (per ADR-009, already landed). No `into_sub_streams()`, no
second-class accessor.
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
sub-demuxes `stream_type` from its `BiStream` (its 5-byte format). Tunnel
uses the `BiStream` as raw bytes. Call length-prefixes JSON. SSH runs
its own channel protocol. The channels layer carries the bytes
transparently.
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.** The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. `stream_type` is the inner layer's
framing byte, carried transparently.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
`STREAM_CTRL_OUT = 4` — ADR-052 amended by Phase 7). The channels layer
doesn't carry control. The "control isn't actually bidirectional" flaw
is fixed at the TTY layer, not the channels layer.
- **Recursive composition is literal.** A channel with ALPN
`alknet/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its own
8-byte header from the payload. Each level is the same shape —
`BiStream → accept_bi → N BiStreams`.
### The wire format decision: 8 bytes
The channels wire format is **8 bytes**: `[channel_id:u32 BE][length:u32
BE]` followed by an opaque payload. The channels layer owns `channel_id`
and `length`; the payload is the handler's framing, carried transparently.
The 9-byte alternative (`[channel_id:u32][stream_type:u8][length:u32]`)
was considered and rejected. The 9-byte format puts `stream_type` in the
channels header, which means the channels layer carries a concept it
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
5-byte header with `channel_id` prepended), but for non-TTY handlers
(tunnel, call, SSH) the `stream_type` byte is dead weight — the channels
layer carries a byte it doesn't understand, and the handler ignores a
byte in a header it doesn't control.
The 8-byte format is uniform across all handlers: the channels layer
carries `channel_id` + `length` + opaque payload. Every handler parses
its own framing from the payload. The cost is that TTY's `wire.rs` is
called from a payload buffer rather than directly from the wire, and the
total header for a TTY chunk is 13 bytes (8 channels + 5 TTY) instead of
9. The two length fields are close but not identical (`ch_len = tty_len +
5`); for typical TTY chunks (4 KiB+), the 5-byte overhead is ~0.1%, and
the trade is clean separation of concerns. See "Consequences" for the
full cost/benefit.
### The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the payload).
The handler layer (e.g. TTY) parses its own framing from the payload
bytes per its existing `wire.rs`. The handler doesn't know or care that
a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is SSH's
model (layered headers, each layer strips its own at its boundary),
applied to channels. A `alknet/channels`-inside-`alknet/channels`
recursive composition is the outer layer stripping its 8-byte header, the
inner layer parsing its own 8-byte header from the payload — same code,
same shape, each level.
### Why this can land now
Three things changed since ADR-034/074/077 were accepted:
1. **ADR-009 landed `BiStream` as the handler leaf.** `accept_bi()`
returns a `BiStream` (a concrete `AsyncRead + AsyncWrite` newtype), not
a split `(SendStream, RecvStream)` pair. The join moves into core's
quinn/iroh/stream impls (once per source, invisible to handlers). This
ADR's "every channel is a `BiStream`" is the channels-layer
consequence of ADR-009's handler-leaf decision — the research-then-sync
pattern applied: ADR-009 settled the transport leaf, this ADR settles
the multiplexing layer above it.
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it into
`STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's 5-byte
format*, not at the channels layer. This removed the load-bearing
reason for the channels layer to carry `stream_type`: the control
bidirectionality fix is a TTY-internal concern, not a channels-layer
concern. ADR-077's two-mode TTY design was motivated by the channels
layer carrying control; with control moved inside TTY, the motivation
dissolves.
3. **No production constraint.** The develop branch is a rewrite of main
(pre-alpha). The channels crate doesn't exist yet (per ADR-044, it's
planned as `alknet-channels-core` + `alknet-channels-call`). The
decision is purely "what's cleanest," not "what's least disruptive."
The 9-byte POC validated the per-`channel_id`/`stream_type` routing
mechanism; the 8-byte spec update changes the header before
implementation begins.
### What this ADR does NOT decide
- **The add/strip API shape** (built into read/write vs. a separate
utility): the stream-unification research proposed `add_channel_id` /
`strip_channel_id` as standalone functions. Ideally the header is
built into the read/write path so the utility isn't needed at the
handler boundary — but there may be a generalized reason to expose it
(recursive composition, test helpers, the hub relay's `channel_id`
rewrite). The exact API shape is an implementation detail for the
channels crate, tracked as OQ-68. The *contract* — the channels layer
strips its 8-byte header on read and the handler parses its own framing
from the payload — is decided here; the *function surface* is not.
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from
an `AsyncRead`. Adapting it to read from a payload buffer (`&[u8]` or
`Cursor<Bytes>`) is a small, well-scoped change (the framing logic —
stream_type constants, length validation, control message parsing — is
unchanged). This is an implementation concern for the channels + TTY
integration, not an architecture decision.
- **Full channel-level flow-control windowing (OQ-56):** unchanged. The
bounded-buffer backpressure (ADR-040) is the v1 mechanism; full
windowing is an additive extension that doesn't change the wire
format. OQ-56 stays deferred(scope).
## Decision
### 1. The channels wire format is 8 bytes
```
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
8 bytes of header, followed by `length` bytes of opaque payload. The
channels layer owns `channel_id` and `length`; the payload is the
handler's framing, carried transparently.
| field | offset | width | meaning |
|-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alknet/call` (ADR-036). Channels 1..N are opened dynamically via `channel/open` (ADR-037). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-052 §5). |
The `stream_type` byte is **removed** from the channels header. The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. What was the channels header's
`stream_type` byte is now the first byte of the payload, owned by the
handler's framing (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol).
This amends ADR-034: the wire format is 8 bytes, not 9; the
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups) is
removed from the channels layer. The stream_type concept survives in
TTY's 5-byte format (ADR-052, amended by Phase 7), which the channels
layer carries transparently.
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
ADR-038's `into_sub_streams()` / `ChannelSubStreams` / `SubStreamHandle`
are removed. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-009). Every handler —
TTY, tunnel, SSH, call — receives a `Connection`, calls `accept_bi()`
once, gets a `BiStream`, and sub-multiplexes it however it wants.
This amends ADR-038: the two-accessor design (`accept_bi` for generic
handlers, `into_sub_streams` for typed handlers) collapses to one
accessor. The "typed handler path" (ADR-038's motivating case for TTY) is
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format —
the same code TTY runs in direct mode. ADR-038's yield-once `accept_bi`
contract is preserved; the `into_sub_streams()` accessor is the amended
part.
### 3. TTY always uses its 5-byte format; the channels layer carries it transparently
ADR-077's two-mode TTY design (direct vs inside-channels) is reversed.
TTY's 5-byte format (`[stream_type:u8][length:u32][payload]`, ADR-052) is
TTY's internal format, used in *both* direct mode and inside-channels
mode. The two modes differ only in *where the `BiStream` comes from*
(a top-level `alknet/tty` connection vs a `channel/open` with ALPN
`alknet/tty`), not in *how TTY parses it*. The same `wire.rs` code runs
in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
and hands TTY the payload bytes. TTY parses its 5-byte header from the
payload. The channels layer carries TTY's 5-byte chunks transparently
in its payload — no shared fields, no leaked abstraction, no
double-chunking concern (the 13-byte total header is 8 channels + 5
TTY, not 8 + 9; the channels `length` is always `tty_len + 5`).
This reverses ADR-077: the 5-byte format is NOT scoped to direct — it's
TTY's internal format, carried transparently in the channels payload.
The `channels` feature on `alknet-tty` becomes "run TTY's sub-demux on a
channels-backed `BiStream`" — the same code as direct mode, different
`BiStream` source. The control channel split (`STREAM_CTRL_IN` /
`STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the channels layer doesn't
know about it.
### 4. The add/strip composition
The channels layer's read path strips the 8-byte header and hands the
payload to the handler. The write path prepends the 8-byte header
(`add_channel_id`) onto the handler's output. The handler never sees
the `channel_id`; it sees only its own framing (the payload bytes).
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The composition is uniform — the same shape at every level. A
`alknet/channels`-inside-`alknet/channels` recursive composition is the
outer layer stripping its 8-byte header, the inner layer parsing its own
8-byte header from the payload — same code, same shape, each level.
### 5. What does NOT change
- **ADR-009's `BiStream` leaf** — unchanged. This ADR is the
channels-layer consequence of ADR-009: `accept_bi` yields a `BiStream`,
handlers sub-multiplex it. The two ADRs compose (ADR-009 settles the
transport leaf; this ADR settles the multiplexing layer above it).
- **`ProtocolHandler` trait shape** (ADR-002) — unchanged. Handlers
receive a `Connection` and call `accept_bi()`.
- **Channel 0 pre-negotiated as `alknet/call`** (ADR-036) — unchanged.
Channel 0's chunks have `channel_id = 0` in the 8-byte header. The call
protocol's `EventEnvelope` framing is the payload; the channels layer
carries it transparently.
- **Channel lifecycle operations** (ADR-037) — unchanged. The four
operations (`channel/open`/`close`/`control`/`resources/subscribe`) and
their `direction` semantics are call-protocol operations on channel 0,
not channels-wire-format concerns.
- **`ChannelsAdapter` / `ChannelManager` split** (ADR-039) —
structurally unchanged. The demux loop reads 8-byte headers (not
9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
transport-blind. The `stream_types` field on `channel/open` and
`ChannelState` is removed (the channels layer doesn't track
per-stream-type reassembly buffers; it tracks one reassembly buffer
per `channel_id`, yielding a `BiStream`).
- **Backpressure, channel limits, ID reuse** (ADR-040) — unchanged. The
bounded-buffer backpressure is per-`channel_id` (was per-
`(channel_id, stream_type)`; now per-`channel_id` since there's one
reassembly buffer per channel). The 256-channel cap, 1 MiB default,
and monotonic-ID-with-wrap strategy are unchanged.
- **Two-pump shutdown-on-completion** (ADR-078) — unchanged. Tunnel/SSH
handlers call `tokio::io::split(bidi)` for their two pump halves; the
shutdown-on-completion contract applies to the `ReadHalf` /
`WriteHalf` unchanged.
- **Hub relay** (ADR-042) — unchanged in contract. The hub translates
`channel/open` on channel 0 and byte-forwards data channels with
`channel_id` rewrite. The relay reads 8-byte headers (not 9-byte) and
rewrites the `channel_id` field (a 4-byte rewrite within the 8-byte
header, not a 9-byte header). The relay does not parse the payload.
- **`ChannelClient`** (ADR-043) — unchanged in API. `from_connection`
primary, `open_channel` returns a `Channel`. The `stream_types` field
on `open_channel` and `Channel` is removed (the channels layer doesn't
negotiate per-stream-type sets; the handler owns its sub-stream
multiplexing). The `channel:stream_type_unavailable` error code is
removed (the channels layer can't refuse a `stream_type` it doesn't
know about).
- **Sub-crate decomposition** (ADR-044) — unchanged. `channels-core`
(pure multiplexer, depends on `alknet-core` only) / `channels-call`
(channel 0 pre-negotiation + lifecycle op registrations, depends on
`channels-core` + `alknet-call`). The 8-byte wire format, demux/mux,
and `ChannelBidiStreamSource` are in `channels-core`; the call-protocol
coupling is in `channels-call`.
- **`BidiStreamSource` trait** (ADR-008) — unchanged in shape.
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
`BiStream` (per ADR-009, already landed).
## Consequences
**Positive:**
- **Clean separation of concerns.** The channels layer has no
`stream_type` concept — not in its header, not in its code, not in its
mental model. The handler owns its framing entirely. This dissolves
the mod 2/mod 3/mod 4 question at the channels layer (there's nothing
to decompose) and fixes the "control isn't actually bidirectional" TTY
flaw at the TTY layer (where it lives, not the channels layer).
- **Uniform across all handlers.** Tunnel, call, SSH, and TTY all
receive the same shape: a `BiStream`. No handler gets a `stream_type`
byte it doesn't use; no handler needs a second accessor
(`into_sub_streams`) to reach its sub-streams. The channels layer's
API surface is `accept_bi -> BiStream`, period.
- **Recursive composition is literal.** A `alknet/channels` channel runs
another channels demux on its `BiStream`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from the
payload. Same code, same shape, each level. This is a property, not a
feature — the primary use case is one level of multiplexing, but the
add/strip composition makes the recursion cleaner than ADR-034's
group framing did.
- **The `into_sub_streams()` accessor and its consuming handler code are
removed.** This is a net simplification: one accessor, one handler
path, no downcast / extension trait / "two paths" ergonomics question
(which ADR-038 left as an implementation detail). The handler crate
destructures its `BiStream` via its own framing (TTY's 5-byte format),
not via a channels-crate-provided typed accessor.
- **TTY's `wire.rs` runs unchanged in both modes.** Direct mode and
inside-channels mode use the same code; only the `BiStream` source
differs. ADR-077's `drive_session_direct` / `drive_session_channels`
split collapses to one `drive_session` function. The `channels` feature
on `alknet-tty` becomes a thin wrapper that gets the `BiStream` from a
channels-backed `Connection` instead of a top-level one.
- **The channels layer is WASM-compatible by construction.** The 8-byte
header's core is pure byte manipulation (the sync core compiles under
`wasm32-unknown-unknown`, validated by the POC). The 8-byte format is
simpler than the 9-byte (one fewer field to parse), strengthening the
WASM-clean property.
**Negative:**
- **5 extra bytes per TTY chunk.** The total header for a TTY chunk
inside channels is 13 bytes (8 channels + 5 TTY), not 9. The two length
fields are close but not identical (`ch_len = tty_len + 5`). For
typical TTY chunks (4 KiB+), this is ~0.1% overhead. For extreme
multiplexing scenarios, the clean separation is worth the trade-off;
for high-throughput bulk transfer, the escape hatch is multi-connection
(one channels connection per leg), not stripping the header. This is
the documented cost of the clean separation; the alternative (9-byte
header with `stream_type` in the channels layer) carries a concept the
channels layer doesn't own, which is the root cause this ADR addresses.
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
reads from an `AsyncRead` (the transport stream). Inside channels, it
reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the bytes
the channels layer handed it after stripping its 8-byte header. The
framing logic (stream_type constants, length validation, control
message parsing) is unchanged. This is a bounded, well-scoped
implementation change, not an architecture change. The same adaptation
applies to any handler that parses its own framing from a payload
buffer (call's `EventEnvelope` framing already reads from a buffer;
tunnel and SSH don't parse the payload, so no adaptation).
- **`channel/open` loses the `stream_types` field.** ADR-037's
`channel/open` input included `stream_types: [u8]` (the active sub-stream
set) and the response echoed the negotiated set. Under this ADR, the
channels layer doesn't negotiate sub-stream sets — the handler owns
its sub-stream multiplexing. The `stream_types` field is removed from
`channel/open` (and from the `channel:stream_type_unavailable` error
code). The `alpn` and `params` fields remain; the handler's sub-stream
set is implicit in its ALPN's wire format. This is a small wire-format
change to `channel/open` (one field removed); since the channels crate
isn't implemented yet, there's no migration cost.
- **`ChannelState.streams: HashMap<u8, ReassemblyBuffer>` becomes
`ChannelState.reassembly: ReassemblyBuffer` (one per channel, not per
`(channel_id, stream_type)`).** This is an internal simplification
(fewer reassembly buffers, simpler drain logic) but is an
implementation change, not an architecture one. The bounded-buffer
backpressure (ADR-040) is per-`channel_id` now, not per-
`(channel_id, stream_type)` — the 1 MiB default and the 256-channel cap
are unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB
for a TTY channel with 5 active stream_types). This is a net
improvement (lower memory ceiling per channel), not a regression.
## Door type
**One-way (wire format, accessor removal, two-mode reversal).** The 8-byte
chunk header layout (`channel_id:u32 + length:u32`), the removal of
`stream_type` from the channels header, and the removal of
`into_sub_streams()` are wire-format and API commitments. Changing them
after the channels crate is implemented and handlers are written against
them requires a version migration. Since the channels crate doesn't exist
yet, the one-way door is being cast now, before implementation — the
right time to cast a one-way door.
The reversal of ADR-077 (TTY always uses its 5-byte format) is one-way in
the same sense: once TTY's `wire.rs` runs in both modes (direct and
inside-channels), re-introducing a separate inside-channels mode would be
a rewrite of TTY's session driver. The trade is one unified session
driver now vs. two-mode maintenance forever.
The add/strip API shape (OQ-68) is a **two-way door** — whether the
header add/strip is built into the read/write path or exposed as a
standalone utility is an implementation detail that can change without
breaking the wire format or the handler contract.
## References
- ADR-034: channels wire format (amended — wire format is 8 bytes, not
9; `stream_type` removed from the channels header; the stream_type
decomposition is removed from the channels layer)
- ADR-038: ChannelBidiStreamSource (amended — `into_sub_streams()`
removed; `accept_bi` is the only accessor, yields one `BiStream` per
channel)
- ADR-077: TTY inside channels (reversed — TTY always uses its 5-byte
format; the channels layer carries it transparently in the payload;
the two-mode design is preserved but differs only in `BiStream`
source, not in parsing)
- ADR-009: `BiStream` as the handler leaf (the transport-leaf layer this
ADR builds on — `accept_bi` returns `BiStream`; `from_bidi` is the only
public stream constructor)
- ADR-008: `BidiStreamSource` trait (the extension point
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
- ADR-036: channel 0 pre-negotiated `alknet/call` (unchanged — channel 0's
chunks have `channel_id = 0` in the 8-byte header; the call protocol's
framing is the payload)
- ADR-037: channel lifecycle operations (amended — `stream_types` field
removed from `channel/open`; `channel:stream_type_unavailable` error
code removed)
- ADR-039: `ChannelsAdapter` and `ChannelManager` (structurally
unchanged — demux reads 8-byte headers; one reassembly buffer per
channel)
- ADR-040: backpressure, channel limits, ID reuse (unchanged —
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
monotonic IDs)
- ADR-078: two-pump shutdown-on-completion (unchanged — the contract
applies to `tokio::io::split(bidi)` halves)
- ADR-042: hub relay (unchanged in contract — 8-byte header, 4-byte
`channel_id` rewrite, payload byte-forwarded)
- ADR-043: `ChannelClient` (amended — `stream_types` field removed from
`open_channel` and `Channel`)
- ADR-044: sub-crate decomposition (unchanged — 8-byte wire format in
`channels-core`; call-protocol coupling in `channels-call`)
- ADR-052: alknet-tty wire format (the 5-byte format carried
transparently in the channels payload; the control channel split
from Phase 7 is TTY-internal)
- `docs/research/stream-unification/findings.md` — the research that
surfaced the structural question and the resolution this ADR commits
- `docs/research/alknet-crate-extraction/findings.md` Phase 8 — the
spec-cleanup phase this ADR is the substance of
- `/workspace/alknet-channels-poc/` — the POC that validated the
per-`channel_id`/`stream_type` routing mechanism (the mechanism
supports any convention; this ADR says the channels layer doesn't have
a convention, the handler does)

View File

@@ -0,0 +1,156 @@
# ADR-036: Channel 0 Is Pre-Negotiated `alknet/call`
## Status
Accepted (amended 2026-07-18 by ADR-035 — channel 0's `stream_types`
field is removed; the channels layer has no `stream_type` concept; the
call protocol's `EventEnvelope` framing is the channels payload, carried
transparently — see "Amendment (ADR-035, 2026-07-18)" below)
## Amendment (ADR-035, 2026-07-18)
Channel 0's `stream_types` field (the `[0, 1]` active set) is **removed**.
The channels layer has no `stream_type` concept (ADR-035) — it carries the
call protocol's `EventEnvelope` framing (ADR-014) transparently in the
8-byte header's payload. The call protocol's bidirectionality (client
writes requests, server writes responses) is a call-protocol concern,
not a channels-layer concern; the channels layer routes by `channel_id`
only and yields a `BiStream` to the `CallAdapter`. The `CallAdapter`'s
`accept_bi()` returns one `BiStream` (per ADR-009); the call protocol
reads/writes `EventEnvelope` frames on it, exactly as on a top-level
`alknet/call` connection.
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-035 for the
resolution rationale and the cross-ADR impacts.
## Context
A channels connection carries N logical channels. One of them must carry the
call protocol — the JSON-RPC layer that orchestrates channel lifecycle
(`channel/open`, `channel/close`, `channel/control`, `channel/resources`).
The question is how channel 0 relates to the call protocol: is it a special
"control plane" with its own framing, or is it just `alknet/call` pre-
negotiated?
The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
§DP-2) recommends channel 0 is `alknet/call` pre-negotiated — no special
framing, no separate control-plane wire format. The call protocol runs on
channel 0 exactly as it runs on a top-level `alknet/call` QUIC connection.
This matters because the alternative (a special control plane) would mean
the channels layer has its own JSON protocol for channel lifecycle, parallel
to and duplicating the call protocol's `OperationRegistry`, `AccessControl`,
`OperationContext`, and `forwarded_for` machinery. That duplication is the
"re-implement every protocol's framing per transport" problem the hub
motivation (§Hub Motivation) identifies as the thing channels exists to
collapse.
## Decision
**Channel 0 is `alknet/call`, pre-negotiated.** Both sides of a channels
connection know that `channel_id = 0` is routed to the `CallAdapter` without
an explicit `channel/open` exchange. The `CallAdapter` receives a
`Connection` backed by channel-0 chunk reassembly and dispatches operations
exactly as it does on a top-level `alknet/call` connection.
### What this means concretely
1. **Channel 0 uses the same 9-byte chunk format as every other channel**
(ADR-034). Its chunks have `channel_id = 0` in the header. No special
first-byte trick, no separate framing.
2. **The `CallAdapter` is unchanged.** It receives a `Connection`, calls
`accept_bi()`, gets one bidi stream (the channel-0 reassembled stream),
and runs its dispatch loop. `EventEnvelope` frames ride on `stream_type =
0` of channel 0. The `CallAdapter` does not know it is inside a channels
connection.
3. **Channel lifecycle operations are call operations.** `channel/open`,
`channel/close`, `channel/control`, `channel/resources` are registered on
the call protocol's `OperationRegistry` at assembly time (ADR-037). They
are dispatched through the existing `OperationContext` (identity, scopes,
capabilities, ownership, `forwarded_for`), gated by the existing
`AccessControl::check`. No new auth machinery, no new framing, no
protocol version bump.
4. **Channel 0 is allocated at `ChannelsAdapter::handle` entry.** The
`ChannelsAdapter` constructs channel 0's reassembly buffers, wraps them
as a `Connection` (via `Connection::from_source` with a
`ChannelBidiStreamSource` — ADR-008/074), and hands that `Connection` to
the `CallAdapter` — exactly as if `alknet/call` had been the top-level
ALPN. The `CallAdapter` is looked up in the same `HandlerRegistry` as
every other ALPN.
### Channel 0's stream_type usage
| stream_type | direction | purpose |
|-------------|-----------|---------|
| 0 | write (client→server) | `EventEnvelope` frames from the client (call.requested, call.aborted) |
| 1 | read (server→client) | `EventEnvelope` frames from the server (call.responded, call.completed, call.error) |
Channel 0 uses stream_types [0, 1] — the call protocol is bidirectional via
two unidirectional halves, the same way every channel type works
(ADR-034 §stream_type decomposition). The call protocol's `(SendStream,
RecvStream)` pair maps directly: `SendStream` backed by stream_type 0,
`RecvStream` backed by stream_type 1. Both sides write to their write half
and read from their read half — no shared stream_type both sides write to.
The call protocol is JSON-only and single-stream by design (ADR-014).
stream_types 2-255 on channel 0 are reserved for future call-protocol
sub-streams.
## Consequences
**Positive:**
- No control-plane duplication. The channels layer reuses the call protocol's
`OperationRegistry`, `AccessControl`, `OperationContext`, `forwarded_for`,
and `StreamingHandler` (ADR-021) machinery verbatim. Channel lifecycle is
just another class of call operations.
- The `CallAdapter` is transport-agnostic by construction — it works
identically whether the `Connection` is a top-level QUIC stream or a
channels-reassembled channel-0 stream. This is the "streams are streams"
insight made concrete.
- `channel/resources/subscribe` (ADR-037) is a `Subscription` operation on
channel 0, using the already-implemented `StreamingHandler` /
`invoke_streaming` path (ADR-021). The resource registry is a live view,
not a polled snapshot.
- Auth is inherited: `channel/open` goes through `AccessControl::check`
exactly like any other call operation. The channels layer does not re-
implement auth.
**Negative:**
- Channel 0 is a single point of orchestration. If channel 0's `CallAdapter`
hangs, no new channels can be opened. This is the same property as the call
protocol today (one dispatch loop per connection) and is not a new
vulnerability.
- The call protocol's JSON-only nature means channel lifecycle operations
are JSON. For high-frequency control (e.g., per-keystroke resize), this is
more overhead than a binary control frame. The division (ADR-037 §DP-4)
handles this: `stream_type 3` on the data channel for data-ordered control,
call operations for lifecycle and infrequent control.
## Door type
**One-way.** Channel 0's role as `alknet/call` pre-negotiated is a wire-
format and protocol-structure commitment. Changing it after deployments
exist (e.g., to a special control plane) requires a version migration and
re-architecting the channel lifecycle operations. The reservation of
`stream_type` 1-255 on channel 0 is a two-way-door detail (they're currently
unused; assigning them is additive).
## References
- ADR-034: channels wire format (the 8-byte chunk header channel 0 uses,
as amended by ADR-035)
- ADR-035: channels pure channel multiplexing (amends this ADR —
channel 0's `stream_types` field removed; the call protocol's framing
is the channels payload, carried transparently)
- ADR-037: channel lifecycle operations (registered on channel 0's
`OperationRegistry`)
- ADR-014: irpc never integrated — hand-rolled EventEnvelope framing (the
call protocol channel 0 carries)
- ADR-021: StreamingHandler for subscriptions (the machinery
`channel/resources/subscribe` uses)
- ADR-008: BidiStreamSource trait (the `Connection` extension point)
- `docs/research/alknet-channels/phase-0-findings.md` §DP-2, §Channel 0

View File

@@ -0,0 +1,326 @@
# ADR-037: Channel Lifecycle Operations on the Call Protocol
## Status
Accepted (amended 2026-07-18 by ADR-035 — `stream_types` field removed
from `channel/open`; `stream_type` field removed from `channel/control`;
`channel:stream_type_unavailable` error code removed; the channels layer
has no `stream_type` concept — see "Amendment (ADR-035, 2026-07-18)"
below)
## Amendment (ADR-035, 2026-07-18)
The `stream_types` field is **removed** from `channel/open`'s input and
output. The `stream_type` field is **removed** from `channel/control`'s
input. The `channel:stream_type_unavailable` error code is **removed**.
The channels layer has no `stream_type` concept (ADR-035) — the handler
owns its sub-stream multiplexing on the `BiStream` it receives. The
handler's sub-stream set is implicit in its ALPN's wire format (e.g.,
TTY's 5-byte format declares its own `stream_type` set internally; the
channels layer carries the bytes transparently).
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-035 for the
resolution rationale and the cross-ADR impacts.
## Context
Channel lifecycle — open, close, control, resource discovery — must be
orchestrated somehow. The phase-0 research (`docs/research/alknet-channels/
phase-0-findings.md` §Channel Open Negotiation, §DP-4) established that
channel lifecycle is orchestrated by the call protocol on channel 0
(ADR-036). This ADR pins the exact operation shapes, the `direction` field
semantics, the control-message division, and the resource-discovery model.
Three things from the research needed real decisions, not hedges:
1. **Resource discovery: poll vs subscribe (OQ-CH-08).** The research
recommended "poll for v1, add subscription if staleness bites." This is a
hedge: the call protocol already has `StreamingHandler` /
`invoke_streaming` (ADR-021, implemented and tested), and the first
consumer (the hub aggregating worker resources) needs live updates. Polling
would be built, immediately found insufficient, and reworked. This ADR
commits to subscribe from day one.
2. **The `direction` field and who writes first (OQ-CH-09).** The research
said "ALPN-specific and probably doesn't need a channels-layer rule…
needs to be pinned down." That IS the rule: the channels layer declares
write-order is ALPN-specific (determined by who is the ALPN-server), not
channels-enforced. This ADR pins which side is the ALPN-server for each
`direction` value.
3. **Control messages: call ops vs stream_type 3 (DP-4).** The research
recommended "both, with clear division." This ADR pins the division.
## Decision
### Four operations on channel 0's `OperationRegistry`
Registered at assembly time by the channels crate (via `ChannelOperations::
register_on(&mut call_registry)`). All four go through the existing
`OperationContext` / `AccessControl::check` path — no new auth machinery.
#### `channel/open` — open a data channel
Request (`call.requested` on channel 0):
```json
{
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"stream_types": [0, 1, 2, 3, 4],
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder"
}
}
```
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. The responder looks this up in its `HandlerRegistry`. |
| `stream_types` | `[u8]` | Which sub-stream types this channel will use. E.g. `[0,1,2,3,4]` for TTY (data in/out/err + control in/out), `[0,1]` for a tunnel, `[0,1]` for channel 0 (call frames). See ADR-034 §stream_type decomposition. |
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is the `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
Response (`call.responded`):
```json
{
"output": {
"channel_id": 7,
"stream_types": [0, 1, 2, 3, 4]
}
}
```
| field | type | meaning |
|-------|------|---------|
| `channel_id` | u32 | The server-assigned channel ID (DP-1: server-assigned). Both sides route chunks with this ID to the new channel. |
| `stream_types` | `[u8]` | The *negotiated* set — the responder may narrow the initiator's requested set (e.g., refuse stderr). The intersection of requested and supported. |
**Channel ID allocation (DP-1): server-assigned.** The responder allocates
the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1, Relaxed)`)
and returns it in the response. One round-trip before data flows — the same
round-trip the call protocol makes for every operation. All current channel
types (TTY, tunnel, SSH) already require a negotiation round-trip, so the
open round-trip is not additive latency.
**Error codes** (new `CallError.code` strings, not new framing):
| code | meaning | retryable |
|------|---------|-----------|
| `channel:unknown_alpn` | ALPN not in responder's `HandlerRegistry` | false |
| `channel:forbidden` | `AccessControl::check` denied the open | false |
| `channel:allocation_failed` | Handler allocate failed (e.g., backend couldn't start) | true (often transient) |
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-040) | false |
| `channel:stream_type_unavailable` | Responder can't provide a requested `stream_type` | false |
#### `channel/close` — tear down a channel
```json
{
"operation": "channel/close",
"input": { "channel_id": 7, "reason": "exit" }
}
```
The responder (the side that didn't send the close) drains its reassembled
streams for `channel_id`, signals EOF to the handler, and returns
`{ "closed": true }`. The `channel_id` is now eligible for reuse after the
drain completes (ADR-040 §channel-id-reuse). `reason` is free-form for
observability — not semantically required.
**Exit-chunk-before-close ordering (generalizes ADR-055):** the channel's
data chunks must be written and flushed before the `channel/close` operation
is sent on channel 0. This is a wire-level invariant: the side closing must
observe the data-channel pump complete before issuing the call operation.
For TTY this is the exit-chunk-is-last invariant (ADR-055) carried forward;
for tunnels it is the last data byte before close. The channels layer's
close handler observes the pump completion; the call operation is issued
after. This is REQ-CH-06.
#### `channel/control` — out-of-band control on channel 0
For control that doesn't need ordering relative to data (resize, signal,
keepalive):
```json
{
"operation": "channel/control",
"input": {
"channel_id": 7,
"stream_type": 3,
"message": { "type": "resize", "cols": 80, "rows": 24 }
}
}
```
The channels layer routes `message` to the handler's control handle for
`channel_id`. The `message` JSON is ALPN-specific; the channels layer does
not interpret it.
#### `channel/resources/subscribe` — live resource discovery
**This is a `Subscription` operation (ADR-021), not a polled Query.** The
research's "poll for v1, add subscription if staleness bites" is a hedge that
would cause rework — the `StreamingHandler` / `invoke_streaming` machinery
exists and is tested, and the hub consumer needs live updates when workers
connect/disconnect or containers start/stop.
```json
{
"operation": "channel/resources/subscribe",
"input": {}
}
```
The responder registers a `StreamingHandler` that emits a
`ResponseEnvelope` whenever the resource set changes. Each event:
```json
{
"output": {
"resources": [
{
"alpn": "alknet/tty",
"backends": ["docker", "local"],
"access": { "required_scopes": ["tty:open"] }
},
{
"alpn": "alknet/tunnel",
"targets": ["container:*", "service:postgres"],
"access": { "required_scopes_any": ["tunnel:open", "admin"] }
}
]
}
}
```
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN this side accepts `channel/open` for. |
| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these; they're for the initiator to know what `params` to send. |
| `access` | object | A preview of the `AccessControl` that `channel/open` will check. Advisory — lets the initiator fail fast. The real check happens on `channel/open`. |
The stream emits an initial snapshot immediately, then subsequent events on
any change (worker connects/disconnects, container starts/stops, resource
exposed/withdrawn). The stream is long-lived; the subscriber cancels by
dropping the subscription (ADR-020 abort cascade applies). This is the
resource-discovery analogue of `services/list`, but live — matching the
bidirectional symmetry of the operation overlay.
A `channel/resources` (non-subscribe, Query) operation is NOT provided. The
subscription's initial snapshot serves the poll use case (subscribe, read
the first event, cancel). Providing both would be redundant and would
pressure consumers toward the stale-poll path.
### Direction semantics (OQ-CH-09 — pinned)
Channel open is **bidirectional** — either side can initiate. The
`direction` field determines who is the ALPN-server (allocates the handler,
writes the negotiation response) vs the ALPN-client (writes the first
request).
| `direction` | Initiator role | Responder role | Who writes first |
|-------------|----------------|----------------|-------------------|
| `initiator-to-responder` | ALPN-client | ALPN-server | Initiator writes first (the request data); responder's handler is the server side of the ALPN. The common case: "open me a TTY on your docker container." |
| `responder-to-initiator` | ALPN-server | ALPN-client | Responder writes first (the negotiation response / server greeting); initiator's handler is the client side. The "worker exposes, hub consumes" case: the worker initiates the open to make itself available; the hub is the client that connects to the exposed resource. |
**The channels layer does not enforce write order.** Write order is
ALPN-specific, determined by which side is the ALPN-server (per the table
above). The channels layer's job is to route chunks; the handlers negotiate
who writes first via their ALPN's `params` contract. This is the rule the
research asked for: "the channels layer declares write-order is ALPN-
specific, not channels-enforced."
**`channel_id` allocation is always by the responder** (DP-1), regardless of
`direction`. The responder is the side that receives the `channel/open` call
operation; it allocates the ID and returns it. In the `responder-to-
initiator` case, the initiator (worker) sends the `channel/open`, so the
responder (hub) allocates the ID — even though the worker is the ALPN-
server for the channel's data. This keeps ID allocation in one place (the
`channel/open` responder) and avoids the collision-prone client-assigned
alternative.
### Control-message division (DP-4 — pinned)
| Control path | When | Examples |
|--------------|------|----------|
| Call operations on channel 0 (`channel/control`, `channel/close`) | Control that doesn't need ordering relative to data, or lifecycle events | resize, signal, keepalive, close |
| `stream_type 3` (write, client→server) and `stream_type 4` (read, server→client) chunks on the data channel | Control that MUST be ordered relative to data | EOF before exit, flush before close |
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
example of data-ordered control — the exit message rides on `stream_type 4`
(read, server→client) because it must arrive after the last data on
`stream_type 1`, guaranteed by per-stream_type ordering. The client's EOF
signal rides on `stream_type 3` (write, client→server), ordered after the
last data on `stream_type 0`. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
**Every stream_type is unidirectional** (ADR-034 §stream_type decomposition).
Control is bidirectional via two halves (3 in, 4 out), not one shared
stream_type both sides write to. This resolves the TTY control channel's
"not actually bidirectional" flaw.
## Consequences
**Positive:**
- Channel lifecycle reuses the call protocol's `OperationRegistry`,
`AccessControl`, `OperationContext`, `forwarded_for`, and
`StreamingHandler` verbatim. Zero new auth, zero new framing.
- `channel/resources/subscribe` gives the hub a live view of worker
resources — no polling, no staleness, no rework when the first consumer
needs subscriptions.
- The `direction` field makes bidirectional open explicit and pins who is
the ALPN-server, resolving the "who writes first" ambiguity without
channels-layer write-order enforcement.
- The control-message division (call ops vs stream_type 3) handles both
lifecycle control (infrequent, benefits from auth/observability) and
data-ordered control (frequent, needs ordering) without duplicating
machinery.
**Negative:**
- Four new operation names in the `OperationRegistry`. The registry already
handles namespaced operations (`docker/container/list`, etc.); these are
in the `channel/` namespace. No registry changes needed.
- `channel/resources/subscribe` is a long-lived `Subscription` stream per
interested peer. This is the same cost as any other subscription (ADR-021);
the hub holds one per connected peer. Acceptable.
- The `direction` field adds one field to the `channel/open` input. It is
required (no default) — the initiator must state its intent. This is a
one-way-door wire-format field (removing it would break the bidirectional
open contract).
## Door type
**One-way.** The four operation names (`channel/open`, `channel/close`,
`channel/control`, `channel/resources/subscribe`), their input/output
schemas, and the `direction` field's semantics are wire-format commitments.
Changing them after deployments exist requires a protocol version migration.
The `reason` field on `channel/close` (free-form, observability-only) is a
two-way-door detail.
The decision to use `Subscription` for resource discovery (not `Query`) is
one-way: consumers will depend on the live stream, and the Decision section
committed to Subscribe-only (a `Query` variant is NOT provided — the
subscription's initial snapshot serves the poll use case). The
`Handler` / `StreamingHandler` / `HandlerKind` API surface (ADR-021) is
the underlying one-way commitment.
## References
- ADR-034: channels wire format (amended by ADR-035 — 8-byte header, no
`stream_type`)
- ADR-035: channels pure channel multiplexing (amends this ADR —
`stream_types` field removed from `channel/open`; `stream_type` field
removed from `channel/control`; handler owns sub-stream multiplexing)
- ADR-036: channel 0 is pre-negotiated `alknet/call`
- ADR-021: StreamingHandler for subscriptions (the machinery
`channel/resources/subscribe` uses — implemented and tested)
- ADR-020: abort cascade (subscription cancellation)
- ADR-026: forwarded-for identity (the auth chain for hub-relayed opens)
- ADR-055: exit-chunk-is-last (the TTY invariant generalized by REQ-CH-06)
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
Negotiation, §DP-4, §OQ-CH-08, §OQ-CH-09

View File

@@ -0,0 +1,245 @@
# ADR-038: ChannelConnection — BidiStreamSource over Chunk Reassembly
## Status
Accepted (**amended 2026-07-18 by ADR-035: `into_sub_streams()` removed;
`accept_bi` is the only accessor, yields one `BiStream` per channel —
see "Amendment (ADR-035, 2026-07-18)" below**)
## Amendment (ADR-035, 2026-07-18)
`into_sub_streams()`, `ChannelSubStreams`, and `SubStreamHandle` are
**removed**. The channels layer exposes one accessor: `accept_bi()`,
which yields one `BiStream` per channel (per ADR-009, already landed).
Every handler — TTY, tunnel, SSH, call — receives a `Connection`, calls
`accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it
wants. The "typed handler path" (this ADR's motivating case for TTY) is
replaced by TTY sub-demuxing its `BiStream` via its own 5-byte format
(ADR-052) — the same code TTY runs in direct mode. The two-accessor
design (`accept_bi` for generic handlers, `into_sub_streams` for typed
handlers) collapses to one accessor.
The body below describes the **original** (two-accessor) shape; the
amendment above is the operative decision. The two-accessor description
is kept as the historical context for the amendment. See ADR-035 for
the resolution rationale (the channels layer has no `stream_type`
concept; the handler owns its sub-stream multiplexing) and the
cross-ADR impacts.
## Context
ADR-008 landed the `BidiStreamSource` trait and `Connection::from_source`
extension point so downstream crates can implement their own connection
shapes without a core edit. The channels crate is the first downstream
consumer: a channels connection carries N logical channels, each a
bidirectional byte stream presented to a `ProtocolHandler` as a `Connection`.
The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
§The Channel Connection Abstraction, §OQ-CH-10) proposed that
`ChannelConnection` *implements* the `Connection` interface (for recursion
and generic handlers) **and** can be destructured into typed sub-stream
handles (`TtyChannel { stdin, stdout, stderr, control }`). The research
recommended "the TTY crate destructures; channels exposes `(channel_id,
stream_type) → (SendStream, RecvStream)` accessors" but did not pin the
exact API shape. This ADR pins it.
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §POC Target
2) validated that `Connection::from_stream` (the yield-once path) is
sufficient — an echo `ProtocolHandler` runs through the full
demux→Connection→handler→mux path with zero channels-layer awareness. But
the POC deliberately used the yield-once path (one `Connection` per channel)
rather than the N-stream `ChannelBidiStreamSource` shape. This ADR commits
to the N-stream shape that ADR-008 unblocked.
## Decision
### `ChannelBidiStreamSource` implements `BidiStreamSource`
The channels crate defines a `ChannelBidiStreamSource` that implements
`alknet-core`'s `BidiStreamSource` trait (ADR-008). One
`ChannelBidiStreamSource` instance represents **one channel** (not the
whole channels connection). Its `accept_bi()` yields one bidi stream — the
`(stream_type 0, stream_type 1)` pair for that channel — then returns
`ConnectionClosed` on subsequent calls (yield-once per channel, matching
the POC's validated shape).
```rust
// In alknet-channels:
pub struct ChannelBidiStreamSource {
// The reassembly buffers for this channel's active stream_types,
// plus the mux handle for writing back onto the transport.
// Constructed by ChannelManager::build_channel_connection (ADR-039).
...
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
// Yields the (stream_type 0, stream_type 1) pair on first call,
// ConnectionClosed on subsequent calls. This is the yield-once
// contract per channel, matching the POC's validated shape.
}
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
// StreamClosed — a single channel cannot open new application
// streams (same as ADR-007's Stream backend). Additional sub-streams
// (stream_type 2, 3) are accessed via sub_streams(), not open_bi().
}
fn remote_addr(&self) -> Option<SocketAddr> { ... }
fn close(&self, _code: u32, _reason: &str) { ... }
}
```
Each channel is presented to its handler as a `Connection` constructed via
`Connection::from_source(ChannelBidiStreamSource::new(...), alpn)`. The
handler calls `accept_bi()` once, gets the main data pair, and drives its
session — exactly as the POC's `EchoHandler` and `TtyAdapter` do today.
### Sub-stream accessor for typed destructure (OQ-CH-10)
Some handlers need access to `stream_type` 2 (stderr), 3 (control in), and
4 (control out) in addition to the main 0/1 pair. The `Connection`
interface alone (accept_bi) only exposes the 0/1 pair. The channels crate
provides a typed-accessor extension:
```rust
// In alknet-channels:
pub struct ChannelSubStreams {
/// (stream_type, handle) for each active stream_type. Each handle is
/// unidirectional: write stream_types (0, 3, 6, ...) carry a SendStream;
/// read stream_types (1, 2, 4, 5, 7, ...) carry a RecvStream.
/// See ADR-034 §stream_type decomposition.
pub streams: Vec<(u8, SubStreamHandle)>,
}
pub enum SubStreamHandle {
Send(SendStream), // write half (stream_type % 3 == 0)
Recv(RecvStream), // read half (stream_type % 3 == 1 or 2)
}
impl ChannelBidiStreamSource {
/// Returns the typed sub-streams for this channel, keyed by stream_type.
/// Consumes the source — call this instead of accept_bi() if the handler
/// needs direct access to stream_types 2/3/4. For handlers that only need
/// the main 0/1 pair, accept_bi() is the path (and sub_streams() is not
/// called).
pub fn into_sub_streams(self) -> ChannelSubStreams { ... }
}
```
The handler crate (e.g., `alknet-tty`) destructures `ChannelSubStreams` into
its typed names:
```rust
// In alknet-tty (inside-channels mode, ADR-077):
let sub = channel_source.into_sub_streams();
let stdin = sub.get_send(0).unwrap(); // SendStream (write, client→server)
let stdout = sub.get_recv(1).unwrap(); // RecvStream (read, server→client)
let stderr = sub.get_recv(2); // Option<RecvStream> (read, optional)
let ctrl_in = sub.get_send(3).unwrap(); // SendStream (write, client→server)
let ctrl_out = sub.get_recv(4).unwrap();// RecvStream (read, server→client)
```
**Every stream_type is unidirectional** (ADR-034). Write stream_types
(`% 3 == 0`) carry a `SendStream`; read stream_types (`% 3 == 1 or 2`) carry
a `RecvStream`. There is no "bidirectional" stream_type — bidirectionality
is two halves (e.g., control is 3 write + 4 read). This resolves the TTY
control channel's "not actually bidirectional" flaw: the TTY adapter reads
exit/keepalive from `ctrl_out` (stream_type 4) and writes resize/signal/eof
to `ctrl_in` (stream_type 3), each with its own flow control and EOF.
**The channels crate does not know about TTY's `stream_type` semantics.**
It exposes `(stream_type, SubStreamHandle)` tuples. The handler crate maps
stream_types to its typed names. This preserves ADR-031's
no-handler-depends-on-another-handler rule and keeps the channels crate
ALPN-blind.
### When to use `accept_bi` vs `into_sub_streams`
| Handler shape | Path | Example |
|---------------|------|---------|
| Main data pair only (0/1) | `accept_bi()` | tunnel handler, SSH handler (SSH multiplexes internally) |
| Needs stderr/control (2/3) | `into_sub_streams()` | TTY handler (stdin/stdout/stderr/control) |
The handler chooses at construction time based on its ALPN's `stream_type`
set (declared at `channel/open` time, ADR-037). The `ChannelsAdapter` passes
the handler a `Connection` (via `from_source`); handlers that need sub-
streams downcast or receive the `ChannelBidiStreamSource` directly via a
channels-crate extension trait. The exact ergonomics (downcast vs. a
channels-crate constructor that hands the source directly to handlers that
opt in) are an implementation detail for the channels crate; the contract is
that both paths are available and the handler crate chooses.
### Recursive composition
A `ChannelBidiStreamSource` is a `BidiStreamSource`, and `Connection::
from_source` wraps it. A handler that is itself `alknet/channels` can open a
sub-channels connection on a data channel. This is recursive composition:
`alknet/channels` inside `alknet/channels`. It is allowed (the `Connection`
abstraction permits it) but not a feature designed for — the primary use
case is one level of multiplexing. Recursive composition is a natural
consequence of the abstraction, not a goal.
## Consequences
**Positive:**
- `ChannelConnection` is a first-class peer of QUIC: one
`BidiStreamSource` impl per channel, constructed via `from_source` — no
core edit (the ADR-008 extension point).
- Handlers that only need the main data pair use `accept_bi()` — identical
to how they work on top-level QUIC connections. Zero handler changes for
the tunnel/SSH shape.
- Handlers that need typed sub-streams (TTY) use `into_sub_streams()` — the
channels crate provides the accessor, the handler crate maps to typed
names. No channels-crate knowledge of TTY semantics.
- The POC's validated yield-once shape is preserved per-channel; the N-stream
generalization is at the connection level (one channels connection = N
channels = N `ChannelBidiStreamSource` instances), not per-channel.
**Negative:**
- Two paths to access channel data (`accept_bi` vs `into_sub_streams`). This
is a necessary divergence: the `Connection` interface alone can't express
"give me four named sub-streams" without four `accept_bi` calls (which
would violate the yield-once contract). The two-path design is the
minimum-complexity solution; the alternative (a new `Connection` variant
with multi-stream semantics) would touch `alknet-core` and break the
ADR-008 extension-point model.
- `into_sub_streams()` consumes the source, so a handler can't call both
`accept_bi()` and `into_sub_streams()`. This is by design — the sub-
streams include the 0/1 pair, so `into_sub_streams()` is the superset.
## Door type
**One-way.** The `ChannelBidiStreamSource` shape (one source per channel,
yield-once `accept_bi`, `into_sub_streams` accessor) is the handler-facing
API surface. Changing it after handlers exist (TTY, tunnel, SSH) is a
rewrite of those handlers' integration code. The trait impl is in the
channels crate (not core), so the one-way door is the channels crate's API,
not a core type.
**Amended by ADR-035 (2026-07-18):** `into_sub_streams()` is removed;
`accept_bi` is the only accessor. The one-way door is re-cast (the
channels crate is not yet implemented, so this is the right time). See
ADR-035 for the amended door-type discussion.
The choice of `into_sub_streams()` returning `Vec<(u8, SendStream,
RecvStream)>` (vs a typed struct, vs a map) is a two-way-door implementation
detail — the return type can change without breaking the contract as long
as the handler crate's destructure code updates.
## References
- **ADR-035**: channels pure channel multiplexing (amends this ADR —
`into_sub_streams()` removed; `accept_bi` is the only accessor, yields
one `BiStream` per channel; the handler owns its sub-stream
multiplexing)
- ADR-009: `BiStream` as the handler leaf (the transport-leaf decision
this ADR's amendment builds on — `accept_bi` returns `BiStream`)
- ADR-008: BidiStreamSource trait (the extension point this implements)
- ADR-007: Connection::from_stream (the yield-once path this generalizes for
channels)
- ADR-034: channels wire format (the chunks this reassembles)
- ADR-039: ChannelsAdapter and ChannelManager (the components that construct
`ChannelBidiStreamSource` instances)
- ADR-077: TTY inside channels (the primary consumer of `into_sub_streams`)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 2, §Issues
Surfaced #1

View File

@@ -0,0 +1,257 @@
# ADR-039: ChannelsAdapter and ChannelManager
## Status
Accepted (amended 2026-07-18 by ADR-035 — demux reads 8-byte headers, not
9-byte; one reassembly buffer per `channel_id` (not per
`(channel_id, stream_type)`); `ChannelState.stream_types` removed; the
channels layer has no `stream_type` concept — see "Amendment (ADR-035,
2026-07-18)" below)
## Amendment (ADR-035, 2026-07-18)
The demux loop reads **8-byte headers** (not 9-byte). `ChannelState` has
**one reassembly buffer per `channel_id`** (not per
`(channel_id, stream_type)`), yielding a `BiStream` to the handler. The
`stream_types: Vec<u8>` field on `ChannelState` is **removed**. The
`ChannelManager` has no `stream_type` concept — it routes by `channel_id`
only, and the handler owns its sub-stream multiplexing on the `BiStream`
it receives (per ADR-035, the channels-layer consequence of ADR-009's
`BiStream` handler leaf).
The body below describes the **original** (9-byte, per-stream_type) shape;
the amendment above is the operative decision. See ADR-035 for the
resolution rationale and the cross-ADR impacts.
## Context
The channels crate has two internal components, split by responsibility
(`docs/research/alknet-channels/phase-0-findings.md` §Channel Manager and
Connection Internals):
1. **`ChannelsAdapter`** — implements `ProtocolHandler` for
`alknet/channels`. Its `handle()` receives one `Connection` (the
transport), reads 9-byte chunk headers, and routes each chunk. It is the
read/demux half.
2. **`ChannelManager`** — the shared state both halves touch. It holds the
map of `channel_id → ChannelState`, the `HandlerRegistry` reference, and
the `OperationRegistry` reference. It is the reassemble/allocate half.
It is what the `channel/open` operation handler closes over.
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §Issues
Surfaced) surfaced three invariants the spec must pin: the mux needs dynamic
registration (handle/runner split — REQ-CH-03), the demux must drop all
channel senders on transport EOF (REQ-CH-02), and the `AsyncWrite::shutdown`
must emit a zero-length sentinel (REQ-CH-01). This ADR pins these as
contracts.
## Decision
### `ChannelsAdapter` — the read/demux half
```rust
#[async_trait]
impl ProtocolHandler for ChannelsAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/channels" }
async fn handle(&self, connection: Connection, auth: &AuthContext)
-> Result<(), HandlerError>
{
// 1. Channel 0 is pre-negotiated as alknet/call (ADR-036).
// The first bidi stream the transport yields is channel 0.
let (send, recv) = connection.accept_bi().await?;
self.manager.preinstall_channel_0(send, recv, auth).await?;
// 2. Accept remaining bidi streams and read 9-byte headers off each.
// On an in-line transport (TCP+TLS, WebTransport), accept_bi()
// yields once and the header demuxes N channels from that stream.
// On QUIC native, accept_bi() yields repeatedly — each stream
// carries one logical channel, and the header provides
// stream_type + channel_id correlation. Same code path, same
// wire format (ADR-034 §substrate modes).
self.manager.run_demux_loop(connection).await
}
}
```
The `preinstall_channel_0` step constructs the reassembly buffers for
`channel_id = 0` using stream_types [0, 1] (ADR-036), wraps them as a
`Connection` (via `Connection::from_source` with a `ChannelBidiStreamSource`
— ADR-038), and hands that `Connection` to the `CallAdapter` — exactly as if
`alknet/call` had been the top-level ALPN. The `CallAdapter` is looked up in
the same `HandlerRegistry` as every other ALPN.
`run_demux_loop` continues accepting bidi streams from the transport. For
each stream, it reads 9-byte headers and routes payloads to the matching
`(channel_id, stream_type)` reassembly buffer. On an in-line transport,
there is only one stream (channel 0 rides inside it via the header); the
header demuxes all channels. On QUIC, each subsequent stream is a new
channel; the header's `channel_id` correlates it. The loop is the same;
only the transport's stream count differs.
### `ChannelManager` — the shared state
```rust
pub struct ChannelManager {
/// channel_id → per-channel state. Channel 0 is pre-inserted at
/// construction by preinstall_channel_0.
channels: Mutex<HashMap<u32, ChannelState>>,
/// The handler registry for looking up ALPNs on channel/open.
handlers: Arc<HandlerRegistry>,
/// The call protocol's operation registry, so channel/open etc. can be
/// registered at assembly time.
call_ops: Arc<OperationRegistry>,
/// Next server-assigned channel_id. Monotonic; wraps at u32::MAX.
next_id: AtomicU32,
/// Per-channel reassembly buffer cap (ADR-040). Default 1 MiB.
buffer_cap: usize,
/// Per-connection channel limit (ADR-040). Default 256.
max_channels: usize,
}
struct ChannelState {
/// The ALPN this channel carries, for routing and observability.
alpn: String,
/// Reassembly buffers per active stream_type.
streams: HashMap<u8, ReassemblyBuffer>,
/// The handler task driving this channel. Dropping this aborts it.
handler_task: JoinHandle<()>,
/// Which stream_types are active (from the open negotiation).
stream_types: Vec<u8>,
}
```
`ChannelManager` is `Clone` (cheap — `Arc` internally) so the
`ChannelsAdapter`, the `channel/open` operation handler, and relay logic
can all hold a handle.
### The demux loop — REQ-CH-02 and REQ-CH-04
`run_demux_loop` reads 9-byte headers, looks up `channel_id` in `channels`,
and pushes the payload into the right `ReassemblyBuffer` for `(channel_id,
stream_type)`.
**REQ-CH-04 (lenient unknown-channel_id):** a chunk with an unallocated
`channel_id` (or `stream_type`) is dropped with a debug log and an error
counter (exposed via `Demux::stats()`), and the demux continues. This
matches SSH's behavior and survives transient mis-ordering during teardown.
Validated by the POC (`demux_unknown_channel_drops_lenient`).
**REQ-CH-02 (transport close → all handlers see EOF):** on transport EOF,
the demux loop clears its `channels` map, dropping all `ReassemblyBuffer`
senders. Every handler's reassembled `RecvStream` sees EOF even without an
explicit zero-length sentinel on the wire. Without this, `read_to_end` /
`tokio::io::copy` in handlers hangs forever waiting for a sender that never
drops. This is a teardown invariant of the `ChannelsAdapter::handle`
contract. Validated by the POC.
### The mux — REQ-CH-03 (handle/runner split)
The mux frames per-channel bytes back onto the transport. The POC surfaced
that the plan's `Mux::run(self, transport)` shape (consume the mux, run
pumps for pre-registered channels) does not compose with the dynamic
`channel/open` model — channels are opened after the run loop starts.
**REQ-CH-03 (dynamic registration):** the mux is split into:
- **`MuxHandle`** — clone-able, `register(channel_id, stream_type) ->
Sender<Bytes>` callable at any time (after the runner has started).
- **`MuxRunner`** — owns the transport, `select!`s on new-pump registrations
and per-channel write pumps.
The runner's `select!` loop exits when all `MuxHandle` clones drop (the
`new_pumps` sender closes), which is the natural shutdown signal. This
matches the dynamic `channel/open` model. The split adds one
`mpsc::UnboundedSender` + `Arc<Mutex<HashMap>>` per mux — cheap. Validated
by the POC.
### `ChannelManager` is ALPN-blind and auth-blind
The `ChannelManager` deliberately does **not** hold:
- **No `ProtocolHandler` implementations.** It holds a `HandlerRegistry`
reference for ALPN lookup, but it doesn't *be* a handler. Handlers live in
their crates and register on the same registry.
- **No ALPN-specific parsing.** It does not parse `NegotiateRequest` JSON,
SSH frames, or tunnel target strings. It hands `params` JSON to the
handler and gets back a handler task; it hands `stream_type 3` JSON to the
handler's control handle.
- **No auth state.** Auth lives in the `OperationContext` that the call
protocol passes to `channel/open`. The `ChannelManager` doesn't check
scopes or ownership — that's `AccessControl::check` in
`OperationRegistry::invoke`, run before the `channel/open` handler.
- **No transport coupling.** It talks to the transport only through the
`ChannelsAdapter`'s read loop and the per-channel write pumps, both of
which use `AsyncRead + AsyncWrite`.
This is what makes the channels layer WASM-compatible and transport-agnostic
— the `ChannelManager` is pure byte routing with no platform or protocol
dependencies.
### The `channel/open` handler — threading into `OperationRegistry`
The `channel/open` (and `channel/close`, `channel/control`,
`channel/resources/subscribe`) operations are registered on the call
protocol's `OperationRegistry` at assembly time. The handler closures close
over a `ChannelManager` clone:
```rust
let channel_ops = ChannelOperations::new(manager.clone());
channel_ops.register_on(&mut call_registry)?;
```
The `channel/open` handler (ADR-037) looks up the ALPN in `HandlerRegistry`,
allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)`, constructs
the `ChannelBidiStreamSource` (ADR-038), spawns the handler task, and
records the `ChannelState`. The key insight: spawning the handler task is
identical to what `TtyAdapter::handle` does today — `tokio::spawn` a
session-driving task. The only difference is the `Connection` passed in is
backed by chunk reassembly rather than a quinn connection.
## Consequences
**Positive:**
- The ChannelsAdapter/ChannelManager split mirrors the TTY crate's
ChunkReader/ChunkWriter + adapter pattern, generalized to N channels.
- The demux/mux contracts (REQ-CH-01..04) are pinned as wire-level
invariants, not implementation details. Both sides must agree, or channels
hang on clean shutdown.
- The `ChannelManager` is ALPN-blind, auth-blind, and transport-blind — the
channels layer is a re-framing proxy, not a protocol engine. This is what
makes it reusable across TTY, SSH, tunnel, and future ALPNs.
**Negative:**
- The mux handle/runner split (REQ-CH-03) adds one `mpsc::UnboundedSender` +
`Arc<Mutex<HashMap>>` per mux. Cheap, but more moving parts than the
pre-register-all-then-run alternative. The alternative doesn't match the
dynamic `channel/open` model, so the split is necessary, not optional.
- The demux loop is one task per transport. If the demux task panics, all
channels on that transport lose their read side. The teardown invariant
(REQ-CH-02) ensures handlers see EOF, not a hang — but a panic in the
demux is still a transport-wide failure. This is the same property as any
single-task read loop (including the call protocol's dispatch loop).
## Door type
**One-way (contracts) + two-way (internals).** The wire-level invariants
(REQ-CH-01..04) are one-way — both sides must agree, and changing them
after deployments exist is a protocol migration. The `ChannelManager`'s
internal structure (fields, `Arc<Mutex<HashMap>>` vs a concurrent map, etc.)
is two-way — implementation details that can change without breaking the
contract.
## References
- ADR-034: channels wire format (the chunks the demux reads, as amended
by ADR-035 — 8-byte header)
- ADR-035: channels pure channel multiplexing (amends this ADR — 8-byte
header, one reassembly buffer per channel, no `stream_type` concept)
- ADR-036: channel 0 pre-negotiated (the `preinstall_channel_0` step)
- ADR-037: channel lifecycle operations (the ops registered on `call_ops`)
- ADR-038: ChannelBidiStreamSource (the per-channel source the manager
constructs, as amended by ADR-035 — `accept_bi` yields a `BiStream`)
- ADR-040: backpressure, channel limits, ID reuse (the `buffer_cap` /
`max_channels` / reuse invariants)
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#6
(REQ-CH-01, 02, 03)

View File

@@ -0,0 +1,237 @@
# ADR-040: Backpressure, Channel Limits, and ID Reuse
## Status
Accepted (amended 2026-07-18 by ADR-035 — backpressure is per-`channel_id`,
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-035,
2026-07-18)" below; **amended 2026-07-19 by ADR-041 — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense; the per-identity DoS defense lives in `channels-call` via
`ChannelLifecyclePolicy` — see "Amendment (ADR-041, 2026-07-19)" below**)
## Amendment (ADR-041, 2026-07-19)
The per-connection `max_channels = 256` cap is **reframed as a
per-connection memory bound**, not a DoS defense. A single peer can
open an unbounded number of transport connections, so a per-connection
cap is not a per-peer DoS defense — it is a bound on one connection's
reassembly-buffer cost. The per-identity DoS defense (256 per
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
is documented in [ADR-041](041-per-identity-channel-cap.md).
What changes in this ADR:
1. **§"Maximum channels per connection: 256 default"** — the cap stays
at 256, but its role is reframed. It is a per-connection memory
bound (limits one connection's reassembly-buffer cost regardless of
policy), not the DoS defense against an authenticated peer. The
per-identity DoS defense is the `ChannelLifecyclePolicy`
consultation in the `channel/open` handler (ADR-041).
2. **§"DoS defense summary"** — the table is **removed**. It framed
the per-connection cap as the DoS defense, which it is not. ADR-041
§2 contains the corrected per-identity DoS defense summary.
3. **The "per-connection, not per-peer — a peer can open more channels
on a second connection" line** — this was the channels layer
confessing a hole and hoping the layer above it would fill it. The
line is **corrected** to state that the per-connection cap is a
memory bound, and that the per-identity cap is the DoS defense
(ADR-041). A peer that opens a second connection gets a second
per-connection memory bound; it does **not** get a second
per-identity quota — the `ChannelLifecyclePolicy` is shared across
connections.
What stays:
- The 256 default and the `max_channels` field on `ChannelManager`
(still returns `channel:too_many_channels` when hit — the
per-identity policy returns the same error code, so an over-cap
peer sees the same error either way).
- The bounded-buffer backpressure decision (DP-5) — unchanged.
- The channel-ID reuse decision (monotonic `next_id` with
wrap-around) — unchanged.
- The drain-before-reuse invariant — unchanged, and the
`channel/close` handler now also calls
`ChannelLifecyclePolicy::on_close` at this point (ADR-041 §3).
## Amendment (ADR-035, 2026-07-18)
The bounded-buffer backpressure is per-`channel_id` (not per
`(channel_id, stream_type)`). The channels layer has one reassembly
buffer per channel (yielding a `BiStream`), not one per
`(channel_id, stream_type)`. The 1 MiB default and the 256-channel cap are
unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a
TTY channel with 5 active stream_types under the per-stream_type model).
This is a net improvement (lower memory ceiling per channel), not a
regression. The bounded-buffer *approach* is unchanged; only the
buffer granularity changes (per-channel, not per-stream_type).
The body below describes the **original** (per-stream_type) shape; the
amendment above is the operative decision. See ADR-035 for the resolution
rationale.
## Context
The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
§DP-5, §OQ-CH-03/04/05/06) raised four operational questions about the
channels layer:
1. **Flow control (DP-5, OQ-CH-03):** if one data channel's consumer is
slow, could it block all other channels on the same transport
(head-of-line blocking)? The research recommended "bounded-buffer
backpressure (option c)… if head-of-line blocking becomes a real problem,
full windowing can be added." The "if it becomes a problem" is a hedge —
the POC validated bounded-buffer with a 1 MiB test and no deadlock. The
decision is bounded-buffer.
2. **Channel ID reuse (OQ-CH-04):** after a channel is closed, can its ID be
reused?
3. **Maximum channels per connection (OQ-CH-05):** is there a limit?
4. **Channel open DoS (OQ-CH-06):** an authenticated peer could open many
channels and never read from them, exhausting memory.
The de-risk POC (`docs/research/alknet-channels/poc-summary.md` §POC Target
1, §POC Target 3) validated the bounded-buffer backpressure path: the 1 MiB
`tunnel_large_payload` test exercises a channel writer faster than the TCP
echo server consumer, with no deadlock and no cross-channel blocking.
## Decision
### Backpressure: bounded-buffer, 1 MiB default (DP-5)
Each `(channel_id, stream_type)` pair has an independent bounded `mpsc`
buffer. When a channel's buffer is full, the demux stops reading chunks for
that `channel_id` until the consumer drains it. Other channels keep flowing
— the demux's per-chunk route awaits the matching sender without holding a
global lock.
**Default buffer cap: 1 MiB per `(channel_id, stream_type)`.** Configurable
per `ChannelManager` (`buffer_cap` field). This prevents memory exhaustion
without the complexity of SSH's sliding-window protocol.
Full channel-level windowing (SSH-style sliding-window per channel) is a
deferred extension, tracked as [OQ-56](../questions/056-full-channel-level-flow-control-windowing.md)
(deferred(scope)). It is blocked on a real deployment observing head-of-
line blocking where the bounded-buffer mitigation is insufficient. The
bounded-buffer decision is made; the extension is not.
### Channel ID reuse: yes, after drain (OQ-CH-04)
After a channel is closed (`channel/close` acknowledged), its `channel_id`
is eligible for reuse. The reassembly buffers must be fully drained before
reuse to prevent data from the old channel leaking into the new one.
**Drain-before-reuse invariant:** the `ChannelManager` marks a closed
channel's ID as "draining" (not in the `channels` map, but not yet returned
to the free pool). The ID returns to the free pool only after:
1. The `channel/close` response is sent (the close is acknowledged).
2. All reassembly buffers for that `channel_id` are empty (the handler has
consumed all data).
The `next_id: AtomicU32` is monotonic (not a free-list) — IDs are not
immediately reused; the monotonic counter wraps at `u32::MAX`. This is
simpler than a free-list and avoids the drain-tracking complexity. With a
default `max_channels` of 256, the `u32` space is effectively unlimited
(~16.7 million channels before wrap). Reuse happens naturally on wrap, by
which time old channels are long drained. **The "reuse" in OQ-CH-04 is
satisfied by the wrap-around, not by a free-list.**
### Maximum channels per connection: 256 default (OQ-CH-05/06 — memory bound)
The `channel_id` is `u32` — the wire format supports ~4 billion channels.
The practical limit is memory (reassembly buffers per channel) and the
transport's flow control.
**Default per-connection channel limit: 256** (`max_channels` field on
`ChannelManager`, configurable). This is a **per-connection memory
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
= 256 MiB worst case per connection) regardless of policy. It composes
with the per-identity DoS defense (ADR-041) but is not itself a DoS
defense — a peer can open an unbounded number of transport connections,
so a per-connection cap cannot bound a peer's total channels. The
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
via `ChannelLifecyclePolicy`) is documented in
[ADR-041](041-per-identity-channel-cap.md).
Exceeding the per-connection limit returns `channel:too_many_channels`
(ADR-037 error codes) — the same error code the per-identity policy
returns when the per-identity cap is hit. An over-cap peer sees the
same error either way; which cap fired first is an implementation
detail. The limit is per-connection as a memory bound; the per-identity
cap (ADR-041) is what bounds a peer's total channels across all its
connections.
### DoS defense summary (OQ-CH-06)
The DoS defense against an authenticated peer opening many channels is
the **per-identity cap** enforced in `channels-call` via
`ChannelLifecyclePolicy` — documented in
[ADR-041](041-per-identity-channel-cap.md). A per-connection cap
cannot be the DoS defense because a peer can open an unbounded number
of transport connections; the unit that must be bounded is the
identity, not the connection.
The per-connection `max_channels = 256` (this ADR) is a **memory
bound** that limits one connection's reassembly-buffer cost. It
composes with the per-identity cap as defense-in-depth (the
`NoCap` policy path still has the per-connection memory bound), but
it is not the security boundary. See ADR-041 §2 for the corrected
DoS defense summary.
## Consequences
**Positive:**
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no
deadlock, no cross-channel blocking). The decision is made, not hedged.
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB
worst-case memory per connection — a clear per-connection memory
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
`PeerId` across all the peer's connections) is documented in ADR-041.
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
complexity while still satisfying ID reuse (on wrap, after ~16.7M
channels).
**Negative:**
- The 256-channel per-connection cap may be too low for a hub with many
concurrent browser sessions each opening multiple channels. The cap
is configurable per `ChannelManager`; the hub deployment may set it
higher for deployments with many concurrent sessions. This is a
deployment-time decision, not an architecture decision. (The
per-identity cap in ADR-041 is the DoS-relevant bound; the
per-connection cap is a memory backstop.)
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
bounds the memory cost. A slow consumer still stalls its own channel's
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
acceptable; full windowing is tracked as OQ-56 (deferred(scope)).
## Door type
**Two-way.** The buffer cap (1 MiB), the channel limit (256), and the
monotonic-ID-with-wrap strategy are all configurable / changeable without a
wire-format change. The bounded-buffer *approach* (vs full windowing) is
one-way in the sense that the demux/mux code is written around it — but
full windowing is an additive extension (per-channel window tracking) that
doesn't change the wire format, so even that reversal is feasible.
## References
- ADR-034: channels wire format (the chunks the buffers hold, as amended
by ADR-035)
- ADR-035: channels pure channel multiplexing (amends this ADR —
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
- ADR-041: per-identity channel cap as DoS defense (amends this ADR —
the per-connection `max_channels = 256` is reframed as a per-connection
memory bound, not a DoS defense; the per-identity DoS defense lives in
`channels-call` via `ChannelLifecyclePolicy`)
- ADR-037: channel lifecycle operations (`channel:too_many_channels`
error; the `channel/open` and `channel/close` handlers that gain the
`ChannelLifecyclePolicy` consultation)
- ADR-039: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
fields; the auth-blindness that forces the per-identity cap into
`channels-call`, not `channels-core`)
- ADR-026: forwarded-for identity (why the spoke caps the hub, not the
browser — `forwarded_for` is metadata, not authority, for the cap as
for `AccessControl::check`)
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
validation), §POC Target 3 (1 MiB tunnel test)
- `docs/research/alknet-channels/phase-0-findings.md` §DP-5, §OQ-CH-03/04/
05/06

View File

@@ -0,0 +1,324 @@
# ADR-041: Per-Identity Channel Cap as DoS Defense
## Status
Accepted (amends ADR-040's DoS-defense framing — the per-connection
`max_channels = 256` is reframed as a per-connection memory bound, not a
DoS defense)
## Context
ADR-040 set the channels-layer channel limit at 256 **per connection**
and framed that cap as the DoS defense against an authenticated peer
opening many channels and never reading from them ("DoS defense
summary" table, "Per-connection channel count cap → 256 channels"). On
review, the per-connection cap is not a DoS defense at all. A single
peer can open an unbounded number of transport connections, and across
those connections, across substrates, the peer gets 256 × N × (substrate
multiplier) channels:
| Substrate a peer can use | Channels per connection |
|--------------------------|--------------------------|
| In-line (TCP+TLS, WebTransport, SSH `direct-tcpip`) | 256 (one stream, header-demuxed) |
| Native (QUIC substreams) | 256 (per-connection demux; each substream carries one channel) |
| Multi-connection (N transport connections) | 256 × N |
A peer that opens 10 transport connections to the same accepting peer
gets 2,560 channels. A peer that opens 100 gets 25,600. There is no
bound on the number of transport connections a peer can open. The
"per-connection, not per-peer — a peer can open more channels on a
second connection" line in ADR-040 was, in retrospect, the channels
layer confessing a hole and hoping the layer above it would fill it.
That is not a DoS defense; it is a per-connection memory bound
(reassembly-buffer cost per connection) labeled as a DoS defense.
The only coherent unit for a channel DoS defense is the **identity**.
The peer, not the connection, is what an authenticated-DoS defense
must bound. This is the same primitive as any other resource ACL:
`OwnershipProvider` (ADR-011) checks "does identity X own resource
Y?"; the channel cap checks "has identity X exceeded their channel
quota?" Same shape, different resource.
### Why the channels layer cannot hold the cap
`ChannelManager` (ADR-039) is auth-blind by design: "No auth state.
Auth lives in the `OperationContext` that the call protocol passes to
`channel/open`." That decision is load-bearing — it is what makes the
channels layer WASM-compatible, transport-agnostic, and ALPN-blind
(ADR-039, ADR-035). Putting per-identity tracking in the channels
layer would reverse ADR-039.
So the per-identity cap lives **one layer up**, in `channels-call`,
where the identity is already on `OperationContext` (the same place
`AccessControl::check` runs). The `channel/open` and `channel/close`
handlers (ADR-037) are in `channels-call` already; they gain a policy
consultation. The channels layer (`channels-core`) is unchanged —
still auth-blind, still WASM-clean.
### This is not a hub-specific concern
The cap is a **channels-accepting-peer concern**. A worker accepting a
direct channels connection from a peer needs the cap just as much as a
hub does. The call protocol does not need a hub to enforce "does this
peer have access to this resource?" (ADR-037: `AccessControl::check` on
`channel/open`), and neither should channels. Framing the cap as
hub-specific would be the "assembly layer" hedging pattern — putting
the hard question off on a fictional "later" that, when it arrives,
turns out to be exactly the same problem. The cap is a peer concern;
the hub is one peer that happens to aggregate others.
The cap is also **symmetric**, like the call protocol. Peer A accepts
a channels connection from Peer B; A enforces its cap on B's channels;
B enforces its cap on A's channels. Both sides have the cap, both
sides check it, same as `AccessControl::check` on any operation.
## Decision
### 1. A `ChannelLifecyclePolicy` trait in `channels-call`
```rust
/// Per-identity channel lifecycle policy. Consulted by the
/// `channel/open` handler (after `AccessControl::check`, before
/// allocation) and the `channel/close` handler (after deallocation).
/// Both handlers have the identity via `OperationContext`.
///
/// A channel slot is a resource; the cap is a quota check on that
/// resource — parallel to `OwnershipProvider::owns` (ADR-011) for
/// spawned resources. Same primitive, different resource.
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-037) when the identity is over its cap. The identity is
/// the direct caller (the peer that opened this channels
/// connection); `forwarded_for` is metadata and is NOT consulted
/// (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-040 §channel-id-reuse).
fn on_close(&self, identity: &Identity);
}
```
### 2. Default: `PerIdentityChannelPolicy::new(256)`
The default constructor enforces 256 per identity out of the box — no
hedging, no "NoOp default + wire it in the assembly layer." A channels
accepting peer that constructs `ChannelOperations::new(manager)` with
no policy argument gets `PerIdentityChannelPolicy::new(256)`. The
default is secure; opt-outs are explicit:
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
peer** and shared (via `Arc`) across every channels connection that
peer accepts. For a hub, that's one `Arc<PerIdentityChannelPolicy>`
on the `Hub`, shared across all worker and browser legs. For a
worker accepting direct channels, that's one `Arc` on the worker's
own state, shared across whatever connections it accepts. For tests
and POCs, the default constructor.
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` — a
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
default cap for specific peers. Used by a spoke that serves a
high-fan-out hub (the hub peer's cap is set higher than a worker
peer's cap — see "Relay consequence" below).
- `NoCap` — no cap (for tests, POCs, and trusted single-peer
deployments). Explicit opt-out, not the default.
The policy is constructed once and passed to `ChannelOperations` at
registration time:
```rust
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
let channel_ops = ChannelOperations::new(manager, policy);
channel_ops.register_on(&mut call_registry)?;
```
The same `Arc<PerIdentityChannelPolicy>` is shared across every
channels connection that peer accepts — that is what makes the cap
per-identity, not per-connection.
### 3. Enforcement point: between `AccessControl::check` and allocation
The `channel/open` handler (ADR-037) gains the policy check after
ACL and before `next_id.fetch_add`:
1. ACL is already checked by `OperationRegistry::invoke` (the existing
`AccessControl::check` path — unchanged).
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if over cap.
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
server-assigned — unchanged).
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
the `ChannelState` (unchanged).
5. Return the `channel_id`.
The `channel/close` handler (ADR-037) gains the decrement after the
drain completes (the same point ADR-040 marks the `channel_id` as
eligible for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-040
§channel-id-reuse).
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
per-identity count.
3. Return `{ "closed": true }` (unchanged).
### 4. `ChannelManager.max_channels = 256` stays as a per-connection memory bound
The per-connection cap (ADR-040) stays, but is reframed. It is no
longer the DoS defense — it is a per-connection **memory bound** that
limits one connection's reassembly-buffer cost regardless of policy.
It composes with the per-identity cap but is not the security
boundary. It still returns `channel:too_many_channels` when hit; the
per-identity policy returns the same error when the per-identity cap
is hit. An over-cap peer sees the same error either way; which cap
fired first is an implementation detail.
Keeping the per-connection bound as a backstop covers deployments that
use `NoCap` (tests, trusted single-peer) and bounds the damage if a
custom policy is buggy. Removing it would leave the channels layer
unbounded in the no-policy case. The cost of keeping it is zero (the
cap is already implemented in the POC); the cost of removing it is a
real hole in the `NoCap` path.
### 5. Relay consequence: the spoke caps the hub, not the browser
When the hub relays a browser's channel to a spoke (ADR-042), the
spoke sees the hub as the direct caller. `forwarded_for` carries the
browser's identity as metadata (ADR-026 — `forwarded_for` is not
authority; `AccessControl::check` never reads it). The channel cap
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
consulted with the **hub's** identity, not the browser's. The spoke
asks "does the hub have access to open another channel?" and the
hub's quota on the spoke reflects the aggregate of all relayed
channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
This is correct and consistent — the spoke authorizes the hub for
container access the same way it authorizes any peer, and the hub's
browser-relay ACL is the hub's own layer. The channel cap follows the
same pattern as any other resource ACL.
**Deployment consequence:** a spoke that serves a hub relaying for
many browsers must set the hub peer's cap higher than a worker peer's
cap, or the spoke denies legitimate relayed channels when the hub's
aggregate count exceeds a worker-sized cap. This is a per-peer-role
policy, set by the spoke via `with_per_identity_caps`. The
architecture provides the mechanism (`PerIdentityChannelPolicy::with_per_identity_caps`);
the deployment sets the numbers. This is not a flaw — it is the same
shape as any per-peer ACL (a spoke may authorize one peer for 1000
containers and another for 10; the channel cap is the same kind of
per-peer policy).
### 6. Recursive channels do not bypass the cap
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
new `ChannelsAdapter` with a new `ChannelManager`. If the same
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
the inner channels are counted against the same identity. If a
different policy is wired, the inner channels are counted against
that policy's identity (which may be a different identity, if the
inner channels connection is authenticated separately). Either way,
the cap applies; recursion is not a bypass. The 13-byte-per-chunk
overhead of recursion is the documented cost (ADR-035); the cap
behavior is unchanged. Recursive channels are an edge case for edge
cases and not specced further.
## Consequences
**Positive:**
- A real per-identity DoS defense. A peer with N transport connections
to the same accepting peer is bounded by 256 (or the configured
per-identity cap), not 256 × N × (substrate multiplier). The cap
composes correctly across substrates because the unit is the
identity, not the connection.
- The cap is symmetric, like the call protocol. Both sides of a
channels connection enforce their cap; the cap is a peer concern,
not a hub-specific concern.
- The cap lives in `channels-call`, where the identity is already on
`OperationContext`. The channels layer (`channels-core`) is
unchanged — still auth-blind, still WASM-clean, still
transport-agnostic. ADR-039's auth-blindness is preserved.
- The default is secure. `PerIdentityChannelPolicy::new(256)` is the
out-of-the-box behavior; opt-outs (`NoCap`) are explicit. A
deployment that forgets to wire a policy still gets a per-identity
cap.
- The cap is the same primitive as any other resource ACL
(`OwnershipProvider` for spawned resources, `AccessControl::check`
for operations). The mental model is uniform: a channel slot is a
resource, the cap is a quota check on that resource.
**Negative:**
- One new trait (`ChannelLifecyclePolicy`) and one new constructor
argument on `ChannelOperations`. The `channel/open` and
`channel/close` handlers gain a policy call. Small implementation
cost; the policy is a single trait method per direction.
- Per-identity state is shared across connections
(`HashMap<PeerId, usize>` on the policy, guarded by a `Mutex`). The
state is touched on `channel/open` and `channel/close` only — not
on every chunk. The contention is per-identity, not per-chunk;
acceptable for the intended use cases.
- A spoke serving a high-fan-out hub must set the hub peer's cap
higher than the default, or legitimate relayed channels are denied.
This is a deployment-time policy decision, surfaced explicitly by
`with_per_identity_caps`. Not a flaw; the same shape as any
per-peer ACL.
- The cap is per direct-caller identity (ADR-026), not per
`forwarded_for` originator. A hub relaying for 100 browsers
consumes one channel slot per relayed channel against the hub's
quota on the spoke, not 100 slots against 100 browser quotas. A
spoke that wants per-browser capping would need to read
`forwarded_for` for authority, which ADR-026 explicitly forbids.
This is the correct trade-off: capping against `forwarded_for`
would reverse ADR-026's "forwarded_for is metadata, not authority"
and is a much bigger change. The hub enforces per-browser caps on
the browser leg; the spoke enforces per-hub caps on the spoke leg.
## Door type
**One-way.** The `ChannelLifecyclePolicy` trait surface
(`check_open(&Identity) -> Result<(), ChannelError>` and
`on_close(&Identity)`) is a one-way-door API commitment — the
`channels-call` `channel/open` and `channel/close` handlers depend on
it, and consumers (`Hub`, worker crates) construct implementations.
Removing the trait or changing the signatures after deployments exist
is a breaking change.
The **default cap value (256)** is a two-way-door implementation
detail within the one-way trait surface — changing the default is
additive (a new constructor or a default-override), not a wire-format
change.
The **reframing of ADR-040's per-connection cap** (from DoS defense
to memory bound) is two-way — it's a documentation change, not a
behavior change. The per-connection cap still exists, still returns
`channel:too_many_channels`, and still bounds one connection's
reassembly-buffer cost.
## References
- **ADR-040**: Backpressure, Channel Limits, and ID Reuse (amended by
this ADR — the per-connection `max_channels = 256` is reframed as a
per-connection memory bound, not a DoS defense; the "DoS defense
summary" table is removed; the "per-connection, not per-peer" line
is corrected)
- **ADR-039**: ChannelsAdapter and ChannelManager (the auth-blindness
this ADR preserves — the cap lives in `channels-call`, not
`channels-core`)
- **ADR-037**: Channel Lifecycle Operations (the `channel/open` and
`channel/close` handlers that gain the policy check; the
`channel:too_many_channels` error code)
- **ADR-035**: channels Pure Channel Multiplexing (the umbrella
decision; the channels layer has no `stream_type` concept, and no
identity concept either — both are above it)
- **ADR-026**: Forwarded-For Identity (Metadata, Not Authority) (why
the spoke caps the hub, not the browser — `forwarded_for` is
metadata; the direct caller's identity is the authority for the cap
just as it is for `AccessControl::check`)
- **ADR-042**: Hub Relay — Translate, Not Transparently Forward (the
relay path where the spoke sees the hub as the direct caller)
- **ADR-011**: Dynamic Resource Ownership for Runtime-Spawned
Resources (the parallel — a channel slot is a resource, the cap is
a quota check, same primitive as `OwnershipProvider::owns`)
- **ADR-025**: PeerEntry and Identity.id Decoupling (`PeerId` =
`Identity.id` — the stable key the per-identity cap counts against)

View File

@@ -0,0 +1,181 @@
# ADR-042: Hub Relay — Translate, Not Transparently Forward
## Status
Accepted
## Context
The hub is the architectural role (ADR-024, ADR-034) that bridges peers and
browsers. With channels, the hub holds one channels connection per leg
(browser↔hub, hub↔spoke) and relays channels between them. The phase-0
research (`docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-11,
§The hub relay) identified the key question: does the hub *translate*
`channel/open` (terminate channel 0 on both legs, re-issue the open on the
spoke leg) or *transparently forward* (pass the call operation through
unchanged)?
This is the most under-specified part of the research for something that is
the *primary motivation* for the channels crate (§Hub Motivation: the
multi-transport collapse). The research said "Phase 1 must specify whether
the hub translates or transparently forwards, and how the `channel_id`
mapping is maintained."
The answer is derivable from the existing machinery:
- The hub terminates channel 0 on both legs (it runs its own `CallAdapter`
per leg — ADR-036).
- The hub's `CallAdapter` receives the browser's `channel/open` as a call
operation, runs `AccessControl::check` with the browser's identity, then
forwards via `from_call` to the spoke (the hub as caller, the browser as
`forwarded_for` — ADR-026 §3).
- The spoke allocates its `channel_id` and returns it; the hub maps
browser-id ↔ spoke-id.
Transparent forwarding (passing the `channel/open` call operation through
without the hub's `CallAdapter` terminating it) would bypass the hub's
`AccessControl::check` and the `forwarded_for` auth chain — the hub would
not authenticate the open, and the spoke would see the browser as the direct
caller (not the hub), breaking the ADR-026/ADR-011 auth model. Translation
is the only option that preserves the auth model.
## Decision
### The hub translates, not transparently forwards
The hub's relay has two layers:
1. **Call-protocol layer (channel 0): translate.** The hub terminates
channel 0 on both legs. A `channel/open` from the browser is received by
the hub's `CallAdapter`, which:
1. Runs `AccessControl::check` on `channel/open` with the browser's
identity (bearer token resolved per ADR-034). If denied →
`channel:forbidden` to the browser.
2. Issues a *new* `channel/open` on the spoke's channel 0 via `from_call`,
with the hub as caller and the browser as `forwarded_for` (ADR-026
§3). The spoke's `AccessControl::check` sees the hub as the direct
peer (authorized per ADR-011) and the browser as `forwarded_for`.
3. The spoke allocates its `channel_id` and returns it.
4. The hub opens a matching channel on the browser's side (the hub is now
the *responder* for the browser leg, *initiator* for the spoke leg)
and records the `channel_id` mapping: `browser_id ↔ spoke_id`.
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** Once the
mapping is established, the relay reads chunks for `browser_id` off the
browser's channels connection, rewrites the `channel_id` field to
`spoke_id`, and writes them onto the spoke's channels connection — and
vice versa. The relay does not parse the payload; it does not know if the
bytes are TTY chunks, SSH frames, or tunnel data. The channels layer on
each end does the chunk↔stream conversion; the relay just moves bytes
between two `AsyncRead + AsyncWrite` pairs with a 4-byte header rewrite.
### `channel_id` mapping
The hub maintains a `HashMap<channel_id, channel_id>` per (browser, spoke)
pair — the relay map. On `channel/open` (translated), the mapping is
inserted. On `channel/close` (translated the same way), the mapping is
removed. The relay task per channel reads the map to determine the rewrite
target.
`channel/control` operations on channel 0 carry `channel_id` in their JSON
payload (not in the chunk header). The hub's `CallAdapter` translates these
too: the browser's `channel/control` for `browser_id` is re-issued on the
spoke leg with `spoke_id` in the payload. The relay does not touch
`channel/control` — it's a call operation, translated by the hub's
`CallAdapter`, not byte-forwarded.
### What the hub runs
| Leg | What the hub runs |
|-----|-------------------|
| Browser leg | `ChannelsAdapter` (the relay's read/demux) + `CallAdapter` (channel 0, for the hub's own ops + translating the browser's ops) |
| Spoke leg | `ChannelsAdapter` + `CallAdapter` (same) |
| Relay | Per-channel byte-forward tasks with `channel_id` rewrite |
The hub never runs a handler for `alknet/tty`, `alknet/ssh`, or
`alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/call`
(for its own hub-level operations + translation). The endpoints at each end
do the protocol work.
### What the hub still owns (unchanged from phase-0 §What the hub does still own)
- **Routing:** which spoke serves `container:abc123`? The hub's resource
registry / ownership store (ADR-011), queried via call operations on
channel 0. Channels doesn't touch this.
- **ACL at the hub:** does this browser's identity have `channel:open` scope
for `alknet/ssh` to `spoke-X`? `AccessControl::check` on `channel/open`,
run by the hub's `CallAdapter` before it forwards. Channels doesn't touch
this.
- **Relay lifecycle:** when a browser disconnects, the hub tears down the
spoke-side channels (and vice versa). `channel/close` on each channel, or
a transport-level close the channels layer observes (REQ-CH-02).
### Scope note: this is a hub-crate concern, not a channels-crate concern
This ADR defines the relay *contract* (translate channel 0, byte-forward
data channels with ID rewrite) so the channels crate's `ChannelManager`
exposes the interface the relay needs (`open_channel_stream(channel_id)
-> BiStream` for the byte-forward pumps). The relay *implementation*
lives in `alknet-hub` (or a downstream hub like alkapi), not in
`alknet-channels`. The channels crate is ALPN-blind and does not know it
is being relayed. The `channel_id` rewrite is a 4-byte field rewrite
within the 8-byte header (per ADR-035); the relay does not parse the
payload.
## Consequences
**Positive:**
- The auth model reuses cleanly: the hub's `AccessControl::check` +
`forwarded_for` (ADR-026) is the existing machinery, not a new one. The
spoke sees the hub as caller, the browser as `forwarded_for` — the
kernel/user-land + forwarded-for model from ADR-011.
- The relay is one pump function per channel, not per (protocol × transport)
cell. The hub's complexity is O(channels), not O(protocols × transports ×
spokes).
- The hub never runs protocol-specific handlers — it doesn't parse TTY
chunks, SSH frames, or tunnel data. It moves bytes and translates call
operations.
- `channel/resources/subscribe` (ADR-037) gives the hub a live view of each
spoke's resources, which the hub aggregates and exposes to the browser.
**Negative:**
- The hub maintains a `channel_id` mapping per (browser, spoke) pair. This
is per-channel state, not per-connection — a hub with many concurrent
browser sessions each with multiple channels has a non-trivial map. The
map is `HashMap<u32, u32>` per pair — cheap per entry, but the entry count
is (browsers × channels-per-browser). Bounded by `max_channels` (ADR-040)
per connection.
- The translate path adds one `channel/open` round-trip per relayed channel
(browser→hub, hub→spoke). This is the same cost as any hub-relayed call
operation and is not avoidable without transparent forwarding, which
breaks the auth model.
- `channel/control` translation requires the hub's `CallAdapter` to rewrite
`channel_id` in the JSON payload. This is a small but real translation
step — the hub is not a pure byte relay for channel 0.
## Door type
**One-way.** The translate-vs-forward decision is structural: transparent
forwarding would bypass the hub's `AccessControl::check` and the
`forwarded_for` chain, breaking the auth model. Reversing to transparent
forwarding after deployments exist would require re-architecting the hub's
auth path. The `channel_id` mapping strategy (`HashMap` per pair) is two-way
— an implementation detail that can change without breaking the contract.
## References
- ADR-024: peer-graph routing model (the hub's role)
- ADR-026: forwarded-for identity (the auth chain the translate path uses)
- ADR-034: outgoing-only X.509 and the three peer roles (browser identity
resolution)
- ADR-011: dynamic resource ownership (the ownership store the hub queries)
- ADR-036: channel 0 is pre-negotiated `alknet/call` (what the hub
terminates on each leg)
- ADR-037: channel lifecycle operations (what the hub translates)
- ADR-039: ChannelsAdapter and ChannelManager (the interface the relay uses)
- ADR-035: channels pure channel multiplexing (the 8-byte header the relay
reads/writes; the 4-byte `channel_id` rewrite; the `BiStream`-yielding
`open_channel_stream` interface)
- `docs/research/alknet-channels/phase-0-findings.md` §Hub Motivation,
§The hub relay, §OQ-CH-11
- `docs/architecture/crates/hub/README.md` — the hub crate (the relay
implementation's home)

View File

@@ -0,0 +1,300 @@
# ADR-043: ChannelClient — the Client Side of a Channels Connection
## Status
Accepted (amended 2026-07-12 — see "Amendment: transport-agnostic API"
below; amended 2026-07-16 — `connect_quic` removed per ADR-045 §5, see
"Amendment: `connect_quic` removed" below; **amended 2026-07-18 by
ADR-035 — `stream_types` field removed from `open_channel` and `Channel`;
the channels layer has no `stream_type` concept, see "Amendment
(ADR-035, 2026-07-18)" below**)
## Amendment (ADR-035, 2026-07-18)
The `stream_types: &[u8]` field is **removed** from `open_channel`'s
signature, and `pub stream_types: Vec<u8>` is **removed** from the
`Channel` struct. The channels layer has no `stream_type` concept
(ADR-035) — the handler owns its sub-stream multiplexing on the
`BiStream` it receives via `Channel.source` (a `ChannelBidiStreamSource`
whose `accept_bi` yields a `BiStream` per ADR-009). The handler's
sub-stream set is implicit in its ALPN's wire format (e.g., TTY's 5-byte
format declares its own `stream_type` set internally; the channels
layer carries the bytes transparently). The `into_sub_streams()` reference
in the `Channel.source` doc comment is moot — `into_sub_streams()` is
removed by ADR-035 (amending ADR-038).
The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-035 for the
resolution rationale and the cross-ADR impacts.
## Amendment: `connect_quic` removed (2026-07-16, per ADR-045 §5)
The `connect_quic(addr, credentials)` convenience constructor is
**removed**. Keeping it as a thin wrapper over
`AlknetClient::dial_quic` would make `alknet-channels-call` depend on
`alknet-client`, contradicting the dep graph (the protocol crates are
parallel to the dial, not downstream of it). Callers compose
`AlknetClient::dial_quic(...).await?` + `ChannelClient::from_connection(conn).await?`.
The `from_connection` primary constructor (the 2026-07-12 amendment
below) is unchanged and remains the one-way-door surface. The
`connect_quic` references in the body of this ADR are the historical
shape; they do not survive into the implementation. See ADR-045 §5 for
the full rationale and the breaking-change acknowledgment.
## Amendment: transport-agnostic API (2026-07-12)
The original Decision named `connect(addr: SocketAddr, credentials)` as the
primary constructor and framed it as "QUIC-only initially" — to be
generalized when a second transport's client exists. That framing welded
the client-side one-way-door API to QUIC, the same welding ADR-007 unwound
on the server side, and masked it as a two-way-door deferral
(anti-patterns #8, #9, #11). "Can be generalized later" meant "can be
rewritten later" — the expensive reversal the one-way-door classification
exists to prevent.
The channels protocol is transport-agnostic by design (ADR-034 substrate
modes; `Connection::from_stream`/`from_bidi`/`from_source` accept any
`AsyncRead + AsyncWrite`). The client side is half of that protocol and
must not be coupled to a transport. This amendment splits the constructor
surface:
- **`from_connection(connection: Connection)`** — the transport-agnostic
primary constructor and the one-way-door API. Takes a pre-established
`Connection` (produced by any transport — TCP+TLS via `from_bidi`,
WebTransport `BiStream`, SSH `direct-tcpip`, a quinn connection, a
WebSocket carrying `alknet/channels` per ADR-044), installs channel 0,
spawns the demux/mux, returns the client. Mirrors the server-side
`ChannelsAdapter::handle(Connection)` (substrate-agnostic) and the
existing `CallClient::spawn_dispatch(Connection)` pattern.
- **`connect_quic(addr, credentials)`** — a QUIC convenience constructor:
dial QUIC, then `from_connection`. Additive and two-way-door.
`connect_tcp_tls`, `connect_webtransport`, etc. join it as transports
are added, without touching the one-way-door surface.
The dial+TLS seam (the transport-specific work each dial helper does —
verifier selection per ADR-034, handshake, produce a `Connection`) is the
correct scope of OQ-55's deferral. `AlknetClient` is the eventual shared
*dial*; `from_connection` is the shared *channels-take-over*. Separating
them now, before the one-way-door API is cast, is the point — not a
deferral. The "QUIC-only initially" framing is removed; it was the
anti-pattern this amendment corrects.
The door-type classification is unchanged: `from_connection` is one-way
(the handler-facing surface), `connect_quic` is two-way (additive
convenience). The `AlknetClient` extraction remains deferred (OQ-55) — but
what is deferred is the shared *dial*, not a QUIC-welded client API.
## Context
Both sides of a channels connection do the demux/mux work. The server side
is a `ProtocolHandler` (`ChannelsAdapter::handle`, ADR-039). The client side
needs a symmetric type — `ChannelClient` — that takes over an established
transport `Connection`, runs the demux/mux, and exposes
`open_channel(alpn, params) -> Channel` to the application. This is the
channels analogue of `CallClient` (server: `CallAdapter`; client:
`CallClient`) in the call protocol.
The phase-0 research (`docs/research/alknet-channels/phase-0-findings.md`
§OQ-CH-14) clarified that there are two concerns here:
1. **`ChannelClient` (channels-specific):** the client type for channels
connections. Decision-ready — build it in `alknet-channels`, same shape
as `CallClient`. The *take-over* half is transport-agnostic
(`from_connection`); the *dial* half is transport-specific
(`connect_quic` and future transport helpers).
2. **`AlknetClient` (core, transport-polymorphic):** the shared *dial+TLS*
seam — the transport-specific work (open socket, TLS handshake, ADR-034
verifier selection, produce a `Connection`) that each transport's dial
helper rebuilds. Genuinely deferred — blocked on a second *transport's*
dial existing (OQ-55 tracks this correctly). A single QUIC dial
(`connect_quic`) does not give enough information to extract the
transport-polymorphic dial seam; two different transport dials do.
This ADR decides #1`ChannelClient`, with `from_connection` as the
transport-agnostic primary constructor and `connect_quic` as a transport-
specific dial helper. #2 (the shared `AlknetClient` dial+TLS seam) stays
deferred per OQ-55.
## Decision
### `ChannelClient` in `alknet-channels`
```rust
pub struct ChannelClient {
manager: ChannelManager,
// The transport-side demux/mux, running in a background task.
...
}
impl ChannelClient {
/// Transport-agnostic primary constructor. Takes a pre-established
/// `Connection` (any transport — TCP+TLS via `from_bidi`,
/// WebTransport BiStream, SSH direct-tcpip, a quinn connection, a
/// WebSocket per ADR-044), installs channel 0 (alknet/call), spawns
/// the demux/mux, and returns the client. Mirrors the server-side
/// `ChannelsAdapter::handle(Connection)`. This is the one-way-door
/// API surface — it must not be coupled to a transport (ADR-034,
/// ADR-007).
pub async fn from_connection(connection: Connection)
-> Result<Self, ChannelError>;
/// QUIC convenience constructor. Dials a QUIC connection to `addr`
/// on ALPN `alknet/channels` (credentials → TLS handshake,
/// ADR-034 verifier selection), then calls `from_connection`.
/// Additive and two-way-door — `connect_tcp_tls`,
/// `connect_webtransport`, etc. join it as transports are added.
///
/// **REMOVED per ADR-045 §5.** The dial is extracted into
/// `AlknetClient`; `connect_quic` is deleted, not delegated.
/// Callers compose `AlknetClient::dial_quic` + `from_connection`.
/// The `CallCredentials` parameter is moot — `CallCredentials` is
/// removed per ADR-012 (amended 2026-07-17); the dial consumes
/// `ConnectionCredentials` from `alknet-core`.
pub async fn connect_quic(
addr: SocketAddr,
credentials: CallCredentials, // REMOVED — CallCredentials is removed
) -> Result<Self, ChannelError>;
/// Open a data channel with the given ALPN and params. Sends
/// `channel/open` on channel 0, waits for the response, and returns
/// the channel's sub-streams.
pub async fn open_channel(
&self,
alpn: &str,
stream_types: &[u8],
params: Value,
direction: ChannelDirection,
) -> Result<Channel, ChannelError>;
/// Subscribe to the peer's resource updates. Returns a stream of
/// resource-set events (ADR-037 channel/resources/subscribe). Part of
/// the one-way-door handler-facing surface (see Door type below).
pub async fn subscribe_resources(&self)
-> Result<BoxStream<ResourceEvent>, ChannelError>;
/// The call-protocol connection on channel 0, for invoking channel
/// lifecycle operations and any other call ops the peer exposes.
pub fn call(&self) -> &CallConnection;
}
pub struct Channel {
pub channel_id: u32,
pub stream_types: Vec<u8>,
/// The sub-streams, accessible via the BidiStreamSource (accept_bi) or
/// into_sub_streams() — ADR-038.
pub source: ChannelBidiStreamSource,
}
```
### Transport-agnostic by construction
`ChannelClient` is the client side of the channels protocol, which is
transport-agnostic (ADR-034 substrate modes; ADR-007 `from_stream`/`from_bidi`). The primary constructor — `from_connection(connection: Connection)` — takes a pre-established `Connection` from any
transport and takes over channels establishment. This mirrors the
server-side `ChannelsAdapter::handle(Connection)`, which is
substrate-agnostic by the same mechanism: the server receives a
`Connection` (QUIC-native, TCP+TLS via `from_bidi`, WebTransport, SSH
`direct-tcpip`, …) and runs the demux loop unchanged; the client receives
a `Connection` the same way and runs the same logic from the dialing side.
`connect_quic(addr, credentials)` is a convenience over `from_connection`:
dial QUIC, then `from_connection`. It is additive and two-way-door.
Transport-specific dial helpers (`connect_tcp_tls`, `connect_webtransport`,
…) join it as transports are added — none of which touch the
`from_connection` contract. The dial helper set is open-ended by design.
This is the client-side analogue of the server-side generalization ADR-007
made. Welding the client's one-way-door API to QUIC would repeat the
welding ADR-007 explicitly unwound.
### Bidirectionality preserved
The channels protocol is bidirectional — either side can open a channel
(ADR-037 §direction semantics). `ChannelClient::open_channel` supports both
`ChannelDirection::InitiatorToResponder` and
`ChannelDirection::ResponderToInitiator`. The client is not "the client
side" in the sense of only initiating — it can also receive `channel/open`
requests from the peer (the peer initiates, the client's `ChannelManager`
responds). This mirrors the call protocol's operation overlay (each side
populates what operations they expose).
This means `ChannelClient` is not purely a "client" in the request/response
sense — it's one endpoint of a bidirectional channels connection. The name
`ChannelClient` follows the `CallClient` convention (the side that dialed),
not a request/response role.
### Relationship to `AlknetClient` (OQ-55 — deferred)
`ChannelClient`'s *API* is transport-agnostic — `from_connection` takes
a pre-established `Connection`. What is deferred (OQ-55) is the shared
*dial+TLS* seam (`AlknetClient`): the transport-specific work each dial
helper does — open a socket, run the TLS handshake, apply ADR-034's
verifier-selection rule, produce a `Connection`. That dial is genuinely
transport-specific (QUIC, TCP+TLS, WebTransport, raw TCP, SSH), and we have
one shape implemented (QUIC, in `connect_quic`). Extracting a QUIC-shaped
connector now and naming it `AlknetClient` would bake QUIC in as *the*
establishment shape — the same welding ADR-007 unwound on the server side.
This is why `from_connection` is the one-way-door surface and
`connect_quic` is a two-way-door convenience over it. `AlknetClient` (when
extracted, after a second transport's dial exists) becomes the shared
*dial*; `from_connection` stays the shared *channels-take-over*. The two
concerns are separated now, before the one-way-door API is cast.
The friction while `AlknetClient` is deferred is duplicated
verifier-selection boilerplate across dial helpers (~20 lines each) — not
duplicated capability and not a QUIC-welded client API.
## Consequences
**Positive:**
- `ChannelClient` gives the channels crate a symmetric client/server pair,
matching the call protocol's `CallAdapter`/`CallClient` shape.
- Bidirectionality is preserved — the client can both initiate and receive
`channel/open`.
- The `AlknetClient` deferral (OQ-55) is not blocked by `ChannelClient`
they are independent concerns. `ChannelClient` builds standalone; the
core extraction happens later when the blocker clears.
**Negative:**
- Each transport-specific dial helper duplicates ~20 lines of
verifier-selection boilerplate (from `connect_quic`). This is the known
cost of not extracting `AlknetClient` yet (OQ-55). Acceptable until the
second transport's dial exists, at which point `AlknetClient` extracts
the shared dial+TLS seam. The `from_connection` API — the one-way-door
surface — is unaffected; only the dial helpers carry the duplication.
## Door type
**One-way.** The `ChannelClient::from_connection` / `open_channel` /
`call` / `subscribe_resources` API is the handler-facing surface;
changing it after consumers exist is a rewrite. `from_connection` is the
one-way-door primary constructor (transport-agnostic).
`connect_quic` (and future `connect_tcp_tls` / `connect_webtransport` /
…) are **two-way** doors — additive convenience constructors over
`from_connection`. Adding, removing, or changing a dial helper is cheap
and does not touch the one-way-door surface.
The `AlknetClient` extraction is a **deferred decision** (OQ-55,
deferred(scope)), not a door-type attribute. Its door type is two-way (the
extraction is a refactor, not a wire-format change), but it is not decided
in this ADR — see OQ-55 for the blocking condition. What is deferred is the
shared *dial+TLS* seam; `from_connection`'s transport-agnostic contract is
decided now.
## References
- ADR-037: channel lifecycle operations (`open_channel` sends `channel/open`)
- ADR-038: ChannelBidiStreamSource (what `Channel.source` wraps, as
amended by ADR-035 — `accept_bi` yields a `BiStream`)
- ADR-035: channels pure channel multiplexing (`stream_types` field
removed from `open_channel` and `Channel`; handler owns sub-stream
multiplexing)
- ADR-039: ChannelManager (the shared state `ChannelClient` holds)
- OQ-55: AlknetClient / client establishment extraction (the deferred core
concern this ADR does NOT block on)
- `docs/research/alknet-channels/phase-0-findings.md` §OQ-CH-14 (the
research-scope question this ADR carries forward)
- `docs/architecture/crates/call/client-and-adapters.md``CallClient` (the
shape `ChannelClient` mirrors)

View File

@@ -0,0 +1,251 @@
# ADR-044: alknet-channels Sub-Crate Decomposition
## Status
Accepted (amended 2026-07-18 by ADR-035 — the 9-byte wire format is now
8-byte; `ChannelSubStreams` / `SubStreamHandle` removed; the channels
layer has no `stream_type` concept — see "Amendment (ADR-035, 2026-07-18)"
below)
## Amendment (ADR-035, 2026-07-18)
The wire format in `channels-core` is now **8-byte** (not 9-byte); the
`ChannelSubStreams` / `SubStreamHandle` typed destructure accessor is
**removed** (the channels layer has no `stream_type` concept —
`accept_bi` yields a `BiStream`, and the handler owns its sub-stream
multiplexing). The channel 0 pre-negotiation in `channels-call` no
longer constructs "reassembly buffers with `stream_types` [0, 1]" — it
constructs one reassembly buffer for `channel_id = 0`, yielding a
`BiStream` to the `CallAdapter`. The "What moves where" table's "9-byte
wire format" row is now "8-byte wire format"; the `ChannelSubStreams`
row is removed. The two-crate split (`channels-core` /
`channels-call`), the dep graph, and the "hub and worker are consumers"
principle are unchanged. See ADR-035 for the resolution rationale.
## Context
The initial channels spec (ADR-034-080) framed `alknet-channels` as a
single crate depending on both `alknet-core` and `alknet-call`. The
dependency on `alknet-call` arises because channel lifecycle operations
(`channel/open`, `channel/close`, `channel/control`,
`channel/resources/subscribe`) register on the call protocol's
`OperationRegistry`, and channel 0 is pre-negotiated as `alknet/call`
(ADR-036).
This creates two issues:
1. **The dependency graph conflates the pure multiplexer with the call-
protocol coupling.** The wire format, demux/mux, and
`ChannelBidiStreamSource` are ALPN-blind and call-protocol-blind — they
depend on `alknet-core` only. The channel 0 pre-negotiation and lifecycle
op registrations are the call-protocol coupling. Baking both into one
crate means any consumer that wants the multiplexer also pulls in the
call-protocol coupling, even if they don't need channel 0 to be
`alknet/call`.
2. **The "no special-casing for downstream crates" principle is
violated.** The user's constraint: "we don't want to be doing anything
special for downstream crates unless it's needed/useful to the point of
they all kind of benefit." The call-protocol coupling is a channels-crate
concern (channels needs call for orchestration), not a downstream-crate
concern. Separating them makes the dependency graph honest: the pure
multiplexer has no opinion about channel 0; the call-protocol coupling
is isolated where it belongs.
The substrate simplification (ADR-034 revised) strengthened this: the wire
format is the same across all substrates, and the `ChannelsAdapter` is
substrate-agnostic. The pure multiplexer (`channels-core`) is cleanly
separable from the call-protocol orchestration (`channels-call`).
## Decision
### Two crates, not four
```
alknet-channels-core — the pure multiplexer (wire format, demux/mux,
│ ChannelBidiStreamSource, ChannelManager). Depends
│ on alknet-core only. ALPN-blind, call-protocol-blind,
│ transport-blind.
└── alknet-channels-call — channel 0 pre-negotiation + lifecycle op
registrations on the call protocol's
OperationRegistry. Depends on channels-core +
alknet-call.
```
There are no `channels-hub` or `channels-worker` sub-crates. The hub and
worker are **consumers** of channels, not sub-crates of it. The existing
`alknet-hub` crate (`docs/architecture/crates/hub/README.md`) IS the hub —
it depends on `channels-call` and uses the channels protocol as its
substrate. A worker is just a worker — it depends on `channels-call` and
uses `ChannelClient` (ADR-043) to dial. The hub relay logic (ADR-042) lives
in `alknet-hub`, not in a channels sub-crate.
### `alknet-channels-core`
The pure multiplexer. Contains:
- The 9-byte chunk wire format (`parse_header` / `write_header` — ADR-034)
- The demux/mux (`Demux`, `MuxHandle`/`MuxRunner` — ADR-039)
- `ChannelBidiStreamSource` (implements `BidiStreamSource` — ADR-038)
- `ChannelSubStreams` / `SubStreamHandle` (the typed destructure accessor)
- `ChannelManager` (the shared state — channel_id → ChannelState, but
**without** the `call_ops: Arc<OperationRegistry>` field; the manager is
ALPN-blind and call-protocol-blind)
- `ChannelsAdapter` (the `ProtocolHandler` on `alknet/channels` — the
read/demux loop, substrate-agnostic per ADR-034 revised)
Depends on `alknet-core` only. No `alknet-call` dependency. No opinion about
what channel 0 carries — that's the consumer's concern.
The `ChannelsAdapter::handle` in `channels-core` does NOT preinstall channel
0 as `alknet/call`. It runs the demux loop and routes chunks by
`channel_id`. Channel 0 is just another channel; what ALPN it carries is
determined by the consumer (the `channels-call` crate pre-negotiates it as
`alknet/call`; a hypothetical other consumer could pre-negotiate it
differently).
### `alknet-channels-call`
The call-protocol coupling. Contains:
- Channel 0 pre-negotiation as `alknet/call` (ADR-036) — the
`preinstall_channel_0` logic that constructs channel 0's reassembly
buffers with stream_types [0, 1] and hands the `Connection` to the
`CallAdapter`.
- The four lifecycle operations (ADR-037): `channel/open`,
`channel/close`, `channel/control`, `channel/resources/subscribe`
registered on the call protocol's `OperationRegistry` at assembly time.
- `ChannelOperations` (the registration helper that closes over a
`ChannelManager` clone).
- `ChannelClient` (ADR-043) — the client-side type that dials a transport,
establishes the channels connection, and exposes `open_channel(alpn,
params) -> Channel`. This is the worker/client entry point; it lives here
because it needs channel 0 pre-negotiation (which is in `channels-call`).
Depends on `channels-core` + `alknet-call`. This is where the
call-protocol coupling lives, isolated from the pure multiplexer.
### Hub and worker are consumers, not sub-crates
The hub and worker are architectural roles, not channels sub-crates:
- **The hub** is the existing `alknet-hub` crate. It depends on
`channels-call` and uses the channels protocol as its substrate. The hub
relay logic (ADR-042 — translate `channel/open` on channel 0,
byte-forward data channels with `channel_id` rewrite) lives in
`alknet-hub`, alongside its existing peer lifecycle, aggregated env, and
service discovery responsibilities. There is no `channels-hub` sub-crate;
`alknet-hub` IS the channels hub.
- **A worker** is any crate that uses `ChannelClient` (ADR-043, in
`channels-call`) to dial a hub. There is no `channels-worker` sub-crate;
a worker depends on `channels-call` and uses `ChannelClient` directly.
The worker may be a CLI binary, a docker-side connector, an SSH-side
connector, or any other role that dials into a hub's channels connection.
This means the channels crate provides the substrate (`channels-core` +
`channels-call`); the hub and worker crates are consumers that build on it.
The dependency direction is: `alknet-hub``channels-call`
`channels-core``alknet-core`; a worker → `channels-call`
`channels-core``alknet-core`. The channels crate has no dependency on
`alknet-hub` or any worker crate.
### What moves where
| Component | Original (ADR-034-080) | Now |
|-----------|------------------------|-----|
| 9-byte wire format | `alknet-channels` | `channels-core` |
| Demux/Mux | `alknet-channels` | `channels-core` |
| `ChannelBidiStreamSource` | `alknet-channels` | `channels-core` |
| `ChannelSubStreams` | `alknet-channels` | `channels-core` |
| `ChannelManager` (without `call_ops`) | `alknet-channels` | `channels-core` |
| `ChannelsAdapter` (demux loop only) | `alknet-channels` | `channels-core` |
| Channel 0 pre-negotiation | `alknet-channels` (ADR-036) | `channels-call` |
| `channel/open`/`close`/`control`/`resources/subscribe` ops | `alknet-channels` (ADR-037) | `channels-call` |
| `ChannelOperations` registration helper | `alknet-channels` | `channels-call` |
| `ChannelClient` (ADR-043) | `alknet-channels` | `channels-call` |
| Hub relay (ADR-042) | `alknet-channels` (spec) | `alknet-hub` (the existing hub crate, consuming `channels-call`) |
### Relationship to `alknet-hub`
The existing `alknet-hub` crate (`docs/architecture/crates/hub/README.md`)
is the hub pattern: peer lifecycle, aggregated env, service discovery. With
channels as the substrate, `alknet-hub` gains a dependency on
`channels-call` and incorporates the relay logic (ADR-042). The hub spec
(`crates/hub/README.md`) will be updated to reflect that the hub uses
channels as its transport substrate — one channels connection per leg
(browser↔hub, hub↔spoke), with the relay translating `channel/open` and
byte-forwarding data channels. The hub's existing responsibilities (peer
lifecycle, aggregated env, service discovery, worker supervision) are
unchanged; channels is the substrate they run on.
This makes "channels hub" and "hub" the same thing — the hub IS built on
channels. There is no separate channels-hub concept.
## Consequences
**Positive:**
- The dependency graph is honest: `channels-core` is the pure multiplexer
with no call dependency; the call-protocol coupling is isolated in
`channels-call`. A consumer that wants the multiplexer without the
call-protocol orchestration can depend on `channels-core` only.
- The "no special-casing for downstream crates" principle is preserved:
`channels-core` doesn't know about `alknet-call`, `alknet-tty`, or any
handler crate. The call-protocol coupling is a channels-crate concern,
not a downstream-crate concern.
- Hub and worker are consumers, not sub-crates. The existing `alknet-hub`
crate IS the channels hub — it depends on `channels-call` and uses
channels as its substrate. A worker depends on `channels-call` and uses
`ChannelClient`. The channels crate has no dependency on `alknet-hub` or
any worker crate. This is the cleanest dependency direction: channels
provides the substrate; hub and worker consume it.
- The WASM and cross-platform story gets easier: `channels-core` is
WASM-compatible by construction (pure byte manipulation, no platform
deps); `channels-call` inherits the call protocol's WASM constraints; the
hub and worker crates are platform-specific as needed.
**Negative:**
- Two channels crates instead of one. The assembly layer must depend on
`channels-core` + `channels-call` instead of one `alknet-channels`. This
is the cost of the clean separation; the assembly layer already wires
multiple crates, so this is consistent with the existing pattern.
- The `ChannelsAdapter` in `channels-core` doesn't preinstall channel 0 —
the consumer does. This means `channels-core`'s `ChannelsAdapter::handle`
exposes a hook (callback or trait method) for the consumer to install
channel 0. `channels-call` provides the `preinstall_channel_0`
implementation; a different consumer could provide a different one. This
is a slightly more complex adapter shape than "channel 0 is always call,"
but it's the cost of the clean separation.
## Door type
**One-way (crate structure).** The two-crate split (`channels-core` /
`channels-call`) is one-way — once consumers depend on `channels-core`
without `channels-call`, re-merging them is a breaking change. The hub and
worker being consumers (not sub-crates) is also one-way — it establishes
the dependency direction (hub/worker → channels, not channels → hub/worker).
## References
- ADR-031: crate decomposition (no-handler-depends-on-another-handler —
preserved; the channels sub-crates depend on core/call, not on handlers)
- ADR-034: channels wire format (revised — substrate simplification; the
wire format is in `channels-core`; **amended by ADR-035 — 8-byte header,
no `stream_type`**)
- ADR-035: channels pure channel multiplexing (amends this ADR — 8-byte
wire format; `ChannelSubStreams` / `SubStreamHandle` removed; the
channels layer has no `stream_type` concept)
- ADR-036: channel 0 pre-negotiated (moves to `channels-call`)
- ADR-037: channel lifecycle operations (move to `channels-call`)
- ADR-038: ChannelBidiStreamSource (in `channels-core`; **amended by
ADR-035 — `into_sub_streams` removed, `accept_bi` yields `BiStream`**)
- ADR-039: ChannelsAdapter and ChannelManager (split: core demux in
`channels-core`, call coupling in `channels-call`)
- ADR-042: hub relay (in `alknet-hub` — the hub crate consumes
`channels-call`; there is no `channels-hub` sub-crate, per this ADR's
Decision §"No hub/worker sub-crates")
- ADR-043: ChannelClient (in `channels-call`; there is no
`channels-worker` sub-crate — a worker is any crate that uses
`ChannelClient` to dial)
- `docs/architecture/crates/hub/README.md` — the existing hub crate (the
relay's consumer)

View File

@@ -0,0 +1,557 @@
# ADR-045: AlknetClient — the Native Client Dial Seam
## Status
Accepted (resolves OQ-55; §3 and §5 amended 2026-07-16 by ADR-012 —
the dial credential bundle is `ConnectionCredentials` (transport-level),
not `CallCredentials` (call-protocol-level); all three dial signatures
unify on `&ConnectionCredentials`; `dial_iroh`'s `node_id` parameter is
derived from `remote_identity`; the `auth_token` stays in the
call-protocol layer, not the dial; `CallCredentials` stays in
`alknet-call`, only `ConnectionCredentials`/`RemoteIdentity` move to
`alknet-core`; §5 further amended 2026-07-17 by ADR-012 —
`CallCredentials` is removed entirely (its `auth_token` field had no
reader); `auth_token` is a per-request payload field; the `from_call`
`credentials_auth_token` dead path is removed)
## Context
### The deferral and why it was valid
OQ-55 deferred `AlknetClient::dial()` — the transport-polymorphic client
dial seam — blocked on "a second transport's real dial existing." The
reasoning (recorded in the OQ-55 file and in
[channel-client.md](../crates/channels/channel-client.md) §"Relationship
to `AlknetClient`") was sound at the time: extracting a QUIC-shaped
connector and naming it `AlknetClient` would bake QUIC in as *the*
establishment shape — the same welding ADR-007 unwound on the server
side. Only one transport dial existed (`CallClient::connect` /
`ChannelClient::connect_quic`, both QUIC). The transport-polymorphic
seam was not extractable from two *different* transport implementations;
it was guessable from one.
### Why the deferral has collapsed
Three decisions landed since the deferral, each removing a blocker:
1. **ADR-086 (endpoint types)** named the native endpoint type and gave
it **two rustls-consuming transports**: QUIC (primary) and TCP+TLS
(fallback when UDP is blocked). Both consume `TlsClientConfig`; both
produce a `Connection` via `Connection::from_quinn_with_alpn` /
`Connection::from_bidi`. The native endpoint type also includes iroh
(key-based, not rustls-consuming). That is **three dial shapes within
one endpoint type** — two sharing `TlsClientConfig`, one using the
raw key directly. The OQ-55 blocking condition ("a second
transport's real dial existing") is met *within one endpoint type*,
not across two unrelated transports.
2. **ADR-087 (`TlsClientConfig`)** broke the circular hedge that linked
the TLS config to the dial. The client-side TLS config is extracted
and buildable today; it is a **prerequisite** for the dial, not a
consequence of it. Each transport-specific dial helper builds a
`TlsClientConfig` and passes it to its transport's connector. The
dial no longer waits on the TLS config; the TLS config is shared.
3. **ADR-083 (endpoint as accept-loop runner)** made the server side a
clean accept-loop runner that takes pre-built transports via
`with_quinn` / `with_iroh` / `with_tcp_tls`. The client-side analogue
— a dialer that takes pre-built transport handles and produces a
`Connection` — is now guessable by symmetry, not a shot in the dark.
The server side separates "build the transport" (assembly layer)
from "run the accept loop" (endpoint); the client side separates
"build the transport handle" (assembly layer) from "dial + produce
`Connection`" (`AlknetClient`).
The three together remove every blocker the deferral named. The dial
seam is extractable from two different rustls-consuming transport
implementations (QUIC + TCP+TLS) plus the key-based iroh path — three
real shapes, not one. The TLS config is shared. The server-side shape
gives the client-side shape by symmetry.
### The tangle this ADR also names
Three concept levels were conflated throughout the initial development,
contributing to the confusion that made `AlknetClient` hard to spec:
1. **Deployment role** — Hub / Worker / Hub-Worker. *Who accepts, who
dials, in the hub-and-spoke topology.* A hub accepts inbound and may
dial outbound (hub-as-client). A worker dials outbound and may
accept inbound (a hub-worker). A pure worker only dials.
2. **Establishment side**`AlknetEndpoint` (server) / `AlknetClient`
(client). *Server-side accept vs. client-side dial.* The endpoint
accepts connections and resolves identity from the incoming
connection; the client dials and presents identity (client cert)
while verifying the remote (ADR-034).
3. **ALPN-level category** — endpoint ALPN / entry-point ALPN
(ADR-086 §2). *Identity-gated vs. bootstrap, at the TLS layer.*
These are orthogonal. A hub *uses* an `AlknetEndpoint` (server side) AND
*uses* an `AlknetClient` (client side, when dialing workers). A worker
*uses* an `AlknetClient` (client side) AND *may* use an `AlknetEndpoint`
(server side, if it accepts inbound). The role determines which side(s)
you instantiate, not what the side IS. `AlknetClient` is the client-side
establishment type — Layer 2 — independent of the deployment role that
uses it and of the ALPN-level category of the ALPN it dials.
## Decision
### 1. `alknet-client` is a new crate
`AlknetClient` lives in a new crate `alknet-client`, not in
`alknet-core` or `alknet-tls`. The dependency profile rules out the
alternatives:
- **`alknet-core` is ruled out by a cycle.** `AlknetClient` needs
`TlsClientConfig` from `alknet-tls`, and `alknet-tls` depends on
`alknet-core`. Putting `AlknetClient` in core creates
`alknet-core → alknet-tls → alknet-core` — a circular dependency.
- **`alknet-tls` is the wrong scope.** That crate is "TLS config + cert
sharing" (`TlsServerConfig` / `TlsClientConfig`), not "dial +
transport establishment." The dial calls `quinn::Endpoint::connect`,
`TcpStream::connect` + `TlsConnector::connect`, and
`iroh::Endpoint::connect` — transport-connection establishment, not
TLS config. Putting the dial in `alknet-tls` would weld transport
establishment to cert config, the same conflation ADR-082 untangled
on the server side.
- **Folding into `alknet-hub` / `alknet-worker`** would make both
depend on each other or duplicate the dial. Both roles need the dial;
the dial is not a role concern.
`alknet-client` depends on `alknet-core` (for `Connection`,
`CallCredentials`, `RemoteIdentity`, types) + `alknet-tls` (for
`TlsClientConfig`) + transport crates (quinn, tokio-rustls, iroh —
feature-gated). The DAG is clean:
`alknet-client → alknet-tls → alknet-core`. `alknet-hub` and
`alknet-worker` (and any assembly layer) depend on `alknet-client` for
the dial; `alknet-call` and `alknet-channels-call` do not — their
take-over APIs (`spawn_dispatch`, `from_connection`) consume the
`Connection` the dial produces, without knowing `AlknetClient` produced
it.
### 2. `AlknetClient` is the client-side analogue of `AlknetEndpoint`
`AlknetEndpoint` (ADR-083) is a multi-transport **accept-loop runner**:
it takes pre-built transport endpoints and runs their accept loops,
dispatching by ALPN. `AlknetClient` is a multi-transport **dialer**: it
takes pre-built transport handles and dials a remote endpoint on a
chosen ALPN, producing a `Connection` for the protocol take-overs to
consume.
The symmetry:
| Concern | `AlknetEndpoint` (server) | `AlknetClient` (client) |
|---------|---------------------------|-------------------------|
| Transports | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer | `with_quinn` / `with_iroh` / `with_tcp_tls` — pre-built by the assembly layer |
| Per-connection work | Accept → extract ALPN + fingerprint → `Connection``dispatch` | Dial → TLS handshake → `Connection` (ALPN + fingerprint carried) |
| Identity | Resolved *from* the incoming connection (fingerprint from client cert, or token on channel 0) | *Presented* (local `TlsIdentity` as client cert) + remote *verified* (ADR-034 — fingerprint pin or CA) |
| What it does NOT do | Run protocols — handlers do | Run protocols — `CallClient` / `ChannelClient` do |
| Config | `TlsServerConfig` (per endpoint type, built by assembly) | `TlsClientConfig` (per-dial, built from `CallCredentials`) |
`AlknetClient` produces a `Connection`; the protocol take-overs
(`CallClient::spawn_dispatch`, `ChannelClient::from_connection`) take
over from there. This is the exact analogue of `AlknetEndpoint`
producing a `Connection` for `ProtocolHandler::handle`.
### 3. Three dial methods, one per transport family
```rust
pub struct AlknetClient {
// Pre-built transport handles, all optional — the client dials
// with whichever transport the remote endpoint type implies.
#[cfg(feature = "quinn")]
quinn: Option<quinn::Endpoint>,
#[cfg(feature = "tcp")]
tcp_connector: Option<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
iroh: Option<iroh::Endpoint>,
}
impl AlknetClient {
/// QUIC dial. Builds a `TlsClientConfig` from `credentials`
/// (ADR-034 verifier selection + ADR-084 provider), dials `addr`
/// on `alpn`, returns a `Connection` via
/// `Connection::from_quinn_with_alpn`. Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
credentials: &CallCredentials,
) -> Result<Connection, ClientDialError>;
/// TCP+TLS dial. Builds a `TlsClientConfig` from `credentials`,
/// connects `TcpStream`, wraps with `TlsConnector`, returns a
/// `Connection` via `Connection::from_bidi`. Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub async fn dial_tcp_tls(
&self,
host: &str,
addr: SocketAddr,
alpn: &[u8],
credentials: &CallCredentials,
) -> Result<Connection, ClientDialError>;
/// Iroh dial. Dials `node_id` on `alpn` via the iroh endpoint. The
/// iroh path does NOT use `TlsClientConfig` — iroh has its own TLS
/// (shares the `Ed25519SecretKey`, not the rustls config —
/// ADR-087 §3). The verifier is iroh's `NodeId` match (fingerprint
/// pin by another name). Feature-gated on `iroh`.
#[cfg(feature = "iroh")]
pub async fn dial_iroh(
&self,
node_id: iroh::NodeId,
alpn: &[u8],
local_key: &alknet_core::config::Ed25519SecretKey,
) -> Result<Connection, ClientDialError>;
}
// NOTE: The signatures above are the ORIGINAL (pre-ADR-012) shapes.
// ADR-012 amends §3 — see the amendment note below.
```
The three dials share `CallCredentials` (the local identity + remote
identity + auth token bundle, from `Capabilities`). The two rustls dials
(QUIC, TCP+TLS) build a `TlsClientConfig` from the credentials; the
iroh dial uses the raw `Ed25519SecretKey` directly. This mirrors the
server side's "iroh shares the key, not the config" (ADR-082, ADR-087
§3) — the consistency is in the rule (ADR-034 verifier selection), not
in the type.
> **Amendment 2026-07-16 (ADR-012):** The dial credential bundle is
> `ConnectionCredentials`, not `CallCredentials`. `CallCredentials`
> couples the dial to the call protocol (its `auth_token` field is a
> call-protocol / hub-layer concept — bearer-token identity correlation
> for browsers and `alknet/register`, not a transport credential). The
> dial uses only the transport-identity dimensions (`local_identity` +
> `remote_identity`); those move to `ConnectionCredentials` in
> `alknet-core`. All three dial signatures unify on
> `&ConnectionCredentials`:
>
> ```rust
> dial_quic(addr, server_name, alpn, creds: &ConnectionCredentials) -> Connection
> dial_tcp_tls(host, addr, alpn, creds: &ConnectionCredentials) -> Connection
> dial_iroh(alpn, creds: &ConnectionCredentials) -> Connection
> ```
>
> The `node_id: iroh::NodeId` parameter on `dial_iroh` is removed — it
> is derived from `creds.remote_identity.fingerprint`
> (`ed25519:<hex>` → `NodeId::from_bytes`), the same extraction pattern
> the rustls dials use for the verifier. The consistency is now in both
> the rule (ADR-034) and the type. `CallCredentials` stays in
> `alknet-call` as the call-protocol credential bundle; the
> `auth_token` is a per-request field on `call.requested` payloads
> (set by the caller or the `from_call` forwarding handler, resolved by
> `Dispatcher::resolve_identity`), not a dial-level credential. See
> [ADR-012](012-connectioncredentials-decouple-dial-from-call.md).
### 4. The dial is transport-polymorphic across the native endpoint type
The native endpoint type (ADR-086) has QUIC + TCP+TLS (both
rustls-consuming) + iroh (key-based). `AlknetClient` dials all three.
The two rustls dials share `TlsClientConfig::new`; the iroh dial is the
exception. The dial is transport-polymorphic within the native endpoint
type — a native client can reach a native endpoint over QUIC, TCP+TLS
(when UDP is blocked), or iroh (relay-assisted p2p). The transport
choice is the caller's, driven by network conditions and the remote
endpoint's reachability.
### 5. `CallClient::connect` / `ChannelClient::connect_quic` are removed
The existing QUIC convenience constructors on `CallClient` and
`ChannelClient` (`connect` / `connect_quic`) are **removed**, not
delegated. Keeping them as thin wrappers over `AlknetClient::dial_quic`
would make `alknet-call` / `alknet-channels-call` depend on
`alknet-client` — contradicting the dep graph (§1: the protocol crates
are parallel to the dial, not downstream of it) and re-coupling every
`CallClient` user to `quinn` + `rustls` + the TLS verifier machinery,
the exact welding the extraction undoes. Duplicating the dial inline in
each convenience constructor would preserve the dep graph but defeats
the point of centralizing the dial.
The dial is a distinct concern from the protocol take-over.
`AlknetClient` is the single home for the dial; `CallClient` /
`ChannelClient` are the single home for the take-over. A caller that
wants the old one-liner shape composes two lines:
`client.dial_quic(...).await?` then
`CallClient::new(...).spawn_dispatch(conn)` (or
`ChannelClient::from_connection(conn).await?`). The one-way-door
surface is the `AlknetClient` dial + take-over pattern; the
per-protocol convenience constructors are gone, not retained as
two-way-door sugar.
This is a breaking change to `CallClient` / `ChannelClient`'s public
APIs. It is expected — the develop branch is a total rewrite addressing
issues not feasible to fix inline against `main`; there are no external
consumers to preserve compatibility for. The migration plan handles
the call-site updates.
**Consequence: `CallCredentials` / `RemoteIdentity` move to
`alknet-core`.** These types were in `alknet-call` because
`CallClient::connect` consumed them. With `connect` removed, the dial
(`AlknetClient`) is the consumer, and the dial must not depend on
`alknet-call` (§1). Both the call and channels clients need them (the
channels client takes `CallCredentials` for its own removed
`connect_quic`, and the dial takes them for all three dials). They move
to `alknet-core` — the shared-types crate, alongside `TlsIdentity` and
`AuthToken` which already live there. This is the cleaner of the two
options the original ADR-045 draft called a "two-way-door
implementation detail"; it is not implementation detail — it determines
the dep graph, and the dep graph requires it.
> **Amendment 2026-07-16 (ADR-012):** This consequence is superseded.
> `CallCredentials` does **not** move to `alknet-core` — it stays in
> `alknet-call` (it is the call-protocol credential bundle; its
> `auth_token` field is a call-protocol / hub-layer concept, not a
> transport credential). What moves to `alknet-core` is
> `ConnectionCredentials` (a new type carrying only the
> transport-identity dimensions: `local_identity` + `remote_identity`)
> and `RemoteIdentity`. The dial consumes `ConnectionCredentials`, not
> `CallCredentials`. All three dial signatures unify on
> `&ConnectionCredentials`. See
> [ADR-012](012-connectioncredentials-decouple-dial-from-call.md).
>
> **Further amendment 2026-07-17 (ADR-012):** `CallCredentials` is
> **removed**, not retained in `alknet-call`. The "stays in
> `alknet-call`" framing above is itself superseded: a trace of the code
> showed `CallCredentials.auth_token` had no reader (`connect()` read
> only `tls_identity` + `remote_identity`; `spawn_dispatch` takes no
> credentials; the `from_call` forwarding path's `auth_token` source was
> `OpSummary.credentials_auth_token: Option<String>`, always `None`,
> never connected to `CallCredentials.auth_token`). The original ADR-012
> rationale ("the `from_call` forwarding handler populates `auth_token`
> from `CallCredentials`") cited a code path that does not exist.
> `auth_token` is a per-request payload field — browsers send it in the
> WebSocket call payload; the HTTP gateway resolves bearer → `Identity`
> at its boundary (the call layer sees the identity, not the token);
> `Dispatcher::resolve_identity` reads `payload.get("auth_token")`.
> There is no call-protocol credential bundle. The `from_call`
> `credentials_auth_token` dead path is removed in the same pass. See
> [ADR-012](012-connectioncredentials-decouple-dial-from-call.md) §"`CallCredentials`
> is removed."
**Consequence: `FingerprintPinVerifier` moves to `alknet-tls`.** With
`connect` removed and the verifier-selection logic centralized in
`TlsClientConfig::new` (ADR-087), `FingerprintPinVerifier` has no
remaining home in `alknet-call`. It is a TLS concern (it implements
`rustls::client::danger::ServerCertVerifier`); moving it to
`alknet-tls` lets `alknet-call` shed its direct `rustls`,
`rustls-pemfile`, and `rustls-native-certs` deps entirely — `CallClient`
becomes a pure protocol crate (`{registry, identity_provider}` +
`spawn_dispatch`). See ADR-087 §5 (amended).
**Consequence: `ClientError` is removed.** The existing
`ClientError { Transport, TlsSetup, ConnectionClosed }` was produced
only by `connect` (`Transport` and `TlsSetup`) and by no current
`spawn_dispatch` path (`ConnectionClosed` is a `FrameError`/`StreamError`
variant internal to the dispatch loop, not a `CallClient` API error).
With `connect` gone, `ClientError` has no producing call site. It is
removed rather than left as a vestigial enum. If `spawn_dispatch` ever
gains a failure path, a fresh error type is cleaner than retrofitting
this one.
### 6. `alknet/register` is a dialable ALPN (entry point, wire protocol deferred)
`AlknetClient::dial_quic` / `dial_tcp_tls` can dial the `alknet/register`
ALPN — the native registration entry point, parallel to HTTP
registration (OQ-58) but without the HTTP layer. The connection is an
**entry point** (ADR-086 §2): accepted without an established peer
identity, authenticated per-request by the registration token (or open
for no-token registration). The dial is the same as any other ALPN; the
difference is the protocol that runs on the resulting `Connection`.
Two registration cases, both hub concerns and both optional:
- **Token registration** — a freshly-provisioned worker (docker,
vast.ai, runpod) generates its local identity, dials the hub on
`alknet/register`, presents the one-time registration token, and
enrolls its key. The hub creates a `PeerEntry` and returns a session
credential.
- **No-token (open) registration** — a hub that hosts public services
over channels, or a relay/gateway, accepts registration without a
token. The enrollment creates a `PeerEntry` with no token
requirement.
The `alknet/register` **wire protocol** (the handshake on the
`Connection` after the dial — what frames the client sends, what the
hub returns) ties into the call crate's ACL and the OQ-58 enrollment
model. It is **deferred** to a dedicated ADR — this ADR names the ALPN
and its entry-point role; it does not specify the wire protocol. The
HTTP registration endpoint (OQ-58) remains the first implementation;
`alknet/register` is the native analogue that removes the HTTP
dependency for workers that have no HTTP client.
### 7. OQ-55 is resolved for the native dial
OQ-55's blocking condition ("a second transport's real dial existing")
is met: the native endpoint type has two rustls-consuming transports
(QUIC + TCP+TLS) + iroh (key-based) — three dial shapes, two sharing
`TlsClientConfig`. The transport-polymorphic dial seam is extractable
from two different transport implementations. `AlknetClient` is that
seam, for the native case.
The **web/browser client** (WebSocket, HTTP — the browser bidirectional
path per ADR-044/048) was never what OQ-55 was about. The browser path
is a different client surface (the JS SDK / wasm), not a Rust dial. It
does not use `AlknetClient`; it negotiates TLS via the browser's
network stack and speaks the wire protocol over WebSocket. OQ-55
deferred the Rust transport-polymorphic dial; the browser path is out
of scope and always was. The non-Rust native clients (Node/Deno/Bun,
Python, wasm) that can negotiate TLS against an X.509 endpoint and
implement the wire protocols directly are also out of scope for
`AlknetClient``AlknetClient` is the Rust native client, one of
several possible native clients sharing the same wire protocols.
## What this does NOT change
- **`AlknetEndpoint` (ADR-083)** — the server side is unchanged. The
client is a new type, not a modification to the endpoint.
- **`TlsClientConfig` (ADR-087)** — the client-side TLS config is
unchanged. `AlknetClient` calls `TlsClientConfig::new` per-dial; the
config is a prerequisite, not a consequence of the dial (the
relationship ADR-087 established).
- **`CallClient::spawn_dispatch` / `ChannelClient::from_connection`**
— the take-over APIs are unchanged. They consume the `Connection`
the dial produces; they do not know `AlknetClient` produced it.
- **`RemoteIdentity`** — **moved to `alknet-core`** (see §5, as amended
by ADR-012). The location changes from `alknet-call` to `alknet-core`
so the dial does not depend on the call protocol. The call and
channels clients consume it from core. (`CallCredentials` is removed
per ADR-012's 2026-07-17 amendment — it is not moved, it is deleted;
its `auth_token` field had no reader. `ConnectionCredentials` is the
new transport-level credential bundle in core, carrying
`local_identity` + `remote_identity`.)
- **The channels substrate (ADR-034)** — unchanged. The dial produces a
`Connection`; the channels protocol runs on it.
- **ADR-086 (endpoint types / entry points)** — the endpoint-type model
is unchanged. `AlknetClient` is the client-side consumer of the
native endpoint type. The entry-point vs. endpoint ALPN distinction
(§2) governs which ALPNs the client can dial and whether identity is
required — `AlknetClient` dials both; the protocol on the resulting
`Connection` differs.
- **The hub's `supervise_worker` (hub README §"Dial")** — the hub's
supervision loop takes a `dial` closure that produces a
`Connection`. That closure can call `AlknetClient::dial_quic` /
`dial_tcp_tls` internally. The hub does not need to know
`AlknetClient` exists — the closure seam is preserved. The hub spec
is updated to note `AlknetClient` as the recommended dial producer
for the closure.
## Consequences
**Positive:**
- **OQ-55 is resolved.** The transport-polymorphic dial seam is
extracted, for the native case. The duplicated dial boilerplate
(the removed convenience constructors each rebuilt
`TlsClientConfig::new` + their transport's connector) is centralized
in `AlknetClient`. The friction the deferral accepted is removed.
- **`alknet-call` becomes a pure protocol crate.** With `connect`
removed and `FingerprintPinVerifier` moved to `alknet-tls` (§5),
`alknet-call` sheds its direct `quinn`, `rustls`, `rustls-pemfile`,
and `rustls-native-certs` deps. `CallClient` is `{registry,
identity_provider}` + `spawn_dispatch` — no TLS, no transport. Every
handler crate that uses `CallClient` stops transitively linking the
TLS/transport stack.
- **The client-side shape is symmetric with the server side.** A
reader who understands `AlknetEndpoint` (accept + dispatch) can
understand `AlknetClient` (dial + produce `Connection`) by
symmetry. The concept layers (role / side / ALPN-category) are
named, reducing the tangle that made the client hard to spec.
- **The hub-as-client case is first-class.** A hub that dials workers
(or another hub) uses `AlknetClient` — the same type a worker uses
to dial a hub. The role asymmetry (hub vs. worker) does not produce
a type asymmetry; both use the same client.
- **Transport selection is the caller's.** A native client that needs
QUIC-with-TCP+TLS-fallback dials QUIC first, falls back to TCP+TLS
on connection failure. `AlknetClient` provides both dials; the
fallback policy is a caller concern (or a future `dial_with_fallback`
helper — two-way-door).
- **`alknet/register` is named.** The native registration entry point
has a home in the ALPN registry, parallel to HTTP registration. The
wire protocol is deferred, but the ALPN and its entry-point role are
decided — a worker that has no HTTP client can register natively.
**Negative:**
- **Breaking change: `CallClient::connect` / `ChannelClient::connect_quic`
removed; `RemoteIdentity` / `FingerprintPinVerifier` relocated;
`CallCredentials` removed; `ClientError` removed.** Call sites that
used the convenience constructors must switch to `AlknetClient::dial_*`
+ `spawn_dispatch` / `from_connection`. Import paths for
`RemoteIdentity` change from `alknet_call` to `alknet_core`;
`CallCredentials` is removed (per ADR-012's 2026-07-17 amendment) —
callers pass `ConnectionCredentials` to the dial and, where needed,
`auth_token` as a per-request payload field. This is expected — the
develop branch is a total rewrite; there are no external consumers to
preserve compatibility for. The migration plan handles the call-site +
import updates.
- **A new crate.** `alknet-client` is one more crate in the workspace.
The cost is low (the dial is narrow), and the dependency profile
rules out the alternatives, but it is a new entry in the crate
graph.
- **The iroh dial is the exception.** It does not use
`TlsClientConfig` — iroh has its own TLS. The dial helper applies
the same ADR-034 rule via iroh's API (NodeId match). The
consistency is in the rule, not in the type. This is the same
exception as the server side (ADR-082, ADR-087 §3) — unavoidable,
and isolated to one dial method.
- **The `alknet/register` wire protocol is still deferred.** This ADR
names the ALPN and its role; the handshake protocol (token/no-token,
the frames, the `PeerEntry` creation, the session credential return)
is a separate ADR tied to OQ-58. A worker cannot register natively
until that ADR lands; the HTTP path (OQ-58) remains the first
implementation.
- **The ADR-086 entry-point/endpoint terminology is under-specified
as a general abstraction.** This ADR uses the current terms
(entry-point = no identity at TLS; endpoint = identity required) but
does not re-litigate them. The broader abstraction — that all
top-level ALPNs are "entry points to the endpoint," each handling
auth in its own way — is a separate conceptual refinement, not
this ADR's scope.
## Door type
**One-way (crate existence + dial seam).** `alknet-client` as the
shared client dial crate is structural — every outbound-dialing role
(hub, worker, hub-worker) depends on it. Reversing would mean
re-distributing the dial across crates, reintroducing the duplicated
boilerplate. The three-dial API (`dial_quic` / `dial_tcp_tls` /
`dial_iroh`) is one-way — changing the signatures after consumers exist
is a rewrite. The internal implementation (how `ConnectionCredentials`
feeds `TlsClientConfig::new`, how the iroh dial maps the
`Ed25519SecretKey`) is two-way. The `alknet/register` ALPN name is
one-way (wire compatibility); its wire protocol is two-way until the
dedicated ADR lands.
## References
- OQ-55 (resolved by this ADR) — `AlknetClient` / client establishment
extraction
- [ADR-083](083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`
as multi-transport accept-loop runner; the server-side shape this
ADR mirrors on the client side
- [ADR-086](086-endpoint-types-and-entry-points.md) — endpoint types
(native has QUIC + TCP+TLS + iroh); entry-point vs. endpoint ALPN
distinction (§2)
- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) —
`TlsClientConfig` not blocked on the dial seam; breaks the circular
hedge; the TLS config is a prerequisite for the dial
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` /
`TlsClientConfig` in `alknet-tls`; "iroh shares the key, not the
config"
- [ADR-007](007-connection-from-stream-generic-single-stream.md) —
`Connection::from_stream` / `from_bidi`; the server-side
generalization whose client-side analogue this ADR completes
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) —
client-side verifier selection (fingerprint pin vs CA vs fail-closed)
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
provider on all paths
- [ADR-043](043-channelclient.md) — `ChannelClient::from_connection`
(the take-over `AlknetClient` feeds)
- [ADR-022](022-call-protocol-client-and-adapter-contract.md) —
`CallClient::spawn_dispatch` (the take-over `AlknetClient` feeds)
- OQ-58 — worker registration flow (the HTTP path; `alknet/register`
is the native analogue)
- `docs/architecture/crates/channels/channel-client.md` §"Relationship
to `AlknetClient`" — the deferral this ADR resolves

View File

@@ -0,0 +1,81 @@
---
status: draft
last_updated: 2026-08-12
---
# Open Questions
Open questions are tracked here, organized by theme. Each question has a
status, priority, and (when resolved) a resolution citing the ADR.
**Status values**: `open`, `resolved`, `deferred(scope)`, `deferred(unclear)`,
`partially resolved`, `dissolved`.
## Call Protocol
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-01 | Call protocol scope within a connection | resolved | medium | ADR-015 — stream model, multiplexing |
| OQ-02 | Operation path format and routing scope | resolved | medium | `/{service}/{op}` is the correct design |
| OQ-03 | Batch operation semantics | resolved | low | Correlated call.requested events |
| OQ-04 | Session-scoped operation registries | resolved | medium | ADR-019 — OperationEnv trait layering |
| OQ-05 | Abort cascade semantics for nested calls | resolved | high | ADR-020 |
| OQ-06 | Privilege model and authority context | resolved | high | ADR-017 |
| OQ-07 | Handler identity registration path and composition authority | resolved | high | ADR-018 |
| OQ-08 | Operation error schemas | resolved | high | ADR-016 |
| OQ-09 | Safe vault operations for call protocol exposure | resolved | high | ADR-010 — none exposed |
| OQ-10 | ~~Remote-safe marking shape~~ | dissolved | medium | ADR-024 — remote_safe/trusted_peer retired |
| OQ-11 | OperationAdapter error type (AdapterError variants) | resolved | medium | DiscoveryFailed, SchemaParse, Transport, Unauthorized, SamePeerCollision |
| OQ-12 | from_call re-import trigger | resolved | low | ADR-028 — manual free function |
| OQ-13 | from_call namespace collision | resolved | low | Same-peer = error; cross-peer dissolved (ADR-024) |
| OQ-14 | CallClient TLS client-auth | resolved | high | quinn client-auth; key-type-aware verification |
| OQ-15 | PeerRef::Any routing policy | resolved | low | Insertion-order first-match |
| OQ-16 | services/list-peers re-export semantics | resolved | low | Opt-in; services/list is own-ops-only |
| OQ-17 | Multi-hop federation | deferred(scope) | low | One-hop model is the commitment; multi-hop is a feature extension |
| OQ-18 | PeerId — crypto identity vs stable logical id | resolved | high | ADR-025 — PeerId = Identity.id (stable) |
| OQ-19 | Persistent peer registry | resolved | medium | ADR-025 — core trait + in-memory default; persistence adapters separate |
| OQ-20 | ~~API key asymmetry~~ | dissolved | medium | PeerEntry supports multiple credential paths |
| OQ-21 | X.509 outgoing-only case | resolved | medium | Three remote roles; PeerEntry asymmetry correct |
## Call Protocol — Pub/Sub (NEW)
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | open | high | Not yet resolved. The call protocol has `subscribe` (consumer → producer: "send me a stream") but no `pub` (producer pushes to subscribers it didn't directly receive a call.requested from). Surfaced during channels spec work — `channel/resources/subscribe` (ADR-037) needs fan-out. See §"Pub/Sub Gap" below. |
### Pub/Sub Gap
The call protocol's `StreamingHandler` / `invoke_streaming()` path
(ADR-021) is point-to-point: a `call.requested` arrives, the handler
produces a stream of `call.responded` events back to that one caller.
There is no mechanism for a producer to push events to subscribers that
it didn't directly receive a `call.requested` from.
The first consumer that needs this is the channels `channel/resources/
subscribe` operation (ADR-037): the hub aggregates worker resources and
needs to push live updates to N browser subscribers when any worker's
resource set changes. Each browser's `subscribe` arrives on the hub's
channel 0, but the worker resource changes arrive on separate worker
connections. The hub needs to fan-out.
**Blocked on**: An ADR specifying the pub primitive's shape — topic-based
fan-out vs. producer-side push to existing subscriptions. This is the
next ADR to write (alkcall ADR-046).
## Channels
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
| OQ-24 | Channels add/strip API shape | open | low | Whether the 8-byte header add/strip is built into the read/write path or a standalone utility. The contract (ADR-035) is decided; the function surface is not |
## Core Types
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-25 | BiStream type definition | resolved | high | ADR-005 — trait, Connection parameter |
| OQ-26 | AuthContext resolution timing | resolved | high | ADR-003 — hybrid resolution |
| OQ-27 | ALPN string naming convention | resolved | medium | ADR-004 — alknet/ prefix |
| OQ-28 | Dynamic handler registration | resolved | low | ADR-019 — curated static, overlays dynamic |
| OQ-29 | Handler-level auth resolution observability | resolved | medium | set_identity() on Connection for observability |
| OQ-30 | Dynamic resource ownership | resolved | high | ADR-011 — OwnershipProvider, resource_id_path |

View File

@@ -0,0 +1,992 @@
---
status: draft
last_updated: 2026-07-09
---
# Operation Registry
OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, and the hand-rolled framing (no irpc — ADR-014).
## What
The operation registry maps operation names to specs and handlers. It is the dispatch core of the call protocol — when a `call.requested` event arrives, the registry looks up the operation by name, checks access control, invokes the handler, and returns the result.
The registry is **layered by trust boundary** (ADR-019): a static, immutable curated layer (`Local` provenance, registered at startup) plus dynamic overlays for session ops (`Session` provenance, per-session) and imported ops (`FromCall` etc., per-connection). The immutability claim that previously applied to the whole registry is now scoped to the curated layer — see ADR-019 for the layering model and the rationale for why immutability is the security control for composing ops but not for imported leaves.
## Why
The operation registry provides:
- **Discoverability**: Clients can query `/services/list` and `/services/schema` to learn what operations exist before calling them
- **Access control**: Each operation declares its required scopes and resources; the registry enforces ACL before invoking the handler
- **Type safety**: JSON Schema for input and output enables validation and client code generation
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alknet-call is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-033).
## Architecture
### OperationSpec
Every registered operation has a spec that declares its name, type, schemas, and access control:
```rust
pub struct OperationSpec {
pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash)
pub namespace: String, // e.g., "fs", "agent"
pub op_type: OperationType, // Query, Mutation, Subscription
pub visibility: Visibility, // External (wire-callable) or Internal (composition-only)
pub input_schema: Value, // JSON Schema for input
pub output_schema: Value, // JSON Schema for output
pub error_schemas: Vec<ErrorDefinition>, // Declared domain errors (ADR-016)
pub access_control: AccessControl,
/// JSON pointer into the input for the resource ID, when
/// `access_control.resource_type` is set and the operation targets a
/// specific runtime-spawned resource (ADR-011). e.g., `"$.containerId"`
/// for `docker/container/exec`. Absent for no-specific-resource
/// operations (the `list` case — scope-gate + result-filter). The
/// dispatcher extracts the resource ID from the input using this path
/// and passes it to `AccessControl::check`. `None` for operations
/// with no `resource_type` or with static resource sets.
pub resource_id_path: Option<String>,
}
pub enum OperationType {
Query, // Read-only, idempotent (e.g., "fs/readFile", "services/list")
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
}
pub enum Visibility {
External, // Callable from the wire (call.requested from a client)
Internal, // Composition-only (env.invoke from a handler)
}
/// A declared operation-level error. See ADR-016.
pub struct ErrorDefinition {
pub code: String, // e.g., "FILE_NOT_FOUND", "RATE_LIMITED"
pub description: String, // Human-readable description
pub schema: Value, // JSON Schema for the error detail payload
pub http_status: Option<u16>, // HTTP status for adapter projection (from_openapi/to_openapi)
}
```
Operation names use slash-based paths without a leading slash, aligned with URL path conventions: `fs/readFile`, `agent/chat`, `services/list`. The leading slash is added when needed for display (`spec.path()` returns `/fs/readFile`) and for wire format (the `call.requested` payload uses `/fs/readFile`). See OQ-13 for the path format decision (single-node `service/op` vs head/worker `node/service/op`).
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
Visibility (ADR-017) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`. (`from_jsonschema` is now a real HTTP-backed adapter in `alknet-http` per ADR-027, not the schema-only placeholder it was.)
### AccessControl
```rust
pub struct AccessControl {
pub required_scopes: Vec<String>, // AND-checked: caller must have ALL
pub required_scopes_any: Option<Vec<String>>, // OR-checked: caller must have at LEAST ONE
pub resource_type: Option<String>, // e.g., "service", "container"
pub resource_action: Option<String>, // e.g., "read", "exec"
}
```
`AccessControl::check` consults an ownership provider for runtime-spawned
resources (ADR-011). The signature:
```rust
impl AccessControl {
/// `ownership` is None when the operation has no `resource_type`
/// (pure scope check) or when no ownership provider is wired
/// (the static `Identity.resources` path — backward compatible).
/// `resource_id` is None for the `list` case (resource_type set,
/// `resource_id_path` absent — scope-gate + result-filter, ADR-011 §4a).
pub fn check(
&self,
identity: Option<&Identity>,
resource_id: Option<&str>,
ownership: Option<&dyn OwnershipProvider>,
) -> bool {
// 1. Scope check (unchanged): identity.scopes ⊇ required_scopes.
// If identity is None and scopes are required, deny here.
// 2. Resource check (only if self.resource_type is Some):
// a. resource_id Some + ownership Some:
// → p.owns(identity?, resource_type, resource_id, resource_action)
// b. resource_id None + ownership Some (the `list` case):
// → p.owns_any(identity?, resource_type) [scope-gate]
// c. ownership None → fall back to static
// identity.resources[resource_type] ∋ resource_action
// (backward compat for non-runtime resources)
}
}
```
The `OwnershipProvider` trait (read side, sync — called on the dispatch hot
path) and the `OwnershipStore` trait (write side, async — called by
handlers that manage resource lifecycles) are defined in `alknet-core` per
ADR-011's storage decision (fourth instance of the repo/adapter pattern,
ADR-033). See [auth.md](../core/auth.md) §"Ownership Provider and Store"
for the trait shapes and the in-memory default adapter.
**The ownership provider is carried on `OperationContext`** (or threaded
by the dispatcher), populated by the dispatch path from the registry's
wiring. When `ownership` is `None`, `check` falls back to the static
`Identity.resources` path — operations with static resource sets work
unchanged. The ownership provider is an additional check, not a
replacement.
**The `resource_id` parameter** is extracted by the dispatcher from the
operation input using `OperationSpec.resource_id_path` (ADR-011 §2a).
When the spec has no `resource_id_path` (the `list` case), the dispatcher
passes `resource_id: None`, and `check` takes the scope-gate path. The
handler is separately responsible for result-filtering via
`OwnershipProvider::owned_resources` (ADR-011 §4a).
When a `call.requested` event arrives:
1. The `CallAdapter` resolves the caller's `Identity` from `AuthContext` (and possibly an `AuthToken` in the payload)
2. The registry checks operation **visibility** — if the operation is `Internal`, returns `call.error` with code `NOT_FOUND` (does not leak existence)
3. The dispatcher extracts `resource_id` from the input via `spec.resource_id_path` (if present)
4. The registry checks `access_control.check(identity, resource_id, ownership)` — for external calls (`internal: false`), ACL runs against the **caller's identity**; for internal calls (`internal: true`), ACL runs against the **handler's identity** (ADR-017)
5. If access is denied, the adapter returns `call.error` with code `FORBIDDEN`
6. If the relevant identity is `None` and the operation has restrictions, the adapter returns `call.error` with code `FORBIDDEN` and message `"authentication required"`
Operations with empty `AccessControl` (no required scopes, no resource checks) are accessible to all callers, including unauthenticated ones.
**Internal calls and authority context**: When a handler invokes another operation through `OperationEnv`, the nested call is marked `internal: true`, meaning it originated from composition (not from a wire request). The `internal` flag switches the authority context: the ACL check runs against the composing handler's `handler_identity` (set at registration), not the caller's identity and not as a blanket skip. This prevents privilege escalation through composition — a handler can only compose operations its own identity is authorized for. See ADR-017.
**Composition and dynamic ownership (ADR-011 §4d)**: When a handler composes an operation that targets a runtime-spawned resource (e.g., a coordinator composing `docker/container/exec` against a specific container), two checks must pass: (a) the coordinator's `CompositionAuthority` has the `container:exec` scope (static, ADR-017/022 unchanged), and (b) the coordinator owns this specific container (dynamic, ownership provider). The composition authority stays static — it doesn't grow a dynamic path. The ownership store handles the dynamic resource-level check. Both must pass; they're orthogonal. ADR-017 and ADR-018 are unchanged.
### Handler
There are two handler types, one per dispatch shape — mirroring the
TypeScript prior art (`@alkdev/operations/src/types.ts:62-78`:
`OperationHandler` returns a single value; `SubscriptionHandler` returns an
`AsyncGenerator`). The split is locked by ADR-021.
```rust
/// Request/response handler — Query and Mutation operations.
pub type Handler = Arc<
dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
/// Streaming handler — Subscription operations. Returns a stream of
/// ResponseEnvelopes: each Ok(value) → call.responded, an Err → call.error
/// (terminal — stream ends), natural stream end → call.completed.
pub type StreamingHandler = Arc<
dyn Fn(Value, OperationContext)
-> Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>
+ Send + Sync,
>;
/// Type alias for the boxed stream shape used by `invoke_streaming()` and
/// `StreamingHandler` return values. The concrete library
/// (`futures::stream::BoxStream<'static, T>` = `Pin<Box<dyn Stream<Item = T>
/// + Send>>`) is a two-way-door implementation detail (ADR-021); the alias
/// exists so the two spellings (the expanded form in `StreamingHandler` and
/// the short form in `invoke_streaming()`) refer to the same type.
pub type ResponseStream = Pin<Box<dyn Stream<Item = ResponseEnvelope> + Send>>;
```
Both handlers are async — many operations (file I/O, HTTP service calls,
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-021). The registry's `Handler` /
`StreamingHandler` trait objects (ADR-021) abstract over this. A handler
receives:
- `input: Value` — the deserialized `payload` from the `call.requested` event
(always `serde_json::Value`)
- `context: OperationContext` — request ID, identity, metadata, env
The **`Handler`** (request/response) returns a single `ResponseEnvelope`
containing the result or an error. `ResponseEnvelope` is defined in
[call-protocol.md](call-protocol.md#responseenvelope) — it carries the request
ID and a `Result<Value, CallError>`. Local dispatch produces it with no
serialization overhead; the `CallAdapter` converts it to `EventEnvelope` for
the wire.
The **`StreamingHandler`** (streaming) returns a `Pin<Box<dyn Stream<Item =
ResponseEnvelope> + Send>>` — the stream analogue of `Handler`'s
`Pin<Box<dyn Future<...>>>`. Each `Ok(value)` in the stream becomes a
`call.responded` event; an `Err` becomes a `call.error` event (terminal — the
stream ends after it); natural stream end becomes `call.completed`. The
dispatch path converts each `ResponseEnvelope` to `EventEnvelope` exactly as
it does for the single-response case — no new wire-format concept is
introduced. See ADR-021 and [call-protocol.md](call-protocol.md) §"CallAdapter
Stream Handling".
When a handler returns an error, the `CallError.code` is matched against the operation's declared `error_schemas` (ADR-016). If the code matches a declared `ErrorDefinition`, the `call.error` event carries that code and the error's detail payload. If it doesn't match, the `call.error` carries `INTERNAL`. This is how handler failures become typed errors on the wire instead of string-matched messages. The same matching applies to `Err` values yielded by a `StreamingHandler`.
A `make_streaming_handler()` helper (analogue of `make_handler()`) wraps a
stream-producing closure into a `StreamingHandler`:
```rust
pub fn make_streaming_handler<S, St>(f: S) -> StreamingHandler
where
S: Fn(Value, OperationContext) -> St + Send + Sync + 'static,
St: Stream<Item = ResponseEnvelope> + Send + 'static,
{
Arc::new(move |input, context| Box::pin(f(input, context)))
}
```
### OperationContext
```rust
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-018)
pub forwarded_for: Option<Identity>, // Original caller when forwarded (ADR-026, metadata only — NOT used by AccessControl::check)
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
/// Reachability set — the operations this handler may compose.
/// Populated from the registration bundle's `scoped_env` (ADR-018).
/// The reachability check in `OperationEnv::invoke()` consults
/// `scoped_env.allows(&name)`. This is data, not a dispatch trait.
pub scoped_env: ScopedPeerEnv,
/// Composition dispatch trait. A handler calls `env.invoke(...)` to
/// compose child operations. This is `Arc<dyn OperationEnv>` (a trait
/// object), not a concrete struct — the trait-object design is what
/// enables registry layering (ADR-019): the CallAdapter composes the
/// root env per call from the active layers (curated base + connection
/// overlay + session overlay), and session/connection overlays wrap
/// the base via trait layering. Same pattern as `IdentityProvider`
/// (ADR-003). See ADR-019.
pub env: Arc<dyn OperationEnv + Send + Sync>,
/// Abort policy for this call's descendants (ADR-020 Decision 6).
/// Default `AbortDependents` — aborting this request aborts all
/// non-terminal descendants. `ContinueRunning` is an opt-in for
/// long-running work that should survive a parent's abort. Set by the
/// composing handler via `OperationEnv::invoke()` (or
/// `invoke_with_policy()`), not by the wire caller.
pub abort_policy: AbortPolicy,
/// Deadline for this call and all descendants. Set by `build_root_context`
/// to `now + CallAdapter.default_timeout` (default 30s). Composed calls
/// inherit the parent's deadline (children do not get a fresh 30s — the
/// root call's deadline bounds the entire call tree). A composed call
/// that exceeds the deadline is cancelled (future dropped, `Drop` guards
/// release resources). `None` means no deadline (unbounded — used for
/// long-running subscriptions). See call-protocol.md → Timeouts.
pub deadline: Option<Instant>,
/// Composition-origin flag. Set by `OperationEnv::invoke()` (true) or the
/// `CallAdapter` dispatch path (false) — never by handlers. Module-private
/// for writes; read via `is_internal()`. See ADR-017.
pub(crate) internal: bool,
}
/// Abort cascade policy for a call's descendants (ADR-020).
///
/// `AbortDependents` (default): aborting this call cascades to all
/// non-terminal descendants.
///
/// `ContinueRunning` (opt-in): descendants that have already started
/// continue to completion; descendants that haven't started are aborted;
/// no new descendants start.
pub enum AbortPolicy {
AbortDependents,
ContinueRunning,
}
impl Default for AbortPolicy {
fn default() -> Self { Self::AbortDependents }
}
impl OperationContext {
pub fn is_internal(&self) -> bool { self.internal }
}
```
- `request_id`: Correlates with the `call.requested` event's `id` field
- `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`). Records the agency chain — the call tree is the principal→agent chain (ADR-017)
- `identity`: The authenticated caller (from `IdentityProvider`) — inbound auth (who is calling me). For external calls, this is who sent the `call.requested`. For internal calls, this is the parent handler's `handler_identity` (propagated through `OperationEnv::invoke()`)
- `handler_identity`: The composition authority of the handler processing this call. `None` for leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose. `Some(...)` for `Local` and `Session` ops that can compose children. For internal calls (`internal: true`), the ACL check runs against this authority (ADR-017, ADR-018). This is NOT a peer `Identity` — it's a declared authority bundle set at registration by the assembly layer
- `forwarded_for`: The original caller when this call was forwarded by a `from_call` handler (ADR-026). **Metadata only**`AccessControl::check` never reads it; the ACL always authorizes the direct caller's `identity`. Handlers may read it for logging, auditing, per-user rate limiting, or application context. Populated from `call.requested.forwarded_for` by the dispatch path; set to `None` for composed children (wire-ingress only). The forwarder's claim, not a verified identity — a malicious hub can lie (same property as HTTP `X-Forwarded-For`). See ADR-026.
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
- `metadata`: Request-scoped context (tracing IDs, connection info). **Must not hold secret material** — see ADR-010. **Does not propagate through `OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`.
- `scoped_env`: The reachability set — the operations this handler may compose. Populated from the registration bundle's `scoped_env` (ADR-018). The reachability check in `OperationEnv::invoke()` consults `scoped_env.allows(&name)`. This is *data* (a `ScopedPeerEnv` struct), not a dispatch trait. `None`/empty for leaves.
- `env`: The composition dispatch trait (`Arc<dyn OperationEnv + Send + Sync>`). A handler calls `context.env.invoke(...)` to compose child operations. This is a trait object, not a concrete struct — the trait-object design enables registry layering (ADR-019): the CallAdapter composes the root env per call from the active layers (curated base + connection overlay + session overlay), and overlays wrap the base via trait layering. Same pattern as `IdentityProvider` (ADR-003). See ADR-019.
- `internal`: When `true`, this call originated from composition (a handler calling another operation via `OperationEnv`), not from a wire request. This switches the authority context: ACL runs against `handler_identity`, not `identity`. The `internal` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `internal: true`, or through the `CallAdapter` dispatch path which sets `internal: false`. The field is not `pub` for writes; only `pub fn is_internal(&self) -> bool` is exposed for reads. See ADR-017.
`identity` and `capabilities` are orthogonal: identity is inbound (who is calling me), capabilities are outbound (what credentials I can use). `identity` and `handler_identity` are the principal/agent pair: `identity` is the principal (who delegated), `handler_identity` is the agent (who is acting). See ADR-010 for capabilities, ADR-017 for the privilege model, and ADR-018 for the composition authority type.
### OperationRegistry
```rust
pub struct OperationRegistry {
operations: HashMap<String, HandlerRegistration>,
}
```
The registry maps operation names to `HandlerRegistration` bundles. The curated layer (Layer 0) is a `HashMap<String, HandlerRegistration>`; session and connection overlays (Layers 1 and 2) are separate maps that the `CallAdapter` composes into the per-call `OperationContext.env` (ADR-019). See ADR-018 for the full registration model and ADR-019 for the layering model. Key methods:
- `register(registration)`: Add an operation to the curated layer at startup. Validates `handler` is the right `HandlerKind` for `spec.op_type` (Once for Query/Mutation, Stream for Subscription — ADR-021). Mismatch is a startup error.
- `registration(name)`: Find a registration by operation name (checks active overlays first, then curated base — ADR-019). Returns spec, handler (`HandlerKind`), provenance, composition authority, scoped env, capabilities.
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return a single `ResponseEnvelope` (request/response path — Query/Mutation). **Errors with `INVALID_OPERATION_TYPE` if the op is a `Subscription`**`invoke()` is the wrong dispatch path for streaming ops; use `invoke_streaming()` (ADR-021).
- `invoke_streaming(name, input, context)`: Look up, check ACL, invoke streaming handler, return a `ResponseStream` (the boxed stream alias — ADR-021) (streaming path — Subscription). Pre-handler errors (not-found, forbidden, `INVALID_OPERATION_TYPE` for a non-Subscription op) yield a single error `ResponseEnvelope` and end the stream. See ADR-021.
- `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
### Request ID Generation
Request IDs correlate `call.requested`/`call.responded` events and index the
abort-cascade tree (`PendingRequestMap` is keyed by request ID, ADR-020).
- **Wire calls**: the root `OperationContext.request_id` is the `id` field
from the wire `call.requested` event (generated by the client).
- **Composed calls**: `OperationEnv::invoke()` generates a new `request_id`
for each child via `generate_request_id()` — a UUID v4 (or
`parent_id + "-" + counter`). Deterministic IDs (e.g.
`format!("env-{name}")`) **must not** be used — they collide across
concurrent invocations of the same operation, corrupting
`PendingRequestMap` correlation and the abort-cascade tree.
- **Wire visibility**: composed child `request_id`s are **internal** — they
appear in `PendingRequestMap` for abort-cascade indexing but are not sent
as `call.requested` to any peer. The client only sees `call.aborted` for
the root ID it sent; the server cascades internally to descendants. The
exception is `from_call` ops, which generate their own wire ID when
forwarding to the remote node (the remote node's `PendingRequestMap`
indexes it).
### HandlerRegistration
The registration bundle carries everything the dispatch path needs to construct an `OperationContext`. See ADR-018 for the full rationale.
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: HandlerKind, // Once or Stream — validated against spec.op_type (ADR-021)
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>, // None for leaves
pub scoped_env: Option<ScopedPeerEnv>, // None for leaves
pub capabilities: Capabilities,
// NOTE: ADR-023 added `remote_safe: bool` here; ADR-024 supersedes it and
// removes the field. Peer authorization is `AccessControl::check(peer_identity)`,
// not a per-op boolean. See ADR-024 §3.
}
/// Which dispatch path a handler uses — locked by ADR-021.
/// Validated against `spec.op_type` at registration:
/// Query/Mutation → Once; Subscription → Stream. Mismatch is a startup error.
pub enum HandlerKind {
Once(Handler),
Stream(StreamingHandler),
}
```
#### OperationProvenance
Where the op came from. Determines composition capability, default
visibility, and trust model. See ADR-018 for rationale.
```rust
pub enum OperationProvenance {
Local, // Assembly-written, trusted, can compose
FromOpenAPI, // HTTP forwarding stub (from_openapi), leaf
FromMCP, // MCP forwarding stub (from_mcp), leaf
FromCall, // call-protocol forwarding stub (from_call), leaf locally
FromJsonSchema, // HTTP forwarding stub (from_jsonschema, single endpoint), leaf
Session, // Agent-written, sandboxed, can compose within sandbox
}
```
| Provenance | Can compose? | Has composition authority? | Default visibility |
|-----------|-------------|---------------------------|-------------------|
| `Local` | Yes | Yes — scopes set by assembly layer | External or Internal (assembly declares) |
| `FromOpenAPI` | No (leaf) | No | Internal |
| `FromMCP` | No (leaf) | No | Internal |
| `FromCall` | No (leaf in local registry) | No | Internal |
| `FromJsonSchema` | No (leaf) | No | Internal |
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
> **`FromJsonSchema` provenance.** `from_jsonschema` is an HTTP-backed
> single-endpoint adapter in `alknet-http` (ADR-027): a real reqwest
> forwarding handler, not a schema-only placeholder. `FromJsonSchema`
> is a leaf, same trust model as `FromOpenAPI` (HTTP endpoint trusted;
> handler is a forwarding stub). Schema validation without a handler is
> served by consuming `OperationSpec` directly, not by registering a
> placeholder op.
#### CompositionAuthority
The declared authority (label + scopes + resources) the handler operates
under when composing children. `None` for leaves. This replaces ADR-017's
`handler_identity: Identity` — it's not a peer identity, it's a declared
authority bundle. See ADR-018.
```rust
pub struct CompositionAuthority {
pub label: String, // e.g., "agent-chat" — not a peer id
pub scopes: Vec<String>, // e.g., ["llm:call", "fs:read"]
pub resources: HashMap<String, Vec<String>>, // e.g., {"service": ["vastai"]}
}
impl CompositionAuthority {
pub fn none() -> Option<Self> { None } // Convenience for leaves
pub fn new(label: &str, scopes: impl IntoIterator<Item = String>) -> Self { ... }
pub fn as_identity(&self) -> Option<Identity> { ... } // Synthetic Identity for ACL
}
```
- `provenance`: Determines composition capability. Only `Local` and `Session` ops can compose; leaves get `composition_authority: None` and `scoped_env: None`.
- `composition_authority`: The declared authority the handler operates under when composing children. `None` for leaves. See ADR-018.
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-017.
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases. The builder validates handler kind against `spec.op_type` at registration time — `with_local` / `with_leaf` accept `Handler` (for `Query`/`Mutation` ops), `with_local_streaming` / `with_leaf_streaming` accept `StreamingHandler` (for `Subscription` ops). Passing a `StreamingHandler` to `with_local` or a `Handler` to `with_local_streaming` is a registration-time error:
```rust
// with_local: Local provenance, full bundle — all 5 args required.
// Accepts Handler (for Query/Mutation ops). Validates op_type at registration.
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
// with_local_streaming: Local provenance, full bundle — all 5 args required.
// Accepts StreamingHandler (for Subscription ops). Validates op_type at registration.
// with_local_streaming(spec, streaming_handler, composition_authority, scoped_env, capabilities)
// with_leaf: Leaf provenance (default FromOpenAPI), no composition authority.
// Accepts Handler (for Query/Mutation ops).
// with_leaf(spec, handler, capabilities)
// with_leaf_streaming: Leaf provenance (default FromOpenAPI), no composition authority.
// Accepts StreamingHandler (for Subscription ops).
// with_leaf_streaming(spec, streaming_handler, capabilities)
// with_leaf_provenance / with_leaf_streaming_provenance: explicit provenance variant.
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
// Agent handler (Local, Subscription — streams call.responded as the
// LLM generates tokens; uses with_local_streaming for the StreamingHandler)
.with_local_streaming(agent_chat_spec(), Arc::new(agent_chat_streaming_handler),
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedPeerEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
Capabilities::new().with_api_key("google", google_api_key))
// Imported ops (leaves — no authority, no scoped env; capabilities for outbound HTTP)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_handler), vastai_credentials)
.build();
```
The CLI binary (or assembly layer) constructs the registry and passes it to the `CallAdapter`. Once built, the **curated layer** (Layer 0 — `Local` provenance ops) is immutable. Session and imported overlays are dynamic at their respective scopes (per-session, per-connection) per ADR-019. The `CallAdapter` composes the root `OperationContext.env` per incoming call from the active layers.
### 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 or on a remote node.
**`OperationEnv` is request/response-only** (ADR-021). 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-021 for the rationale and the `@alkdev/pubsub` `operators.ts` prior art.
```rust
/// The composition dispatch trait. A handler composes child operations
/// through its `OperationContext.env` (which implements this trait).
///
/// This must remain a trait, not a concrete type — session-scoped
/// registries (OQ-19) depend on wrapping the global env via trait
/// layering. Making `OperationEnv` concrete or hardcoding the global
/// registry into the dispatch path would close the session-overlay
/// pattern.
#[async_trait]
pub trait OperationEnv: Send + Sync {
/// Compose a child operation. The child's `OperationContext` is
/// constructed with `internal: true`, inheriting the parent's
/// composition authority as the child's caller identity. The abort
/// policy defaults to the parent's (ADR-020 Decision 6, W19).
///
/// Default impl: delegates to `invoke_with_policy` with
/// `parent.abort_policy.clone()`. Impls only need to implement
/// `invoke_with_policy` — `invoke` is provided.
async fn invoke(
&self,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
) -> ResponseEnvelope {
self.invoke_with_policy(namespace, operation, input, parent, parent.abort_policy.clone()).await
}
/// Compose a child with an explicit abort policy (ADR-020 Decision 6).
/// Use `AbortPolicy::ContinueRunning` for long-running work that
/// should survive a parent's abort. This is the required method —
/// `invoke()` delegates to it with the parent's policy.
async fn invoke_with_policy(
&self,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope;
/// Does this env contain the named operation? Used by
/// `PeerCompositeEnv` to probe overlays before dispatching
/// (ADR-019 + ADR-024). The composite checks `session.contains()` →
/// each peer's sub-overlay (in `connection_order`) → base,
/// dispatching to the first overlay that contains the op. Default
/// impl returns `true` (a single-layer env like `LocalOperationEnv`
/// contains everything it can dispatch).
fn contains(&self, name: &str) -> bool { true }
/// Peer-routing composition (ADR-024 §2). Routes to a specific peer
/// (`PeerRef::Specific`) or to the first peer that serves the op
/// (`PeerRef::Any`). The default impl ignores the peer selector and
/// delegates to `invoke_with_policy`, preserving back-compat for
/// single-layer envs (`LocalOperationEnv`, `OverlayOperationEnv`)
/// that don't override it. `PeerCompositeEnv` overrides with real
/// peer-keyed routing.
async fn invoke_peer(
&self,
peer: &PeerRef,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope {
// default: ignore peer selector, dispatch via invoke_with_policy
let _ = peer; // unused — single-layer envs don't route by peer
self.invoke_with_policy(namespace, operation, input, parent, policy).await
}
/// Does this env contain the named op *on the named peer*? Used by
/// `PeerCompositeEnv` to probe a specific peer's sub-overlay before
/// dispatching via `invoke_peer` with `PeerRef::Specific`. Default
/// impl delegates to `contains` (single-layer envs ignore the peer
/// dimension). `PeerCompositeEnv` overrides to check the specific
/// peer's sub-overlay.
fn peer_contains(&self, _peer: &PeerId, name: &str) -> bool { self.contains(name) }
}
```
The `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.handler_identity` as the caller identity, and is marked `internal: true`.
The `invoke_peer` / `peer_contains` methods (ADR-024 §2) take a `PeerRef`
selector and a `PeerId`. These types are defined alongside the
`PeerCompositeEnv` struct — see [client-and-adapters.md](client-and-adapters.md#peer-keyed-composition-env-adr-029)
and [ADR-024](decisions/029-peer-graph-routing-model.md) §2:
```rust
pub enum PeerRef {
Specific(PeerId), // route to this peer; NOT_FOUND if it doesn't serve the op
Any, // first peer (insertion order) that serves it
}
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
// = PeerEntry.peer_id (stable, not crypto material — ADR-025)
```
**Metadata does not propagate through composition.** Nested calls get fresh metadata (`HashMap::new()`), not the parent's metadata bag. This is a security constraint (ADR-010): `metadata: HashMap<String, Value>` accepts any `serde_json::Value`, including secret material. If metadata propagated through `env.invoke()`, a handler that accidentally placed a secret in metadata would leak it to every child operation — and if a child is a `from_call` operation (ADR-022), the metadata would cross the wire to the remote node. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`, not in ambient context.
**Local dispatch only.** The initial `OperationEnv` implementation for the
curated layer (Layer 0) dispatches directly through the local
`OperationRegistry`. The composite env (curated + session + peer-keyed
connection overlays) is a separate type built by the `CallAdapter` per call —
see ADR-019, ADR-024, and the `PeerCompositeEnv` sketch below.
```rust
/// Layer 0 dispatch — the curated registry. This is the base env that
/// overlays wrap. See ADR-019 for the layering model.
pub struct LocalOperationEnv {
registry: Arc<OperationRegistry>,
}
#[async_trait]
impl OperationEnv for LocalOperationEnv {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check (ADR-017, ADR-018): is this op in the parent's
// scoped env? If not, return NOT_FOUND. This bounds the
// parameterized-dispatch attack surface — a handler (or an LLM
// picking tools) can only reach declared ops. The reachability set
// is on `parent.scoped_env` (data), not on `parent.env` (dispatch
// trait) — see ADR-019 for the split.
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
let registration = self.registry.registration(&name);
let context = OperationContext {
// Unique per invocation — a UUID v4 or parent_id + counter.
// A deterministic ID (e.g. format!("env-{name}")) collides across
// concurrent invocations of the same operation, which corrupts
// PendingRequestMap correlation and the abort-cascade tree
// (ADR-020), which is indexed by parent_request_id.
request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()),
// Parent's composition authority becomes the caller for the child.
// This is the authority switch: the child's ACL checks against
// the parent's authority, not the original wire caller's identity.
identity: parent.handler_identity.as_identity(),
// Child's own composition authority (from its registration).
// None for leaves — they don't compose, so this is never used
// for ACL on a grandchild.
handler_identity: registration.composition_authority.clone(),
// Composed children do not inherit forwarded_for — it's a
// wire-ingress field, not a composition-ingress field (ADR-026).
forwarded_for: None,
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-010)
abort_policy: policy, // Explicit policy (from invoke() default or invoke_with_policy)
deadline: parent.deadline, // Inherit parent's deadline (children don't get a fresh 30s)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedPeerEnv::empty), // Child's own scoped env (empty for leaves)
// Dispatch trait: the child inherits the parent's env (the same
// composite of curated base + active overlays). See ADR-019.
env: parent.env.clone(),
internal: true, // Nested calls use handler authority
};
self.registry.invoke(&name, input, context).await
}
// `contains` uses the default impl (returns true — the curated registry
// contains everything it can dispatch). For a single-layer env, the
// reachability check in `invoke_with_policy` is the real gate.
}
```
The composite env (built by the `CallAdapter` per incoming call) wraps the
curated base and any active overlays. Per ADR-024, the connection overlay is
**peer-keyed** — a head node with N worker connections holds a
`HashMap<PeerId, connection_overlay>`, not one overlay. The singular-connection
case (one peer) is the degenerate case with a single-entry map.
```rust
/// Per-call composite env (ADR-019 + ADR-024). Built by the CallAdapter in
/// build_root_context from the active layers. The child inherits this by
/// Arc::clone through invoke(). The connection overlay is peer-keyed
/// (ADR-024 §1) to handle head→N-workers routing.
pub struct PeerCompositeEnv {
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
}
#[async_trait]
impl OperationEnv for PeerCompositeEnv {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
// PeerRef::Any routing (ADR-024 §2): session → peers in insertion
// order → curated base. First overlay that *contains* the op wins.
let name = format!("{namespace}/{operation}");
// Reachability check against parent.scoped_env (same as LocalOperationEnv).
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
if let Some(session) = &self.session {
if session.contains(&name) {
return session.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
// Peer-keyed overlay: iterate peers in insertion order, dispatch to
// the first peer whose sub-overlay contains the op. This is the
// head→N-workers fan-out primitive (ADR-024 §2, OQ-30: insertion-
// order first-match).
for peer_id in &self.connection_order {
if let Some(conn_env) = self.connections.get(peer_id) {
if conn_env.contains(&name) {
return conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
}
self.base.invoke_with_policy(namespace, operation, input, parent, policy).await
}
// `invoke_peer` overrides the default impl with real peer-keyed
// routing (ADR-024 §2). `PeerRef::Specific` routes to the named peer's
// sub-overlay only (no fallthrough — NOT_FOUND if that peer doesn't
// serve the op). `PeerRef::Any` reuses `invoke_with_policy` (the
// insertion-order fan-out above).
async fn invoke_peer(
&self,
peer: &PeerRef,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
match peer {
PeerRef::Specific(peer_id) => {
// Route to this peer's sub-overlay only. No fallthrough —
// explicit routing must be honored or fail loudly (ADR-024 §2).
match self.connections.get(peer_id) {
Some(conn_env) if conn_env.contains(&name) => {
conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await
}
_ => ResponseEnvelope::not_found(name),
}
}
PeerRef::Any => {
// Same as invoke_with_policy: session → peers in order → base.
self.invoke_with_policy(namespace, operation, input, parent, policy).await
}
}
}
fn contains(&self, name: &str) -> bool {
// The composite contains the op if any layer does (peer-agnostic).
self.session.as_ref().map_or(false, |s| s.contains(name))
|| self.connections.values().any(|c| c.contains(name))
|| self.base.contains(name)
}
fn peer_contains(&self, peer: &PeerId, name: &str) -> bool {
// Does the named peer's sub-overlay contain the op?
self.connections.get(peer).map_or(false, |c| c.contains(name))
}
}
```
The `contains()` method (review #003 C9) is the overlay-dispatch contract.
It replaces the previous "sentinel or contains check — two-way door" framing,
which was ambiguous enough to produce non-interoperable `OperationEnv` impls.
The structural decision (composite trait object, overlay order, `Arc::clone`
inheritance) is locked by ADR-019; the peer-keyed overlay extension
(`PeerCompositeEnv`, `invoke_peer`, `peer_contains`) is locked by ADR-024; the
dispatch contract (`contains` probe before `invoke_with_policy`) is locked by
both.
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-017, now wired via ADR-018.
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-019): 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-022 `from_call`) are both overlays on the same base, using the same mechanism. The peer-keyed extension (`PeerCompositeEnv`, `invoke_peer`, ADR-024) 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-003). See OQ-19, ADR-019, and ADR-024.
### Service Discovery
Two built-in operations expose what the node offers:
| Operation name | Display path | Type | Description |
|---------------|-------------|------|-------------|
| `services/list` | `/services/list` | Query | List registered operation names and metadata |
| `services/schema` | `/services/schema` | Query | Get the `OperationSpec` for a specific operation |
These are read-only — no admin operations are exposed through the call protocol itself.
`services/list` only returns `External` operations to remote callers. `Internal` operations are not part of the wire-facing API surface — they're implementation details of composition. A remote client cannot enumerate the internal call tree. See ADR-017.
`services/list` returns:
```json
{
"operations": [
{ "name": "fs/readFile", "namespace": "fs", "op_type": "query" },
{ "name": "agent/chat", "namespace": "agent", "op_type": "subscription" },
{ "name": "events/subscribe", "namespace": "events", "op_type": "subscription" }
]
}
```
`services/schema` accepts `{ "name": "fs/readFile" }` (no leading slash —
registry form, same as `OperationSpec.name`) and returns the full
`OperationSpec` including input/output JSON Schemas and declared
`error_schemas` (ADR-016). The `CallAdapter` normalizes the leading slash
from wire `operationId`s before lookup, so `services/schema` accepts both
`fs/readFile` and `/fs/readFile`. This enables client code generation: a
client reading the schema can produce typed error enums instead of generic
error handling.
### Operation Registry (hand-rolled, no irpc)
The operation registry is hand-rolled in alknet-call. ADR-013 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-014 supersedes ADR-013 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.
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-021), not an irpc `Service`.
### Operation Registration at Startup
The CLI binary (or assembly layer) constructs `HandlerRegistration` bundles with provenance, composition authority, scoped env, and capabilities (from the vault — see [Capability Injection](#capability-injection)), then registers them before starting the endpoint:
```rust
// Assembly layer: unlock vault, derive credentials
let vault = VaultServiceHandle::new();
vault.unlock(&mnemonic, passphrase.as_deref())?;
let google_api_key = vault.decrypt(&google_key_blob)?;
let github_signing_key = vault.derive_ed25519(PATHS::GITHUB_SIGNING)?;
let vastai_credentials = Capabilities::new().with_http_token("vastai", vastai_token);
// Register operations — vault operations are NOT registered here
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedPeerEnv::empty(), Capabilities::new())
// Agent handler (Local, Subscription — composes; streaming handler
// wrapped in HandlerKind::Stream by the builder per ADR-021)
.with(HandlerRegistration {
spec: agent_chat_spec(),
handler: HandlerKind::Stream(Arc::new(agent_chat_streaming_handler)),
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority::new(
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
scoped_env: Some(ScopedPeerEnv::new(
["fs/readFile", "vastai/listMachines", "llm/generate"])),
capabilities: Capabilities::new().with_api_key("google", google_api_key),
})
// Vastai ops (FromOpenAPI, leaves — no authority, no scoped env)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_listMachines_handler),
vastai_credentials.clone())
.build();
let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider);
// Agent deployment: let call_adapter = CallAdapter::new(...).with_session_source(source);
```
The vault is used at construction time to populate `capabilities` in the registration bundle, not registered as call protocol operations. The curated layer (Layer 0) is immutable after construction — adding a `Local` op requires restarting the process. Session and imported overlays are dynamic at their respective scopes (ADR-019). This is consistent with OQ-04 (scoped to the `HandlerRegistry` by ADR-019), ADR-008, ADR-010, and ADR-018.
### Capability Injection
Handlers that need outbound credentials (LLM provider API keys, signing keys, HTTP service tokens) receive them through the `Capabilities` type on `OperationContext`, not by calling vault operations over the wire and not from environment variables. This is the mechanism that ADR-008 described in prose ("derived keys and decrypted credentials are injected into operation contexts at the assembly layer") and that ADR-010 specifies as a one-way door. ADR-018 specifies the registration path: capabilities live on the `HandlerRegistration` bundle, and the dispatch path populates `OperationContext.capabilities` from the bundle at call time.
The flow is:
```
Assembly layer (CLI startup):
1. Unlock vault (local, mnemonic from secure prompt or file)
2. Derive / decrypt the credentials each handler needs
3. Construct HandlerRegistration bundles with capabilities from the vault
4. Register the bundles in the OperationRegistry
5. Start the endpoint
Handler invocation (at call time):
call.requested → CallAdapter looks up registration by op name
→ build_root_context populates OperationContext.capabilities from registration.capabilities
→ handler reads context.capabilities → uses the credential for its outbound call
```
The handler closure does **not** capture capabilities — that was the pre-ADR-018 "Model A" that created a circular dependency with per-request `OperationContext.capabilities`. Capabilities live on the registration bundle, and the dispatch path populates the context from the bundle. One model, one wiring path. See ADR-018 Decision 6.
The `Capabilities` type holds non-serializable, zeroized secret material. It does not implement `Serialize` — it cannot cross the call protocol wire even by accident. The concrete shape of the type (a typed map, a struct with named fields, a trait object) is a two-way door for implementation. The one-way constraints are fixed by ADR-010:
- Capabilities are populated by the assembly layer at registration (on the `HandlerRegistration` bundle). They are never populated from call protocol inputs.
- Capabilities hold secret material that does not implement `Serialize` and does not appear in `EventEnvelope` payloads.
- The call protocol carries no secret material. See [call-protocol.md](call-protocol.md) for the wire-level constraint.
- **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared on the registration bundle), and children run under the parent's composition authority (ADR-017, ADR-018). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model.
- **Capabilities must be immutable after construction.** No interior mutability, no `Mutex<Map>`, no `RefCell`. This makes the clone-semantics two-way door genuinely two-way: Arc-based clone (shared immutable state) and deep-copy clone (isolated state) are behaviorally identical when neither supports mutation. Without this guard, a handler that mutates capabilities (e.g., adds a derived key for a child) would make the mutation visible to siblings and the parent under Arc-based clone — shared mutable state across the call tree, a security-relevant behavior. Once shipped, handlers may depend on shared mutation, and switching from Arc-shared to deep-copy-isolated later is a behavior change that breaks them. The immutability guard prevents the "two-way door" from becoming a future one-way door.
**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-010). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation.
**Adapters take credential sources.** All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call` — see ADR-022, constrained by ADR-010) register HTTP-backed, MCP-backed, or remote-call-backed operations. The credential each service needs (bearer token, API key, TLS identity for the remote connection) is provided by the assembly layer at registration time — the adapter receives a credential source, not a static token string. This is the integration point where the vault feeds credentials into backed operations, including LLM providers that expose OpenAPI-compatible endpoints. Adapter-registered operations are `Internal` by default (ADR-017) — they're composition material, not directly callable from the wire.
**`from_call` imports remote operations.** The `from_call` adapter (ADR-022) discovers operations on a remote call protocol endpoint via `services/list` and `services/schema`, then registers them with handlers that forward calls over the call-protocol connection (transport-agnostic — QUIC, TCP+TLS, or any `Connection::from_stream` source, ADR-007). This makes cross-node composition transparent — a handler calling `env.invoke("worker", "exec", ...)` doesn't know whether the operation is local or remote. Connection direction (who opened the connection) is independent of call direction (who calls whom) — both sides can call each other once connected.
**`from_call` trust is transitive.** A `from_call`-imported operation executes the remote node's code, not yours. The scoped env (ADR-017) bounds *which* operations are reachable, but not *what* they do. A compromised remote node can do anything its operations are declared to do (and anything its handler bugs allow). This is inherent to remote composition — same as trusting any RPC endpoint — but it must be explicit in the threat model. `from_call` means "I trust the remote node as much as my own handlers." The scoping protects the caller from reaching arbitrary ops; it does not protect against what the reached op does.
**Scoped composition env.** The `OperationEnv` given to a handler is scoped — it can only invoke a declared set of operations, set at registration on the `HandlerRegistration` bundle by the assembly layer (ADR-018). This bounds the parameterized-dispatch attack surface: a handler (or an LLM picking tools, or a quickjs sandbox) can only reach declared operations, not the entire registry. The scoped env is the reachability control; the composition authority is the authority control. Both are needed for least privilege. See ADR-017 and ADR-018.
**No-env-vars invariant.** No handler reads outbound credentials from any source other than `OperationContext.capabilities`. This is the dispatch-side corollary of the capability-injection flow above: because the dispatch path populates `OperationContext.capabilities` from the registration bundle (ADR-018 §6), and because the assembly layer constructs handlers with vault-derived credentials rather than calling `Default::default()`, downstream consumers' `std::env::var` credential reads are unreachable by construction. The full invariant, the credential injection path, and the downstream-consumer framing are recorded in [client-and-adapters.md](client-and-adapters.md); this section documents the dispatch-path mechanism that makes it enforceable.
## Constraints
- The registry is **layered by trust boundary** (ADR-019). 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-019 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. Internal handler dispatch is via `Handler` / `StreamingHandler` trait objects (ADR-021), 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-017, ADR-018.
- **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-017.
- **The composition env is scoped.** A handler can only invoke operations declared in its scoped env (on the `HandlerRegistration` bundle). This bounds parameterized-dispatch attack surface. See ADR-017, ADR-018.
- **No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-010). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-010.
- **Metadata does not propagate through composition.** `OperationEnv::invoke()` constructs fresh metadata for nested calls (`HashMap::new()`), not the parent's metadata. This prevents a handler that accidentally places a secret in metadata from leaking it to child operations — and if a child is a `from_call` operation (ADR-022), across the wire to a remote node. The tracing link is `parent_request_id`, not metadata propagation. See ADR-010.
- **Provenance determines composition capability.** Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `composition_authority: None` and `scoped_env: None` — they don't compose, so they don't need authority or reachability bounds. See ADR-018.
- **`HandlerKind` matches `op_type`** (ADR-021). `Query`/`Mutation` ops register a `HandlerKind::Once(Handler)`; `Subscription` ops register a `HandlerKind::Stream(StreamingHandler)`. Mismatch is a startup error. `invoke()` on a `Subscription` and `invoke_streaming()` on a `Query`/`Mutation` both return `INVALID_OPERATION_TYPE`. `OperationEnv::invoke()` (composition) is request/response-only and errors with `INVALID_OPERATION_TYPE` on `Subscription` ops — stream composition is a handler-level concern, not a protocol composition concern.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Hand-rolled EventEnvelope framing (irpc never integrated) | [ADR-014](decisions/064-irpc-never-integrated-hand-rolled-framing.md) | Hand-rolled framing, registry, dispatch; supersedes ADR-013 |
| Call protocol stream model | [ADR-015](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-019 (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 |
| Secret material flow and capability injection | [ADR-010](decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
| Privilege model and authority context | [ADR-017](decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
| Handler registration, provenance, and composition authority | [ADR-018](decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Operation registry layering | [ADR-019](decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| Operation error schemas | [ADR-016](decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
| Call protocol client and adapter contract | [ADR-022](decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
| `from_jsonschema` as HTTP-backed single-endpoint adapter | [ADR-027](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from `alknet-call` (broken schema-only placeholder) to `alknet-http` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alknet-call` as a leaf (now handler-bearing, not "no handler") |
| Peer-graph routing model (supersedes ADR-023) | [ADR-024](decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
| Forwarded-for identity | [ADR-026](decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
| Streaming handler for subscriptions | [ADR-021](decisions/049-streaming-handler-for-subscriptions.md) | `StreamingHandler` type alongside `Handler`; `HandlerKind` enum on `HandlerRegistration` validated against `op_type`; `invoke_streaming()` on `OperationRegistry`; `invoke()` and `OperationEnv::invoke()` error with `INVALID_OPERATION_TYPE` on `Subscription` ops; composition stays request/response-only, stream composition is handler-level |
| Dynamic resource ownership for runtime-spawned resources | [ADR-011](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | `AccessControl::check` consults an `OwnershipProvider` (sync read trait, ADR-033 repo/adapter pattern); `OperationSpec` gains `resource_id_path` (JSON pointer into the input); proxy-only access pattern (spawner owns, proxy to share, teardown revokes); `list` = scope-gate + result-filter; teardown = automatic, handler-driven; composition = two orthogonal checks, ADR-017/022 unchanged |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-16** (resolved by ADR-010): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-018) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-019 to cover connection-scoped overlays as well.
- **OQ-25** (dissolved by ADR-024): `remote_safe` marking shape — moot.
`remote_safe`/`trusted_peer` are retired; peer authorization is
`AccessControl::check(peer_identity)`, the existing mechanism. See
[client-and-adapters.md](client-and-adapters.md) and ADR-024 §3.
- **OQ-26** (resolved): `OperationAdapter` error type — `AdapterError`
variants: `DiscoveryFailed`, `SchemaParse`, `Transport`, `Unauthorized`,
`SamePeerCollision` (replaces flat `Conflict`). `#[non_exhaustive]`. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-27** (resolved): `from_call` re-import trigger — `from_call` is a
manual free function; the assembly layer calls it after the dial (in
`AlknetClient`). A `CallConnection::refresh()` method is a genuine
feature addition — non-breaking, additive. See
[ADR-028](decisions/069-from-call-manual-free-function.md).
- **OQ-28** (resolved): `from_call` namespace collision — same-peer
collision = error; cross-peer dissolved by ADR-024 (separate sub-overlays).
`namespace_prefix` is optional local-naming sugar. See
[client-and-adapters.md](client-and-adapters.md).
- **OQ-29..37** are tracked in [client-and-adapters.md](client-and-adapters.md)
(they concern the `CallClient` / adapter surface and peer-graph routing,
not the registry layering this document covers). In brief: OQ-29
(CallClient TLS client-auth) resolved; OQ-30 (`PeerRef::Any` routing
policy) resolved; OQ-31 (`services/list-peers` re-export semantics)
resolved; OQ-32 (multi-hop federation) open (feature extension);
OQ-33 (PeerId source) resolved by ADR-025; OQ-34 (persistent peer
registry) resolved by ADR-025+033; OQ-35 (API key asymmetry) dissolved;
OQ-36 (concrete persistence adapter shapes) resolved by ADR-035;
OQ-37 (X.509 outgoing-only) resolved by ADR-034.
- **OQ-42** (resolved by ADR-011): Dynamic resource ownership for
runtime-spawned resources — `AccessControl::check` consults an
`OwnershipProvider`; `OperationSpec` gains `resource_id_path`; proxy-only
access pattern; four edge specifics pinned (`list`, teardown, fleet,
composition). See [auth.md](../core/auth.md) §"Ownership Provider and
Store" for the trait shapes.
## References
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
- ADR-014: Hand-rolled EventEnvelope framing (irpc never integrated; supersedes ADR-013)
- ADR-008: Vault integration point
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-019 for the distinction)
- ADR-015: Call protocol stream model
- ADR-019: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
- ADR-024: Peer-graph routing model (peer-keyed overlays + `PeerRef` routing; `PeerCompositeEnv` supersedes the singular-connection `CompositeOperationEnv`)
- ADR-025: PeerEntry and Identity.id decoupling (`PeerId` source = `Identity.id` = `PeerEntry.peer_id`)
- ADR-026: Forwarded-for identity (`forwarded_for` on `OperationContext` and `call.requested`; metadata only)
- ADR-021: Streaming handler for subscriptions (`StreamingHandler`, `HandlerKind`, `invoke_streaming()`, `INVALID_OPERATION_TYPE`)
- ADR-011: Dynamic resource ownership for runtime-spawned resources (`OwnershipProvider` consulted by `AccessControl::check`; `OperationSpec.resource_id_path`; proxy-only access pattern; composition = two orthogonal checks, ADR-017/022 unchanged)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`