Review of the three new crates (alknet-tls, alknet-endpoint, alknet-client)
+ revised core found compile-blocking inconsistencies, stale claims, and
dep-graph contradictions. All resolved:
Critical:
- C1: CallClient::connect / ChannelClient::connect_quic REMOVED (not
delegated) — keeping them as thin wrappers over AlknetClient::dial_quic
would make protocol crates depend on alknet-client, contradicting the
dep graph. Callers compose dial + take-over (2 lines).
- C2: alknet-client feature gates now pull alknet-core/quinn +
alknet-core/iroh (for Connection::from_quinn_with_alpn / from_iroh).
- C3: rustls-native-certs + webpki-roots added to alknet-tls deps
(always-present, not feature-gated — CA-verify path is transport-agnostic).
Warning:
- W1: CallCredentials/RemoteIdentity moved to alknet-core (from
alknet-call) — the dial must not depend on the call protocol; not a
two-way-door, it determines the dep graph.
- W2: webpki-roots fallback implemented in spec (ADR-088 §5 added) —
the code claimed a fallback that never existed; now the store is never
empty, NoRootAnchors unreachable, containerized deployments work.
- W3: EndpointError removed entirely (BindFailed + HandlerNotFound both
vestigial after ADR-083); shutdown() is now infallible.
- W4: FingerprintPinVerifier moved to alknet-tls (from alknet-call) —
alknet-call sheds quinn/rustls/rustls-pemfile/rustls-native-certs
entirely; CallClient becomes a pure protocol crate.
Plus: ClientError removed (only produced by removed connect); S1
(CallCredentials → ClientVerifierContext mapping + auth_token stripped
at TLS boundary documented); amendment notes on ADR-017, ADR-069,
ADR-080, ADR-082, ADR-087, ADR-090; overview crate graph + README index
updated.
29 files, consistency-reviewed.
The iroh-proxy POC (/workspace/iroh-proxy-poc, 5/5 runs clean) settled
OQ-67: iroh does NOT expose a socket-injection hook for the IP/direct
transport (noq_endpoint() is pub(crate), the IP transport binds its own
netwatch::UdpSocket, CustomTransport operates on a separate CustomAddr
address space iroh's hole-punching doesn't route through). The quinn
POC's Socks5UdpSocket does not transfer to iroh. The decision: force
relay-only when a proxy is configured, via three stable public iroh
Builder knobs — clear_ip_transports() + addr_filter(relay_only) +
proxy_url. The peer sees the relay's IP; the relay sees the proxy's IP;
the client's real IP is hidden on both surfaces. No iroh fork required.
Because iroh's proxy_url expects an HTTP CONNECT proxy (not SOCKS5),
the integration runs a tiny local HTTP-to-SOCKS5 bridge (~80 lines) so
a single Socks5ProxyConfig covers all three dials uniformly: UDP
ASSOCIATE for dial_quic, CONNECT for dial_tcp_tls, force-relay-only +
HTTP-to-SOCKS5 bridge for dial_iroh.
The POC also corrected a factual error: iroh's proxy_url proxies the
relay WebSocket only, not pkarr/DoH (those use pkarr/hickory-resolver
directly). Acceptable for the force-relay-only config (QAD disabled);
spec text corrected.
Force relay-only forgoes iroh's direct-path latency advantage (negligible
for the hub deployment, which runs its own relay) and makes relay
availability a hard dependency — the intended privacy/availability
tradeoff; a caller that prefers availability over privacy for the iroh
path simply does not set the proxy.
- ADR-090 §5 amended: iroh force-relay-only decision + proxy_url
coverage correction + HTTP-to-SOCKS5 bridge
- OQ-67: resolved (force relay-only)
- client README: iroh proxy row, bridge, limitations, ADR/OQ entries
- README/open-questions: OQ-67 resolved, Current State amendment
Option 2 (force relay-only via clear_ip_transports + addr_filter(relay_only)
+ proxy_url) validated end-to-end: a relay-only proxied iroh client connects
through an HTTP CONNECT proxy to a local relay; selected path is relay, no
direct IP path established, 45-byte echo completes (5/5 runs clean).
Option 1 (SOCKS5 UDP ASSOCIATE over iroh's direct path, the quinn-PoC
analogue) is not feasible without forking iroh: iroh exposes no
socket-injection hook for the IP/direct transport (noq_endpoint is
pub(crate), IpTransport binds its own netwatch::UdpSocket), and the only
public injection surface (unstable-custom-transports CustomTransport)
operates on a separate CustomAddr address space that iroh's hole-punching
does not route through.
Correction to OQ-67 premises: iroh's proxy_url covers the relay WebSocket
(HTTP CONNECT) only, not pkarr/DoH. Recommendation: Option 2 as default,
Option 1 deferred unless a direct-path-privacy use case justifies a fork.
PoC at /workspace/iroh-proxy-poc.
AlknetClient gains an optional SOCKS5 proxy (with_socks5_proxy) so a
native client can hide its real IP from the hub. dial_quic routes QUIC
through SOCKS5 UDP ASSOCIATE (validated by the /workspace/quinn-proxy-poc
PoC — quinn's AsyncUdpSocket + new_with_abstract_socket is the extension
point, 5/5 runs clean); dial_tcp_tls routes through SOCKS5 CONNECT. The
proxy is invisible above the dial (Connection, dispatch, credentials,
TLS config all proxy-unaware) and the no-proxy path is the zero-cost
default (socks5 feature + fast-socks5 dep are opt-in).
SOCKS5 is the sole proxy protocol (covers both TCP and UDP, so no HTTP
CONNECT variant needed). The two distinct SOCKS5 concepts — the
client-dial proxy (ADR-090, transport-layer privacy) and the planned
alknet-socks5 channels data-channel handler (ADR-085 scope, a service
one side offers the other) — compose at the SOCKS5 protocol level
without alknet-type-level coupling.
iroh is the exception: dial_iroh does not consume Socks5ProxyConfig —
iroh's proxy_url covers the relay-exposure surface, but the
direct-connection peer-exposure case is OQ-67 (deferred(unclear) — the
pieces exist but the iroh socket-stack composition isn't clear; does
not block the first hub deployment, which uses QUIC/TCP+TLS).
- ADR-090: Client-Dial SOCKS5 Proxy Seam
- OQ-67: iroh Proxy Support (Direct-Connection Peer Exposure)
- client README: proxy section, struct/builder, Proxy error variant,
socks5 feature gate, deps, assembly example, decisions/open-questions
- README/open-questions: index entries, Current State, OQ count 67/20
Resolves the 'quinn has no proxy support' blocker for QUIC client
connections in alknet-call. Quinn routes every network byte through the
public quinn::AsyncUdpSocket trait, and Endpoint::new_with_abstract_socket
accepts any impl — so a SOCKS5 UDP ASSOCIATE tunnel wrapped as that trait
gives full QUIC-through-proxy support with no fork.
An end-to-end PoC (/workspace/quinn-proxy-poc) confirms a quinn client can
complete a QUIC handshake and exchange stream data through a SOCKS5 proxy
with UDP support (5/5 runs clean, clippy clean). The load-bearing impl is
~250 lines. Integration into alknet-call is ~30 lines in connect() plus one
new module, behind a new optional socks5 feature flag.
Limitations: ECN is lost across the proxy (quinn falls back to non-ECN), and
the proxy must support UDP ASSOCIATE (ssh -D does not). Both are acceptable
for alknet's call-protocol use.
Amend ADR-083 with the crate-extraction decision: the endpoint moves
from alknet-core into a new crate alknet-endpoint, mirroring the
alknet-client extraction (ADR-089). The ADR's shape (new + builder
methods + public dispatch + run/shutdown) is unchanged; only the
location changes.
The extraction is structural pruning, not an inline refactor. The
endpoint is a leaf consumer of core's shared types (zero handler crates
import it; 124 import sites for the other core modules). Extracting it
lets core shed quinn/iroh/rcgen/rustls-acme — handler crates no longer
transitively link those. A pure worker (client-only) does not pull
alknet-endpoint at all. The dep graph is symmetric: alknet-core is the
shared types crate; alknet-endpoint and alknet-client are the
server-side and client-side establishment crates.
New spec: crates/endpoint/README.md (the canonical endpoint spec).
core/endpoint.md is deprecated to a stub. Cross-references updated
across 8 docs (README, overview, tls, hub, client, core README, ADR-083,
ADR-089 references). Architecture review passed (3 critical, 9 warnings
— all addressed).
Extract the deferred AlknetClient as a new crate alknet-client — the
client-side analogue of AlknetEndpoint. Three dial methods (QUIC +
TCP+TLS via TlsClientConfig, iroh via key) produce a Connection for
CallClient::spawn_dispatch / ChannelClient::from_connection to consume.
The deferral collapsed because ADR-086 gave the native endpoint type
three dial shapes within one endpoint type, ADR-087 broke the circular
hedge, and ADR-083 gave the server-side shape to mirror by symmetry.
Names the three concept layers that were tangled throughout the initial
development (deployment role / establishment side / ALPN-level category)
so the fix is legible. Names alknet/register as a dialable entry-point
ALPN (native registration, parallel to HTTP registration in OQ-58); its
wire protocol is deferred to OQ-66 (blocked on OQ-58's token model).
Cross-references updated across 11 existing docs (README, overview,
open-questions, OQ-55, tls, hub, core, channels README/overview/
channel-client, call client-and-adapters) to reflect OQ-55 resolved and
the new alknet-client crate. Architecture review passed (2 critical, 7
warnings — all addressed).
TLS spec review before task decomposition. The client side was
under-specified relative to the server side — fixed:
- W1+W2: TlsClientConfig gets the full accessor API (for_quinn,
for_tcp_tls, rustls_config) mirroring TlsServerConfig, plus the
local TlsIdentity input for client-auth cert presentation. Both
clients (call + channels) consume it via the same three accessors.
- W3: Client-side extraction table for call_client.rs items that move
to alknet-tls, including the Ed25519SigningKey / load_cert_chain /
load_private_key duplicates that consolidate into one copy.
- W5: Server-side rustls_config() doc comment no longer claims iroh
uses it (iroh reads the key directly).
- S6: Dropped 'remote cert type' from ClientVerifierContext — it
doesn't drive any construction decision.
- S7: Implementation ordering note (tls first, then endpoint refactor,
then assembly) — the call sites don't exist until step 2/3.
- N8: Noted alknet-core's acme feature + deps become vestigial.
- Flagged client spec work for the next session (two clients: call +
channels; same TlsClientConfig shape; prerequisite for first hub).
- Advanced ADR-082/083 to Accepted; TLS README to reviewed.
Grounded in the actual error-producing call sites (endpoint.rs server
side, call_client.rs client side) and the dependency-crate sources read
from the cargo cache (rustls 0.23.41, rustls-pemfile 2.2.0, rcgen 0.13.2,
quinn-proto 0.11.15, rustls-acme 0.12.1).
Decision: single #[non_exhaustive] enum, one variant per failure
category, owned by alknet-tls (not re-exported from core). Six variants:
CertLoad(io::Error), SelfSigned(rcgen::Error), Rustls(rustls::Error),
VerifierBuild(VerifierBuilderError), QuinnWrap(NoInitialCipherSuite)
[quinn-gated], AcmeConfig(String).
Three findings drove single-enum over thin wrapper: (1) for_quinn()
fails with NoInitialCipherSuite, not rustls::Error — a rustls::Error
wrapper cannot represent the for_quinn() failure; (2) rustls_pemfile::Error
is not a std::error::Error (no Display, no Error impl) so #[from] would
not compile — pemfile BufRead APIs return io::Error; (3)
WebPkiServerVerifier::build() returns VerifierBuilderError, not
rustls::Error — a thin wrapper cannot represent empty-CA-root-store as
a first-class failure.
Deliberately NOT variants: ACME EventError/OrderError (stream events,
logged not returned from new); unknown-raw-key fail-closed (handshake-
time rejection at dial time, not a config-construction error —
corrects OQ-63's original framing); provider init (infallible); resolver
construction (infallible).
Address the root cause of rework-causing hedging: the architect was
put in a logical bind where it couldn't express justified uncertainty
('the pieces exist but the shape isn't clear yet'). The only options
were 'decide now' (premature) or 'deferred(scope)' (false — the
information isn't missing, it's un-synthesized). The agent picked
deferred(scope) with a circular blocking condition (OQ-64 blocked on
OQ-55, OQ-55 needs OQ-64) because there was no honest way to say 'I
can see the pieces but I can't see the shape.'
Changes to the architect role spec:
- Add deferred(unclear) state: the pieces exist but the composition
isn't clear; resolution requires investigation (work through
examples, POC), not waiting. Has an investigation target and an
impacts field.
- Add 'Impacts' field to the OQ format: what does this block
downstream? Be specific ('blocks the first hub deployment because
the hub dials workers' not 'blocks the hub crate'). The triage
signal that makes deferral urgency visible — the field that would
have made the AlknetClient circular hedge visible.
- Add circular-reasoning guard to self-review: 'check that your
blocking condition isn't a prerequisite of the thing you're
deferring.'
- Trim anti-patterns #9-#11 (hedging synonyms catalog, ~40 lines):
detection belongs in the reviewer, not the architect's self-review.
The architect is too close to its own reasoning to see its own
circular hedges.
- Trim door-types section (30→10 lines): keep the one-paragraph
summary, cut the elaboration.
Changes to the architecture-reviewer role spec:
- Add Decision Quality (F) category: false-deferral check
distinguishing three cases — (1) hedging on a resolved decision, (2)
false deferral / circular hedge (the blocking condition is a
prerequisite of the thing being deferred), (3) legitimate deferral.
- Add Impacts Field Coverage (G) category: check that unresolved OQs
have specific impacts fields.
- Note: the Decision Quality category is often the highest-value
check on poorly-defined projects — the architect cannot self-review
it (circular reasoning is invisible from inside the circle).
Retrofit existing OQs:
- Add Impacts field to all 16 unresolved OQs (10 deferred, 6 open).
- Update OQ-63 (TlsError shape) to reflect ADR-087's client-side
addition — the error type now covers both server and client
variants.
- Move OQ-65 (WebSocket carrying channels) to alknet-http theme
(done in prior commit; this commit adds its impacts field).
- Verified: no circular reasoning found in existing deferrals. The
AlknetClient hedge (OQ-64) was the circular one; it's already
resolved by ADR-087.
OQ-65 is about the WebSocket browser path — an alknet-http concern. It
surfaced during the TLS/ALPN-list discussion in passing (because the
web config's ALPN list needed to account for whether alknet/channels
is advertised), but the question itself lives with the WebSocket
spec, not with TLS or the hub. Remove from alknet-hub and alknet-tls
theme tables; add to alknet-http.
OQ-64 and OQ-55 were linked in a circular dependency: the client-side
TLS config was deferred behind the dial seam (OQ-55), but the dial
needs the TLS config. No second transport can dial until it has a TLS
config; the TLS config was deferred until a second transport dials.
Schrödinger's code — required and not required until observed.
ADR-087 breaks the circle by separating two concerns that were
conflated as 'the same seam':
1. TlsClientConfig — rustls::ClientConfig + ADR-034 verifier selection
+ ADR-084 crypto provider. Transport-agnostic. All decisions made.
Buildable today. A PREREQUISITE for any dial, not a consequence of it.
2. The dial (AlknetClient::dial()) — transport-specific connection
establishment. Extracting a transport-polymorphic dial from one
shape (QUIC) would bake QUIC in. Legitimate deferral (OQ-55,
unchanged).
The hub makes this non-optional: a hub dials out to workers it
supervises and to other hubs (hub-as-client). The first hub deployment
(web + native) dials workers over QUIC with the worker's fingerprint
pinned. There is no 'later' for the TLS config — it is on the critical
path for the first hub and for alknet-worker.
Changes:
- ADR-087: TlsClientConfig in alknet-tls, not blocked on OQ-55
- OQ-64: resolved (yes, alknet-tls provides TlsClientConfig)
- OQ-55: amended — only the dial seam is deferred; TLS client config
is explicitly NOT part of the deferral
- TLS README: 'Server-only (for now)' section replaced with
TlsClientConfig section; crate is no longer server-only
- Hub README: dial/supervision section references TlsClientConfig for
outbound connections
Name the three-endpoint-type model (web/native/iroh) and the
entry-point vs. endpoint ALPN distinction. This untangles the
hub/endpoint/ALPN-config confusion that OQ-62 hinted at:
- A hub composes a SUBSET of three endpoint types (web, native, iroh),
each with its own identity model, auth model, and transport(s). A
full hub runs all three; a minimal hub runs iroh alone (no public
IP required). The first real use case is web + native. Corrects the
hub README's 'must support TCP+TLS and QUIC' framing.
- ALPNs split into entry points (accepted without identity — h2,
http/1.1, future alknet/register) and endpoints (identity required
before dispatch — alknet/channels, alknet/call, alknet/ssh). This
resolves OQ-62: split ALPN lists by endpoint type (Option B), because
each endpoint type serves a different client class with different
negotiable ALPNs. The assembly-layer wiring pattern is now guessable.
- Foundational handlers are two categories, not one: channels
data-channel ALPNs (tunnel, socks5, fs, sftp — gated by channels,
not in any TLS ALPN list) vs. SSH (an endpoint ALPN that wraps
channels inside it, RFC 7250 keys, legacy compat, comes later).
Files OQ-65 (WebSocket carrying channels — the browser story update)
filed as a one-way-door question, open. ADR-048 is not superseded;
OQ-65 may extend it. The web config advertises alknet/channels by
default so the hub is ready if OQ-65 resolves to 'WebSocket carries
channels.'
ADR-085 records the actual workspace scope: the mono-repo is the core
networking toolkit (substrate: core, tls, call, channels; deployment
shapes: hub, worker; foundational handlers: tty, http, ssh, tunnel,
socks5, fs, sftp; vault). Consumer repos (docker, agent) are separate
repos depending on the published core crates. The overview's crate
graph had been describing the wrong scope since ADR-003 — a flat
~12-crate workspace including DNS/messaging/NAPI while omitting
channels, hub, worker, and tls. This stale scope was a causal factor
in the 'assembly layer' hedging pattern: when the overview implies
everything lives in one repo but the architecture needs a hub/worker
composition layer not in the graph, the gap gets filled with
'assembly layer' as an escape hatch. The overview is rewritten to
match the real boundary.
TLS spec review fixes (from architecture review):
- C3: hub/worker/hub-worker terminology pointers (tls README + endpoint.md)
- W1: server-only statement + OQ-64 (client-side TLS helper, deferred)
- W2: ACME task lifecycle semantics (returns immediately, no first-cert await)
- W3: remove stale EndpointError::TlsConfig variant
- W4: update stale ALPN section for two-config hub
- W5: add alknet-tls to hub dep graph (assembly-layer dep)
- W6: trim inline rationale -> point to ADR-084
- W7: ADR-084 status dependency note
New open questions:
- OQ-62: ALPN list sharing for two-config hub (open, high)
- OQ-63: TlsError shape (open, high)
- OQ-64: client-side TLS helper (deferred, blocked on OQ-55)
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.
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.
Implements the wiring task: LocalTtyBackend::allocate() dispatches to
pty::allocate_pty when TtyParams.terminal is Some (PTY mode, stderr None)
and pipe::allocate_pipe when None (runner mode, stderr Some). Validates
non-empty cmd, ignores backend_params (local backend has no backend-
specific selector fields), and resource_id() returns None (local backend
creates its own resource — process). Adds async-trait dependency.
Implement TtyAdapter (ProtocolHandler on alknet/tty) and the
drive_session three-pump bidirectional driver in adapter.rs.
TtyAdapter holds a HashMap<String, Arc<dyn TtyBackend>> keyed by the
negotiation frame's backend string and an optional OwnershipProvider.
handle() loops accept_bi and spawns drive_session per stream.
drive_session proceeds in three phases:
1. Negotiation — read length-prefixed JSON, parse NegotiateRequest,
validate (carriage == raw, cmd non-empty), look up backend, scope-gate
(tty:open), resource ownership check via backend.resource_id() +
OwnershipProvider::owns(), construct TtyParams. Errors sent as JSON
in negotiation framing, stream closed.
2. Allocation — backend.allocate(¶ms). On failure send
allocate_failed error, close.
3. Raw carriage — three concurrent pumps:
A. stdout/stderr → client (stream_type 1/2), zero-length sentinel on EOF
B. client → backend: stdin chunks → TtyHandle.stdin, control chunks →
Resize/Signal/Eof dispatch (Exit ignored, unknown types ignored)
C. exit → exit chunk: await exit_code; on resolve enqueue exit chunk
Exit-chunk-is-last invariant (ADR-055): adapter waits for BOTH
stdout/stderr pumps to complete AND exit_code to resolve before
enqueueing the exit chunk (tokio::join!). On TtyError → code -1.
Cancel cleanup (ADR-056): on connection drop/stream reset, pump tasks
drop, TtyHandle drops, exit_code future drops without resolve →
backend kill-on-Drop fires. Client write-half close is NOT a cancel —
session runs to completion.
Access control (ADR-050): scope gate at negotiation (tty:open scope,
configurable constant), resource ownership via backend.resource_id() +
OwnershipProvider::owns() when wired.
18 integration tests with a TestBackend fixture over tokio::io::duplex:
happy path, exit-chunk-is-last, stdin EOF, resize/signal control,
unknown control ignored, exit control ignored, unknown_backend,
malformed_negotiation (bad JSON, carriage != raw, empty cmd),
allocate_failed, exit error (-1), cancel cleanup, scope gate forbidden,
ownership check deny/allow, stderr pump, eof control closes stdin.
Also fix two clippy unused_mut warnings in negotiation.rs tests.
Implements the runner case (terminal: None, no PTY) in pipe.rs:
- allocate_pipe spawns via tokio::process::Command with Stdio::piped(),
returning a TtyHandle with separate stdout/stderr (stderr=Some).
- stdin: ChildStdin boxed as Box<dyn AsyncWrite + Send + Unpin>.
- stdout/stderr: ChildStdout/ChildStderr wrapped via tokio_util::io::ReaderStream
into Pin<Box<dyn Stream<Item = Bytes> + Send>> (errors map to stream EOF).
- exit_code: PipeExitFuture holding the Child; poll drives Child::wait() and
disarms the guard on resolve; Drop calls Child::start_kill() on cancel
(ADR-056 spec-compliant kill-on-Drop, not kill_on_drop(true) alone).
- control: PipeControl (no-op resize, libc::kill(pid, sig) signal on Unix,
SIGKILL fallback for unknown names; non-Unix logs and relies on Drop guard).
Doc comment documents the no-process-group limitation of pipe mode.
Adds tokio-util (io) and tokio-stream (dev) deps. 9 unit tests cover the
happy path, stdin round-trip, separate stderr, SIGTERM signal, cancel
cleanup (asserts child killed via kill(pid,0)), resize no-op, unknown
signal SIGKILL fallback, pid recording, and empty-command error.
Implement allocate_pty in src/pty.rs: portable_pty openpty + spawn as
session leader (set_controlling_tty(true)), with the three-thread
blocking→async bridge (REQ-TTY-01): reader → mpsc<Bytes> with
zero-length EOF sentinel, writer draining mpsc<StdinCmd> (Bytes/Eof),
waiter → oneshot<i32>. TtyHandle.stdout is ReceiverStream<Bytes>,
stdin is a StdinSink AsyncWrite wrapper over mpsc::Sender<StdinCmd>
(parks a reserve+send future when the channel is full), stderr is None
(PTY merges), exit_code is LocalExitFuture.
PtyControl implements TtyControl: resize via MasterPty::resize; signal
via libc::kill(-pgid, sig) with kill(pid, sig) fallback (Unix, REQ-TTY-02)
using alknet_tty::signal_from_name; unknown names and non-Unix fall back
to ChildKiller::kill (SIGHUP).
LocalExitFuture wraps the oneshot::Receiver<i32> + a ChildKiller kill
guard (ADR-056). poll delegates to the receiver and disarms the guard
on Ready (no-op Drop on happy path); Drop on cancel calls
ChildKiller::kill (SIGHUP) — best-effort; the waiter thread reaps.
Bump portable-pty 0.8 → 0.9 to match the POC API (MasterPty: Send).
Add tokio-stream, serde_json (dev), tempfile (dev) deps.
Tests: happy path (echo), stdin round-trip (cat), resize, signal INT
kills child, signal reaches process group (bash -c "sleep 60"),
cancel cleanup on drop, unknown signal falls back to ChildKiller.
Replace placeholder negotiation types with the full Phase 1 wire shape:
- NegotiateRequest/TerminalParamsWire with serde(flatten) capturing
backend-specific fields into backend_params
- NegotiationReader/Writer: self-contained 4-byte BE length-prefixed
framing on tokio AsyncRead/AsyncWrite, bounds-checked against MAX_CHUNK_LEN
- NegotiationError (Io, ConnectionClosed, FrameTooLarge, Json) via thiserror
- error_response_bytes helper for server-side JSON error frames
- into_inner reclaims the stream for raw-chunk use
- Error frames stay under 16 MiB so the high byte of the length prefix is
0x00, making the framing-disambiguation trick sound (ADR-052 §5)
Unit tests cover round-trip, serde(flatten) capture, FrameTooLarge,
ConnectionClosed on truncated header/body, error-response shape, and the
0x00-first-byte framing-disambiguation invariant. backend.rs's
From<NegotiateRequest> for TtyParams still compiles (same field shapes).
Create crates/alknet-tty-local with Cargo.toml (alknet-tty workspace dep,
portable-pty, libc unix-only, tokio, bytes, futures-core, tracing,
thiserror), src/lib.rs with module declarations and LocalTtyBackend
re-export, and skeleton module files (pty, pipe, backend) with doc
comments and TODO markers. Add the crate to the workspace members list.
backend.rs defines its own BoxFuture type alias
(Pin<Box<dyn Future + Send>>) using std::future::Future + futures_core,
so the futures crate is unused. Removes the dead dependency.
Port the POC's control channel schema (alknet-tty-poc/src/control.rs)
into crates/alknet-tty/src/control.rs: the ControlMessage tagged enum
(Resize/Signal/Eof/Exit) with snake_case type tag, to_json/from_slice
helpers, and the Unix-only signal_from_name mapping the 9 supported
signal names to libc numbers. Unknown type tags return a serde_json
error; the adapter (later task) ignores that error per the wire spec's
extensibility policy. Adds libc as a target.'cfg(unix)' dependency.
Refs: docs/architecture/crates/tty/tty-wire.md §"Control Channel"
Refs: docs/architecture/decisions/055-exit-code-on-control-chunk.md
Create crates/alknet-tty with Cargo.toml (alknet-core path dep, tokio,
bytes, futures-core, tokio-stream, serde, serde_json, async-trait,
tracing, thiserror — no portable_pty/bollard/russh/alknet-call per
ADR-057), the `local` feature gate (dep wiring deferred to
tty/local-feature-reexport), src/lib.rs with the crate doc comment and
module declarations, and skeleton modules (wire, control, negotiation,
backend, adapter) with doc comments and TODO markers. Add the crate to
the workspace members list.
cargo check, clippy -D warnings, and fmt --check all pass.
Break the tty spec set (docs/architecture/crates/tty/) into atomic,
dependency-ordered tasks across two crates:
alknet-tty (lean core, depends on alknet-core only — ADR-057):
- crate-init, wire-codec, control-messages, negotiation, backend-trait,
adapter, review-tty
alknet-tty-local (sibling, portable_pty + libc — ADR-054):
- crate-init, pty-mode, pipe-mode, backend-impl, review-tty-local
integration:
- local-feature-reexport, integration-test, review-tty-final
Three review checkpoints at critical points: after the core crate (before
the local backend builds on the one-way-door TtyBackend trait — ADR-053),
after the local backend (validating REQ-TTY-01/02 and ADR-056), and a final
merge-readiness review. The high-risk tasks (backend-trait, adapter,
pty-mode) are flagged; the ADR-056 kill-on-Drop guard the POC lacked is
called out in both pty-mode and pipe-mode.
Validated with taskgraph: 115 tasks, no cycles, 10-generation parallel
structure with 3-way parallelism after crate-init and 2-way after the
backend trait.
The earlier specs claimed alknet-tty reuses alknet-call's
FrameFramedReader/FrameFramedWriter 'framing utility' (ADR-052 §6,
ADR-003 Am. 1). 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 utility. alknet-tty's
negotiation payload is a NegotiateRequest, not an EventEnvelope, so the
claimed reuse did not exist in a usable form.
ADR-057: alknet-tty implements its own ~30-line length-prefixed
framing directly on tokio AsyncRead/AsyncWrite. The format coincides
with alknet-call's framing by convention (both are length-prefixed
JSON); the implementations are independent. alknet-tty depends on
alknet-core only — no alknet-call dependency. The ~30 lines of framing
is an idiom, not a domain abstraction worth a cross-crate dependency.
Updates:
- ADR-003 Amendment 2: clarifies alknet-tty does not depend on
alknet-call; the Am. 1 protocol-foundation exception remains for
alknet-http/agent/napi (actual type reuse), not alknet-tty.
- ADR-052 §6: framing is self-contained in alknet-tty; format coincides
by convention, not by code reuse.
- overview.md: dependency block drops alknet-call; new 'Why no
alknet-call dependency' subsection explains the unsound-reuse finding.
- tty-wire.md, tty-backend.md, tty README, crates/tty/README.md,
top-level README: ADR tables, Design Decisions tables, and dependency
edges updated to reflect alknet-core-only dependency.
- ADR-053, ADR-054: References updated to ADR-003 Am. 2 + ADR-057.
C1: Box<dyn TtyControl + Clone> does not compile (Clone is not object-safe).
Replace with a TtyControlHandle newtype: #[derive(Clone)] wrapping
Arc<dyn TtyControl + Send + Sync>. The trait stays object-safe; the
newtype carries the Clone-ability. Updated across tty-backend.md,
ADR-053, OQ-43, and the tty README.
W1: 'Drop is the cleanup, threads exit on channel close' was false for
the local PTY waiter thread (blocked in Child::wait(), does not observe
channel close) — a child that ignores stdin EOF would be orphaned on
session cancel. New ADR-056 commits a backend cleanup contract: dropping
the exit_code future MUST kill the session target. The local backend
implements it via a ChildKiller held in the exit_code future's Drop
guard (disarmed on resolve). Corrected the lifecycle section in
tty-adapter.md, added the mechanism + constraint to tty-local.md, and
referenced ADR-056 from tty-backend.md, overview.md, both READMEs.
S1: error-frame < 16 MiB stated as a wire-format invariant (not an
assumption) so the 0x00-first-byte disambiguation trick is sound by
construction.
S2: carriage field validation (MUST be "raw" in v1, else
malformed_negotiation) specified.
S3: NegotiateRequest + TerminalParamsWire Rust structs added with
serde defaults and validation rules.
S4: modes field annotated 'backends MUST ignore content in v1' in both
tty-backend.md and ADR-053.
S5: AsyncWrite/Stream qualified with crate origins (tokio::io,
futures_core) in the TtyHandle struct definitions.
The BackendParams typed enum was a spec bug: it created a partial
inversion (trait inverted, params not), modeled the SSH channel as an
input when it is an output (the backend opens it in allocate()), and
created a dependency contradiction — SshChannelRef could not live in
alknet-ssh (alknet-tty would depend on it) nor in alknet-tty (would pull
in russh types) without violating the inversion ADR-053 establishes.
Replaced with an opaque serde_json::Map: the adapter passes the
negotiation frame's backend-specific fields through verbatim; each
backend deserializes its own strongly-typed params struct. alknet-tty
has zero knowledge of any backend's params shape. Added a resource_id()
default method to TtyBackend so the ownership check (ADR-050) is
backend-driven rather than the adapter hardcoding docker's container
field extraction. The inversion is now complete — both the trait and
the params are inverted; adding SSH (or any future backend) is purely
additive: zero changes to alknet-tty, zero changes to existing call
sites.
The backpressure chain is complete by construction: QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer
→ process write() blocks. Every link awaits its producer; no unbounded
buffer breaks the chain. The reversal path (an additive ControlMessage
variant, not a wire-format header change) is noted in ADR-052's
consequences as a two-way door, not the expected path.
Tightened ADR-052 assumption 1 from a hedge ('expected to work; a POC
would confirm') to a decided constraint. Updated all OQ-45 references
across the tty spec docs from 'open (low risk)' to 'resolved'.
Establishes the tasks/architecture/ blocker-task half of the Safe Exit
protocol. Each deferred OQ now has an external-trigger tracker task
representing its unblocking condition, and the OQ's Blocked on field names
the tracker task ID — closing the gap where the deferral field existed but
the task-graph half was unenforced.
Changes:
- Add 6 external-trigger tracker tasks for the deferred OQs (OQ-09, 10, 32,
41, 44, 46) under tasks/architecture/, tagged [external-trigger, deferred-oq]
- Add structured Blocked on field to OQ-09 and OQ-10 (previously used legacy
'deferred' status with the reason in the Resolution prose); index now
surfaces all 6 deferred OQs with concrete conditions, no placeholders
- Document the architecture-task level mapping (level: implementation for
ADRs/specs, decomposition for spec-to-ADR breakdown, planning for backlog
seeding, research/review direct fits) and the deferred-OQ blocker-task
pattern in docs/sdd_process.md
- Mark architecture/safe-exit-blocker-task-mechanism and
architecture/oq-09-10-blocking-conditions completed (implemented by this pass)
- Fix 4 pre-existing ADR-050 implementation tasks: status: done → completed
(taskgraph enum is 'completed', not 'done') — taskgraph validate now passes
for all 100 tasks
The three architecture tasks I seeded in the previous commit used invalid
enum values for the taskgraph frontmatter (level: architecture, impact: local,
scope: small). Corrected to the valid taskgraph enums: level: implementation/
decomposition/planning, impact: project/component, scope: moderate/narrow.
taskgraph validate now passes for tasks/architecture/.
(Pre-existing tasks/call/registry/*.md and tasks/core/ownership-store-trait.md
use status: done instead of completed — out of scope for this fix.)
The monolithic open-questions.md (1310 lines, 47 OQs) was large enough to be
unmanageable, with high size variance (OQ-42 at 220 lines next to OQ-06 at 8).
Decomposed into one file per OQ under docs/architecture/questions/ (NNN-slug.md,
mirroring the ADR convention), with open-questions.md retained as the index:
theme-grouped tables plus a cross-theme Deferred/Blocked section that surfaces
the 6 deferred OQs with their Blocked-on conditions inline (the safe-exit
visibility surface). Per-OQ content moved verbatim; all 62 inbound links stay
valid (none used anchors). README's curated OQ summary dropped (now redundant
with the index tables).
Also seeds tasks/architecture/ with this task plus two follow-ups found during
the decompose: OQ-09/10 missing structured Blocked-on fields, and the
tasks/architecture/ blocker-task half of the Safe Exit protocol being
unenforced.
Grounded in the alknet-docker POC (seed codec) and the alknet-tty POC
(2026-07-05, validated the control channel, the local-PTY blocking→async
bridge, and the signal-delivery contract).
Four ADRs:
- ADR-052: wire format — alknet/tty ALPN, two-carriage (JSON negotiation
then raw chunks), fixed channel set 0-3, control as JSON, negotiation-
error framing disambiguation
- ADR-053: TtyBackend trait + TtyHandle — the backend inversion point;
exit_code as a Future; REQ-TTY-01 (backends need not be natively async)
- ADR-054: local backend as alknet-tty-local sibling crate behind a
feature re-export; PTY vs pipe per-session; runner pattern preserved
- ADR-055: exit code on a stream_type 3 control chunk; "exit chunk is
last" invariant; adapter owns the ordering
Five component specs (crates/tty/): README, overview, tty-wire, tty-
backend, tty-adapter, tty-local. The docker/SSH backends are future
crates (out of scope); the trait shape is committed so they can be
built against it.
Five OQs (OQ-43…47): two resolved (TtyControl Clone, stdin closure),
two deferred(scope) (terminal modes, runner API surface), one open
low-risk (flow control).
Zero critical issues from a general-subagent architecture review;
warnings addressed (inline rationale trimmed to ADR refs, TtyError and
StdinCmd defined, framing disambiguation documented, missing ADRs added
to index tables).
Implementation verified yaml_serde 0.10.4 (and serde_yaml 0.9) implement the
YAML 1.2 core schema, where bare yes/no/on/off are strings, not booleans. The
original §2 rationale cited YAML 1.1 boolean-coercion as a present hazard
justifying JSON-first detection; it does not exist with this dependency
version.
The JSON-first decision is retained (Accepted ADR) — reframed as a defensive
default that locks the contract against a future YAML-parser swap, not a
guard against a present hazard. §2 heading, Consequences, and Assumption #2
amended; an in-place amendment note records what changed and when. The two
coercion-related tests already document yaml_serde 0.10.x's actual behavior
with explicit comments. Source doc-comments on from_yaml/from_str corrected
to stop propagating the flawed assumption. 256 tests pass; clippy clean.
Adds OpenAPISpec::from_yaml and OpenAPISpec::from_str alongside the
existing from_json. from_str is JSON-first, YAML-fallback (ADR-051 §2):
a valid JSON doc parses through serde_json (stricter grammar, immune to
YAML-specific coercion); only on JSON failure does it fall back to the
YAML parser. yaml_serde 0.10 (resolves to 0.10.4, latest stable) is the
maintained official-YAML-org fork of the deprecated serde_yaml; not
feature-gated per ADR-051 §3 (YAML OpenAPI is first-class).
Note: ADR-051 §2's stated YAML 1.1 boolean-coercion rationale does not
hold for yaml_serde 0.10 (YAML 1.2 core schema — yes/no/on/off are
strings). The JSON-first rule is retained per the Accepted ADR as a
defensive default; tests document the actual 0.10.x behavior with
explicit comments. The ADR rationale should be amended separately.
256 tests pass; clippy + fmt clean.
from_openapi's spec already stated the contract as "JSON/YAML doc" but the
implementation only delivered the JSON half. vast.ai publishes a YAML
OpenAPI schema, which surfaces the gap. This is gap-filling against an
existing constraint — no architecture invariants touched (OpenAPISpec
stays serde_json::Value-based, forwarding handler / no-env-vars / error
fidelity / to_openapi output unchanged).
ADR-051 records: (1) from_yaml + format-detecting from_str constructors;
(2) JSON-first/YAML-fallback detection as a correctness rule (YAML 1.1
coerces yes/no/on/off to booleans — running JSON through YAML would
silently corrupt string fields); (3) yaml_serde dependency (maintained
official-YAML-org fork of deprecated serde_yaml, not feature-gated);
(4) to_openapi output stays JSON (scope boundary).
Specs updated to reference ADR-051 by number (http-adapters, overview,
both README ADR tables). Implementation task added at
tasks/http/adapters/from-openapi-yaml-input.md.
Built /workspace/alknet-tty-poc against portable_pty 0.9 to validate the
local-PTY path (Step 2 of the build order) before Phase 1 specs. The POC
surfaced two constraints that were not knowable from reading the
portable_pty docs alone and that the architect must carry into the
tty-backend.md and tty-local.md specs:
- REQ-TTY-01: portable_pty is a blocking std::io API; the TtyBackend
trait must accommodate blocking backends that bridge to async via std
threads + tokio mpsc. exit_code resolves to a Future the adapter
awaits (resolves the load-bearing half of OQ-TTY-01).
- REQ-TTY-02: signal forwarding must target the process group
(kill(-pgid, sig)), which depends on the child being a session leader
(portable_pty's controlling_tty=true default).
The POC also validated the control channel (stream_type 3), JSON control
messages (DP-3), and exit-code-on-control-chunk (DP-5). OQ-TTY-01 is
marked resolved with the control-as-Clone-trait-object sub-question left
open with a POC-informed recommendation. The POC itself lives in the dev
workspace, not the repo; this doc is the durable record.
- alknet-ssh: remove paragraphs describing old hedging problem in channel
decomposition section and DP-4 — state the insight directly
- alknet-tty: replace 'defer for v1' / 'reserved for future use' with
'not needed for the current scope' in OQ-TTY-02
Implements the 4-task DAG for runtime-spawned resource ownership so
AccessControl::check can answer "does this identity own this specific
container/TTY/process" instead of relying only on static
Identity.resources grants.
1. core/ownership-store-trait: OwnershipProvider (sync read) +
OwnershipStore (async write) traits + InMemoryOwnershipStore +
OwnershipError in alknet-core. Fourth instance of the repo/adapter
pattern (ADR-033), mirroring CredentialStore/store.rs.
2. call/registry/operation-spec-resource-id-path: resource_id_path
field on OperationSpec — JSON pointer into input for resource ID
extraction. Single field addition, all ~40 construction sites across
alknet-call + alknet-http updated to pass None (no semantic change).
3. call/registry/access-control-ownership-check: check() signature
gains resource_id + Option<&dyn OwnershipProvider>. 3-case decision
tree: ownership Some + resource_id Some -> owns(); ownership Some +
resource_id None -> owns_any() (list scope-gate); ownership None ->
static Identity.resources fallback (backward compat). 7 call sites
updated to (None, None) — including a 7th in alknet-http/gateway_routes
not listed in the original spec.
4. call/registry/dispatch-resource-id-extraction: wire the dispatch
path. OperationContext gains ownership field; Dispatcher/CallAdapter
gain with_ownership_provider builders; invoke/invoke_streaming/
invoke_with_policy extract resource_id via spec.resource_id_path
and thread context.ownership to check(). extract_json_pointer helper
handles $.field syntax (graceful None on missing/non-string). 20
OperationContext literals updated across both crates.
Backward compatibility is load-bearing throughout: ownership=None
falls back to the existing static resource-check path. Deployments
without runtime-spawned resources wire nothing and behave identically.
821 tests pass workspace-wide (was ~770); clippy clean; fmt clean.
Task specs marked done.