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.
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.
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
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.
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.
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 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.
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
operation-registry.md:
- OperationSpec gains resource_id_path: Option<String> (JSON pointer
into the input for runtime-spawned resource ID extraction)
- AccessControl::check signature updated: consults an OwnershipProvider
for dynamic resource ownership; falls back to static Identity.resources
when no provider is wired (backward compatible)
- Dispatch flow updated: step 3 extracts resource_id via
spec.resource_id_path before the ACL check
- Added composition + dynamic ownership interaction (ADR-050 §4d):
two orthogonal checks, ADR-015/022 unchanged
- Design Decisions table + Open Questions + References updated
auth.md:
- New 'Ownership Provider and Store (ADR-050)' section: OwnershipProvider
(sync read trait) + OwnershipStore (async write trait) + InMemoryOwnershipStore
default adapter; fourth instance of the repo/adapter pattern (ADR-033)
- How it integrates with AccessControl::check
- Access pattern: proxy-only (spawner owns, proxy to share, teardown
revokes; no grant mechanism in core)
- Per-node ownership (no cross-node propagation in the base model)
- Resource-scoped ACLs table gains the dynamic ownership path
- Design Decisions table + Open Questions updated
Writes OQ-42's five decisions into ADR format:
1. Storage: reuse the repo/adapter pattern (ADR-033, fourth instance
alongside IdentityProvider/IdentityStore/CredentialStore). New traits:
OwnershipProvider (sync read, hot-path) + OwnershipStore (async write,
handler lifecycle). In-memory default; persistence adapter additive.
2. Integration: AccessControl::check consults the ownership provider
directly (Option 2). OperationSpec gains resource_id_path (JSON pointer
into the input). Backward-compatible — ownership=None falls back to
the static Identity.resources path.
3. Access pattern: proxy-only. Spawner owns, proxy to share via from_call
+ forwarded_for (ADR-032), teardown revokes. No grant mechanism in
core. Future grant is additive (new trait method), stated as
reversal-cost classification, not deferral.
4. Four edge specifics: list = scope-gate + result-filter; teardown =
automatic, handler-driven; fleet = per-node ownership, downstream app
tracks 'who is this for'; composition = two orthogonal checks,
ADR-015/022 unchanged.
Reviewed: zero critical issues. Two warnings fixed (None-handling in the
check sketch, missing ADR-004 cross-ref). One suggestion applied
('v1 mechanism' → 'initial mechanism' to avoid hedging misread).
Lock in the dynamic resource ownership model for runtime-spawned
resources (containers, TTYs, runner workspaces). Three decisions:
1. Storage: reuse the repo/adapter pattern (ADR-033, fourth instance
alongside IdentityProvider/IdentityStore/CredentialStore) with an
in-memory default adapter; sync read on the dispatch hot path.
2. Integration: AccessControl::check consults an ownership provider
directly (Option 2); OperationSpec gains resource_id_path (JSON
pointer into the input) so the spec is fully self-describing for
authorization.
3. Access pattern: proxy-only — spawner owns, proxy to share via
from_call + forwarded_for (ADR-032), teardown revokes. No grant
mechanism in core; 'poking holes' is a downstream-app concern. A
future grant is additive (new trait method), stated as reversal-cost
classification, not deferral.
Four edge specifics pinned: list = scope-gate + result-filter; teardown
= automatic, handler-driven; fleet = per-node ownership, downstream app
tracks 'who is this for'; composition = two orthogonal checks, ADR-015/022
unchanged.
Removes the prior hedging language ('decision direction set', 'open for
the ADR') and the contingent qualifiers from specifics 3/4 now that the
proxy-vs-grant call is made. The dependent crate specs (docker, tty,
runner, fleet) can declare their AccessControl shapes against this model.
- Add Safe Exit section to architect spec: when a decision genuinely can't
be made, mark OQ as deferred(scope) with concrete blocking condition,
create blocker task, move on
- Add anti-patterns #10-#11 covering hedging synonyms (feature extension,
additive, not a v1 blocker, for now, resolved with escape hatch)
- Add hedging audit to architect self-review checklist
- Clean hedging language from resolved OQs (OQ-04, OQ-13, OQ-14, OQ-16)
- Reclassify OQ-32 and OQ-41 as deferred(scope) with blocking conditions
- Add deferred(scope) status to OQ format in sdd_process.md
Two structural decisions for dynamic resource ownership (OQ-42), recorded
in the OQ so ADR drafting starts from a clear position:
1. Storage side reuses the repo/adapter pattern (ADR-033) — a fourth
instance alongside IdentityProvider/IdentityStore/CredentialStore.
Trait in alknet-core with an in-memory default adapter; persistence
adapter separable. Sync read with ArcSwap + honker-NOTIFY cache
invalidation, same shape as ConfigIdentityProvider (ADR-035). No new
shape invented; no Phase 0 needed for the storage side.
2. Integration point is Option 2 — AccessControl::check consults the
ownership provider directly. Rejected Option 1 (augment identity with
a per-request snapshot) because its purity was theatrical — the
question 'can X exec into container C' was never purely a function of
identity, it just looked that way because the resource set was static.
Option 2 makes check's signature honest about what ACL checking is in
the presence of dynamic resources. Cost is a check signature change
(one-way door, every call site updates) — implementation cost, not
semantic cost, per the project's decision principle.
Refinement that makes Option 2 clean: OperationSpec gains resource_id_path
(JSON pointer into the input, e.g. '$.containerId'). Fits naturally with
the existing JSON-Schema-backed input_schema — the pointer is within an
existing schema on the same spec. OperationSpec becomes fully
self-describing for authorization: resource type, action, and which input
field drives the resource lookup, all declared on the spec.
Four specifics remain open for the ADR: the no-specific-resource (list)
case, teardown coupling, fleet representation (spoke resources on the
hub), and composition interaction with dynamic ownership. These were
surfaced by choosing Option 2 rather than by leaving the integration point
undecided.
The alknet-docker POC research surfaced that containers are a natural
AccessControl resource, but the resource set is dynamic (containers are
created at runtime) and ownership is derived from creation — which the
current static Identity.resources model (config-sourced via
PeerEntry/CompositionAuthority) doesn't fit. The issue generalizes to
every crate that spawns a thing at runtime and exposes it over the call
protocol (docker, tty, opencode-runner wrapper, alknet-container fleet
layer); solving it per-crate would diverge. Recording as OQ-42 in the
centralized tracker with the generalized framing so the architecture
workflow sees it: one-way door at the model level (core/call), two-way
at the mechanism level, high priority, blocks the dependent crate specs.
A Phase 0 research/POC pass is likely warranted before the ADR.
The call protocol spec describes streaming (call.responded*N +
call.completed, PendingRequestMap::Subscribe, CallConnection::subscribe),
but the server-side Handler type returned a single ResponseEnvelope —
a Subscription op had no way to produce a stream. The TS predecessor
(@alkdev/operations) had separate OperationHandler / SubscriptionHandler
types; the Rust port collapsed them, losing the streaming path. This
restores it end-to-end: StreamingHandler type, HandlerKind on
HandlerRegistration validated against op_type, invoke_streaming() on
OperationRegistry, server-side dispatch branches on op_type, new
INVALID_OPERATION_TYPE protocol code for wrong-dispatch-path misuse,
GatewayDispatch::invoke_streaming() for /subscribe SSE, from_call stream
forwarding via CallConnection::subscribe(), from_openapi SSE forwarding.
OperationEnv::invoke() stays request/response-only (stream composition is
handler-level, not protocol-level). Amends ADR-023's protocol-code list
(five → six). Tracks the stream-operators library as OQ-41 (feature
extension, not an unmade decision).
Three issues found in the http crate spec sanity check that would have
caused problems during task decomposition, now fixed:
C1 — /subscribe GET→POST: the gateway's /subscribe is an invoke endpoint
carrying { operation, input } in the body, but was listed as GET (which
has no body). Flipped to POST with Accept: text/event-stream negotiating
the SSE response, consistent with /call's flat-JSON-body invariant.
Browsers using EventSource can't POST but use WebSocket for the
bidirectional path; the HTTP gateway's /subscribe is for non-browser
HTTP clients (fetch + ReadableStream). Touches ADR-042, ADR-047,
ADR-048, http-adapters.md, http-server.md.
C2 — stale direct-call references: three spots contradicted ADR-047
(which removed the POST /{service}/{op} direct-call surface) and
ADR-046 §3 (which states /{service}/{op} is no longer reserved).
Cleaned up in http-server.md (custom-routes intro + collision list) and
ADR-046 §6 (default-surface list).
W2 — from_mcp output handling: the spec's fallback for tools without
outputSchema was Type.Unknown(), but the correct fallback is the MCP
ContentBlock union (text|image|audio|resource|resource_link) — a
well-defined MCP type, not Unknown. Fixed http-mcp.md with the full
structuredContent-preferred-over-content-blocks logic (matching the TS
adapter and rmcp SDK), enriched references with specific rmcp source
files. Also added shared-dispatch-spine notes to http-mcp.md and
http-adapters.md cross-referencing the new research findings.
Research (docs/research/alknet-http-gateway-factoring/findings.md):
to_mcp and to_openapi share a dispatch spine (resolve → invoke → map).
Recommendation: extract a thin shared struct now, not a GatewayDispatch
trait — the server-integration layers (axum routes vs rmcp
StreamableHttpService) and wire-framing stay per-gateway. A third
gateway is not on the horizon; if one appears its server-integration
needs its own shape anyway.
Minor: WS route precedence note (websocket.md), OpenAPISpec
shared-type-not-shape clarification (http-adapters.md), date bumps.
Document the two codebases that inform the ShaderGenerator's op table
and the wgpu+handlebars+remote-GPU patterns:
- wonnx (MIT/Apache-2.0, archived): comprehensive ONNX op set in
Tera-templated WGSL at wonnx/templates/ — arithmetic, activation,
gemm, conv, batchnorm, softmax, etc. Port the shader implementations,
swap Tera for handlebars. compiler.rs's add_raw_template +
include_str! pattern maps 1:1 to handlebars-rs register_template_string.
- Handlebars + wgpu + remote-GPU patterns (private reference, patterns
reusable): validates the handlebars-rs side and the vast.ai deployment
shape. Patterns carried over: {{> partial}} includes for shared
fragments, inline-able constant tables via switch statements (SHA-256
k-values, universal across wgpu versions), default-valued template
parameters, wgpu-on-remote-GPU sync. sha256 as a base shader
demonstrating non-ML compute on the same dispatch surface.
Updated the WGSL codegen probe POC to reference wonnx's op set as the
porting source.
Extract the shared JS+wgpu substrate (verified by the alknet-desktop POCs)
as alknet-runtime — the generalized QuickJS-NG + wgpu runtime that both
alknet-desktop (render) and alknet-compute (tensor compute) build on. Key
property driving the split: wgpu on llvmpipe is genuinely useful compute
with no physical GPU (WGSL → optimized SIMD beats JS for non-trivial
workloads), so wgpu is unconditional in the runtime rather than a feature
flag.
Reframes the original alknet-tensor architecture-summary as alknet-compute
(builds on alknet-runtime + alknet-tensor) with ShaderGenerator as a trait
(WGSL first impl, SPIR-V/GLSL/naga-IR later per wgpu multi-input-language
support). alknet-tensor/metatensor-format.md is now clearly the pure binary
format crate (no JS or wgpu dep), usable standalone by a pure-Rust model
server.
Layering: alknet-runtime depends on alknet-call (registry authority stays
per ADR-013); alknet-compute and alknet-desktop depend on alknet-runtime;
alknet-tensor is a pure-format sibling.
Promote the WebSocket browser path from a section in http-server.md to a
first-class spec (websocket.md) and commit the contract-pattern decision
(ADR-048): a WS connection carries the native EventEnvelope call-protocol
session, not the HTTP gateway shape. The gateway endpoints are HTTP-only;
discovery on WS is via services/list/services/schema as ordinary call-protocol
ops; subscriptions project as native call.responded events (no SSE).
ADR-044 already decided WS as the v1 browser bidirectional path; ADR-048
clarifies the shape of what ADR-044 committed (§1 implies native session;
the ADR makes it an explicit implementer-visible rule). The from_wss adapter
(importing a remote node's ops over WS) is recorded as out-of-scope with a
concrete reversal trigger so it is not re-derived later.
Spec cleanup: http-server.md WS section collapsed to a stub pointer;
websocket.md Why section references ADRs rather than re-arguing them;
length-prefix decision made canonical (no prefix on WS — message boundary
is the delimiter); default upgrade path pinned (/alknet/call) with HTTP/2
extended CONNECT noted; indexes (README, http/README, overview) updated.
OQ-39 (to_openapi published-spec versioning) resolved by ADR-045:
info.version semver tracks the gateway endpoint contract, not the
operation set — per-caller operations discovered via /search do not
bump the version. The gateway pattern (ADR-042) dissolved most of the
original churn concern.
ADR-046: assembly-layer custom HTTP routes on HttpAdapter. The HTTP
router had no documented extension point for deployment-specific
endpoints (e.g., an OAI-compatible proxy at /v1/chat/completions). Adds
extra_routes: Option<Router> at construction; raw HTTP, not operations;
default surface takes precedence on collision. The mechanism is the
one-way door; specific routes are two-way.
ADR-047: remove the direct-call POST /{service}/{op} HTTP surface. The
gateway /call is the sole invoke path — the simplified contract is a
few fixed endpoints, not a per-operation REST tree. The direct-call
surface re-introduced the 'dump the full API regardless of privs'
failure mode at the HTTP level that the gateway /search was built to
escape. ADR-036's routing decision is superseded; its non-routing
clauses (SSE, Bearer auth, /healthz, stealth, error mapping) survive.
A deployment wanting a REST-like per-operation surface builds it as a
custom route projection (ADR-046).
ADR-044 updated with the tradeoff framing (WSS is the right tool for
the call-protocol-from-browser case; WebTransport is the right tool for
the generalized ALPN-stream-proxy case we don't have yet — coexist, not
migrate) and the @alkdev/pubsub concrete prior art (the EventEnvelope
{type,id,payload} the call protocol was derived from already has a
working WebSocket client/server; the sync is a small adjustment, not a
from-scratch build).
call-protocol.md references the pubsub lineage for the
transport-agnosticism claim.