OQ-59 resolved to Option A: fingerprint.rs stays in alknet-core. The
client-side FingerprintPinVerifier in alknet-call uses fingerprint
functions and must not depend on alknet-tls (which would pull TLS setup
infra into client-only deployments). The rustls dep in core is narrow —
production fingerprint code uses only sha2 + manual DER parsing; the
rustls::sign usage is a test helper only. alknet-tls re-exports the
fingerprint functions for convenience.
ADR-084: aws-lc-rs as the TLS crypto provider on all server + client
config paths. Records the decision that was already in the code (to
match iroh's tls-aws-lc-rs feature) but had no ADR. FIPS-capable, broad
platform support, consistent across quinn/iroh/TCP+TLS/client. Switching
to ring or process-default requires a new ADR. ADR-082's
behavior-preservation invariant now references ADR-084 for the decision
record.
ADR-083 revised: TCP+TLS moves from an external sibling loop calling
public dispatch to a first-class owned transport via
with_tcp_tls(listener, acceptor), running inside run() alongside the
quinn and iroh accept loops. The endpoint owns all its accept loops;
shutdown() stops them all. The multi-owner shutdown problem (OQ-61)
does not arise — dissolved.
The reason TCP+TLS was structurally excluded (ADR-010 Am. 1: the
endpoint built transports internally, TCP+TLS couldn't fit) is gone
after ADR-083 — the endpoint no longer builds transports; it runs
accept loops on whatever it's given. TCP+TLS is a listener transport,
same shape as quinn and iroh. ADR-010 Amendment 2 supersedes Am. 1's
struct-level exclusion.
dispatch stays public — but for genuinely external shapes (SSH channels,
future WebTransport streams), which are connection-internal multiplexing,
not listener transports. The listener-vs-multiplexing distinction is now
explicit.
OQ-60 resolved: the TCP+TLS loop lives in alknet-core behind a tcp
feature (owned by the endpoint); builder functions are inlined by the
assembly layer. A alknet-transport crate was rejected — it would contain
only trivial builders; the real component (the loop) is in core. Hub-
specific composition lives in the hub crate; transport runtimes that any
node might need live in core.
Updated: ADR-010 (Amendment 2), ADR-082 (TCP+TLS loop location), ADR-083
(revised), core/endpoint.md (struct + dispatch + shutdown), hub/README.md
(transport table + assembly example + stale sibling references),
tls/README.md (endpoint section + TCP+TLS loop location + references),
open-questions.md (OQ-60 resolved, OQ-61 dissolved).
Review: zero critical issues, five warnings fixed (stale hub README
prose, stale core endpoint.md struct/dispatch listings, stale ADR-082
TCP+TLS loop location, stale TLS README reference entry, hub front-matter
date).
ADR-083: AlknetEndpoint becomes a pure accept-loop runner with a public
dispatch method. Transport construction moves out of the endpoint — the
assembly layer reads StaticConfig, builds transports from
TlsServerConfig(s), and hands pre-built quinn/iroh endpoints to the
endpoint via with_quinn/with_iroh. TCP+TLS dispatch is first-class (same
dispatch path as quinn/iroh); ADR-010 Amendment 1's duplicated-dispatch
workaround is retired. StaticConfig stays in core as the assembly-layer
config; the endpoint takes only drain_timeout.
The acme-tls/1 guard moves from dispatch_quinn to the shared dispatch
method — ACME TLS-ALPN-01 challenges arrive over TCP (CAs validate via
TCP to port 443, not QUIC), so the guard's quinn-specific location was a
latent bug once TCP+TLS exists. The guard is transport-agnostic; the
rationale (no handler, silent close) is unchanged. ADR-027 §5 amended.
OQ-60: where build_iroh_endpoint lives (assembly layer / alknet-tls
helper / transport module). build_quinn_server_config_from_rustls is
decided — it moves to alknet-tls as for_quinn() per ADR-082; only
build_iroh_endpoint is genuinely undecided.
OQ-61: multi-owner shutdown coordination. Boundary committed (endpoint
owns dispatched handlers; assembly layer owns spawned accept loops);
mechanism open.
ADR-082 amended: drops the Arc<TlsServerConfig> endpoint signature
(superseded by ADR-083); keeps its scope as the alknet-tls crate's
TlsServerConfig + accessors.
Review: zero critical issues, five warnings fixed (build_iroh_endpoint
destination contradiction, undeclared shutdown_sender, missing ADR-027
amendment marker, underspecified dispatch no-match behavior, stale
door-type timing clause).
The endpoint refactor findings doc relegated TCP+TLS to a 'sibling
accept loop' that duplicated the endpoint's dispatch logic at the
assembly layer. That was ADR-010 Amendment 1's workaround for the
endpoint being welded to quinn — not a deliberate design.
The endpoint now exposes a public dispatch() method so every transport
(quinn, iroh, TCP+TLS, future SSH, future WebTransport) calls the same
dispatch path: handler lookup, build_auth_context, spawn. The
transport-specific parts (ALPN extraction, fingerprint extraction,
Connection construction, no-handler close) happen before dispatch is
called. No duplicated logic at the assembly layer.
Also: the acme-tls/1 guard is quinn-specific (ACME challenges arrive
over QUIC); a TCP+TLS listener serving HTTPS does not advertise
acme-tls/1. build_auth_context becomes a private helper called by
dispatch, not a standalone function.
Captures the full picture of the AlknetEndpoint refactor interleaved
with the alknet-tls extraction:
- AlknetEndpoint becomes a pure accept-loop runner (no transport
construction, no StaticConfig, no tls_identity reading)
- A hub holds one or two TlsServerConfigs (raw key + X.509), not one;
cert-reuse is within each identity, shared across that identity's
transports
- TCP fallback for native clients uses the raw-key config (no X.509
needed); raw-key/X.509 mixing works in the TLS handshake
- iroh and SSH share the Ed25519SecretKey, not the TlsServerConfig
- The reverse-proxy as reference for ACME lifecycle and live renewal
- The remaining tls spec review items (C2-C6, W3-W5) carried forward
C1: Correct dep-change claim — only rustls-pemfile, rcgen, rustls-acme
leave core; quinn, iroh, ed25519-dalek stay (endpoint struct, accept
loops, and Ed25519SecretKey remain in core).
W1: Trim duplicated 'Why' section in README to summary + ADR-082 link;
keep the three-use-cases table as reference.
W2: Clarify TlsServerConfig is not Clone (holds JoinHandle); share via
Arc, accessors clone the inner rustls::ServerConfig. Fix in README and
ADR-082.
W6: Resolve futures dep inconsistency — acme-gated in both README and ADR.
S1: Behavior-preservation invariants now reference ADR-027 as their
origin.
S2: Document for_quinn fallibility (QuicServerConfig::try_from can fail)
vs for_tcp_tls infallibility (TlsAcceptor::new cannot fail).
The TLS setup in alknet-core is welded to quinn: build_rustls_server_config
produces a rustls::ServerConfig, then build_quinn_server_config_from_rustls
consumes it into quinn::ServerConfig — the rustls config is moved, not
shareable. ACME is worse: the AcmeState task is spawned inside the quinn
endpoint, so a TCP+TLS listener would need a second ACME state machine
(two orders for the same domain, two cert caches, Let's Encrypt
rate-limit risk).
alknet-tls extracts the TLS setup into a shareable TlsServerConfig:
- TlsServerConfig::new(identity, alpns) builds the rustls::ServerConfig
once (including the ACME state machine if ACME)
- for_quinn() clones it for quinn (QuicServerConfig::try_from)
- for_tcp_tls() clones it for tokio-rustls (TlsAcceptor::from)
- rustls_config() borrows it for any other consumer
- One cert, one ACME state machine, N transports
TlsIdentity and Ed25519SecretKey stay in core (config types).
fingerprint.rs stays in core (shared by server endpoint + client
FingerprintPinVerifier in alknet-call — OQ-59 tracks whether it should
move; likely stays because moving would force alknet-call to depend on
alknet-tls, pulling TLS infra into client-only deployments).
iroh shares the key, not the rustls config — iroh has its own TLS built
into the Endpoint (takes iroh::SecretKey, not rustls::ServerConfig).
alknet-tls has no for_iroh() method; the assembly layer passes
Ed25519SecretKey to iroh directly.
Feature gates: quinn / tcp / acme as independent opt-ins. rustls always
present. tokio-rustls only when tcp. quinn only when quinn. rustls-acme
only when acme.
Files:
- crates/tls/README.md: crate spec (TlsServerConfig API, what moves/
stays, feature gates, deps, behavior-preservation invariants)
- decisions/082-alknet-tls-extraction.md: the ADR (Proposed)
- questions/059-fingerprint-module-location.md: OQ-59 (should
fingerprint.rs stay in core or move to alknet-tls? open, two-way,
likely stays)
- open-questions.md: OQ-59 added to alknet-core table
- README.md: tls doc table row + ADR-082 row added
Review (architecture-reviewer): 3 critical (missing
build_quinn_server_config_from_rustls in README table, missing futures
dep, two TlsServerConfig code blocks disagreed on feature gates), 7
warnings (behavior-preservation invariants: max_early_data_size,
aws_lc_rs provider, verifier scheme list, acme-tls/1 ALPN;
rustls-pki-types dep; fingerprint.rs rustls-usage imprecision; ADR
missing acme-tls/1), 4 suggestions. All criticals and warnings addressed.
Same fix as ADR-080 (ChannelClient) applied to the call crate. The
existing code already had the right structure — spawn_dispatch is not
feature-gated (transport-agnostic), connect is #[cfg(feature = "quinn")]
— but the docs inverted the framing: connect was 'primary,' spawn_dispatch
was 'lower-level.' That welding masked the call protocol's
transport-agnosticism (ADR-012 EventEnvelope; ADR-065 from_stream/from_bidi
accept any AsyncRead + AsyncWrite).
Changes:
- client-and-adapters.md: reframe spawn_dispatch as the transport-
agnostic primary constructor (one-way door), connect as the QUIC
convenience (two-way door, feature-gated on quinn). Mirror the
from_connection / connect_quic pattern from ADR-080/channel-client.md.
Fix all 'QUIC-backed' / 'over QUIC' / 'opens a QUIC connection' framing
to be transport-agnostic. Fix from_call description, adapter location
map, exchange-of-operations example, Constraints section.
- call-protocol.md: 'runs over QUIC bidirectional streams' → 'runs over
any ordered, reliable bidirectional stream.' Fix stream model, stream
lifecycle (connection drop / stream reset now list all transports,
not just QUIC). Fix CallConnection.connection doc comment.
- operation-registry.md: FromCall provenance comment 'QUIC forwarding
stub' → 'call-protocol forwarding stub.' Fix from_call description.
- call README.md: fix client-and-adapters.md description, fix design
principle #11 framing.
- ADR-017: add Amendment (2026-07-13) documenting the spawn_dispatch /
connect reframe, mirroring ADR-080's amendment.
- overview.md, architecture README: update ADR-017 and
client-and-adapters.md table summaries.
- OQ-015, OQ-007: fix 'opens QUIC connections' / 'bidirectional QUIC
streams' framing in resolution text.
No code changes — spawn_dispatch and connect already exist with the
right feature-gate structure. This is a documentation reframe.
The hub README predates channels (2026-07-09) and was built on the
call-protocol-directly-over-QUIC model: 'One QUIC connection per peer
carrying the call protocol,' CallClient::connect as the dial path,
QUIC as the only transport. The channels crate replaced that substrate
(ADR-071/079/080), and the hub's primary use case (worker provisioning
on docker/vast.ai/runpod) requires TCP+TLS for registration and QUIC
or TCP+TLS for the ongoing session — coexisting on one hub, not as
alternatives.
Rewrite:
- Multi-transport stated as decided: 'A hub MUST support TCP+TLS and
QUIC endpoints simultaneously.' TCP+TLS accept loop (ADR-010 Am. 1)
lives in the hub, shares the HandlerRegistry with the quinn endpoint.
- Channels substrate: the hub uses ChannelsAdapter/ChannelClient
(from_connection primary, connect_quic convenience — ADR-080),
not CallClient/CallAdapter directly. One channels connection per
peer; CallAdapter runs on channel 0 (ADR-072).
- Transport-agnostic dial/accept: dial_worker_connection takes a
Connection (one-way door); connect_quic_worker is a two-way-door
convenience. supervise_worker takes a dial closure, not a SocketAddr.
- Identity over transports: fingerprint path (QUIC+raw-key,
X.509 client cert) via resolve_from_fingerprint; bearer-token path
(TCP+TLS no client cert, WebTransport, WebSocket) via
resolve_from_token on the call first frame. Both resolve to the
same PeerEntry (ADR-030/034).
- Worker registration flow (6 steps): provision → token → download →
key gen → HTTP POST over TCP+TLS → channels connect. Step 4 is HTTP
on HttpAdapter; step 6 is channels over QUIC or TCP+TLS. The hub
creates a mixed-fingerprint PeerEntry (ADR-034 §3) at registration.
- OQ-58: worker registration flow — enrollment-token model,
endpoint shape, register_worker API. Open (decision-ready, not
blocked), one-way door, high priority.
- ADR-081: fix stale references to channels-hub/channels-worker
sub-crates (the ADR's Decision says they don't exist; the References
section said they did).
Review (architecture-reviewer): 3 critical (ADR-081 stale refs;
bearer-token extraction flow; assembly example QUIC-only +
unspecced into_connection), 7 warnings (registration 'or'→'both',
channel/control translation, OQ-52 interim, HubError variant naming,
RegistrationError definition, X.509-client-cert prose, adapter
ownership phrasing), 5 suggestions. All criticals and warnings #4/#7/#8
addressed; #5/#6/#9/#10 addressed; #11-15 noted as optional.
The channels protocol is transport-agnostic by design (ADR-071 substrate
modes; ADR-065 unwound the server-side QUIC-welding via
Connection::from_stream/from_bidi). ADR-080 had welded the client-side
one-way-door API to QUIC and masked it as a deferral ('QUIC-only
initially', 'can be generalized later') — anti-patterns #8/#9/#11
(door-type-as-deferral + resolved-with-escape-hatch). 'Can be
generalized later' meant 'can be rewritten later' — the expensive
reversal the one-way-door classification exists to prevent.
Fix: split the constructor surface.
- from_connection(connection: Connection) — transport-agnostic primary,
one-way door. Mirrors server-side ChannelsAdapter::handle(Connection)
and the existing CallClient::spawn_dispatch pattern.
- connect_quic(addr, credentials) — QUIC convenience, two-way door,
additive. Future connect_tcp_tls / connect_webtransport join it
without touching the one-way-door surface.
OQ-55 reframed: the deferred thing is the shared dial+TLS seam
(AlknetClient), not a QUIC-welded client API. The client take-over APIs
(CallClient::spawn_dispatch, ChannelClient::from_connection) are
transport-agnostic and decided; only the shared dial across transports
is blocked on a second transport's dial existing.
Files:
- decisions/080-channelclient.md: amendment section, Decision code
block, Transport-agnostic by construction (replaces QUIC-only
initially), Consequences, Door type, subscribe_resources added to
Decision block (was referenced by Door type but missing)
- crates/channels/channel-client.md: from_connection primary API,
Transport-agnostic by construction section, OQ-55 relationship
- crates/channels/overview.md, crates/channels/README.md,
crates/core/README.md, README.md, open-questions.md,
questions/055-...md: cross-reference summaries aligned
Two refinements from the review:
1. Control stream_types (3/4/5) carry ALPN-specific payloads, not
JSON. The channels layer is blind to what control stream_types carry —
it reassembles bytes and delivers them to the handler. TTY happens to
use JSON for its control channel because its control messages map
cleanly to JSON; another ALPN might use a binary format. The channels
layer does not mandate JSON on control stream_types, the same way it
doesn't mandate a format for data stream_types. This prevents the
TTY/PTY JSON constraint from becoming a binding constraint on all
future channel types. (ADR-071, channels-wire.md)
2. Hub and worker are consumers of channels, 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-079) living
in alknet-hub. A worker is any crate that uses ChannelClient to dial.
There are no channels-hub or channels-worker sub-crates. The dependency
direction is: alknet-hub → channels-call → channels-core → alknet-core;
worker → channels-call → channels-core → alknet-core. The channels
crate has no dependency on alknet-hub or any worker crate. (ADR-081,
overview.md)
Three simplifications to the channels spec, all flowing from the review:
1. Substrate simplification (ADR-071 revised): the 9-byte chunk header is
used in ALL substrates — in-line (TCP+TLS, WebTransport), native (QUIC
bidi streams), and multi-connection. The ChannelsAdapter reads headers
off every bidi stream it accepts; the transport's native multiplexing
is a performance optimization (independent flow-control windows), not a
protocol change. One wire format, one code path, one handler experience.
The channel_id in the header is the correlation key across substrates.
2. Stream_type decomposition (ADR-071 revised): every stream_type is
unidirectional. Bidirectionality is two stream_types (write + read), not
one 'bidirectional' stream_type. Grouped in threes: 0/1/2 = data
write/read/err, 3/4/5 = control write/read/err, % 3 formula. This
resolves the TTY control channel's 'not actually bidirectional' flaw —
control is now 3 (write, client→server) + 4 (read, server→client), each
with its own reassembly buffer and EOF. Channel 0 uses [0,1] (call frames
bidirectional via 0=in, 1=out). TTY uses [0,1,2,3,4]. ADR-072, 073, 074,
077 updated for the new stream_type assignments.
3. Sub-crate decomposition (ADR-081 new): channels-core (pure multiplexer —
wire format, demux/mux, ChannelBidiStreamSource, ChannelManager; depends
on alknet-core only, no call dependency, ALPN-blind) / channels-call
(channel 0 pre-negotiation + lifecycle op registrations; depends on
channels-core + alknet-call) / channels-hub (relay) / channels-worker
(ChannelClient). Isolates the call-protocol coupling from the pure
multiplexer so the dependency graph is honest.
ADR-077 (TTY inside channels) updated: TTY now uses 5 sub-streams [0,1,2,3,4]
with control properly bidirectional via 3/4; amends ADR-052's stream_type
assignments for direct mode too (direct alknet/tty now uses 0-4, not 0-3).
Spec docs updated: channels-wire, channels-connection, channels-adapter,
channel-operations, overview, README.
The from_source constructor gap surfaced by this POC has been resolved by
commit e8bbc74 (pub fn from_source(impl BidiStreamSource, alpn) at types.rs:574).
Updated the POC scope note on issue #1 to record this and explain why the POC
was deliberately not retrofitted to use from_source + a ChannelBidiStreamSource:
the POC's de-risk objective was reached, the surfaced issues are resolved, and
rewiring to the N-stream shape now would be rework the Phase 1 crate will do
authoritatively anyway. POC stands as the de-risk artifact; Phase 1 builds on
the now-unblocked trait and constructor.
The BidiStreamSource trait (ADR-070) made Connection hold Box<dyn
BidiStreamSource> so downstream crates can add connection shapes without
editing core — but the public constructor for that path was missing.
The source field is private; the channels crate (or any future crate)
had no way to build a Connection from its own BidiStreamSource impl.
The trait was unusable from outside core.
Add Connection::from_source(source: impl BidiStreamSource, alpn: Vec<u8>)
-> Self: the extension point. Takes impl BidiStreamSource (not Box<dyn>)
to match the ergonomic style of from_stream / from_bidi; boxes the
source internally. No feature gate (always available, like from_stream).
Initializes alpn and identity: OnceLock::new() the same as the other
constructors.
Test: a custom RecordingSource BidiStreamSource impl (not a built-in)
constructed via from_source, verifying remote_alpn / remote_addr /
accept_bi (round-trips real bytes via tokio::io::duplex) / open_bi
(StreamClosed) / close (records code+reason) all delegate to the custom
impl.
Verified: cargo test + clippy clean across default, --no-default-features,
--features iroh, --all-features; cargo check -p alknet-call succeeds
unchanged; cargo fmt --check passes.
ADR-070 made Connection hold Box<dyn BidiStreamSource> so downstream
crates can add connection shapes without editing core. The trait and
three built-in impls landed, but the public constructor that lets a
downstream crate construct a Connection from its own BidiStreamSource
impl was never added — the source field is private with no from_source
constructor. The channels POC update surfaced this when it tried to
use the trait directly and found no way to build a Connection from a
ChannelBidiStreamSource.
This is a gap in the ADR-070 implementation, not a new decision. ADR-070
§Consequences says 'the channels crate implements ChannelBidiStreamSource
in its own crate and constructs Connection from it' — the constructor for
that path is what was missing.
- ADR-070: add from_source to the Constructors table + reword the
downstream-crates paragraph to make the two paths explicit
(from_source for custom impls; from_quinn/from_iroh/from_stream for
built-in impls)
- core-types.md: add from_source to the impl Connection block, the
built-in implementations table, and the design-decisions table entry
- tasks/core/connection-from-source-constructor.md: the implementation
task (one constructor + one test, scope narrow, risk low)
Re-verified the POC against the post-refactor alknet-core (BidiStreamSource
trait, ADR-070): all 28 tests still pass, clippy fully clean (the upstream
Connection::close unused-arg warnings are gone). Updated the POC's handler
tests to use AuthContext::anonymous(alpn) (REQ-CORE-03), removing the
four-None-field literal that recurred across echo_handler and tunnel_handler.
Issues #1 (BidiStreamSource), #2 (Connection::close unused args), and #3
(AuthContext verbosity) in the POC summary are now marked RESOLVED with
pointers to ADR-070 / commit 60cce22. Issues #4-#7 (channels-side: zero-length
sentinel on shutdown, dynamic mux registration, demux EOF teardown, two-pump
shutdown-on-completion) remain for Phase 1. POC scope note added: the POC
correctly keeps Connection::from_stream (yield-once) — building
ChannelBidiStreamSource is Phase 1's job, now unblocked by the trait.
The implementer flipped the frontmatter status to completed but left the
acceptance checkboxes empty and omitted the Summary section. Fill both
to match the convention every other completed task in tasks/core/ follows
(core-types.md, fingerprint-normalization.md, review-core.md — all check
boxes [x] and add a ## Summary section recording what landed).
Extract stream-yield ops from Connection into a BidiStreamSource trait;
Connection now holds Box<dyn BidiStreamSource> instead of the closed
ConnectionKind enum. Three crate-private impls wrap the existing
constructors: QuinnBidiStreamSource (feature quinn), IrohBidiStreamSource
(feature iroh), StreamBidiStreamSource (no gate, yield-once per ADR-065).
Public Connection API is preserved verbatim — handlers dispatch through
the trait object transparently.
The StreamBidiStreamSource::close impl prefixes code/reason with _ and
documents why they're ignored (the drop is the close — ADR-065). This
resolves the ADR-065 leftover clippy warning under --no-default-features
(REQ-CORE-02).
Add AuthContext::anonymous(alpn) convenience constructor (REQ-CORE-03):
sets identity/fingerprint/remote_addr to None, only alpn is populated.
Removes the four-None-field literal that recurred in handler POCs/tests.
Verified: cargo test + clippy clean across default, --no-default-features,
--features iroh, and --all-features; cargo check -p alknet-call succeeds
unchanged; cargo fmt --check passes.
Three core-crate changes surfaced by the alknet-channels POC:
- ADR-070: extract BidiStreamSource trait so Connection holds
Box<dyn BidiStreamSource> instead of a closed ConnectionKind enum;
downstream crates (channels, future transports) implement the trait
to add connection shapes without editing core. Public Connection API
preserved verbatim. REQ-CORE-02 (close() params clippy warning under
--no-default-features) folded in — the signature stays on the trait,
non-QUIC impls ignore the args.
- OQ-55: AlknetClient / client establishment extraction deferred(scope).
Blocked on a second *transport's* real client (not a second QUIC
client) — extracting a QUIC-shaped connector now would bake QUIC in
as the establishment shape, the same welding ADR-065 unwound on the
server side. Each crate builds its own client standalone for now.
- core-types.md / auth.md / overview.md / README indexes updated to
reflect ADR-070 and OQ-55. Architecture review: zero critical issues.
- tasks/core/bidistreamsource-trait.md: the implementation task (Parts
1-3: BidiStreamSource refactor, close() fix, AuthContext::anonymous).
Incorporates the completed de-risk POC (poc-summary.md, 28 tests passing)
into phase-0-findings.md:
- Marks OQ-CH-12 RESOLVED (lenient drop, validated by POC), OQ-CH-13
CONFIRMED +EV (do the BidiStreamSource refactor), and clarifies OQ-CH-14
with the AlknetClient context: a general downstream-facing client in
alknet-core paired with whichever ALPN handler a crate provides, with
server/client bidirectionality preserved (hub/worker can act as both,
each side fills its registry with the other's resources).
- Adds §POC-Validated Requirements carrying the POC's surfaced invariants
into Phase 1 as requirements (not open questions):
- REQ-CH-01..07: wire-level invariants for the channels crate spec
(shutdown emits zero-length sentinel, transport close drops all
senders, mux handle/runner split, lenient unknown channel_id, bounded
backpressure, two-pump shutdown-on-completion, PollSender adapter).
- REQ-CORE-01..04: the light alknet-core refactor to land alongside the
channels crate (BidiStreamSource trait, Connection::close fix,
AuthContext test helper, AlknetClient).
- Updates the De-risk POC section to COMPLETE status with what was
validated, what was surfaced, and what remains out of scope.
- Updates the opening revision note to reflect the POC pushed the core
mechanics beyond speculation into validated territory.
Adds docs/research/alknet-channels/poc-plan.md — a standalone three-step POC
plan that derisks the channels layer in isolation (no call protocol, no real
transport, no real adapters):
- Step 1: chunk format + N-channel demux/mux — generalizes TTY's 5-byte
ChunkReader/ChunkWriter to the 9-byte format, validates decompose→stream→
recompose for 3+ concurrent channels with the sync-core/async-shell split
pattern from TTY's REQ-TTY-01.
- Step 2: per-channel Connection presentation — wraps each reassembled
channel as Connection::from_stream and runs a minimal echo ProtocolHandler
through the full path. Validates the existing Connection abstraction is
sufficient; no core changes needed for the POC.
- Step 3: tunnel handler — opens a TcpStream and pumps bidirectionally,
reusing TTY's pump_session shape with two pumps. Validates the same
concepts behind the TTY crate work as a generic port proxy.
Stretch goals: WASM build of the sync core, mixed channel types on one
connection, hub relay sketch with channel_id remapping.
Adds three open questions to phase-0-findings.md:
- OQ-CH-12: unknown channel_id on demux (lenient drop vs strict error).
- OQ-CH-13: core BidiStreamSource trait — additive refactor to make
ChannelConnection a first-class peer of QUIC (many bidi streams) rather
than a bag of yield-once Connections. Likely +EV; not needed for the POC
but should be evaluated in Phase 1.
- OQ-CH-14: client-side channels endpoint — the symmetric ChannelClient
type (analogue of AlknetEndpoint vs CallClient). After the POC we're
probably going to need some light refactoring to the core to make these
easier; mostly additive and not breaking in major/pita ways.
Updates phase-0-findings.md De-risk POC section to point at the detailed
plan and adds the poc-plan to References.
Captures the single-stream flow-control ceiling as a known constraint with
the recursive-composition escape hatch: parallel throughput comes from N
independent channels connections (client optimization), not cross-connection
tokens or channels-layer coordination. Pins that the channels layer does no
cross-connection channels, no cross-connection primitives, and no parallel-
transfer optimization — those are downstream client concerns.
Adds three sections filling the conceptual gaps in the phase-0 findings:
- Hub Motivation: The Multi-Transport Collapse — diagnoses the
O(protocols × transports × spokes) mess the hub crate faces and shows how
channels collapses it to one connection per leg with channel-by-channel
byte forwarding, reusing the call protocol's auth/forwarded-for model.
- Channel Open Negotiation — concrete channel/open, channel/close,
channel/control, channel/resources operation payloads with field tables,
error codes as CallError strings, bidirectional open semantics, and the
end-to-end ACL flow for browser→hub→spoke. No new wire framing; all four
operations register on the call protocol's existing OperationRegistry.
- Channel Manager and Connection Internals — the ChannelsAdapter/
ChannelManager split, ChannelManager state sketch, ChannelsAdapter::handle
read/demux loop with channel 0 preinstall, the channel/open handler
closure threading into OperationRegistry, ChannelConnection as a
Connection via the existing from_stream path, the hub relay as
ChannelManager-to-ChannelManager byte pumping, and the boundary
(ChannelManager holds no handlers, no ALPN parsing, no auth, no transport
coupling).
Also adds four new open questions (OQ-CH-08 through OQ-CH-11) on resource
staleness, responder-to-initiator lifecycle, typed destructure ownership,
and hub relay channel_id remapping; two new POC stretch goals (hub relay,
channel/resources); and updates the opening to note the revision scope.
Channel 0 is not a special control plane with its own framing. It is
simply the alknet/call ALPN, pre-negotiated so both sides route it to
the CallAdapter without an explicit channel/open exchange. Every channel
works the same way: reassemble chunks into a stream, look up the ALPN
in the HandlerRegistry, hand off to the handler. Channel open is
bidirectional — either side can initiate.
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.
- Replace on_worker_connected() post-hoc call with WorkerConnectedCallback
that fires inside CallAdapter::handle(). handle() blocks until disconnect
— there is no 'after handle accepts' point for the assembly layer to
hook into. The callback carries both on_connected (from_call + attach_peer)
and on_disconnected (detach_peer + drop channels).
- Add CallAdapter::with_worker_connected_callback(callback) builder method.
- Consolidate duplicate WorkerConnectedCallback struct definitions.
- Fix channel role: 'channel proxying' → 'channel management'. The hub
tracks channels; it does not proxy streams. What the caller does with
the resulting channel is the assembly layer's business.
- Resolve OQ-54: callback is the committed design. Update OQ file and
open-questions.md table.
Replace the 'future strategies' section with the channel model:
- Channel 0 is the call protocol — the universal control plane. Handles
operation discovery (from_call), operation routing (invoke_peer),
service discovery, and channel negotiation (channel/open, channel/close,
channel/list).
- Channels 1..N carry any ALPN as data planes. Opened via channel/open on
the call protocol. Each channel is a bidirectional QUIC stream, wrapped
as Connection::from_bidi (ADR-065), and handed to the same
ProtocolHandler::handle() that handles dedicated connections. The
handler does not know it's on a multiplexed channel.
- Channel negotiation is symmetric — either side can open a channel.
Same pattern as from_call: bidirectional, symmetric, negotiated over
the call protocol.
- The hub's role for channels 1..N is transparent stream proxying. The
hub does not interpret the protocol; the client and worker speak the
ALPN directly.
- Hub struct gains channel tracking (PeerId → ChannelId → ChannelInfo).
HubError gains ChannelAlreadyOpen, ChannelNotFound, ChannelOpenFailed.
- New OQ-55: channel/open operation spec (deferred to call-protocol
implementation phase).
Generalizes TTY's chunk format into a universal channel multiplexer
(alknet-channels) that serves as a transparent proxy between the call
protocol (control plane) and data-plane protocols (TTY, SSH, tunnels).
Key design: 9-byte chunk header (channel_id + stream_type + length),
ChannelConnection implementing the existing Connection interface, and
ACL inherited from the call protocol's OperationContext.
Resolve the deferred docker system events subscription question.
docker/system/events is now a v1 Subscription operation using the
same StreamingHandler pattern already wired for logs, exec, and
image/pull. The internal ownership-store subscription for stale-entry
cleanup on destroy events is a follow-up refinement.
Scrub hedging language from ADR-060 and docker specs:
- Remove 'marginal gain' / 'future feature is additive' framing
- Replace 'no reaper' / 'not promptly cleaned up' with clean
statement that events subscription provides the prompt-cleanup path
- Remove stale-entry policy from ADR-060's two-way door classification
- Replace .unwrap() with .expect() in spec pseudocode; remove .unwrap()
from ADR-067 prose. Specs describe intent, not implementation details.
- Remove 'ownership reaping' from hub's 'does NOT do' list. Ownership
is an alknet-docker concern, not a hub concern. The other agent's
hedging leaked into the spec; removed.
- Restructure hub README: separate core peer lifecycle (protocol-agnostic)
from call-protocol strategy (first strategy). Add extension points
section for future tty/blobs/custom strategies. A QUIC connection can
multiplex multiple protocols over separate streams; the hub's core
manages the connection, each strategy manages its own protocol-specific
state. The call-protocol strategy handles the majority of real-world
use cases; the architecture keeps the door open for others.
Three ADRs addressing gaps surfaced by the first hub consumer (alkapi):
- ADR-067: Aggregated peer-env wiring — Dispatcher::with_aggregated_env hook
so compose_root_env reads a shared PeerCompositeEnv across all calls,
not a fresh per-call one. The hub-defining gap (alkapi OQ-08 / G.1).
- ADR-068: PeerCompositeEnv::peer_operations override — adds
list_operation_names() to OperationEnv, overrides on OverlayOperationEnv
and PeerCompositeEnv. Fixes services/list-peers returning empty operation
lists for non-local peers (alkapi G.6).
- ADR-069: from_call is a manual free function, not auto-wired — reverses
the aspirational OQ-27 resolution to match the implementation. Cleans
the 'v1 default' hedging language in ADR-017 and client-and-adapters.md
(alkapi G.4).
New crate spec: alknet-hub — reusable hub pattern (aggregated env,
connection lifecycle, worker supervision with backoff, service discovery).
Spec fixes: builder API drift (with_local/with_local_streaming separation),
ScopedOperationEnv → ScopedPeerEnv type name, OQ count 51→54.
Three new OQs: OQ-52 (wait_for_close), OQ-53 (backoff defaults),
OQ-54 (inbound hook placement).
from_jsonschema was in alknet-call as a schema-only placeholder with a
NOT_FOUND handler — broken (an op in the registry needs a real handler)
and in the wrong crate (alknet-call has no HTTP client; a useful
from_jsonschema needs reqwest like from_openapi). ADR-066 moves it to
alknet-http as a real reqwest-backed single-endpoint adapter for
non-standard/non-OpenAPI REST endpoints, functionally similar to
from_openapi but one endpoint at a time. FromJsonSchema provenance
stays in alknet-call (now a handler-bearing leaf).
- New ADR-066 (supersedes ADR-017 §5 from_jsonschema clause + ADR-022
FromJsonSchema row; both amended with strikethrough + pointer)
- Updated specs: call/client-and-adapters, call/README,
call/operation-registry, http/http-adapters (new from_jsonschema
section), http/overview, http/README, arch README + overview
- New task: tasks/http/adapters/from-jsonschema.md (depends on
from-openapi; includes alknet-call cleanup of the broken placeholder)
- Old task tasks/call/client/from-jsonschema.md marked superseded
taskgraph validate: 116 tasks valid
The from_mcp_integration test invoked bundle.handler directly as a
function, but HandlerRegistration.handler is a HandlerKind enum
(Once/Stream), not a callable. This caused E0618 (expected function,
found HandlerKind) and a downstream E0282 on the response match arm.
Pattern-match HandlerKind::Once to extract the inner Handler before
calling, mirroring the pattern used in protocol/connection.rs and
from_openapi.rs tests.
Three code commits landed a clean sweep discovered when building an
external app against the crates. This syncs the architecture specs to
match the codebase and amends the affected decisions.
ADR-064 (supersedes ADR-005): irpc was never integrated — no .rs file in
the workspace ever imported it. The wire protocol (wire.rs) is hand-rolled
length-prefixed JSON; the EventEnvelope shape was derived from the
@alkdev/pubsub TypeScript prior art (ADR-013), not from irpc. ADR-005's
premise ('irpc as the call protocol foundation') was never implemented as
stated. Superseded with a clear header; the body is kept as historical
record.
ADR-065: Connection::from_stream / from_bidi — Connection now accepts any
AsyncRead + AsyncWrite pair via ConnectionKind::Stream (yield-once
accept_bi contract: QUIC yields many streams, everything else yields one
then ConnectionClosed). Unblocks TCP+TLS, SSH channel dispatch,
WebTransport streams, and wasm streams through the same HandlerRegistry
as QUIC connections, with zero handler code changes. MockConnection /
ConnectionKind::Mock removed (tests use from_stream with sink/empty).
Stream-level Mock variants renamed to Stream (they were already generic).
Amended ADRs: ADR-003 (irpc removed from dep table), ADR-007 (from_stream
opens the server-side door; MockConnection removed), ADR-010 (TCP+TLS can
now dispatch through the registry via from_bidi; iroh 1.0 migration noted).
Updated specs: core-types.md (Connection/SendStream/RecvStream sections),
endpoint.md (TCP section, iroh note), call-protocol.md, operation-registry
(irpc Integration section replaced), overview.md, http-server.md,
webtransport.md, call README. OQ-09 (WASM) resolution amended: the
Connection door is now open via from_stream; the accept-loop runtime door
remains closed (tokio doesn't run on WASM).
See docs/research/transport-generalization/findings.md for the full trace.
Add ConnectionKind::Stream: a single read/write pair behind a
Mutex<Option<...>> that accept_bi yields once (then ConnectionClosed).
Add Connection::from_stream(send, recv, alpn, remote_addr) and
Connection::from_bidi(stream, alpn, remote_addr) constructors.
Rename the stream-level SendStreamKind::Mock / RecvStreamKind::Mock to
::Stream (they were already generic Box<dyn AsyncRead/Write> — the
wrong name). Rename from_mock to from_stream.
Remove MockConnection trait + ConnectionKind::Mock entirely. Migrate
all alknet-call test StubConnections to use from_stream with
tokio::io::sink() + tokio::io::empty() (immediate EOF on the read side
causes handle_stream to exit cleanly, then accept_bi returns
ConnectionClosed and the run_loop exits).
The yield-once accept_bi contract: QUIC yields many streams, everything
else yields one. Handlers that loop (TtyAdapter) get one iteration per
single-stream connection; handlers that call once (HttpAdapter) get the
stream directly. Both correct, no branching on transport.
This unblocks TCP+TLS listeners, SSH channel dispatch, WebTransport
streams, and wasm — all via from_stream, all through the same
HandlerRegistry, zero handler code changes.
See docs/research/transport-generalization/findings.md for the full
trace and the yield-once contract.
irpc 0.16 was declared in the workspace Cargo.toml and consumed by
alknet-call, but no .rs file in the workspace imports it. alknet-call's
wire protocol (src/protocol/wire.rs) is hand-rolled length-prefixed JSON.
The dep was dead weight.
Re-add as 0.17 when alknet-blobs lands (it depends on iroh-blobs 0.103
which pulls irpc 0.17 transitively).
Three-commit plan to unblock api.alk.dev (standard TCP+TLS HTTP), alknet-ssh
(per-channel dispatch over russh), and alknet-blobs (iroh 1.0 prerequisite):
1. Drop dead irpc/irpc-derive workspace deps (never imported by any .rs file)
2. Migrate alknet-core iroh 0.35 -> 1.0 (3 edits in build_iroh_endpoint + Cargo.toml)
3. Add Connection::from_stream / from_bidi — a generic single-stream connection
kind that makes every ProtocolHandler work over TCP+TLS, SSH channels,
WebTransport streams, and wasm without handler code changes. Rename the
existing stream-level Mock variants to Stream (they were already generic
stream holders, just misnamed).
The yield-once accept_bi contract: QUIC yields many streams, everything else
yields one. Handlers that loop (tty) get one iteration; handlers that call
once (http) get the stream directly. Both correct, no branching on transport.
No ProtocolHandler trait shape change (one-way door per ADR-009). SSH's
per-channel dispatch is handler-internal, same pattern as TtyAdapter's
per-stream dispatch. Incorporates ../iroh-update/findings.md by reference.
Traces the version gap deferred by the alknet-blobs probe: verifies latest
stable iroh 1.0.2 / iroh-blobs 0.103 / irpc 0.17 against crates.io, rules out
the 'iroh 0.35 is a stale pin' hypothesis (it's load-bearing — 3 APIs broke),
identifies the real cheap win (irpc 0.16 is a dead dep in alknet-call, never
imported), and traces the iroh 1.0.x Preset trait so the migration is a
concrete 3-edit diff rather than a TODO.
Includes the sibling probe doc (alknet-blobs-external-store-probe.md) which
the findings cross-references.
Greenfield architecture spec set for the alknet-docker crate — a thin,
single-host bollard wrapper exposing docker container/image operations
as call-protocol ops on the shared alknet/call ALPN, plus a
DockerTtyBackend (impl TtyBackend) behind a tty feature for interactive
terminal sessions into containers over alknet/tty.
Six ADRs:
- 058: docker ops on alknet/call (no separate ALPN; raw-carriage handoff
dissolved by alknet-tty extraction — interactive attach moved to
alknet/tty via DockerTtyBackend, no carriage field on call.requested)
- 059: bollard 0.21 (verified current on crates.io) + feature selection
(http+pipe+time; no ssl/ssh/websocket/buildkit)
- 060: container resource model (ADR-050 application) — alknet.managed/
alknet.owner labels, list owned_only flag, hosted-services operator
role via static-resource fallback, handler-driven revoke with
autonomous-death tolerance, resource-action vocabulary
- 061: DockerTtyBackend in alknet-docker behind tty feature (attach vs
exec mode; POC drive_attach_raw as reference)
- 062: Docker client + OwnershipStore injection via closure capture
(not Capabilities, not OperationContext — matches from_openapi pattern)
- 063: exit code on terminal call.responded for non-interactive exec
(call.completed stays empty, ADR-012 unchanged)
Four spec docs (crates/docker/): README, overview, docker-operations,
docker-tty-backend. Four deferred-scope OQs (048-051): network/volume
ops, buildkit, system events subscription, create options surface.
Updates the tty-backend.md Backend implementations table (DockerTtyBackend
row now specced) and the architecture README (doc table, ADR table,
current-state paragraph, OQ count).
Grounded in the alknet-docker POC (docs/research/alknet-docker/poc-summary.md)
which validated the hard parts; the remaining lifecycle ops are mechanical
bollard wrapping. Reviewed by architecture-reviewer subagent; criticals
(the docker_client injection model conflicting with the Capabilities
contract) resolved via ADR-062/063 before commit.
Final review verified:
- 103 tests pass (61 alknet-tty + 42 alknet-tty-local), clippy clean, fmt clean
- Cross-crate seam: portable_pty only in alknet-tty-local, not in alknet-tty default
- ADR-055 exit-chunk-is-last enforced in adapter, validated in integration tests
- ADR-056 cancel-cleanup guards in both PTY and pipe exit futures, integration tests confirm no orphans
- REQ-TTY-01 three-thread bridge, REQ-TTY-02 process-group signal forwarding
- No bollard/russh/alknet-call deps; alknet-tty depends on alknet-core only
- Deviation: local feature re-export deferred to assembly layer (cargo cycle constraint)
Add 18 integration tests in crates/alknet-tty-local/tests/ exercising
the full stack (LocalTtyBackend + TtyAdapter::drive_session over
tokio::io::duplex, real commands), validating the two crates work
together through the TtyBackend trait seam.
Tests live in alknet-tty-local (not alknet-tty) per the feature-gate
deviation: the cyclic dependency alknet-tty → alknet-tty-local →
alknet-tty prevents alknet-tty from depending on alknet-tty-local, so
the integration tests live in the crate that depends on both. Unix-only
tests (signal, process-group) use #[cfg(unix)].
PTY mode (8): happy path echo, interactive cat round-trip, resize,
SIGINT signal, process-group signal (REQ-TTY-02), stdin EOF sentinel,
cancel cleanup (ADR-056), exit-chunk-is-last (ADR-055).
Pipe mode (6): happy path echo, separate stderr, SIGTERM signal,
cancel cleanup, resize no-op, stdout chunk + sentinel.
Negotiation errors (5): unknown_backend, malformed_negotiation (bad
JSON, carriage != raw, empty cmd), allocate_failed (nonexistent
binary).
A shared test harness (tests/common/mod.rs) provides the client-side
wire protocol helpers: write_negotiation, write_chunk, write_control,
try_read_chunk, read_chunk_timeout, read_error_frame (asserts the
0x00 first-byte framing-disambiguation invariant), read_until_exit,
assert_no_more_chunks, close_write_half, plus spawn_session which
wires drive_session over a duplex pair with a test identity carrying
tty:open.
Verification: cargo test -p alknet-tty-local (42 tests pass), cargo
clippy -p alknet-tty-local -- -D warnings (clean), cargo fmt --check
-p alknet-tty-local (clean).
ADR-054 prescribes with a re-export
module, but cargo rejects the cyclic dependency (alknet-tty →
alknet-tty-local → alknet-tty) even with an optional dep. The re-export
is deferred to the assembly layer: consumers depend on alknet-tty-local
directly and register LocalTtyBackend in the TtyAdapter backend map.
The feature gate stays declared (empty) for forward compatibility.
Assembly pattern documented in the crate root doc comment.