docs(arch): remove channels from hub spec — channels are research-phase, not specced

The channels concept (docs/research/alknet-channels/phase-0-findings.md)
was developed in a separate session and is research-phase, not
architecture. The hub spec was inadvertently built assuming a channel
model that doesn't exist yet.

Changes:
- Remove all channels references from hub README (subtitle, channel
  model section, channel management bullet, channel proxying section,
  'does NOT do' bullet, references)
- Remove OQ-55 (channel/open operation) — channels research has its
  own question tracking (OQ-CH-01 through OQ-CH-07)
- Hub spec now describes what it actually provides: peer lifecycle,
  aggregated operation env, service discovery. One QUIC connection
  per peer carrying the call protocol. No channels, no future
  multiplexing, no research references.
This commit is contained in:
deepseek-v4-pro committed 2026-07-11 07:52:02 +00:00
1 parent 5d56bae1ba
commit deea6de38a
3 files changed
+22 -217

No files matched your search

+22 -164
View File
@@ -6,9 +6,8 @@ last_updated: 2026-07-09
# alknet-hub
The hub pattern as a reusable crate: peer lifecycle management, connection
supervision, operation aggregation, and channel management across connected
peers. One QUIC connection per peer; channel 0 carries the call protocol as
the universal control plane; channels 1..N carry any ALPN as data planes.
supervision, operation aggregation, and service discovery across connected
peers. One QUIC connection per peer carrying the call protocol.
## What
@@ -18,52 +17,17 @@ not introduce new types — it wires the existing `PeerCompositeEnv`,
`CallClient`, `CallAdapter`, `from_call`, and `Dispatcher` types into a
coherent hub runtime.
### The channel model
A hub-worker connection is a single QUIC connection. QUIC multiplexes
multiple bidirectional streams over one connection. The hub uses this
multiplexing to carry multiple protocols over one connection:
- **Channel 0 — call protocol (control plane).** Always `alknet/call`. This is
the bootstrap channel. It handles operation discovery (`from_call`),
operation routing (`invoke_peer`), service discovery (`services/list`,
`services/list-peers`), and channel negotiation (`channel/open`,
`channel/close`). Every hub-worker connection has channel 0.
- **Channels 1..N — any ALPN (data planes).** Opened via `channel/open` on
the call protocol. Each channel is a bidirectional QUIC stream, wrapped as
a `Connection` via `Connection::from_bidi` (ADR-065), and handed to the
same `ProtocolHandler::handle(connection, auth)` that handles dedicated
connections. The handler does not know it's on a multiplexed channel — it
sees a `Connection` and dispatches normally.
Channel negotiation is symmetric — either side can open a channel. A hub can
open a tty channel to a worker (hub wants a terminal on the worker), or a
worker can open a tty channel to the hub (worker exposes a terminal to the
hub). Same pattern as `from_call`: bidirectional, symmetric, negotiated over
the call protocol.
The hub's role for channels 1..N is **channel management** — tracking which
channels are open on which peer, answering `channel/list`, and rejecting
duplicate `channel/open` requests. The hub does not interpret the protocol
running on the channel and does not proxy streams. What the caller does with
the resulting channel is the assembly layer's business.
### What the hub provides
1. **Peer lifecycle** — dial, accept, disconnect, reconnect with backoff.
Identity resolution via `IdentityProvider`. One connection per peer.
2. **Aggregated operation env** — a shared `PeerCompositeEnv` across all
calls (ADR-067). Operations discovered via `from_call` on channel 0 are
registered in each peer's connection overlay and aggregated into the
shared env. `invoke_peer` routes operation calls to the right peer.
calls (ADR-067). Operations discovered via `from_call` are registered in
each peer's connection overlay and aggregated into the shared env.
`invoke_peer` routes operation calls to the right peer.
3. **Channel management** — tracking which channels are open on which peer.
`channel/open` and `channel/close` operations on the call protocol.
Channel tracking for channels 1..N.
4. **Service discovery** — `services/list-peers` returns each connected
3. **Service discovery** — `services/list-peers` returns each connected
worker's operation list via `PeerCompositeEnv::peer_operations` (ADR-068).
## Why
@@ -92,7 +56,7 @@ independently, `alknet-hub` provides it once, as a reusable crate.
### Hub struct
The `Hub` is the central type. It owns the aggregated `PeerCompositeEnv`, the
`OperationRegistry`, the `Dispatcher`, and channel tracking:
`OperationRegistry`, and the `Dispatcher`:
```rust
pub struct Hub {
@@ -100,13 +64,6 @@ pub struct Hub {
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
dispatcher: Dispatcher,
identity_provider: Arc<dyn IdentityProvider>,
/// Open channels per peer: PeerId → set of channel numbers.
/// Channel 0 (call protocol) is tracked implicitly via the
/// CallConnection; channels 1..N are tracked here. The hub tracks
/// which channels exist so it can answer channel/list and reject
/// duplicate channel/open requests. The hub does not hold the
/// channel's Connection — the ProtocolHandler owns it.
channels: Arc<RwLock<HashMap<PeerId, HashSet<u32>>>>,
}
```
@@ -129,7 +86,6 @@ impl Hub {
aggregated_env,
dispatcher,
identity_provider,
channels: Arc::new(RwLock::new(HashMap::new())),
}
}
@@ -145,12 +101,11 @@ impl Hub {
The `Hub` exposes builder methods for optional hooks (`with_session_source`,
`with_ownership_provider`, `with_timeout`) that delegate to the `Dispatcher`.
### Peer lifecycle (channel 0 bootstrap)
### Peer lifecycle
Every hub-worker connection starts with channel 0 — the call protocol. The
hub establishes channel 0, runs `from_call` to discover the peer's operations,
and attaches the peer to the aggregated env. After channel 0 is established,
either side can open additional channels via `channel/open`.
Every hub-worker connection carries the call protocol. The hub establishes the
connection, runs `from_call` to discover the peer's operations, and attaches
the peer to the aggregated env.
#### Inbound workers (workers dial the hub)
@@ -163,8 +118,8 @@ registers the bundles, and attaches the peer to the aggregated env:
/// Callback invoked by CallAdapter::handle when a worker connects inbound.
/// Fires after identity resolution and before the dispatch loop starts.
/// Carries both on_connected (runs from_call, registers discovered ops,
/// attaches peer to aggregated env) and on_disconnected (detaches peer,
/// drops channel tracking on run_loop exit).
/// attaches peer to aggregated env) and on_disconnected (detaches peer
/// on run_loop exit).
pub struct WorkerConnectedCallback {
hub: Arc<Hub>,
config: FromCallConfig,
@@ -204,10 +159,6 @@ The callback is wired into `CallAdapter` via a new builder method:
```rust
impl CallAdapter {
/// Register a callback invoked when a worker connects inbound.
/// Fires after identity resolution and before the dispatch loop.
/// The callback runs from_call, registers discovered operations,
/// and attaches the peer to the aggregated env.
pub fn with_worker_connected_callback(
mut self,
callback: WorkerConnectedCallback,
@@ -226,7 +177,7 @@ The `CallAdapter::handle` flow becomes:
bundles, attaches peer to aggregated env.
4. Run the dispatch loop (`run_loop`).
5. On `run_loop` exit, invoke `on_disconnected` on the callback — detaches
peer from aggregated env, drops channel tracking.
peer from aggregated env.
The assembly layer constructs the callback and passes it to `CallAdapter`:
@@ -237,11 +188,10 @@ let adapter = CallAdapter::new(registry, identity_provider)
.with_worker_connected_callback(callback);
```
> **Note on OQ-54**: The callback approach replaces the earlier "explicit
> post-hoc call" design. The callback fires inside `handle()` because
> `handle()` blocks until disconnect — there is no "after handle accepts"
> point for the assembly layer to hook into. The callback is the committed
> design; OQ-54 is resolved.
> **Note on OQ-54**: The callback fires inside `handle()` because `handle()`
> blocks until disconnect — there is no "after handle accepts" point for the
> assembly layer to hook into. The callback is the committed design; OQ-54 is
> resolved.
#### Outbound workers (hub dials workers)
@@ -252,9 +202,9 @@ configurable backoff:
```rust
impl Hub {
/// Dial a worker, establish channel 0 (call protocol), discover its
/// Dial a worker, establish the call-protocol connection, discover its
/// operations, and attach it to the aggregated env. Returns the
/// worker's PeerId and the live CallConnection (channel 0).
/// worker's PeerId and the live CallConnection.
pub async fn dial_worker(
&self,
addr: SocketAddr,
@@ -282,17 +232,13 @@ impl Hub {
Ok((peer_id, connection))
}
/// Detach a peer from the aggregated env and drop all channel tracking.
/// Called on disconnect (both inbound and outbound).
/// Detach a peer from the aggregated env. Called on disconnect
/// (both inbound and outbound).
fn on_worker_disconnected(&self, peer_id: &PeerId) {
self.aggregated_env
.write()
.expect("aggregated env lock poisoned")
.detach_peer(peer_id);
self.channels
.write()
.expect("channels lock poisoned")
.remove(peer_id);
}
/// Supervise an outbound worker: dial, discover, attach. On disconnect,
@@ -345,73 +291,6 @@ impl Hub {
}
```
### Channel negotiation (channels 1..N)
After channel 0 is established, either side can open additional channels via
the call protocol. The hub registers `channel/open`, `channel/close`, and
`channel/list` as built-in operations on its curated registry.
#### channel/open
Opens a new bidirectional stream on the QUIC connection, wraps it as a
`Connection` via `Connection::from_bidi`, and hands it to the
`HandlerRegistry` for the requested ALPN. The handler runs on a spawned task;
the channel is tracked until closed.
```
Request: { "alpn": "alknet/tty", "channel": 1 }
Response: { "channel": 1, "status": "open" }
```
The `channel` field is a hint — the caller suggests a channel number. The
receiver may accept it or assign a different number. Channel 0 is reserved
for the call protocol.
The operation is symmetric: either side can call it. When the hub calls
`channel/open` on a worker, the worker opens the stream and dispatches to its
handler. When a worker calls `channel/open` on the hub, the hub opens the
stream and dispatches to its handler.
#### channel/close
Closes a previously opened channel. Drops the `Connection`, which cancels the
handler task and releases resources.
```
Request: { "channel": 1 }
Response: { "channel": 1, "status": "closed" }
```
#### channel/list
Lists open channels on this connection. Channel 0 (call protocol) is always
present and always listed.
```
Response: { "channels": [{ "channel": 0, "alpn": "alknet/call" }, { "channel": 1, "alpn": "alknet/tty" }] }
```
#### Channel usage
`channel/open` is a call-protocol operation. The hub provides it. What the
caller does with the resulting channel is the caller's business — the hub
crate does not prescribe a specific proxying pattern.
The general mechanism: when `channel/open` is invoked, the receiver opens a
new QUIC stream on the connection, wraps it as `Connection::from_bidi(stream,
alpn, ...)`, and hands it to the `HandlerRegistry` for the requested ALPN.
The handler runs on the receiver side. The caller gets back a channel number
and can interact with the handler through the call protocol (e.g. by invoking
operations that the handler registered, or by opening a reciprocal channel).
A common pattern (but not the only one): a hub client wants a tty session on
a worker. The hub calls `channel/open { alpn: "alknet/tty" }` on the worker.
The worker opens the stream, wraps it, and hands it to `TtyAdapter::handle()`.
The hub now has a tty session running on the worker. The hub can proxy the
raw stream to the client, or expose tty operations through the call protocol,
or both. The hub crate provides the `channel/open` mechanism; the assembly
layer decides the proxying pattern.
### Backoff configuration
```rust
@@ -460,12 +339,6 @@ pub enum HubError {
Discovery(#[from] AdapterError),
#[error("call client error: {0}")]
Client(#[from] ClientError),
#[error("channel {channel} already open on peer {peer}")]
ChannelAlreadyOpen { peer: PeerId, channel: u32 },
#[error("channel {channel} not found on peer {peer}")]
ChannelNotFound { peer: PeerId, channel: u32 },
#[error("channel {channel} open failed: {reason}")]
ChannelOpenFailed { channel: u32, reason: String },
}
```
@@ -486,10 +359,6 @@ pub enum HubError {
- **Worker provisioning.** The hub does not spawn workers, configure them, or
manage their lifecycle beyond connection supervision. Worker provisioning
is an assembly-layer concern.
- **Protocol interpretation for channels 1..N.** The hub manages channels
(tracking, listing, open/close). It does not interpret the protocol
running on the channel and does not proxy streams. What the caller does
with the resulting channel is the assembly layer's business.
## Crate dependencies
@@ -515,9 +384,6 @@ A downstream hub (alkapi) uses `alknet-hub` like this:
let registry = OperationRegistryBuilder::new()
.with_local(agent_chat_spec(), agent_chat_handler, ...)
.with_local(services_list_spec(), services_list_handler, ...)
.with_local(channel_open_spec(), channel_open_handler, ...)
.with_local(channel_close_spec(), channel_close_handler, ...)
.with_local(channel_list_spec(), channel_list_handler, ...)
// ... other curated ops
.build();
let registry = Arc::new(registry);
@@ -559,7 +425,6 @@ so the assembly layer can wire it into `CallAdapter::with_aggregated_env`.
| from_call is manual | [ADR-069](../../decisions/069-from-call-manual-free-function.md) | `from_call` is a free function; the hub calls it after connect |
| Peer-graph routing model | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays, `PeerRef` routing, `AccessControl`-based peer auth |
| PeerEntry and Identity.id | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable) |
| Connection::from_stream / from_bidi | [ADR-065](../../decisions/065-connection-from-stream-generic-single-stream.md) | Channel streams wrap as `Connection`; any `ProtocolHandler` consumes them unchanged |
## Open Questions
@@ -577,12 +442,6 @@ See [open-questions.md](../../open-questions.md) for full details.
start. `handle()` blocks until disconnect, so there is no "after handle
accepts" point for the assembly layer to hook into. The
`WorkerConnectedCallback` is the committed design.
- **OQ-55** (open): `channel/open` operation — this is a new call-protocol
operation. It needs a spec in `alknet-call` (the operation shape, the
stream-opening mechanism, the handler dispatch). The hub registers it as a
built-in operation; the implementation lives in `alknet-call`. The channel
model is committed; the operation spec is deferred to the call-protocol
implementation phase.
## References
@@ -593,7 +452,6 @@ See [open-questions.md](../../open-questions.md) for full details.
- [operation-registry.md](../call/operation-registry.md) — `OperationRegistry`,
`OperationRegistryBuilder`, `OperationEnv`
- ADR-029: Peer-Graph Routing Model
- ADR-065: `Connection::from_stream` — Generic Single-Stream Connections
- ADR-067: Aggregated Peer-Environment Wiring
- ADR-068: PeerCompositeEnv::peer_operations Override
- ADR-069: from_call Is a Manual Free Function