From 7cd8a57bc7c5bc3697fbf3db73fe82fe9aa5c653 Mon Sep 17 00:00:00 2001 From: deepseek-v4-pro Date: Fri, 14 Aug 2026 09:25:18 +0000 Subject: [PATCH] docs: roles, composition, and dependency layering for downstream crates - docs/architecture/README.md: add Roles and Composition section with the four roles (producer, consumer, hub, spoke), dependency layering diagram, protocol crate pattern, and the two-path adapter model - docs/architecture/channels-overview.md: fix stale alknet ADR numbers (071/072/073/074/075/076/077/078/079/080/081 -> 034-044), update relationship section for post-extraction world, update crate dependencies to reflect single-crate alkcall - src/lib.rs: add Downstream composition section with role table and protocol crate pattern, linking to the architecture README Verification: cargo test (483 passed), cargo clippy (clean), cargo fmt (clean), cargo doc (no warnings) --- docs/architecture/README.md | 127 +++++++++++++++++ docs/architecture/channels-overview.md | 188 +++++++++++-------------- src/lib.rs | 39 +++++ 3 files changed, 250 insertions(+), 104 deletions(-) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 863a1e8..23fbed6 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -140,6 +140,133 @@ questions affecting this crate: 10. **Wire formats are stable**: EventEnvelope shape and the 8-byte chunk header are one-way doors. See ADR-014, ADR-034. +## Roles and Composition + +alkcall is a pure protocol crate — no networking, no transport +dependencies. It provides the call and channels protocols as a library. +Downstream crates compose on top of it in a layered dependency chain. + +### The four roles + +| Role | Definition | Call protocol | Channels protocol | +|------|-----------|---------------|-------------------| +| **Producer** | Provides a resource consumers can use (often called "server") | Registers ops on an `OperationRegistry`, runs a `Dispatcher` to handle incoming `call.requested` | Runs a `ChannelsAdapter`, registers openable ALPNs via `ChannelCore::register_openable` | +| **Consumer** | Consumes a resource from a producer (often called "client") | Uses `CallConnection` to call ops, uses `from_call` to discover/import remote ops | Uses `ChannelClient` to open channels via `call_open_op` + `open_channel` | +| **Hub** | Central location spokes connect to; relays and routes between them | Both: runs a `Dispatcher` for ops it produces, holds `CallConnection`s to spokes for ops it consumes. Relays calls via `OperationEnv` peer routing | Both: runs a `ChannelsAdapter` for inbound connections, holds `ChannelClient`s to spokes. Relays data channels with `channel_id` rewrite (ADR-042) | +| **Spoke / Worker** | Connects to a hub; provides and consumes resources | Both: produces ops (its own services), consumes hub ops (e.g. `services/list` to discover peers) | Both: produces channels (TTY, tunnel), may consume hub channels | + +A single process can be a producer of some ops, a consumer of others, a +channel opener for TTY, and a channel acceptor for tunnels — all on the +same `alknet/channels` connection. The types don't encode the system +role; they just don't prevent any combination. + +Within a single connection, direction is independent of role: + +- **Call initiator** / **call responder** — who sent `call.requested` vs + who handles it. Either side can initiate on any connection. +- **Channel opener** / **channel acceptor** — who called the per-ALPN + open op vs who allocated the `channel_id`. The connection owner + allocates (ADR-047 §5); either side can open. + +### Dependency layering + +``` +┌──────────────────────────────────────────────┐ +│ alknet / alknode │ +│ (networking + composition) │ +│ │ +│ • QUIC / TCP+TLS dial and accept │ +│ • Hub: ChannelsAdapter + Dispatcher + │ +│ peer routing (PeerCompositeEnv) │ +│ • Spoke: ChannelClient + CallConnection + │ +│ from_call │ +│ • Wires protocol crates' producers into │ +│ registries, consumers into clients │ +└──────────────────┬───────────────────────────┘ + │ depends on + ┌──────────────┼──────────────┐ + │ │ │ +┌───┴───────┐ ┌────┴─────┐ ┌─────┴──────────┐ +│ alktty │ │alktunnels│ │ alktrader │ +│ (protocol)│ │(protocol)│ │ (protocol) │ +│ │ │ │ │ │ +│ • Session │ │ • Tunnel │ │ • Backend trait │ +│ • Backend │ │ • Backend│ │ • register_ops()│ +│ • 5-byte │ │ • bytes │ │ • TypedClient │ +│ wire │ │ • OpenH. │ │ │ +│ • OpenH. │ │ • reg_*()│ │ │ +│ • reg_*() │ │ │ │ │ +└───┬───────┘ └────┬─────┘ └─────┬───────────┘ + │ │ │ + └──────────────┼──────────────┘ + │ depends on +┌──────────────────┴───────────────────────────┐ +│ alkcall │ +│ (call + channels protocols, no networking) │ +│ │ +│ • Connection, BiStream, ProtocolHandler │ +│ • OperationRegistry, OperationSpec, Handler │ +│ • CallConnection, Dispatcher, from_call │ +│ • ChannelClient, ChannelsAdapter, │ +│ ChannelManager, ChannelCore │ +└───────────────────────────────────────────────┘ +``` + +**Protocol crates** (alktty, alktunnels, alktrader) depend only on +alkcall. They provide two halves: + +1. **Producer half** — a `register_*()` function that takes an + `&mut OperationRegistry` and registers ops with their handlers. For + channels-based protocols, an `OpenHandler` factory. The crate doesn't + know whether it's running on a hub, a spoke, or a standalone process. + +2. **Consumer half** — a typed client wrapper around `CallConnection` + (or `ChannelClient`) that exposes the crate's ops as async methods + (e.g. `trader_client.status().await` instead of + `call_connection.call("trader/status", ...).await`). + +**alknet/alknode** depends on alkcall + whichever protocol crates are +needed. It's the composition layer — the only place that knows about +network topology, peer routing, and which protocol crates are wired in. +Protocol crates never import a QUIC or TLS dependency. + +### Pattern for protocol crates + +A protocol crate that uses channels (e.g. alktty) follows this pattern: + +``` +┌─────────────────────────────────────────┐ +│ alktty (protocol crate, no network) │ +│ │ +│ TtySession { │ +│ drive(send, recv, backend) -> ExitCode │ ← pure protocol, takes +│ } │ AsyncRead + AsyncWrite +│ │ +│ TtyBackend trait │ +│ NegotiateRequest / ControlMessage │ +│ ChunkReader / ChunkWriter (5-byte) │ +└─────────────────────────────────────────┘ + ▲ ▲ + │ │ +┌────────┴────────┐ ┌───────┴──────────────┐ +│ Direct ALPN │ │ Through channels │ +│ (alknet/tty) │ │ (alknet/channels) │ +│ │ │ │ +│ TtyAdapter │ │ OpenHandler │ +│ impl Protocol │ │ (registered via │ +│ Handler │ │ ChannelCore:: │ +│ │ │ register_openable) │ +│ Gets Connection │ │ │ +│ loops accept_bi │ │ Gets BiStream per │ +│ │ │ session │ +└─────────────────┘ └───────────────────────┘ +``` + +The protocol crate doesn't know which path it's on. It takes a +`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The +two adapters are thin and live either in the protocol crate (behind +feature flags) or in the downstream alknet crate. + ## References - `@alkdev/alknet: docs/architecture/` — the source architecture docs diff --git a/docs/architecture/channels-overview.md b/docs/architecture/channels-overview.md index aa38257..0919d50 100644 --- a/docs/architecture/channels-overview.md +++ b/docs/architecture/channels-overview.md @@ -120,50 +120,38 @@ design. ## Crate dependencies +alkcall is a single crate with two internal subsystems (call + channels). +The channels subsystem is split into two modules: + +- **`channels` (core)** — the pure multiplexer: wire format, demux/mux, + `ChannelBidiStreamSource`, `ChannelManager`. ALPN-blind, + call-protocol-blind, transport-blind. This is where the "streams are + streams" insight lives. +- **`channels` (call coupling)** — channel 0 pre-negotiation as + `alknet/call` (ADR-036), the lifecycle operations (ADR-037) registered + on the call protocol's `OperationRegistry`, `ChannelCore` (ADR-047 §3), + and `ChannelClient` (ADR-043). This is where the call-protocol coupling + lives, isolated from the pure multiplexer. + +Downstream crates depend on alkcall as a single dependency: + ``` -alknet-channels-core -├── alknet-core (ProtocolHandler, Connection, HandlerRegistry, -│ BidiStreamSource, BiStream, AuthContext) -├── tokio (spawn, mpsc, io) -├── bytes (Bytes for chunk payloads) -├── async-trait -├── thiserror -└── tracing +alktty / alktunnels / alktrader (protocol crates) +└── alkcall (call + channels protocols, no networking) -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 / alknode (networking + composition) +├── alkcall +├── alktty (optional — whichever protocol crates are wired in) +├── alktunnels (optional) +└── quinn / tokio-rustls (transport) ``` -`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. +The hub and worker are **consumers** of alkcall, not sub-crates. The +downstream alknet crate IS the hub — it depends on alkcall and uses +channels as its substrate, with the relay logic (ADR-042) living +alongside its 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. ## ALPN @@ -209,62 +197,54 @@ 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 +## Relationship to downstream crates + +alkcall is a pure protocol crate — no networking, no transport +dependencies. Downstream crates compose on top of it. See +[README.md](README.md) §"Roles and Composition" for the full dependency +layering and the producer/consumer/hub/spoke role definitions. + +### Protocol crates (alktty, alktunnels, etc.) + +Protocol crates depend only on alkcall. They provide: + +- **Producer half** — a `register_*()` function that takes an + `&mut OperationRegistry` and registers ops with their handlers. For + channels-based protocols, an `OpenHandler` factory registered via + `ChannelCore::register_openable`. +- **Consumer half** — a typed client wrapper around `CallConnection` + (or `ChannelClient`) that exposes the crate's ops as async methods. + +A protocol crate can support two paths for its wire format: + +| Path | How it connects | Wire format | +|------|----------------|-------------| +| Direct ALPN | `ProtocolHandler` on its own ALPN (e.g. `alknet/tty`), gets a `Connection`, loops `accept_bi` | Own wire format (e.g. TTY's 5-byte header) | +| Through channels | Registered via `ChannelCore::register_openable`, gets a `BiStream` per session | Own wire format rides inside channels 8-byte payload | + +The protocol crate doesn't know which path it's on — it takes a +`BiStream` (or `AsyncRead + AsyncWrite`) and drives the session. The +two adapters are thin and live either in the protocol crate (behind +feature flags) or in the downstream alknet crate. ### 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. +The call protocol runs on channel 0 exactly as on a top-level +`alknet/call` connection. The `Dispatcher` 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-hub / alknode -### 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). +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 per-ALPN open ops 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 @@ -272,18 +252,18 @@ 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 | +| [034](decisions/034-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 | +| [035](decisions/035-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 | +| [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`; subscribe not poll; `direction` pinned | +| [038](decisions/038-channelconnection-bidistreamsource.md) | ChannelConnection | Per-channel `BidiStreamSource`; yield-once `accept_bi` (amended by ADR-035 — `into_sub_streams` removed) | +| [039](decisions/039-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 | +| [040](decisions/040-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer (1 MiB), 256-channel cap, monotonic IDs | +| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy`; per-connection `max_channels` reframed as a memory bound | +| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels with ID rewrite | +| [043](decisions/043-channelclient.md) | ChannelClient | Client side; transport-agnostic `from_connection` primary; dial lives in `AlknetClient` (ADR-045, resolves OQ-55) | +| [044](decisions/044-channels-subcrate-decomposition.md) | Sub-Crate Decomposition | `channels-core` (pure multiplexer) / `channels-call` (call coupling + ChannelClient); hub and worker are consumers | +| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops; `ChannelCore` wrapper; connection-owner allocates `channel_id` | ## Open Questions @@ -294,7 +274,7 @@ Key questions affecting this crate: — 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). + [ADR-045](decisions/045-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. diff --git a/src/lib.rs b/src/lib.rs index f5842b7..bf5d0e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,45 @@ //! pre-negotiated as `alknet/call`; channels 1..N are opened via //! per-ALPN open ops (`channels//sub`, `channels//pub`) //! on channel 0 (ADR-047). +//! +//! ## Downstream composition +//! +//! alkcall is a pure protocol crate — no networking, no transport +//! dependencies. Downstream crates compose on top of it in a layered +//! dependency chain. See `docs/architecture/README.md` §"Roles and +//! Composition" for the full layering diagram and role definitions. +//! +//! ### The four roles +//! +//! | Role | Call protocol | Channels protocol | +//! |------|---------------|-------------------| +//! | **Producer** | Registers ops on an `OperationRegistry`, runs a `Dispatcher` | Runs a `ChannelsAdapter`, registers openable ALPNs via `ChannelCore::register_openable` | +//! | **Consumer** | Uses `CallConnection` to call ops, uses `from_call` to discover/import remote ops | Uses `ChannelClient` to open channels via `call_open_op` + `open_channel` | +//! | **Hub** | Both: runs a `Dispatcher` for ops it produces, holds `CallConnection`s to spokes for ops it consumes | Both: runs a `ChannelsAdapter` for inbound connections, holds `ChannelClient`s to spokes | +//! | **Spoke / Worker** | Both: produces ops (its own services), consumes hub ops | Both: produces channels (TTY, tunnel), may consume hub channels | +//! +//! A single process can be a producer of some ops, a consumer of others, +//! a channel opener for TTY, and a channel acceptor for tunnels — all on +//! the same `alknet/channels` connection. +//! +//! ### Pattern for protocol crates +//! +//! A protocol crate (e.g. alktty, alktunnels) depends only on alkcall +//! and provides two halves: +//! +//! 1. **Producer half** — a `register_*()` function that takes an +//! `&mut OperationRegistry` and registers ops with their handlers. +//! For channels-based protocols, an `OpenHandler` factory registered +//! via [`channels::operations::ChannelCore::register_openable`]. +//! 2. **Consumer half** — a typed client wrapper around +//! [`protocol::connection::CallConnection`] (or +//! [`channels::client::ChannelClient`]) that exposes the crate's ops +//! as async methods. +//! +//! The protocol crate doesn't know whether it's running on a hub, a +//! spoke, or a standalone process. The networking + composition layer +//! (alknet/alknode) wires protocol crates' producers into registries +//! and consumers into clients. pub mod channels; pub mod client;