phase 4: architecture docs + BAST schema + renumbered ADRs

Port the alknet-tty architecture docs into alktty and add the BAST
document for the alk/tty wire format. Docs-only; no Rust source
changes.

Spec docs (docs/architecture/, flat layout — single-crate repo):
- overview.md — crate purpose, two-carriage model, deps, ALPN,
  backend location map, feature gates
- tty-wire.md — 5-byte chunk codec, control channel split
  (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels
- tty-backend.md — TtyBackend trait, TtyHandle, TtyControl,
  REQ-TTY-01 (backends need not be natively async)
- tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk
  ordering (ADR-004), cancel cleanup (ADR-005), access control
- tty-local.md — LocalTtyBackend (local feature module), PTY +
  pipe modes, REQ-TTY-02 (signal forwarding to process group)
- README.md — architecture index

ADRs (docs/architecture/decisions/, renumbered 001..008 from
alknet 052,053,054,055,056,057,077,093 in order):
- 001 wire format + two-carriage model (incl. Phase 7 control-
  channel split amendment)
- 002 TtyBackend trait + TtyHandle
- 003 local backend placement (records both the alknet sibling-
  crate decision and the alktty single-crate consolidation behind
  a local feature)
- 004 exit code on a control chunk
- 005 backend cleanup on session cancel
- 006 self-contained negotiation framing
- 007 tty inside channels (reversed by 008; kept for historical
  context with reversal notice)
- 008 channels pure channel multiplexing (reverses 007; TTY
  always uses its 5-byte format)

BAST document (docs/architecture/tty-bast.md):
- Normative JSON spec for the alk/tty wire format, conforming to
  the BAST meta-schema at https://alk.dev/bast/v1/schema
- 5-byte chunk header (struct, big-endian: stream_type uint8,
  length uint32) + StreamType enum (Stdin=0..CtrlOut=4)
- ControlMessage union (field-name discriminator on type:
  resize/signal/eof/exit) with documented deviation that on-wire
  control payloads are UTF-8 JSON, not BAST's binary union
  encoding
- NegotiationFrame (4-byte BE length + UTF-8 JSON body) +
  NegotiateRequest / TerminalParams JSON shapes
- StreamType enum deviation noted: on-wire uint8, not BAST's
  standard u32 enum index (chunk header is 5 bytes, not 8)
- alktty does not depend on alktype; the hand-rolled wire.rs is
  the runtime codec, the BAST is the human-readable contract

AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8
mapping (the previous table substituted ADR-050 for 054, relabeled
056 as control-message split, dropped 077, and added a new
control-split ADR at 006 — inconsistent with both the plan and the
prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet-
core ADR, not tty-specific, and is not ported; the Phase 7 control
split stays as an amendment inside ADR-001, mirroring alknet.

Verification (all pass, no Rust source changed):
- cargo test (80 passed)
- cargo test --all-features (103 passed)
- cargo clippy --all-targets -- -D warnings (clean)
- cargo fmt --check (clean)
- cargo check --target wasm32-unknown-unknown (clean)
- cargo clippy --target wasm32-unknown-unknown -- -D warnings
  (clean)
- cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in
  src/session.rs and src/channels.rs (untouched by this commit;
  not introduced here)
- BAST JSON parses; StreamType indices match wire.rs constants
  (0=Stdin..4=CtrlOut)
- all markdown cross-reference links resolve
This commit is contained in:
2026-08-17 10:52:26 +00:00
parent 712e7ae071
commit b3f50d1836
17 changed files with 5165 additions and 46 deletions
@@ -0,0 +1,413 @@
# ADR-001: alktty Wire Format and Two-Carriage Model
## Status
Accepted (amended 2026-07-18 — Phase 7: the control channel is split
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
(server→client) halves; see §"Control channel split" below. Ported
from alknet ADR-052 2026-08-17; cross-references renumbered to
alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003,
ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008.
Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012,
044, 049, 050) are not ported into alktty's ADR range because they are
not tty-specific; the alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
## Context
The alknet-docker POC validated that interactive attach — bidirectional
byte pumping over a framed bidi stream with a 1-byte stream-type
multiplexer — is the same problem regardless of whether the backend is
`bollard::attach_container()` or russh's `pty_request`. The POC's raw
chunk format (`[stream_type: u8][length: u32 be][payload bytes]`,
stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished
version of SSH's channel multiplexer: fixed set of channel types, no
negotiation, no open/close handshake, no windowing (QUIC provides flow
control on the bidi stream). That impoverishment is the feature — a
terminal session needs exactly those channels and no more.
The alknet-tty POC (built 2026-07-05) extended that format with a 4th
stream_type (3 = control) carrying JSON control messages (resize,
signal, eof, exit) and validated the full round-trip against a real
`portable_pty` PTY: negotiate → PTY alloc → bidirectional echo →
mid-session resize → EOF → exit code, plus SIGINT forwarding to a child
process group. The wire format this ADR commits is the POC's raw codec
+ control schema, generalized from one backend to the backend-agnostic
crate.
Three load-bearing questions are decided here:
1. **Separate ALPN with raw carriage, not call-protocol operations.** A
terminal session could be modeled as a call-protocol `Subscription`
operation (`tty/open`) streaming `call.responded` events. That is
rejected: JSON-encoding every byte chunk is wasteful (base64 for
binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams
partial bytes with no message boundary that maps to a JSON object).
The two-carriage model — a single JSON negotiation frame, then raw
chunks — keeps the call protocol's JSON-RPC shape for the *request*
and switches to bytes for the *body*, which is the part that is
actually bytes. This is the pattern the docker POC validated and the
SSH research independently arrived at for PTY.
2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary
services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with
string-named types negotiated per channel. alktty multiplexes one
service — a terminal session — with a fixed `u8` set and no
negotiation. Adding a 6th channel type is a wire-format change
(one-way door). The ALPN model handles extensibility at the protocol
level: a genuinely new sideband (e.g., file transfer alongside the
terminal) is a different ALPN, not a 6th tty channel type. A new
ALPN is cheap; a wire-format change is not.
3. **Control messages as JSON, not binary.** A binary control format
(`[control_type: u8][params...]`) would be faster but harder to
extend and inconsistent with the negotiation layer. Control messages
are rare (resize on window drag, signal on Ctrl-C, one eof, one exit
per session) — serialization cost is negligible against the data
chunks. If a hot control path appears, a binary `control_type` can
be added without breaking the chunk format (additive within the
control channel).
## Decision
### 1. alktty is a `ProtocolHandler` on ALPN `alk/tty`
`alk/tty` is a custom ALPN per the alknet ADR-006 `alk/<name>` convention
(renamed from `alknet/<name>` in alkcall 0.1.1). The `TtyAdapter`
implements `ProtocolHandler` (alknet ADR-002, revised by alknet ADR-007
to receive a `Connection`). The handler owns the entire connection
lifecycle and accepts one bidi stream per terminal session. This is a
separate ALPN, not a set of operations in the call protocol's
`OperationRegistry` — the raw-carriage byte pump is not a
`StreamingHandler` (alknet ADR-049); it is its own wire format after a
single JSON negotiation frame.
### 2. Two-carriage model: JSON negotiation, then raw chunks
The bidi stream has two phases:
- **Negotiation (JSON carriage).** The client opens a bidi stream and
writes a single length-prefixed JSON frame — a 4-byte big-endian
length prefix + UTF-8 JSON body. The frame carries the terminal
parameters and backend selector the server needs to allocate the
session:
```json
{
"carriage": "raw",
"backend": "local",
"tty": {
"term": "xterm-256color",
"cols": 80,
"rows": 24,
"pixel_width": 0,
"pixel_height": 0,
"modes": {}
},
"cmd": ["/bin/bash"],
"cwd": null,
"env": {}
}
```
The `carriage` field is `"raw"` for terminal sessions (the only
carriage in v1). The `tty` block is `null` for the pipe/runner case
(no PTY — see ADR-003). The `backend` field selects the `TtyBackend`
(ADR-002); backend-specific fields (e.g., `container` for docker)
ride alongside.
- **Raw carriage.** After the negotiation frame, the stream switches to
the chunk format below for the life of the session. There is no
`call.responded`/`call.completed` — this is not the call protocol.
### 3. Chunk format
```text
[stream_type: u8][length: u32 be][payload bytes]
```
- `stream_type` — fixed set, no negotiation:
| stream_type | channel | direction | payload |
|---|---|---|---|
| 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes |
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) |
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) |
`stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
amendment, §4a: the original `3 = control (bidirectional)` is split
into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
`> 3`.) There is no extension escape hatch in the byte — a 6th
channel is a wire-format change requiring a new ALPN (`alk/tty/v2`
per alknet ADR-006), not a negotiated addition to this format.
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
16 MiB bound accommodates large paste operations and large `env`
blocks while bounding memory per chunk (a reader can allocate a
bounded buffer up front); it mirrors the docker POC's limit.
- Zero-length data chunks are sentinels: a zero-length stdin chunk is
EOF from the client; a zero-length stdout chunk is "drained" from the
server (output stream ended). Control chunks are never zero-length
(the JSON payload is at least `{}`).
### 4. Control channel (stream_type 3) carries JSON control messages
Control chunks carry a small JSON payload, tagged by `type`:
| Direction | Message | Shape |
|---|---|---|
| client→server | resize | `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}` |
| client→server | signal | `{"type":"signal","name":"INT"}` |
| client→server | eof | `{"type":"eof"}` |
| server→client | exit | `{"type":"exit","code":0}` |
- **resize** — window-size change. Maps to SSH `window-change`, docker
exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY.
- **signal** — signal forwarding. `name` is an uppercase string
(`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`,
`"USR1"`, `"USR2"`). Unknown names fall back to the backend's default
kill (see ADR-002 / `tty-local.md` REQ-TTY-02).
- **eof** — client signals no more stdin. Maps to SSH channel EOF,
docker stdin close, or `ChildStdin::drop` / PTY writer close. This is
one of two canonical "stdin done" signals; the other is a zero-length
stdin chunk (the docker POC's sentinel). Both are accepted as
equivalent — `eof` is recommended for explicitness (it's a control
message, not a data-length hack); the zero-length stdin chunk is kept
for compatibility with the docker POC's pattern. See OQ-47.
- **exit** — server signals process exit with code. This is the last
control chunk before stream close (see ADR-004).
The `type` tag is the extensibility seam: new control message types are
added by extending the tagged enum. Unknown `type` values are ignored
(not a protocol error) so that a newer client sending a control message
an older server doesn't recognize degrades gracefully rather than
tearing down the session. This is a two-way-door extension point within
the one-way-door wire format — adding a control message type is
additive; changing the chunk header is not.
### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
The single `stream_type 3 = control (bidirectional)` in §3 and §4 above
is **split into two halves** so the control channel is genuinely
bidirectional on the wire:
| stream_type | channel | direction | payload |
|-------------|-------------|----------------|------------------------------------------------------|
| 3 | `STREAM_CTRL_IN` | client→server | JSON control message (`Resize`, `Signal`, `Eof`) |
| 4 | `STREAM_CTRL_OUT` | server→client | JSON control message (`Exit`) |
The `stream_type > 3` protocol-error bound becomes `stream_type > 4`.
The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
**Why the split.** The original §4 documented stream_type 3 as
"bidirectional" and listed the four control messages with their
directions. But the adapter had no way to distinguish the two
directions on the same stream_type — `Exit` from the client was always
ignored (the adapter's `pump_client_to_backend` matched `Exit` and
logged "ignoring Exit control from client (server→client only)"). The
spec said "bidirectional"; the code was half-duplex. The split makes
the bidirectionality literal: each direction has its own stream_type,
the adapter enforces the direction (an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation; a `Resize` arriving on
`STREAM_CTRL_OUT` is a protocol violation), and a client can route
exit vs. control without parsing the JSON `type` tag first.
**Door type.** One-way, same as the original §3 / §4. The stream_type
set is bytes clients and servers parse. A client written against the
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
`InvalidStreamType (> 3)` and tear down the session — the split is a
wire-format change, not an additive extension. The reversal path is
the same as the original: a new ALPN (`alk/tty/v2`), which coexists
rather than replaces. The trade is one new stream_type byte now vs.
the half-duplex-in-disguise flaw forever.
**What changes in the spec.** §3's stream_type table gains a 5th row
(`4 = ctrl_out, server→client`); the bound becomes `> 4`. §4's
direction table is unchanged in content (the four messages keep their
directions and shapes) but the direction is now encoded in the
stream_type, not just in the adapter's behavior. ADR-004's "exit chunk
is last" invariant is unchanged — the exit chunk still rides the
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
the old single `STREAM_CONTROL` (stream_type 3).
### 5. Negotiation errors use the JSON framing, not the raw chunk format
If the server cannot allocate the session (unknown backend, PTY
allocation failed, the command is invalid), it sends a JSON error
response in the same 4-byte length-prefixed framing as the negotiation
frame and closes the stream without entering raw mode. The error
response shape is `{"error":"<code>","message":"..."}` (see
[tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the
codes). This is not `call.error` — this is not the call protocol; the
error is a JSON response in the negotiation framing, and the stream
closes after it.
**Framing disambiguation (success vs error).** Both a successful
allocation (raw chunks) and a failed allocation (JSON error frame)
begin with bytes the client must read before knowing which framing
applies. The disambiguation is by the first byte: a JSON error frame's
4-byte big-endian length prefix always starts with `0x00` (error frames
MUST be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this
is a wire-format invariant, not an assumption), while a raw chunk's
first byte is a `stream_type` in `{0, 1, 2, 3, 4}`. A stream_type of
`0` (stdin from server) is invalid — the server never sends stdin
chunks — so the client distinguishes: read the first byte; if it is
`0x00`, interpret the next 4 bytes as a big-endian length prefix and
read that many bytes as a JSON error frame; otherwise interpret it as
a `stream_type` byte and continue reading the raw chunk header. This is
a one-way-door wire-format invariant: error frames use the negotiation
framing (length prefix) and MUST be under 16 MiB; success uses the raw
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs
`0x00`-as-invalid-stream_type disambiguation is what makes the two
distinguishable on the wire. (With the Phase 7 split, the server-sent
set is `{1, 2, 4}` — stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is
still unambiguous.)
### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency)
The 4-byte length prefix + JSON body is implemented in alktty as a
small, self-contained module (`src/negotiation.rs`, ~30 lines: read
4-byte BE length, bounds-check, read N bytes; write the inverse). The
format coincides with alkcall's `EventEnvelope` framing by convention
(both are length-prefixed JSON) — not by code reuse. alktty does not
depend on alkcall's internal wire types: the negotiation payload is a
tty-specific struct (`NegotiateRequest`), not a `call.requested`
`EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is
hardcoded to deserialize `EventEnvelope` (the length-prefix read and
the type-specific deserialize are one entangled call), so it is not
reusable for a different payload type. alktty implements its own
framing on tokio's `AsyncRead`/`AsyncWrite`. See
[ADR-006](006-negotiation-framing-self-contained.md) for the decision
(and the three options considered: duplicate / promote to alkcall::core
/ use alkcall) and alknet ADR-003 Amendment 2 for the dependency-edge
clarification.
## Consequences
**Positive:**
- The wire format is POC-validated twice (docker POC for stream_type
0/1/2 + bidirectional pump; tty POC for stream_type 3 + control
messages + local PTY). No new wire-format invention in Phase 1.
- The fixed channel set is a `match`, not a hash lookup — fast on the
hot path where every chunk is data.
- The two-carriage model keeps the call protocol's JSON-RPC shape for
the structured request while letting the body be raw bytes, which is
what a terminal actually is. No base64, no per-chunk `EventEnvelope`
overhead.
- Control messages as JSON are consistent with the negotiation layer
and trivially extensible (tagged enum), at negligible cost for rare
messages.
- A separate ALPN composes with the ALPN dispatch model (alknet
ADR-001/006): the endpoint routes `alk/tty` to the `TtyAdapter`; the
call protocol is unaffected. Browser terminals (xterm.js over
WebTransport, when WebTransport revives) connect to `alk/tty` directly
without implementing SSH or the call protocol.
**Negative:**
- A 6th channel type is a wire-format change (one-way door). The
justification is that the use cases are bounded — a terminal session
has stdin, stdout, stderr, and control (now split into ctrl-in /
ctrl-out). New sideband needs are different ALPNs, not 6th channels.
If this proves wrong, the reversal is a new ALPN string
(`alk/tty/v2`), which coexists with the old one rather than replacing
it — but every client and server implementing the old format would
need updating to speak the new one.
- Control as JSON means a `serde_json` deserialize per control chunk.
Control chunks are rare (one per resize, one per signal, one eof, one
exit), so this is negligible. A hot control path would warrant a
binary format — the `type`-tagged enum leaves that door open without
a wire-format change.
- The negotiation frame is a custom JSON shape, not a `call.requested`
event, so a client library can't reuse its call-protocol client to
open a tty session — it speaks the tty wire format directly. This is
intentional (the tty session is not a call-protocol operation) but
means the tty client is a separate small client, not a `CallClient`
method.
- **No application-level flow-control window (OQ-45 resolved).** The
chunk format carries no window; backpressure relies on QUIC's
per-stream flow control composing with the bounded channels in the
adapter's pump and the OS pipe/PTY buffer. This is sufficient by
construction (see §Assumptions 1). If a workload ever requires
sub-QUIC-window backpressure signaling, the reversal is an additive
`ControlMessage::WindowUpdate` (or similar) variant on the control
channel — a two-way-door extension to the control channel, not a
wire-format header change. The `type`-tagged enum's "unknown types
ignored" rule means older peers degrade gracefully if such a variant
is introduced.
## Door type
**One-way.** The chunk header (5 bytes: 1 type + 4 length), the fixed
stream_type set (0-4), and the two-carriage sequence (JSON frame → raw
chunks) are bytes clients and servers parse. Changing any of them breaks
every client and server implementing the format. The reversal path is a
new ALPN (`alk/tty/v2`), which coexists rather than replaces — but the
cost of migrating every consumer is the one-way-door cost.
The control message `type` enum is a two-way-door extension point within
the one-way wire format: adding a control message type is additive
(unknown types are ignored), changing the meaning of an existing type is
not.
## Assumptions
1. **No application-level windowing; QUIC per-stream flow control is the
backpressure mechanism.** The chunk format carries no window field.
This is decided (OQ-45 resolved): the backpressure chain from a slow
client read all the way back to the child process's stdout write 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, if a workload ever
surfaces a problem QUIC's defaults cannot handle, is an additive
window-update `ControlMessage` variant (a two-way-door extension to
the control channel, not a wire-format header change) — noted in
§Consequences. See OQ-45.
2. **The negotiation frame fits in one chunk of the underlying stream's
initial flow-control window.** The frame is small (terminal params +
command + env, typically < 4 KiB). QUIC's default initial bidi-window
(quinn defaults are tens of KiB) accommodates it without a
flow-control round-trip. A pathological `env` block larger than the
window would stall until the window opens; the 16 MiB chunk limit is
the hard cap.
3. **Control messages are rare enough that JSON serialization cost is
negligible.** Validated by the tty POC: resize on window drag, one
signal per Ctrl-C, one eof, one exit per session. No measurable cost
observed.
## References
- alknet ADR-001 — ALPN-based dispatch
- alknet ADR-002 — ProtocolHandler trait
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per
connection; new ALPN for incompatible versions
- alknet ADR-007 — handler receives a `Connection`, accepts bidi streams
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call;
self-contained negotiation framing
- [ADR-006](006-negotiation-framing-self-contained.md) — the
dependency-edge decision this ADR's §6 reflects
- alknet ADR-012 — the call protocol's stream model (which tty is *not*
using for the body, by design)
- alknet ADR-049 — the `StreamingHandler` path tty explicitly does not
use for the byte body
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend trait
the negotiation frame's `backend` field dispatches to
- [ADR-003](003-local-backend-placement.md) — the local backend's
module placement (the `tty: null` pipe-mode case)
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk
ordering this ADR's control channel carries
- [ADR-008](008-channels-pure-channel-multiplexing.md) — TTY always
uses its 5-byte format, including inside channels (the channels layer
carries this wire format transparently in its payload)
- Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md)
- Port origin: alknet ADR-052 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/052-alknet-tty-wire-format-and-two-carriage.md`
@@ -0,0 +1,435 @@
# ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point
## Status
Accepted (ported from alknet ADR-053 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017,
050) are not ported into alktty's ADR range because they are not
tty-specific; the alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
## Context
alktty's wire format (ADR-001) is backend-agnostic — the chunk codec
pumps bytes and JSON control messages without knowing whether the
backend is a docker container, an SSH session channel, or a local
process. The question this ADR answers: **what is the seam between the
wire-format adapter and the backends?**
The alknet-tty research identified the `TtyBackend` trait as the
inversion point. The guiding insight:
> A terminal session is not an SSH concern, or a Docker concern — it is
> a terminal concern. SSH and Docker are just two backends that can
> allocate a PTY.
The trait is what makes that insight load-bearing: alktty defines the
trait and the wire-format adapter; the backend crates (alknet-docker,
alknet-ssh, alktty's `local` feature module) implement the trait. This
preserves alknet ADR-003's no-handler-depends-on-another-handler rule:
alktty depends on alkcall; backend crates depend on alktty for the
trait; alktty does not depend on any backend (and, per ADR-006, does
not depend on alkcall's internal wire types either — the negotiation
framing is self-contained).
### What the local-PTY POC discovered about the trait shape
The Phase 0 POC was built *before* this ADR specifically to discover
constraints the trait sketch would have missed by reading docs alone.
Two requirements fell out of it (recorded as REQ-TTY-01 and REQ-TTY-02
in the alknet findings doc):
- **REQ-TTY-01: backends are not required to be natively async.**
`portable_pty` is a blocking `std::io::{Read, Write}` API with a
blocking `Child::wait()`. The POC bridges it to async via three
dedicated std threads (reader, writer, waiter) feeding tokio
mpsc/oneshot channels — the same pattern wezterm (portable_pty's
primary consumer) uses. The trait's adapter-facing types
(`AsyncWrite`, `Stream<Item = Bytes>`, `BoxFuture`) are the
*adapter's* contract; a backend may expose blocking handles
internally and bridge them. The bridging pattern is a documented,
supported implementation strategy, not a workaround.
- **`exit_code` is a `Future` the adapter awaits, not a method on
`TtyHandle`.** A `oneshot::Receiver<i32>` (or any
`BoxFuture<'static, i32>`) lets the adapter `select` between exit and
stream-close without coupling to the handle's other fields. The local
backend's waiter thread produces exactly this shape for free.
The POC's `LocalPty` struct is the reference implementation of what a
backend produces: `stdout: mpsc::Receiver<Bytes>`,
`stdin: mpsc::Sender<StdinCmd>`, `control: PtyControl` (Clone),
`exit_code: oneshot::Receiver<i32>`. The trait shape below generalizes
this.
### What the local-PTY POC did not resolve
The POC used a separate cloneable `PtyControl` struct for resize/signal,
not a trait object. The research noted this worked cleanly because the
control-chunk dispatcher needs to be `Clone` to hand off to the spawned
pump task. Phase 1 confirms the `control` field as a separate
`TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct
wrapping `Arc<dyn TtyControl + Send + Sync>` (the trait is NOT `Clone`;
`Clone` is not object-safe — see OQ-43). The newtype carries the
`Clone`-ability; the trait stays object-safe.
## Decision
### 1. `TtyBackend` trait
```rust
#[async_trait]
pub trait TtyBackend: Send + Sync {
/// Allocate a terminal/process session and return the handles the
/// adapter pumps. The `backend` field of the negotiation frame
/// (ADR-001) selects which registered backend's `allocate` is called.
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
/// The pre-existing resource this session targets, for ownership
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session
/// creates its own — local process, SSH channel). `Some((kind, id))`
/// = the session targets an existing resource the caller must own
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
/// adapter calls this at negotiation to gate access; the backend
/// extracts the id from its own `backend_params`. Default `None`
/// (most backends create their own resource).
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None }
}
```
The adapter holds a `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
negotiation frame's `backend` string (`"local"`, `"docker"`, `"ssh"`).
The assembly layer registers backends at startup; the adapter dispatches
by the `backend` field. A backend is the *thing that allocates a
session*; the wire-format pump is backend-agnostic.
### 2. `TtyParams` — the allocation request
```rust
pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
/// ADR-003). `Some` = allocate a PTY with these dimensions.
pub terminal: Option<TerminalParams>,
/// Command vector (argv[0] + args).
pub cmd: Vec<String>,
/// Working directory (backend-specific; None = inherit/default).
pub cwd: Option<PathBuf>,
/// Environment variables (backend-specific; empty = inherit).
pub env: HashMap<String, String>,
/// Backend-specific selector fields from the negotiation frame,
/// unparsed. The adapter passes the JSON object through verbatim; the
/// backend deserializes its own strongly-typed params struct from it.
/// alktty has zero knowledge of any backend's params shape. See
/// §"Backend params are opaque" below.
pub backend_params: serde_json::Map<String, serde_json::Value>,
}
pub struct TerminalParams {
pub term: Option<String>, // e.g., "xterm-256color"; None = backend default
pub cols: u16,
pub rows: u16,
pub pixel_width: u16,
pub pixel_height: u16,
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
}
```
`terminal: None` is the pipe/runner case — no PTY, separate
stdout/stderr (ADR-003). `terminal: Some` is the PTY case —
stdout/stderr merged into the single stdout stream
(`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal
delivery to process group).
### Backend params are opaque
`backend_params` is a `serde_json::Map<String, serde_json::Value>`, not
a typed enum. The adapter passes the negotiation frame's
backend-specific fields through verbatim; the backend deserializes its
own strongly-typed params struct. alktty has zero knowledge of any
backend's params shape — not docker's `container`, not an SSH host
selector, not anything.
Each backend defines its own params struct:
```rust
// in alknet-docker
#[derive(Deserialize)]
struct DockerBackendParams { container: String }
// in alknet-ssh
#[derive(Deserialize)]
struct SshBackendParams { /* host selector if multi-host; else empty */ }
// in alktty's local feature module
// no backend-specific params — backend_params is empty
```
And deserializes from `params.backend_params` inside `allocate()`:
```rust
impl TtyBackend for DockerTtyBackend {
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
let p: DockerBackendParams = serde_json::from_value(
serde_json::Value::Object(params.backend_params.clone())
).map_err(|e| TtyError::Backend { message: e.to_string() })?;
// use p.container ...
}
}
```
This is a complete inversion: the *trait* is inverted (backends
implement, alktty doesn't depend on them) and the *params* are inverted
(backends define their own typed shape, alktty doesn't carry it). A new
backend crate requires zero changes to alktty — no enum variant to add,
no forward-reference type to place, no dependency edge.
**Why not a typed enum.** The earlier draft of this ADR defined
`BackendParams` as a `#[non_exhaustive]` enum with `Local`, `Docker {
container }`, and `Ssh { channel: SshChannelRef }` variants. Three
problems:
1. **Rust enums are closed.** `#[non_exhaustive]` prevents *consumers*
from matching exhaustively, but only the *defining crate* (alktty)
can add variants. A backend crate cannot add a variant; every new
backend requires modifying alktty. The inversion is only partial.
2. **`SshChannelRef` was an output, not an input.** The SSH channel is
what `allocate()` *opens* (`session.channel_open_session()`
`pty_request``shell_request`). It doesn't exist until the backend
creates it; the client doesn't send one.
3. **Dependency contradiction.** `SshChannelRef` "wraps a russh
`ChannelId` and session reference." If it lives in alknet-ssh,
alktty depends on alknet-ssh (violates the inversion). If it lives in
alktty, alktty pulls in russh types (same violation). Opaque params
dissolve the contradiction — there is no `SshChannelRef` type in
alktty at all.
The earlier draft rejected `serde_json::Value` because "it loses type
safety and forces the adapter to parse backend-specific JSON it
shouldn't interpret." The first concern doesn't apply (each backend has
its own strongly-typed struct via serde; type safety moves from alktty
to the backend where it belongs). The second was already inconsistent
with the adapter, which hardcoded extraction of docker's `container`
field for the ownership check — the adapter *was* parsing
backend-specific JSON. The opaque approach removes that: the adapter
delegates the resource-id extraction to the backend via `resource_id()`
below.
### 3. `TtyHandle` — what a backend produces
```rust
pub struct TtyHandle {
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
/// one — they are incompatible traits; the tokio stack is the
/// adapter's runtime).
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
/// Ends when the backend's stdout reaches EOF (process exited,
/// container output stream ended, SSH channel closed).
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
/// into `stdout`). `Some` for pipe backends (separate streams).
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
/// Exit code — a `Future` the adapter awaits. Resolves when the
/// process/container/SSH exec exits. The adapter sends the result
/// as the `{"type":"exit","code":N}` control chunk (ADR-004) and
/// closes the stream. This is `BoxFuture`, not a method on
/// `TtyHandle`, so the adapter can `select` between exit and
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can
/// hand it to the spawned control-chunk dispatcher. `None` only
/// when the backend genuinely has no control path (e.g., a pipe
/// backend with no PTY — signal still works via `kill(pid, sig)`,
/// but resize is a no-op). See OQ-43 for the `TtyControlHandle`
/// newtype rationale.
pub control: Option<TtyControlHandle>,
}
pub trait TtyControl: Send + Sync {
/// Resize the terminal. Maps to SSH `window-change`, docker exec
/// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe
/// backends without a PTY (the adapter still calls it; the backend
/// ignores).
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
/// Forward a signal by name. Best-effort delivery to the foreground
/// process group (see tty-local.md REQ-TTY-02). Unknown names fall
/// back to the backend's default kill.
fn signal(&self, name: &str);
}
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
/// lives on this concrete newtype, which holds the trait object behind an
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
/// control-chunk dispatcher. See OQ-43.
#[derive(Clone)]
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
```
### 4. Backends are not required to be natively async (REQ-TTY-01)
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`BoxFuture`, the `TtyControl` trait object) are the **adapter's
contract**. A backend may expose blocking handles internally and bridge
them to these async-facing types. The bridging pattern — blocking
`std::io` on dedicated std threads or `tokio::task::spawn_blocking`,
feeding tokio mpsc/oneshot channels — is a **documented, supported
implementation strategy**, not a workaround.
The local backend (ADR-003) uses this pattern: `portable_pty` is a
blocking API, and the backend's `allocate()` spawns reader/writer/waiter
threads that feed `mpsc::Receiver<Bytes>` (stdout), `mpsc::Sender<StdinCmd>`
(stdin wrapped as `AsyncWrite`), and `oneshot::Receiver<i32>` (exit).
The adapter consumes the bridged async-facing types and is unaware of
the threading. See `tty-local.md` for the bridge details.
### 5. Backend registration and the assembly layer
The `TtyAdapter` does not know the set of backends at compile time — it
holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at
construction. The assembly layer (the CLI binary) constructs backends
with their dependencies (a `DockerTtyBackend` wraps a `bollard::Docker`
client; an `SshTtyBackend` wraps an SSH session; a `LocalTtyBackend`
takes no deps) and registers them:
```rust
let mut backends = HashMap::new();
backends.insert("local".into(), Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
backends.insert("docker".into(), Arc::new(DockerTtyBackend::new(docker_client)) as _);
backends.insert("ssh".into(), Arc::new(SshTtyBackend::new(ssh_session)) as _);
let tty_adapter = TtyAdapter::new(Arc::new(backends));
```
A deployment that doesn't want docker registers only `local`. A browser
terminal endpoint that proxies to remote docker/ssh registers `docker`
and/or `ssh` backends. The adapter is backend-agnostic; the assembly
layer chooses what's available.
## Consequences
**Positive:**
- The wire-format adapter is backend-agnostic and testable with a mock
backend (in-memory pipes). The build order (ADR-001 wire format +
mock backend first, real backends last) follows directly.
- alktty stays dependency-light: no bollard, no russh, no `portable_pty`
in the default crate. The heavy deps live in the backend crates /
the `local` feature. This is the same inversion as
`OperationAdapter` (alknet ADR-017): the trait lives where the types
live; the implementations live where their transport dependencies
live.
- Blocking-API backends (portable_pty) are first-class — the trait
accommodates them by making the adapter-facing types the contract and
the bridging pattern a documented strategy. No re-spec required when
a future backend is also blocking.
- `exit_code` as a `Future` (not a method on the handle) lets the
adapter `select` between exit and stream-close — the load-bearing
composition the session lifecycle needs (the exit chunk is sent after
the child is reaped, then the stream closes — ADR-004).
**Negative:**
- **Backend params are opaque (`serde_json::Map`), not a typed enum.**
Each backend deserializes its own params struct; the adapter passes
the JSON through verbatim. The cost is one serde deserialize per
`allocate()` call (negligible — allocation is once per session, not on
the hot path). The benefit is a complete inversion: alktty has zero
knowledge of any backend's params shape, and a new backend crate
requires zero changes to alktty (no enum variant, no forward-reference
type). See §"Backend params are opaque" for the full rationale and
why the typed-enum alternative was rejected (Rust enums are closed;
the earlier `SshChannelRef` variant was an output modeled as an input
and created a dependency contradiction).
- **`TtyControl` is not `Clone`; the `TtyControlHandle` newtype is.**
`Clone` is not object-safe (`fn clone(&self) -> Self` returns `Self`,
which forbids `dyn` dispatch), so `Box<dyn TtyControl + Clone>` does
not compile. The design splits the concerns: the `TtyControl` trait
stays object-safe (`Send + Sync`, no `Clone`); the `TtyControlHandle`
newtype (a concrete struct holding `Arc<dyn TtyControl + Send +
Sync>`) implements `Clone` by cloning the `Arc`. This is the cost of
the POC-discovered constraint that the control-chunk dispatcher needs
to be `Clone` to hand off to the spawned pump task. See OQ-43 for the
confirmation and the concrete newtype approach.
- A backend that produces neither a PTY nor a process (a hypothetical
"recorded session replay" backend) would have a no-op `TtyControl`
and a synthetic `exit_code`. The trait accommodates it but the
`TtyParams` shape (`cmd` is `Vec<String>`, `terminal` is `Option`)
assumes command-spawning. A non-command backend would supply an empty
`cmd` and synthesize one internally. Not a current use case; the
trait shape doesn't preclude it but doesn't optimize for it.
## Door type
**One-way.** The `TtyBackend` trait method `allocate()`, the
`TtyHandle` field set, and the `TtyControl` trait are the API surface
every backend crate implements and the adapter consumes. Changing them
after backends exist is a rewrite across crates. `backend_params` as an
opaque `serde_json::Map` is part of the one-way `TtyParams` shape — the
*carrier* is fixed (opaque JSON), but the *contents* are
backend-defined and require no alktty change for new backends. The
`resource_id()` default method is additive (a new method with a default
impl doesn't break existing implementors); its return type
`Option<(&'static str, String)>` is one-way.
## Assumptions
1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone`
on it; the `TtyControlHandle` newtype holds the trait object behind
an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used
a concrete `PtyControl` struct (inherently `Clone` — it held
`Arc<Mutex<...>>` fields). The newtype generalizes the POC's shape so
a backend produces its own control type via
`TtyControlHandle::new(Arc::new(MyControl))` without the adapter
knowing the concrete shape. `Clone` cannot live on the trait itself
(it is not object-safe); the newtype is the seam. OQ-43 confirms.
2. **Backends produce a single session per `allocate()` call.** The
adapter calls `allocate()` once per accepted bidi stream (one session
per stream — ADR-001). A backend that multiplexed multiple sessions
over one `allocate()` would not fit the trait; no such backend is
contemplated.
3. **The adapter, not the backend, owns the exit-chunk ordering.** The
backend resolves `exit_code`; the adapter awaits it, sends the exit
control chunk, and closes the stream (ADR-004). The backend does not
write to the wire — it produces handles; the adapter pumps. This
keeps the wire-format logic in one place (the adapter) and the
backend focused on its allocation target (docker, ssh, local
process).
## References
- alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another-
handler; alktty depends on alkcall (no alkcall-internal-wire-types per
Am. 2 / ADR-006); backends depend on alktty for the trait
- [ADR-006](006-negotiation-framing-self-contained.md) — alktty does not
depend on alkcall's internal wire types (self-contained negotiation
framing)
- alknet ADR-007 — `Connection`, `SendStream`, `RecvStream` (the adapter
receives a `Connection`, accepts bidi streams, pumps per-session)
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format this
trait's backends feed
- [ADR-003](003-local-backend-placement.md) — the local backend's
module placement (folded into alktty behind a `local` feature)
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit chunk ordering
this trait's `exit_code` field feeds into
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel-
cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel
kills the session target)
- alknet ADR-017 — the adapter-location-map pattern (trait where types
live, implementation where deps live) this ADR follows
- alknet ADR-050 — the ownership model the `resource_id()` default
declares against
- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed)
- OQ-44 — terminal modes (deferred(scope): not needed for current scope)
- Spec: [tty-backend.md](../tty-backend.md)
- Port origin: alknet ADR-053 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md`
@@ -0,0 +1,268 @@
# ADR-003: Local TTY Backend Placement (Single Crate with `local` Feature)
## Status
Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate
consolidation recorded as the alktty resolution. Cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. Alknet ADRs referenced by alknet number (003, 009, 017)
are not ported into alktty's ADR range because they are not tty-specific;
the alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
## Context
The alknet-tty research (DP-1) posed the placement question for the
local-process backend (`std::process::Command` with piped stdio, or
`portable_pty` for a real PTY):
- **(a) In alktty**: the crate ships with the local backend built-in.
Pro: zero-config runner, one crate gets a terminal/process-streaming
endpoint. Con: alktty pulls in `portable_pty` even for deployments
that only use docker/ssh backends.
- **(b) In a sibling crate (`alknet-tty-local`)**: alktty defines the
trait (ADR-002); the local backend is a separate crate. Pro: alktty
stays dependency-light; consumers opt into the local backend
explicitly. Con: one extra crate for the common case.
The local backend is the simplest backend and the one that enables the
runner pattern (a process whose stdin/stdout/stderr/exit-code stream
over a framed bidi connection — the same shape as GitHub/Gitea Actions
runners, just over alk's transport instead of HTTP polling). It has no
heavy dependencies in the pipe case — just `std` — but the PTY case
pulls in `portable_pty` (a non-trivial native dependency that builds on
Unix `openpty`/`ioctl` and Windows ConPTY).
The relevant constraint from ADR-002: alktty itself depends only on
alkcall and the wire-format codec (ADR-001). The `portable_pty`
dependency does not belong in the core tty crate — a docker-only
deployment or an SSH-PTY-only deployment should not pull in PTY
allocation code. This is the same inversion as `OperationAdapter`
(alknet ADR-017): the trait lives where the types live; the
implementations live where their transport dependencies live.
### The alknet mono-repo decision (sibling crate + feature re-export)
In the alknet mono-repo, the research recommended (b) sibling crate
**behind a feature flag on alknet-tty** for the common case
(`features = ["local"]` → re-export from `alknet-tty-local`). This
keeps alknet-tty's default dependency surface minimal while making the
local backend a one-feature opt-in. The `portable_pty` dependency lives
in `alknet-tty-local`; alknet-tty itself never depends on
`portable_pty`.
The sibling-crate placement was motivated by a cyclic-dependency
workaround: `alknet-tty-local` depends on `alknet-tty` for the trait,
and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
behind a `local` feature — which cargo rejects (a crate cannot re-export
from a sibling crate it depends on via an optional dep AND have that
sibling depend back on it). The assembly-layer workaround (consumer
depends on both crates directly) worked but was awkward.
### The alktty consolidation (single crate, `local` feature)
alktty is a standalone single crate (not a mono-repo). The cyclic-dep
workaround that motivated the alknet sibling-crate decision does not
apply: the local backend can live in the same crate as the trait, gated
behind a `local` cargo feature. The feature gate still keeps
`portable_pty` out of the default dependency tree (a docker-only or
ssh-only deployment enables neither feature and never pulls in
`portable_pty`); the only thing that changes is the crate boundary.
This ADR records both decisions: the original alknet sibling-crate
decision (preserved as the historical context for why the local backend
is a separable concern), and the alktty consolidation (the operative
decision — the local backend is folded into alktty behind a `local`
feature).
## Decision
### 1. The local backend is folded into alktty behind a `local` feature
`LocalTtyBackend` lives in `src/local/` (a feature-gated module),
implements `TtyBackend` (ADR-002), and is re-exported as
`alktty::local::LocalTtyBackend`. The `local` cargo feature pulls in
the heavy deps:
```toml
# alktty Cargo.toml
[features]
default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
[dependencies]
portable-pty = { version = "0.9", optional = true }
tokio-util = { version = "0.7", features = ["io"], optional = true }
```
A consumer that wants the local backend enables `features = ["local"]`
on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that
only wants docker/ssh backends uses the default features and depends on
`alknet-docker` / `alknet-ssh` (or their own backend crate) directly —
no `portable_pty` in the dependency tree.
This is the same feature-re-export pattern the Rust ecosystem uses for
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
`tokio-util`, `h2`, etc.). The seam is the `TtyBackend` trait; the
feature gate is the only thing keeping `portable_pty` out of the
default crate. Folding the local backend in is cheaper than the alknet
sibling-crate pattern (no cross-crate coordination, no cyclic-dep
workaround) and the local backend is small (~1.4k lines) and tightly
coupled to the trait.
### 2. The local backend's dependency tree
```
alktty (local feature enabled)
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alkcall::core (via alktty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, here not in alktty default)
├── libc (signal forwarding — REQ-TTY-02, Unix only)
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
```
The `local` feature is inherently non-wasm (`portable-pty` +
`tokio::process` need a real OS); enabling `local` on
`wasm32-unknown-unknown` is a build error by design. The default crate
(no features) stays wasm-clean — this is what makes the downstream
TS/Python adapter story work (a wasm-compiled alktty is the protocol
layer for a sandboxed adapter; the local-process backend runs on a real
OS, never in a sandbox).
### 3. PTY vs pipe is a per-session choice, not a per-deployment choice
`TtyParams.terminal: Option<TerminalParams>` (ADR-002) selects the mode:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY
via `portable_pty`. Terminal semantics: resize (via
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
(the kernel PTY's line discipline). stdout and stderr are merged
(kernel PTY property — one output stream from the slave), so
`TtyHandle.stderr` is `None`.
- **`terminal: None`** — pipe mode, no PTY. `tokio::process::Command`
with `Stdio::piped()` for stdin/stdout/stderr. No resize, no
escape-sequence handling, but `kill(pid, sig)` still works for signal
forwarding. stdout and stderr are separate streams, so
`TtyHandle.stderr` is `Some`. This is the **runner** case — a
command-streaming endpoint with no terminal semantics.
The same `LocalTtyBackend` serves both; the `allocate()` call branches
on `params.terminal`. A deployment that only does terminals always
sends `Some`; a deployment that only does runners always sends `None`;
a deployment that does both (a hub that runs agents in PTYs and runs
`cargo test` as a runner) sends the appropriate one per session.
### 4. The runner pattern is preserved, not specialized
The pipe mode (`terminal: None`) is the "runner" generalization the
research identified: a process whose stdin/stdout/stderr/exit-code
stream over a framed bidi connection. This is functionally identical to
GitHub/Gitea Actions runners, just over alk's transport instead of HTTP
polling:
- A coordinator sends a negotiation frame with
`{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`.
- The endpoint runs `cargo test` with piped stdio, streams stdout/stderr
chunks back, sends `{"type":"exit","code":N}` when it finishes
(ADR-004).
- The coordinator gets reliable completion notification (the exit
control chunk + stream close) — no polling.
The runner-specific API surface (job management, log persistence, task
graph integration) is **out of scope for alktty**. alktty provides the
*mechanism* (a framed byte stream for a process); the runner *policy*
is a downstream crate's job. This ADR commits to preserving the option
(`terminal: None` → pipe mode) and not building runner policy into
alktty. See OQ-46.
## Consequences
**Positive:**
- alktty's default dependency surface is minimal (alkcall + the
wire-format codec). A docker-only or ssh-only deployment never pulls
in `portable_pty`.
- The local backend is a one-feature opt-in (`features = ["local"]`)
for the common case — a consumer that wants a terminal/runner
endpoint with no docker or SSH gets it with one feature flag, not a
separate dependency.
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
and runners. A hub that does both doesn't need two backends.
- The runner pattern is preserved without baking runner policy into
alktty. The mechanism is the framed byte stream; the policy is
downstream.
- The single-crate placement composes with alknet ADR-003's
no-handler-depends-on-another-handler rule: the local backend is in
the same crate as the trait, so there is no cross-crate dependency
edge to worry about. The docker and SSH backends remain separate
crates (real external deps — `bollard`, `russh` — and their own
resource models).
- The alktty consolidation resolves the alknet cyclic-dep workaround
that motivated the original sibling-crate decision. No assembly-layer
"depend on both crates directly" pattern; no cargo-rejected
feature-re-export-from-an-optional-dep dance.
**Negative:**
- A consumer that wants the local backend must enable a feature flag
(`features = ["local"]`). Forgetting the flag results in the
`alktty::local` module not existing — a compile error, not a silent
miss. This is the standard Rust feature-flag trade and is
self-documenting.
- The `local` feature is non-wasm by design. A consumer that targets
`wasm32-unknown-unknown` must not enable `local` — the build error is
intentional (the local-process backend cannot run in a sandbox). The
default crate stays wasm-clean so the downstream TS/Python adapter
story works.
- The runner-specific API surface (job management, log persistence) is
not in alktty. A downstream crate that wants a full runner builds on
the pipe mode + the wire format. This is the right layering
(mechanism vs policy) but means a "runner crate" is a separate future
deliverable, not part of alktty. See OQ-46.
## Door type
**Two-way.** The single-crate-with-feature-gate placement is
reversible: if the local backend ever grows large enough to warrant
splitting out (e.g., a future docker or SSH backend shares enough
portable-pty bridge code to motivate a shared "blocking-backend bridge"
crate), extracting `src/local/` into a sibling crate behind the same
`local` feature is mechanical — the trait is the seam, and the feature
gate already exists. The cost of reversal is low (the module becomes a
crate; the feature gate switches from `dep:portable-pty` to
`dep:alktty-local`), and no downstream consumer breaks (the
`alktty::local::LocalTtyBackend` path stays valid if the sibling crate
re-exports it).
This is a two-way door that is **decided** (single crate + `local`
feature), not deferred. The decision is made now; the reversal is cheap
if a future split warrants it. See alknet ADR-009 §"What this framework
is NOT" — door type classifies reversal cost, not urgency.
## References
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the
single-crate placement preserves it; the local backend is in the same
crate as the trait, not a separate crate that depends back)
- alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's
two-way-door classification is reversal cost, not a deferral)
- alknet ADR-017 — the adapter-location-map pattern (trait where types
live, implementation where deps live) this ADR follows (the `local`
feature is where the deps live)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
trait this module implements
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the
adapter pumps to/from this backend
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk
ordering the local backend's waiter thread feeds
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel-
cleanup contract the local backend's `exit_code` future's `Drop`
implements
- OQ-46 — runner API surface (deferred(scope): mechanism in alktty,
policy is a downstream crate)
- Spec: [tty-local.md](../tty-local.md)
- Port origin: alknet ADR-054 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/054-local-tty-backend-sibling-crate.md`
@@ -0,0 +1,219 @@
# ADR-004: Exit Code on a Control Chunk (the Last Chunk Before Stream Close)
## Status
Accepted (ported from alknet ADR-055 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. The alknet ADR referenced by alknet number (052, 053) is
not ported into alktty's ADR range as a separate ADR — it is ADR-001
and ADR-002 here. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative for any ADR not yet ported.)
## Context
The alknet-docker POC validated exit-code propagation for the JSON
carriage path: exec with an exit code rides on a final `call.responded`
frame `{ "exitCode": N }` before `call.completed`. That works because
the JSON carriage path is the call protocol — `call.responded` and
`call.completed` exist and carry the result.
The raw-carriage path (ADR-001) has no `call.responded` and no
`call.completed` — after the negotiation frame, the stream is raw
chunks. The exit code must ride on the chunk format itself. Two options:
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last control
chunk (`STREAM_CTRL_OUT`, stream_type 4) before stream close. Clean,
explicit, carries the code as structured data on the channel that
already exists for control metadata.
- **(b) Final data chunk with exit code**: a special stdout chunk with
an exit-code payload. Overloads the data channel for metadata — a
client parsing stdout chunks would have to special-case "this stdout
chunk is actually an exit code," conflating data and control.
The local-PTY POC validated option (a) end-to-end: the
`{"type":"exit","code":N}` chunk fires after the child is reaped (the
waiter thread's `oneshot::Receiver<i32>` resolves) and is the last
control chunk before the stream closes. The POC's `session.rs`
`pump_exit` task awaits `pty.exit_code`, serializes the result as
`ControlMessage::Exit { code }`, enqueues it as a control chunk, and
the drainer writes it to the client before the writer closes.
### Why this is a one-way door
Clients will depend on the **"exit chunk is last"** invariant: after
the exit control chunk, no more data chunks follow, and the stream
closes. This is the deterministic completion notification the docker
POC identified as the stopgap coordination property — a coordinator
spawns a process, streams its output, and gets a reliable "it exited
with code N" signal without polling or plugin state. Changing the
ordering after clients exist would break every consumer that reads
stdout until the exit chunk and then stops.
## Decision
### 1. The exit code rides on a control chunk, not a data chunk
The exit code is control metadata (the process's termination status),
not data (process output). It rides on the control channel
(`STREAM_CTRL_OUT`, stream_type 4, ADR-001) as:
```json
{"type":"exit","code":0}
```
The `code` is an `i32` (matches `std::process::ExitStatus::code()` and
Unix wait-status convention; negative values are signal-terminated,
e.g., `-9` for SIGKILL, matching `ExitStatus::code()`'s behavior on
Unix). The chunk is the last control chunk before stream close.
### 2. The "exit chunk is last" invariant
After the `{"type":"exit","code":N}` control chunk:
- The server sends no more data chunks (stdout/stderr) and no more
control chunks.
- The server closes the write half of the bidi stream.
A client reads stdout/stderr/control chunks until it sees the exit
chunk, records the exit code, and treats subsequent stream close as the
session end. The exit chunk is the deterministic completion signal.
### 3. The adapter owns the exit-chunk ordering, not the backend
Per ADR-002 assumption 3, the backend resolves `exit_code` (a
`BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it,
sends the exit control chunk, and closes the stream. The backend does
not write to the wire — it produces handles; the adapter pumps. This
keeps the wire-format logic (including the "exit is last" invariant) in
one place (the adapter's session driver) and the backend focused on its
allocation target.
The adapter's session driver (see `tty-adapter.md`) runs three
concurrent pumps:
1. **stdout → client**: backend stdout → stdout chunks (and stderr
chunks if `TtyHandle.stderr` is `Some`).
2. **client → backend**: stdin chunks → backend stdin; control chunks →
`TtyControl::resize`/`signal`/`eof`.
3. **exit → exit chunk**: await `TtyHandle.exit_code`; on resolve,
enqueue `{"type":"exit","code":N}` as a control chunk; after the
drainer writes it, close the write half.
The exit-chunk task coordinates with the stdout pump: the stdout pump
completes (backend stdout EOF) before or concurrently with the exit
resolve, and the exit chunk is enqueued only after the exit resolves.
The drainer writes chunks in arrival order; the exit chunk is last
because the exit is the last thing to resolve (the child must exit
before its stdout drains, but the exit chunk is sent only after
`exit_code` resolves, which is after `Child::wait()` returns — i.e.,
after the child is reaped).
### 4. Error exit codes
A backend `TtyError` during `allocate()` (the PTY couldn't be
allocated, the docker exec failed to start, the SSH channel request was
rejected) is handled before the raw-carriage phase begins — the adapter
sends a JSON error response to the negotiation frame and closes the
stream without entering raw mode. See `tty-adapter.md` §"Negotiation
errors".
A `TtyError` from the `exit_code` future (the child couldn't be reaped,
or the backend's wait path failed) is serialized as an exit code of
`-1` (`ControlMessage::Exit { code: -1 }`) and the stream closes. The
client treats `-1` as "the backend reported an exit error, not a real
exit code." This is a best-effort signal; a backend that cannot
determine the exit code still sends the exit chunk so the client gets
the completion notification.
## Consequences
**Positive:**
- The exit code is structured data on the control channel, not a hacky
overload of the data channel. Clients parse it as a
`ControlMessage::Exit`, not as a special-cased stdout chunk.
- The "exit chunk is last" invariant gives coordinators deterministic
completion notification — the same stopgap property the docker POC
validated for logs subscriptions. No polling, no plugin state; the
process exiting is the signal.
- The adapter owns the ordering, so the invariant is enforced in one
place; backends don't have to know the wire format's completion
semantics.
- The error-exit `-1` fallback keeps the completion notification
reliable even when the backend can't determine the real code — the
client still knows the session ended.
**Negative:**
- The "exit chunk is last" invariant is a one-way door — clients depend
on it. Reversing it (allowing data chunks after the exit chunk, or
moving the exit code to a data chunk) would break every consumer.
This is the intended commitment: the invariant is the value.
- A client that doesn't read until the exit chunk (e.g., a runner that
cancels mid-stream by closing the write half) won't see the exit
code. That's correct — a cancelled stream doesn't have a
deterministic exit; the client that cancels already knows it
cancelled. The exit chunk is for the client that reads to completion.
- The `-1` error-exit code conflates "the backend couldn't determine
the exit" with "the process exited with code -1" (which doesn't
happen on Unix — `ExitStatus::code()` returns `None` for signal
termination, not -1; the POC's waiter thread sends -1 only on
`wait()` failure, not on signal termination — signal termination
sends the negative signal number, e.g., -9 for SIGKILL). A client
that needs to distinguish "real exit -1" from "backend error" can't
from the code alone. This is a documented edge case; if it becomes
load-bearing, a future control message type
(`{"type":"exit_error","message":"..."}`) can carry the distinction
additively (the `type`-tagged enum is the extension seam per
ADR-001).
## Door type
**One-way.** The "exit chunk is last" invariant is what clients depend
on for deterministic completion. Changing it after clients exist breaks
every consumer. The `{"type":"exit","code":N}` shape is also one-way
(clients parse it as a `ControlMessage::Exit`), though the
`type`-tagged enum (ADR-001) makes adding *new* control message types
additive.
## Assumptions
1. **The child exits before its stdout fully drains, and the exit chunk
is sent after `exit_code` resolves.** On Unix, `Child::wait()`
blocks until the child is reaped, which happens after the child
exits and its stdout pipe/PTY buffer drains. The POC validated this
ordering: the reader thread sees EOF (buffer drained), the waiter
thread reaps (exit code available), and the exit chunk is enqueued
after the exit resolves. There is no race where stdout chunks arrive
after the exit chunk.
2. **`exit_code` resolving implies the stdout pump is done or will be
soon.** The adapter's session driver waits for both the stdout pump
to complete (backend stdout EOF) and the exit to resolve before
sending the exit chunk and closing. If a backend's stdout outlives
the exit resolve (a hypothetical backend where the process exits but
a buffer flush is still in flight), the adapter waits for the stdout
pump before the exit chunk. The `TtyHandle.stderr` (if `Some`) is
pumped concurrently with stdout and also drains before the exit
chunk.
## References
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format
(control channel, `STREAM_CTRL_OUT` stream_type 4) this ADR's exit
chunk rides on
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the
`TtyHandle.exit_code` field (the `Future` the adapter awaits) this
ADR's ordering consumes
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel path
that bypasses this ADR's happy-path ordering (no exit chunk is sent
on cancel — the stream is gone)
- [ADR-008](008-channels-pure-channel-multiplexing.md) — the
exit-chunk-is-last invariant generalizes to channels mode (the exit
chunk is the last `STREAM_CTRL_OUT` chunk before the channel closes)
- Spec: [tty-adapter.md](../tty-adapter.md) (the session driver that
enforces the ordering)
- Port origin: alknet ADR-055 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/055-exit-code-on-control-chunk.md`
@@ -0,0 +1,298 @@
# ADR-005: Backend Cleanup on Session Cancel (Drop of `exit_code` Kills)
## Status
Accepted (ported from alknet ADR-056 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. The alknet ADR referenced by alknet number (052, 053, 054,
055) is not ported into alktty's ADR range as a separate ADR — it is
ADR-001, 002, 003, 004 here. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative for any ADR not yet ported.)
## Context
A `alk/tty` session can be cancelled before the child process exits
naturally. Three cancel paths exist (see `tty-adapter.md` §"Connection
and Stream Lifecycle"):
1. **Connection drop** — the QUIC connection closes; all in-flight
sessions on it are cancelled.
2. **Stream reset** — the client (or transport) resets the bidi stream
mid-session.
3. **Task panic / adapter shutdown** — the session pump task exits
without completing the normal exit-chunk sequence.
In all three, the adapter's per-session pump tasks are dropped (Rust
`Drop`). The pump tasks hold the `TtyHandle`, whose fields — `stdin`,
`stdout`, `stderr`, `exit_code`, `control` — are dropped too.
The problem the local-PTY POC surfaced: **`Drop` alone is not sufficient
cleanup for a backend whose child may outlive the session.** The local
backend's three std threads illustrate the gap:
- **Reader thread** — blocking `MasterPty::try_clone_reader()` reads.
On `mpsc::Sender::drop` (the stdout channel's sender side drops when
the handle drops), the reader's next `blocking_send` returns a
`SendError`; the thread exits. ✓ `Drop` works.
- **Writer thread** — drains an `mpsc::Receiver<StdinCmd>`. On
`Receiver::drop`, the writer's `recv()` returns `None`; the thread
drops the master writer (EOF to the slave's stdin) and exits. ✓
`Drop` works.
- **Waiter thread** — blocking `Child::wait()`. This is a syscall that
returns **only when the child is reaped**. It does not observe
channel close. If the child ignores stdin EOF (a daemon, a
long-lived process with no stdin reader, a process in an
uninterruptible state), the waiter thread stays blocked indefinitely
and the child is **orphaned**. ✗ `Drop` does not work here.
The same concern applies to any backend whose session target can
outlive the bidi stream: a docker container with `tty: true` whose
process ignores the channel close; an SSH exec whose remote process
doesn't exit on channel close. The unifying property is **a
backend-allocated session target that may outlive the client's interest
in it.**
The earlier spec text (`tty-adapter.md` §"Connection and Stream
Lifecycle" pre-this-ADR) asserted:
> No explicit cleanup is needed — `Drop` is the cleanup.
That is wrong for the waiter thread and any backend in the same shape.
This ADR corrects the claim and commits a cleanup contract that closes
the gap.
### Why this is architectural, not implementation
The cleanup contract is part of the `TtyBackend` trait's behavioral
contract (ADR-002), not a backend-internal detail, for two reasons:
1. **The adapter depends on the property.** The adapter's session
driver holds the `TtyHandle` and, on cancel, drops it. The adapter
cannot itself call a backend-specific kill — it doesn't know the
child's pid (the local backend owns it; the adapter never sees it).
The kill must be wired into the backend's handle shape, specifically
into the `exit_code` future the adapter drops on cancel. The
contract is what makes "the adapter drops the handle" sufficient.
2. **A missing contract leaks processes.** An implementer writing a
backend from the trait sketch, without this contract, would ship a
backend that orphans processes on cancel. The bug is silent (the
client got what it wanted; the orphaned process is a server-side
leak), surfaces only under cancel-heavy workloads or long-lived
sessions, and is expensive to attribute after the fact. The
contract makes the property load-bearing at the seam, not a property
each backend rediscovers.
## Decision
### 1. The `TtyBackend` cleanup contract: cancelling the `exit_code` future kills the session target
When the adapter cancels a session (drops the pump tasks, which drops
the `TtyHandle`), the backend's `exit_code` future — the
`BoxFuture<'static, Result<i32, TtyError>>` field of `TtyHandle`
(ADR-002) — is dropped *without being driven to completion*. The
cleanup contract:
> **Dropping the `exit_code` future MUST kill the session target (the
> child process, the docker exec, the SSH channel's process).** The
> kill is best-effort (the target may already be exiting; the kill is
> a no-op then), but it MUST be attempted. The kill MUST be delivered
> even when the session target is blocked in a state that ignores
> stdin EOF (a daemon, a process in uninterruptible sleep, a container
> whose process ignores channel close).
`exit_code`'s `Drop` is the cancel path. The adapter drives the future
to completion (the happy path — the child exits, the future resolves,
the adapter sends the exit chunk); on cancel, the adapter drops the
future (their `Drop`), which runs the kill.
This is a behavioral contract on the `TtyBackend` trait, not a new
method. The trait's `allocate()` returns a `TtyHandle` whose
`exit_code` field is a `Future` with a `Drop`-on-cancel that kills. The
mechanism is backend-specific (see §3 for the local backend); the
contract is backend-agnostic.
### 2. `exit_code`'s `Drop`-on-cancel MUST be safe to run after the future resolves
If the future resolved normally (the adapter awaited it, got the exit
code, sent the exit chunk), the `Drop` runs on an already-resolved
future. The kill MUST be a no-op in that case — the child is already
reaped, the kill is delivered to a nonexistent pid, etc. This is the
"best-effort" qualifier: a kill on an already-exited child is not an
error. Backends implement this with a guard that distinguishes
"resolved" from "cancelled" (a flag, an `Option` taken on resolve, an
`Arc`-shared state).
### 3. Local backend mechanism: `ChildKiller` held in the `exit_code` future's `Drop` guard
The local backend (ADR-003) implements the contract using
`portable_pty::ChildKiller` — the kill handle `portable_pty` exposes
alongside `Child::wait()`. The pattern:
- `allocate()` spawns the child via `portable_pty`, obtaining a `Child`
(with `wait()`) and a `ChildKiller` (with `kill()`). It moves the
`Child` into the waiter thread (which blocks on `wait()`). It wraps
the `ChildKiller` and the `oneshot::Receiver<i32>` (from the waiter
thread) into a `Future` that becomes `TtyHandle.exit_code`.
- The `exit_code` future's `poll` delegates to the inner
`oneshot::Receiver::poll` (resolves when the waiter thread sends the
exit code).
- The `exit_code` future's `Drop` (runs on cancel only — on resolve,
the `Drop` is a no-op via the guard) calls `ChildKiller::kill(SIGHUP)`
(or the backend's configured cancel signal). The kill causes the
child to exit; the waiter thread's `wait()` returns; the waiter
thread's `oneshot::send` fails silently (the receiver was dropped
with the future). The waiter thread then exits. The child is reaped
by the waiter thread's `wait()`; no zombie.
For pipe mode (`terminal: None`), the same pattern applies with
`tokio::process::Child::start_kill()` (or `Child::kill()`) instead of
`ChildKiller`. The `exit_code` future's `Drop` guard calls
`start_kill()`; the `Child` is reaped by the future's `wait()` (or by
the waiter task).
### 4. Future backends (docker, SSH) follow the same contract
- **Docker (`DockerTtyBackend`)** — `bollard`'s exec stream is
cancelled by dropping the `AttachContainer` / `start_exec` stream
and calling `bollard::container::kill_container` (or `exec::kill_exec`
if available). The `exit_code` future's `Drop` holds the
container/exec id and the `bollard::Docker` client; on cancel, it
issues the kill.
- **SSH (`SshTtyBackend`)** — russh's `Channel::close()` and/or
`Channel::signal(SIGHUP)` terminate the remote process. The
`exit_code` future's `Drop` holds the russh channel handle; on
cancel, it closes the channel.
The docker and SSH backends are future work (out of scope for this
spec set); the contract is what they implement. A future backend that
does NOT fit the contract (e.g., a "recorded session replay" backend
with no live process) implements a no-op `Drop`-on-cancel — the
contract is "kill if there is a killable target; no-op if not."
### 5. The adapter does not call a backend kill method
The adapter has no `TtyBackend::cancel()` or `TtyHandle::kill()` method
to call — the cleanup is wired into the `exit_code` future's `Drop`,
which the adapter triggers by dropping the future. This keeps the
trait surface unchanged (no new method) and the cleanup in the backend
(where the kill handle lives). The adapter's only responsibility is to
drop the `TtyHandle` (and therefore the `exit_code` future) when the
session is cancelled — which it already does by virtue of dropping the
pump tasks.
The `TtyControl::signal("HUP")` path (ADR-002) is the
*client-initiated* signal forwarding path — a client sends a
`{"type":"signal","name":"HUP"}` control chunk. It is NOT the
cancel-cleanup path. The cancel-cleanup path is server-internal (the
adapter drops the handle) and does not involve the wire format. These
are two different signal paths; both end in the child receiving SIGHUP
(or the backend's configured cancel signal), but they are triggered by
different actors (client vs. server cancel).
## Consequences
**Positive:**
- A backend that conforms to the contract cannot orphan a process on
cancel. The local-PTY POC's waiter-thread gap is closed at the
contract level, not left to each backend to rediscover.
- The cleanup is idiomatic Rust — `Drop`-on-cancel of a `Future` is
the standard pattern for resource cleanup in async Rust (the same
pattern `tokio::process::Child` uses; the same pattern `tokio::io`
AsyncRead guards use). No new trait method; no adapter-side kill
call.
- The contract is backend-agnostic — the mechanism (`ChildKiller` for
local, `kill_container` for docker, `channel::close` for SSH) lives
in the backend; the contract ("drop the future, the target dies")
lives at the seam.
- The happy path (the child exits, the adapter drives `exit_code` to
completion, sends the exit chunk, then drops the resolved future) is
unaffected — the `Drop`-on-resolve is a no-op via the guard.
**Negative:**
- The `exit_code` future is no longer a trivial `oneshot::Receiver<i32>`
wrapper; it carries a kill guard. This is a small implementation
complexity increase (a struct with a `Drop` impl and a
resolved-flag), but it is the cost of the contract. The POC's
`LocalPty::exit_code` was a bare `oneshot::Receiver<i32>`; the
spec'd `TtyHandle.exit_code` is a struct wrapping it. An implementer
who copies the POC's bare shape without the kill guard violates the
contract.
- The contract is behavioral, not type-enforced. Rust cannot require
"the `Drop` of the future returned by `allocate()` kills the child"
in the type system. The contract is documented in the `TtyBackend`
trait's doc comment and in this ADR; conformance is the
implementer's responsibility. A test (a "cancel mid-session" test
that asserts the child is reaped after the session is dropped)
should be part of each backend's integration suite.
- A backend whose session target genuinely cannot be killed (a
backend that wraps an immutable shared resource, e.g., a "view a
log stream" backend) implements the contract as a no-op. The
contract is "best-effort kill if there is a killable target"; a
no-op `Drop`-on-cancel is conformant for a non-killable target.
## Door type
**One-way.** The cleanup contract is part of the `TtyBackend`
behavioral contract. Clients (the adapter) depend on "drop the handle,
the session is cleaned up." Changing the contract after backends exist
— e.g., adding a separate `TtyBackend::cancel()` method and migrating
the cleanup out of `exit_code`'s `Drop` — would require every backend
to change. The `exit_code`-future-`Drop`-on-cancel mechanism is the
seam.
## Assumptions
1. **The `exit_code` future's `Drop` is the only cancel path.** The
adapter does not call a separate kill method; it drops the handle.
This means the cleanup runs in the same place the cancel happens
(the pump task's `Drop`), not in a separate cancel call. This is
the idiomatic Rust async cancel pattern and the one the trait
commits.
2. **The kill signal is the backend's configured cancel signal (SIGHUP
for the local backend, the docker/SSH equivalent).** This is a
server-internal signal path, distinct from the
client-initiated `TtyControl::signal()` path (ADR-002). The cancel
signal is not configurable from the wire format in v1; a backend
that needs a different cancel signal configures it internally.
3. **The waiter thread (local backend) reaps the killed child.** After
the `Drop`-on-cancel calls `ChildKiller::kill(SIGHUP)`, the child
exits; the waiter thread's `wait()` returns and reaps it (no
zombie). The waiter thread then exits. The `oneshot::send` from the
waiter thread fails silently (the receiver was dropped with the
future) — this is expected and not an error.
## References
- [tty-adapter.md](../tty-adapter.md) §"Connection and Stream
Lifecycle" — the cancel paths (connection drop, stream reset) that
trigger the contract
- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)" — the
local backend's three-thread bridge and the waiter-thread gap this
ADR closes
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the
cancel does not involve (the cleanup is server-internal)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
trait this contract is part of; the `exit_code` field the cleanup
wires into; the `TtyControl::signal()` path the cancel-cleanup path
is distinct from
- [ADR-003](003-local-backend-placement.md) — the local backend this
ADR's reference mechanism (`ChildKiller`) is for
- [ADR-004](004-exit-code-on-control-chunk.md) — the happy-path
exit-chunk sequence (the cancel path bypasses it; no exit chunk is
sent on cancel — see `tty-adapter.md` §"Stream reset")
- `src/local/pty.rs` — the local backend's `LocalExitFuture` (the
`Future` + `Drop` guard this ADR specifies, with the `ChildKiller`
held in the guard)
- `src/local/pipe.rs` — the pipe-mode equivalent
(`tokio::process::Child::start_kill()` on `Drop`-on-cancel)
- `portable-pty` 0.9 `ChildKiller` — the kill handle the local
backend's cancel-cleanup uses
- Spec: [tty-backend.md](../tty-backend.md),
[tty-adapter.md](../tty-adapter.md),
[tty-local.md](../tty-local.md)
- Port origin: alknet ADR-056 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md`
@@ -0,0 +1,190 @@
# ADR-006: Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency)
## Status
Accepted (ported from alknet ADR-057 2026-08-17; the dependency-edge
decision carries over unchanged — alktty depends on alkcall (for the
`ProtocolHandler` trait, auth, ownership types) but not on alkcall's
internal wire types. The framing is self-contained in
`src/negotiation.rs`. Cross-references renumbered to alktty's ADR
range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADR
referenced by alknet number (003) is not ported into alktty's ADR
range — it is an alknet-core ADR. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
## Context
The alknet-tty specs (alknet ADR-052, 053, 054) and alknet ADR-003
Amendment 1 previously stated that alknet-tty depends on alknet-call for
the `FrameFramedReader`/`FrameFramedWriter` "framing utility" — the
4-byte big-endian length prefix + UTF-8 JSON body framing the
negotiation frame uses. The claim was "reuse the framing utility, not
the `EventEnvelope` type" (alknet ADR-052 §6).
A pre-implementation sanity check surfaced that **the framing utility
is not actually reusable as the spec described.**
`FrameFramedReader` (in alkcall's protocol/wire module) is hardcoded to
deserialize `EventEnvelope`:
```rust
pub async fn read_frame(&mut self) -> Result<EventEnvelope, FrameError> {
// ... read 4-byte length prefix, read body ...
let envelope: EventEnvelope = serde_json::from_slice(&body)?;
Ok(envelope)
}
```
The framing logic (read 4 bytes → length, read N bytes) and the
`EventEnvelope` deserialization are entangled in the same method. The
"framing utility" the spec claimed to reuse does not exist as a
separable thing — the length-prefix read and the type-specific
deserialize are one call. alktty's negotiation frame is a
`NegotiateRequest`, not an `EventEnvelope`, so `read_frame()` cannot
return what alktty needs.
This left three options (see alknet ADR-003 Amendment 2 for the full
comparison):
1. **Duplicate the ~30 lines of framing logic in alktty.** The framing
is a trivial length-prefix idiom (4-byte BE length + body); the two
copies would share an idiom, not a domain abstraction.
2. **Promote a generic length-prefixed framing utility to alkcall::core.**
Makes the spec's claim true (a reusable utility exists), but
accretes a framing module to the foundation crate for the sake of
two consumers — a shared utility pays for itself at ≥2 consumers,
but the framing is trivial enough that the cost of the shared
abstraction (a new module, a new type, a refactor of alkcall)
exceeds the cost of the duplication.
3. **Actually use alkcall** (e.g., model the tty control channel as
call-protocol operations). A different architecture, not a
dependency-edge fix — the current `ControlMessage` tagged enum
(ADR-001) is the two-way-door seam; replacing it with the call
protocol is a v2 ALPN decision, not a v1 dependency choice.
## Decision
### 1. alktty does not depend on alkcall's internal wire types
alktty implements its own length-prefixed framing for the negotiation
frame directly on tokio's `AsyncRead`/`AsyncWrite`. The crate's
dependency edge is:
```
alktty
└── alkcall (ProtocolHandler, Connection, AuthContext, Identity,
AccessControl, OwnershipProvider — alknet ADR-050)
```
No alkcall-internal-wire-types dependency. The crate depends on alkcall
(which every handler crate depends on anyway for the `ProtocolHandler`
trait) and nothing else for the protocol surface. `portable_pty`,
`bollard`, `russh` remain in the backend crates / the `local` feature
(ADR-003).
### 2. The framing format coincides with alkcall's by convention, not by code reuse
Both alktty's negotiation frame and alkcall's `EventEnvelope` frame use
a 4-byte big-endian length prefix + UTF-8 JSON body. This is a shared
*format convention* (length-prefixed JSON is a standard framing
pattern), not a code dependency. The two implementations are
independent: alktty's reader deserializes `NegotiateRequest`; alkcall's
`FrameFramedReader` deserializes `EventEnvelope`. They share an idiom
(length-prefix framing), not a module.
### 3. The framing logic lives in alktty as a small, self-contained module
alktty implements the negotiation framing as a small module
(`src/negotiation.rs`, ~30 lines for the framing reader/writer: read
4-byte BE length, bounds-check, read N bytes; write the inverse). The
module's types (`NegotiationReader`/`NegotiationWriter`) are private to
the crate — they are not a reusable utility for other crates. If a
future crate wants length-prefixed JSON framing, it implements its own
(the idiom is trivial) or a future ADR promotes a generic utility to
alkcall::core at that point (deferred — not needed for the current
scope; two consumers is the threshold but the second consumer does not
yet exist).
## Consequences
**Positive:**
- alktty's dependency surface is minimal and correct: alkcall only. No
dependency on alkcall's internal wire types for a "framing utility"
that wasn't reusable as specced. The "weird" dependency edge (a
handler crate depending on another handler crate's internal wire
module for 30 lines of glue) is gone.
- The spec is honest: it describes what the code does (a
self-contained framing module) rather than what a previous draft
hoped for (a reusable utility in alkcall that doesn't exist in a
separable form).
- The framing logic is trivial and self-contained; bugs in it are
local to alktty (no cross-crate coordination if alkcall's framing
changes for call-protocol reasons).
- alknet ADR-003's "no handler crate depends on another handler crate's
internal wire types" rule is preserved without the Amendment 1
exception for alktty. (alknet ADR-003 Amendment 1's exception
remains for alknet-http/agent/napi, which use alkcall's
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type
reuse, not framing glue. alktty does not need that exception.)
**Negative:**
- ~30 lines of framing logic are duplicated between alktty and alkcall.
The duplication is an idiom (length-prefix framing), not a domain
abstraction; the cost of the shared abstraction (a new module in
alkcall::core + a refactor of alkcall) exceeds the cost of the
duplication for two consumers. If a third consumer appears, this
trade-off should be revisited (promote to alkcall::core).
- A bug found in the length-prefix framing edge cases (e.g., a
partial-read handling bug) would need fixing in two places. The
framing is mature (a standard `read_exact`-based pattern); the edge
cases are known and tested in both crates independently.
## Door type
**One-way.** alktty not depending on alkcall's internal wire types is a
dependency-edge commitment. Adding the dependency back later (if, e.g.,
a generic framing utility is promoted to alkcall::core) is a new ADR.
The framing logic being self-contained in alktty is two-way (it could
be refactored to a shared utility in alkcall::core later without a
wire-format change), but the dependency edge is one-way.
## Assumptions
1. **The framing logic is trivial enough that duplication is cheaper
than a shared abstraction.** Length-prefixed framing (4-byte BE
length + body) is a ~30-line idiom. The cost of a shared utility in
alkcall::core (a new module, a new type, a refactor of alkcall's
wire module to extract the generic layer) is higher than the cost of
two independent implementations for two consumers. If a third
consumer appears, revisit (promote to alkcall::core).
2. **The format coincidence (both use 4-byte BE length + JSON body) is
stable.** Both crates use the same length-prefix convention. If
alkcall's framing changes (e.g., a different max-frame-size, a
different prefix width), alktty's is unaffected — they are
independent implementations that happen to share a format today.
## References
- alknet ADR-003 Amendment 1 (protocol-foundation exception for
alknet-http/agent/napi) and Amendment 2 (this ADR's effect on the
Amendment 1 framing-reuse claim for alktty)
- [ADR-001](001-wire-format-and-two-carriage.md) §2 (negotiation frame
format), §6 (revised: format coincides by convention, not by code
reuse)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the crate
decomposition this ADR's dependency edge affects
- `src/negotiation.rs` — the self-contained framing reader/writer this
ADR commits (`NegotiationReader`, `NegotiationWriter`,
`error_response_bytes`)
- alkcall's `FrameFramedReader`/`FrameFramedWriter` — the
`EventEnvelope`-bound methods that are NOT reused (the entangled
length-prefix-read + `EventEnvelope`-deserialize that motivated this
ADR)
- Spec: [overview.md](../overview.md) (dependency edge),
[tty-wire.md](../tty-wire.md) (negotiation framing)
- Port origin: alknet ADR-057 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/057-alknet-tty-no-alknet-call-dep.md`
@@ -0,0 +1,270 @@
# ADR-007: TTY Inside Channels — Sub-Streams, Not Wire Format
## Status
Accepted (**reversed 2026-07-18 by alknet ADR-093, ported here as
[ADR-008](008-channels-pure-channel-multiplexing.md): TTY always uses
its 5-byte format; the channels layer carries it transparently in the
payload — see "Reversal (ADR-008, 2026-07-18)" below.** Ported from
alknet ADR-077 2026-08-17; cross-references renumbered to alktty's
ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADRs
referenced by alknet number (052, 053, 055, 057, 061, 071, 073, 074,
092) are not ported into alktty's ADR range — they are alknet-core /
alknet-channels ADRs. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
## Reversal (ADR-008, 2026-07-18)
The two-mode TTY design (direct vs inside-channels, with different
sub-stream access paths) is **reversed**. TTY's 5-byte format
(`[stream_type:u8][length:u32][payload]`, ADR-001) is TTY's internal
format, used in **both** direct mode and inside-channels mode. The two
modes differ only in *where the `BiStream` comes from* (a top-level
`alk/tty` connection vs a `channel/open` with ALPN `alk/tty`), not in
*how TTY parses it*. The same `wire.rs` code runs in both modes.
When TTY is inside channels, the channels layer strips its 8-byte header
(alknet ADR-093 / ADR-008) and hands TTY the payload bytes. TTY parses
its 5-byte header from the payload. The channels layer carries TTY's
5-byte chunks transparently in its payload — no shared fields, no
leaked abstraction, no double-chunking concern (the 13-byte total
header is 8 channels + 5 TTY, not 8 + 9; the channels `length` is
always `tty_len + 5`).
The `channels` feature on alktty (if such a feature were added) becomes
"run TTY's sub-demux on a channels-backed `BiStream`" — the same code
as direct mode, different `BiStream` source. The control channel split
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
channels layer doesn't know about it. alknet ADR-074's
`into_sub_streams()` (the accessor this ADR's two-mode design relied
on) is removed by alknet ADR-093 / ADR-008; TTY sub-demuxes its
`BiStream` via its own 5-byte format instead.
The body below describes the **original** (two-mode) shape; the
reversal above is the operative decision. The two-mode description is
kept as the historical context for the reversal. See ADR-008 for the
resolution rationale (the channels layer has no `stream_type` concept;
the handler owns its sub-stream multiplexing) and the cross-ADR
impacts.
## Context
ADR-001 defines the alktty wire format: `[stream_type: u8][length: u32
be][payload]`, a 5-byte chunk header for five sub-streams
(stdin/stdout/stderr/ctrl-in/ctrl-out) within one bidi stream. This
format is stable, implemented (`src/wire.rs`), and used for direct
`alk/tty` connections.
The phase-0 research recommended that the TTY chunk format be "absorbed
into channels" and that `alk/tty` remain as a "direct-connect
shortcut." But the research did not pin what changes in the TTY crate
when a TTY session runs *inside* a channels connection. This is a real
integration question the research hand-waved.
The problem: the `TtyAdapter`'s current `handle()` loops `accept_bi()`,
spawning a `drive_session` task per bidi stream that parses 5-byte TTY
chunks off the stream. Inside a channels connection, the stream is
*already de-chunked* by the channels layer's 9-byte format — the
handler sees an `AsyncRead + AsyncWrite` pair, not a chunk-encoded
stream. If the TTY adapter tries to parse 5-byte chunks off an
already-de-chunked stream, it breaks.
## Decision
### Two modes for TTY, one adapter
The `TtyAdapter` operates in two modes, determined by how it receives
its `Connection`:
| Mode | When | Wire format | How the adapter gets sub-streams |
|------|------|-------------|---------------------------------|
| **Direct (`alk/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alk/tty` | TTY's 5-byte format (ADR-001, amended — see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
| **Inside channels** | `channel/open` with ALPN `alk/tty` on a channels connection | Channels' 9-byte format (alknet ADR-071) — the channels layer de-chunks | `into_sub_streams()` (alknet ADR-074) → five named handles for stream_types 0-4 |
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
(ADR-002). The backend allocates a PTY and returns a `TtyHandle`; the
adapter pumps data between the handle and the sub-streams. The
difference is only in how the adapter gets the sub-streams — 5-byte
chunk parsing (direct) vs. `into_sub_streams()` (channels).
### The control channel is now properly bidirectional
The phase-0 findings flagged that the TTY control channel "isn't
actually bidirectional… the adapter ignores Exit from the client." The
root cause: `stream_type 3` was "bidirectional" — one stream both sides
wrote to, which is not properly multiplexed.
alknet ADR-071's stream_type decomposition fixes this: **every
stream_type is unidirectional.** Control is now two halves:
| stream_type | direction | purpose |
|-------------|-----------|---------|
| 3 | write (client→server) | control in: resize, signal, eof |
| 4 | read (server→client) | control out: exit, keepalive response |
The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3)
and reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its
own reassembly buffer, its own flow control, its own EOF. The control
channel is *actually* bidirectional — two unidirectional streams, not
one shared stream both sides write to.
This amends ADR-001's stream_type assignments for direct mode too:
direct `alk/tty` connections now use stream_types [0, 1, 2, 3, 4] (data
in/out/err + control in/out), not [0, 1, 2, 3]. The 5-byte format's
`stream_type` field gains value 4; the `ControlMessage` enum is
unchanged (the JSON shape is the same; the stream_type it rides on
splits from 3 into 3+4).
### What changes in alktty
1. **The adapter's session-driving code splits into two entry
points:**
- `drive_session_direct(send, recv, backends, ...)` — the existing
path: parse 5-byte chunks, split into stream_types 0-4, pump. Used
for direct `alk/tty` connections.
- `drive_session_channels(sub_streams, backends, ...)` — the new
path: receive `ChannelSubStreams` (five named handles: stdin=
SendStream, stdout=RecvStream, stderr=Option<RecvStream>,
ctrl_in=SendStream, ctrl_out=RecvStream), pump directly without
chunk parsing. Used when the channel's `Connection` is backed by
`ChannelBidiStreamSource`.
2. **The `TtyAdapter::handle()` branches on the `Connection`'s source
type.** The channels crate's `ChannelBidiStreamSource` is a
`BidiStreamSource` (alknet ADR-070); the `Connection` wraps it. The
adapter detects whether the `Connection` is channels-backed (via a
downcast or a channels-crate extension trait — exact ergonomics per
alknet ADR-074's implementation detail) and calls
`drive_session_channels` instead of `drive_session_direct`.
**This is the one place alktty knows about channels.** It is a
branch on the connection source, not a dependency on channels' wire
format. The branch can be feature-gated (`channels` feature on
alktty) so the direct-only path has no channels dependency.
3. **The 5-byte wire format (ADR-001) is unchanged for direct
connections.** ADR-001's scope is now "the wire format for direct
`alk/tty` connections." The channels path does not use it. This
amends ADR-001's scope — the format is not replaced, it's scoped.
4. **The control channel works the same in both modes, now properly
bidirectional.** In direct mode, control-in JSON rides in 5-byte
chunks with `stream_type=3` and control-out rides with
`stream_type=4`. In channels mode, control-in rides in 9-byte chunks
with `stream_type=3` (write) and control-out with `stream_type=4`
(read) — but the channels layer de-chunks them, so the adapter
reads raw JSON bytes from `ctrl_in`/`ctrl_out` in both cases. The
`ControlMessage` enum (resize, signal, eof, exit) is unchanged —
the JSON shape is the same; only the stream_type assignments change
(3 splits into 3+4).
5. **The exit-chunk-is-last invariant (ADR-004) generalizes.** In
direct mode, the exit chunk is the last 5-byte chunk on
`stream_type=4` (read, server→client) before stream close (ADR-004,
amended). In channels mode, the exit control message is the last
data on `stream_type=4` before `channel/close` is sent on channel 0
(alknet ADR-073 §channel/close). The ordering invariant is the same
— exit before close — but the mechanism differs: 5-byte chunk
ordering on stream_type 4 (direct) vs. `stream_type 4` ordering +
`channel/close` after pump completion (channels).
### What does NOT change
- **`TtyBackend` trait, `TtyHandle`, `TtyControl`** (ADR-002) —
unchanged. Backends don't know about channels or direct mode.
- **`DockerTtyBackend`, `LocalTtyBackend`** — unchanged. They implement
`TtyBackend::allocate()` and return a `TtyHandle`.
- **`ControlMessage` enum** — unchanged. The JSON shape is the same in
both modes.
- **The `alk/tty` ALPN string** — unchanged. Direct connections use it;
channels `channel/open` requests it.
### Crate dependency
`alktty` does **not** depend on `alknet-channels` unconditionally. The
channels-integration code is behind a `channels` feature on `alktty`.
When the feature is off, `TtyAdapter` only supports direct mode (the
existing behavior). When the feature is on, the adapter branches into
channels mode for channels-backed connections. This preserves alknet
ADR-003's no-handler-depends-on-another-handler rule for the default
build; the feature-gated dependency is opt-in, same as `alknet-docker`'s
`tty` feature (alknet ADR-061).
## Consequences
**Positive:**
- The TTY crate's direct mode is unchanged — existing `alk/tty`
deployments (browser terminals over WebSocket, direct QUIC TTY) keep
working with the 5-byte format.
- The channels path uses the channels layer's de-chunking — no
double-chunking (5-byte inside 9-byte). The TTY adapter sees clean
sub-streams.
- The `TtyBackend` trait is insulated — backends don't know which mode
the adapter is in. Docker, SSH, and local backends work in both
modes without changes.
- The control channel and exit-chunk invariant carry forward cleanly —
the `ControlMessage` enum and ordering semantics are
mode-independent.
**Negative:**
- `alktty` has two session-driving entry points
(`drive_session_direct` vs `drive_session_channels`). This is the
necessary cost of supporting both direct and channels modes without
double-chunking. The alternative (always use channels format, even
for direct) would break existing direct deployments and add 4 bytes
of overhead per chunk for no benefit.
- The `channels` feature on `alktty` adds a dependency edge (`alktty`
`alknet-channels`, feature-gated). This is the same pattern as
`alknet-docker`'s `tty` feature (alknet ADR-061) and is opt-in.
- ADR-001's scope is amended (from "the TTY wire format" to "the TTY
wire format for direct connections"). This is a scope clarification,
not a format change — the 5-byte format itself is unchanged.
## Door type
**One-way (scope amendment) + two-way (feature gate).** ADR-001's scope
amendment (direct-only) is one-way — once the channels path exists,
re-merging the formats would require unifying 5-byte and 9-byte chunk
handling, which is a rewrite. The `channels` feature gate is two-way —
it can be removed if channels integration is no longer needed.
**Reversed by ADR-008 (2026-07-18):** the two-mode design is reversed —
TTY always uses its 5-byte format, carried transparently in the
channels payload. The one-way door is re-cast (the channels crate is
not yet implemented, so this is the right time). See ADR-008 for the
amended door-type discussion.
## References
- **[ADR-008](008-channels-pure-channel-multiplexing.md)**: channels
pure channel multiplexing (reverses this ADR — TTY always uses its
5-byte format; the channels layer carries it transparently; the
two-mode design is preserved but differs only in `BiStream` source,
not in parsing)
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
(amended — scoped to direct connections by this ADR; **re-amended by
ADR-008 — TTY always uses its 5-byte format, in both direct and
inside-channels modes**)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): TtyBackend trait
and TtyHandle (unchanged by this ADR)
- [ADR-004](004-exit-code-on-control-chunk.md): exit-chunk-is-last
(generalized by this ADR + alknet ADR-073)
- [ADR-006](006-negotiation-framing-self-contained.md): alktty does not
depend on alkcall's internal wire types (preserved — the channels
feature is on alknet-channels, not alkcall's internal wire types)
- alknet ADR-071: channels wire format (the 9-byte format the channels
path uses; **amended by alknet ADR-093 / ADR-008 — 8-byte format, no
`stream_type`**)
- alknet ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the
accessor the channels path uses; **amended by alknet ADR-093 /
ADR-008 — `into_sub_streams()` removed**)
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
decision that enables the reversal — `accept_bi` returns `BiStream`)
- alknet ADR-061: DockerTtyBackend in alknet-docker (the feature-gated
dependency pattern this ADR mirrors)
- alknet channels phase-0 findings §DP-3, §OQ-CH-02, §Relationship to
Existing Crates / alktty
- Port origin: alknet ADR-077 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md`
@@ -0,0 +1,521 @@
# ADR-008: Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`)
## Status
Accepted (amends alknet ADR-071 — wire format is 8 bytes, not 9, and
the channels layer has no `stream_type` concept; amends alknet ADR-074
`into_sub_streams()` removed, `accept_bi` is the only accessor and
yields one `BiStream` per channel; reverses [ADR-007](007-tty-inside-channels.md)
— TTY always uses its 5-byte format, the channels layer carries it
transparently in the payload. Ported from alknet ADR-093 2026-08-17;
cross-references renumbered to alktty's ADR range — ADR-052→001,
ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006,
ADR-077→007, ADR-093→008. The alknet ADRs referenced by alknet number
(052, 070, 071, 072, 073, 074, 075, 076, 078, 079, 080, 081, 092) are
not ported into alktty's ADR range — they are alknet-core /
alknet-channels ADRs. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.)
## Context
alknet ADR-071 committed the channels wire format as a 9-byte chunk
header (`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte
extension of TTY's 5-byte format, with `stream_type` carried in the
channels header and decomposed into unidirectional halves (0/1/2 =
data write/read/err, 3/4/5 = control write/read/err, `% 3` formula).
alknet ADR-074 added a second accessor (`into_sub_streams()`)
alongside `accept_bi` for handlers that need typed sub-streams (TTY's
stdin/stdout/stderr/ctrl-in/ctrl-out). [ADR-007](007-tty-inside-channels.md)
split TTY's wire format into two modes — direct (5-byte) and
inside-channels (the channels layer de-chunks and the adapter
destructures via `into_sub_streams()`).
The stream-unification research surfaced that these three decisions
share one root: the channels layer carries a concept (`stream_type`)
it doesn't own. The 9-byte header bakes TTY's sub-stream multiplexing
into the channels wire format. The `into_sub_streams()` accessor exists
because the channels layer reassembles per-`stream_type` and needs to
expose the result. The two-mode TTY design exists because the channels
layer's `stream_type` overlaps with TTY's own `stream_type`. The mod
2/mod 3/mod 4 numbering question (settled as mod 3 in alknet ADR-071
revised) was a symptom of this overlap — a numbering convention for a
concept the channels layer shouldn't carry.
### The structural question
The channels layer has two objectives in tension:
1. **"Pass a stream to/from any ALPN"** — every channel is a
`BiStream`; any handler gets `accept_bi()` and treats the channel
as a duplex stream. Uniform, transport-agnostic,
recursive-composition-friendly.
2. **"Channels carry N sub-streams"** — a TTY channel carries
stdin/stdout/stderr/control; the handler destructures via
`into_sub_streams()`. Carries what the source produces.
The tension is real when a sub-stream is *unidirectional* (stderr). You
can't represent stderr as a `BiStream` without wasting the write half;
you can't make it a "third half" (mod 3) without breaking pair
symmetry; you can't make the channel a single `BiStream` without
losing the stdout/stderr distinction.
alknet ADR-074's two-accessor design resolves this by making the "pass
a stream to/from any ALPN" objective *qualified* — it applies to
single-stream channels (tunnel, SSH, call), not multi-stream channels
(TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified
design.
### The resolution: channels layer is pure channel multiplexing
The channels layer's job is "one connection carries N channels, routed
by `channel_id`." It does not know about TTY's sub-streams, SSH's
channel protocol, or how call frames its JSON. Handlers own their
sub-multiplexing on the `BiStream` the channels layer gives them.
- **Every channel is a `BiStream`.** `accept_bi()` yields one
`BiStream` per channel (per alknet ADR-092, already landed). No
`into_sub_streams()`, no second-class accessor.
- **Handlers sub-multiplex their `BiStream` however they want.** TTY
sub-demuxes `stream_type` from its `BiStream` (its 5-byte format).
Tunnel uses the `BiStream` as raw bytes. Call length-prefixes JSON.
SSH runs its own channel protocol. The channels layer carries the
bytes transparently.
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.**
The channels layer has no `stream_type` concept — not in its header,
not in its code, not in its mental model. `stream_type` is the inner
layer's framing byte, carried transparently.
- **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
`STREAM_CTRL_OUT = 4` — ADR-001 amended by Phase 7). The channels
layer doesn't carry control. The "control isn't actually
bidirectional" flaw is fixed at the TTY layer, not the channels
layer.
- **Recursive composition is literal.** A channel with ALPN
`alk/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its
own 8-byte header from the payload. Each level is the same shape —
`BiStream → accept_bi → N BiStreams`.
### The wire format decision: 8 bytes
The channels wire format is **8 bytes**:
`[channel_id:u32 BE][length:u32 BE]` followed by an opaque payload. The
channels layer owns `channel_id` and `length`; the payload is the
handler's framing, carried transparently.
The 9-byte alternative (`[channel_id:u32][stream_type:u8][length:u32]`)
was considered and rejected. The 9-byte format puts `stream_type` in the
channels header, which means the channels layer carries a concept it
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
5-byte header with `channel_id` prepended), but for non-TTY handlers
(tunnel, call, SSH) the `stream_type` byte is dead weight — the
channels layer carries a byte it doesn't understand, and the handler
ignores a byte in a header it doesn't control.
The 8-byte format is uniform across all handlers: the channels layer
carries `channel_id` + `length` + opaque payload. Every handler parses
its own framing from the payload. The cost is that TTY's `wire.rs` is
called from a payload buffer rather than directly from the wire, and
the total header for a TTY chunk is 13 bytes (8 channels + 5 TTY)
instead of 9. The two length fields are close but not identical
(`ch_len = tty_len + 5`); for typical TTY chunks (4 KiB+), the 5-byte
overhead is ~0.1%, and the trade is clean separation of concerns. See
"Consequences" for the full cost/benefit.
### The add/strip composition
Each layer has its own add/strip pair. The channels layer:
`add_channel_id(channel_id, payload_bytes) -> chunk` on write (prepends
the 8-byte header); `strip_channel_id(chunk) -> (channel_id,
payload_bytes)` on read (strips the 8-byte header, returns the
payload). The handler layer (e.g. TTY) parses its own framing from the
payload bytes per its existing `wire.rs`. The handler doesn't know or
care that a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is
SSH's model (layered headers, each layer strips its own at its
boundary), applied to channels. A `alk/channels`-inside-`alk/channels`
recursive composition is the outer layer stripping its 8-byte header,
the inner layer parsing its own 8-byte header from the payload — same
code, same shape, each level.
### Why this can land now
Three things changed since alknet ADR-071/074/077 were accepted:
1. **alknet ADR-092 landed `BiStream` as the handler leaf.**
`accept_bi()` returns a `BiStream` (a concrete `AsyncRead +
AsyncWrite` newtype), not a split `(SendStream, RecvStream)` pair.
The join moves into core's quinn/iroh/stream impls (once per
source, invisible to handlers). This ADR's "every channel is a
`BiStream`" is the channels-layer consequence of alknet ADR-092's
handler-leaf decision — the research-then-sync pattern applied:
alknet ADR-092 settled the transport leaf, this ADR settles the
multiplexing layer above it.
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it
into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's
5-byte format*, not at the channels layer. This removed the
load-bearing reason for the channels layer to carry `stream_type`:
the control bidirectionality fix is a TTY-internal concern, not a
channels-layer concern. ADR-007's two-mode TTY design was motivated
by the channels layer carrying control; with control moved inside
TTY, the motivation dissolves.
3. **No production constraint.** The develop branch is a rewrite of
main (pre-alpha). The channels crate doesn't exist yet (per
alknet ADR-081, it's planned as `alknet-channels-core` +
`alknet-channels-call`). The decision is purely "what's cleanest,"
not "what's least disruptive." The 9-byte POC validated the
per-`channel_id`/`stream_type` routing mechanism; the 8-byte spec
update changes the header before implementation begins.
### What this ADR does NOT decide
- **The add/strip API shape** (built into read/write vs. a separate
utility): the stream-unification research proposed `add_channel_id`
/ `strip_channel_id` as standalone functions. Ideally the header is
built into the read/write path so the utility isn't needed at the
handler boundary — but there may be a generalized reason to expose
it (recursive composition, test helpers, the hub relay's
`channel_id` rewrite). The exact API shape is an implementation
detail for the channels crate. The *contract* — the channels layer
strips its 8-byte header on read and the handler parses its own
framing from the payload — is decided here; the *function surface*
is not.
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads
from an `AsyncRead`. Adapting it to read from a payload buffer
(`&[u8]` or `Cursor<Bytes>`) is a small, well-scoped change (the
framing logic — stream_type constants, length validation, control
message parsing — is unchanged). This is an implementation concern
for the channels + TTY integration, not an architecture decision.
In alktty, `drive_session` already runs against a `BiStream` whose
`AsyncRead` yields the post-channels-strip payload bytes; the same
`wire.rs` code runs in both modes.
- **Full channel-level flow-control windowing (alknet OQ-56):**
unchanged. The bounded-buffer backpressure (alknet ADR-076) is the
v1 mechanism; full windowing is an additive extension that doesn't
change the wire format.
## Decision
### 1. The channels wire format is 8 bytes
```
[channel_id: u32 BE][length: u32 BE][payload bytes]
```
8 bytes of header, followed by `length` bytes of opaque payload. The
channels layer owns `channel_id` and `length`; the payload is the
handler's framing, carried transparently.
| field | offset | width | meaning |
|-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (alknet ADR-072). Channels 1..N are opened dynamically via `channel/open` (alknet ADR-073). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-001 §5). |
The `stream_type` byte is **removed** from the channels header. The
channels layer has no `stream_type` concept — not in its header, not in
its code, not in its mental model. What was the channels header's
`stream_type` byte is now the first byte of the payload, owned by the
handler's framing (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol).
This amends alknet ADR-071: the wire format is 8 bytes, not 9; the
`stream_type` decomposition (mod 3, unidirectional halves, 85 groups)
is removed from the channels layer. The stream_type concept survives in
TTY's 5-byte format (ADR-001, amended by Phase 7), which the channels
layer carries transparently.
### 2. `into_sub_streams()` is removed; `accept_bi` is the only accessor
alknet ADR-074's `into_sub_streams()` / `ChannelSubStreams` /
`SubStreamHandle` are removed. The channels layer exposes one accessor:
`accept_bi()`, which yields one `BiStream` per channel (per alknet
ADR-092). Every handler — TTY, tunnel, SSH, call — receives a
`Connection`, calls `accept_bi()` once, gets a `BiStream`, and
sub-multiplexes it however it wants.
This amends alknet ADR-074: the two-accessor design (`accept_bi` for
generic handlers, `into_sub_streams` for typed handlers) collapses to
one accessor. The "typed handler path" (alknet ADR-074's motivating
case for TTY) is replaced by TTY sub-demuxing its `BiStream` via its
own 5-byte format — the same code TTY runs in direct mode. alknet
ADR-074's yield-once `accept_bi` contract is preserved; the
`into_sub_streams()` accessor is the amended part.
### 3. TTY always uses its 5-byte format; the channels layer carries it transparently
ADR-007's two-mode TTY design (direct vs inside-channels) is reversed.
TTY's 5-byte format (`[stream_type:u8][length:u32][payload]`, ADR-001)
is TTY's internal format, used in *both* direct mode and
inside-channels mode. The two modes differ only in *where the
`BiStream` comes from* (a top-level `alk/tty` connection vs a
`channel/open` with ALPN `alk/tty`), not in *how TTY parses it*. The
same `wire.rs` code runs in both modes.
When TTY is inside channels, the channels layer strips its 8-byte
header and hands TTY the payload bytes. TTY parses its 5-byte header
from the payload. The channels layer carries TTY's 5-byte chunks
transparently in its payload — no shared fields, no leaked
abstraction, no double-chunking concern (the 13-byte total header is
8 channels + 5 TTY, not 8 + 9; the channels `length` is always
`tty_len + 5`).
This reverses ADR-007: the 5-byte format is NOT scoped to direct —
it's TTY's internal format, carried transparently in the channels
payload. The `channels` feature on alktty (if added) becomes "run
TTY's sub-demux on a channels-backed `BiStream`" — the same code as
direct mode, different `BiStream` source. The control channel split
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
channels layer doesn't know about it.
### 4. The add/strip composition
The channels layer's read path strips the 8-byte header and hands the
payload to the handler. The write path prepends the 8-byte header
(`add_channel_id`) onto the handler's output. The handler never sees
the `channel_id`; it sees only its own framing (the payload bytes).
```
channels: [channel_id:u32 BE][length:u32 BE][payload]
= 8-byte header + opaque payload
8 bytes
TTY inside channels:
[channel_id:u32][ch_len:u32][stream_type:u8][tty_len:u32][payload]
4 bytes 4 bytes 1 byte 4 bytes N bytes
\_________ __________/ \_________ _____________/
| |
channels header TTY chunk (5+N bytes)
(8 bytes) carried as channels payload
```
The composition is uniform — the same shape at every level. A
`alk/channels`-inside-`alk/channels` recursive composition is the
outer layer stripping its 8-byte header, the inner layer parsing its
own 8-byte header from the payload — same code, same shape, each
level.
### 5. What does NOT change
- **alknet ADR-092's `BiStream` leaf** — unchanged. This ADR is the
channels-layer consequence of alknet ADR-092: `accept_bi` yields a
`BiStream`, handlers sub-multiplex it. The two ADRs compose (alknet
ADR-092 settles the transport leaf; this ADR settles the
multiplexing layer above it).
- **`ProtocolHandler` trait shape** (alknet ADR-002) — unchanged.
Handlers receive a `Connection` and call `accept_bi()`.
- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072) —
unchanged. Channel 0's chunks have `channel_id = 0` in the 8-byte
header. The call protocol's `EventEnvelope` framing is the payload;
the channels layer carries it transparently.
- **Channel lifecycle operations** (alknet ADR-073) — unchanged. The
four operations (`channel/open`/`close`/`control`/`resources/subscribe`)
and their `direction` semantics are call-protocol operations on
channel 0, not channels-wire-format concerns.
- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075) —
structurally unchanged. The demux loop reads 8-byte headers (not
9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
transport-blind. The `stream_types` field on `channel/open` and
`ChannelState` is removed (the channels layer doesn't track
per-stream-type reassembly buffers; it tracks one reassembly buffer
per `channel_id`, yielding a `BiStream`).
- **Backpressure, channel limits, ID reuse** (alknet ADR-076) —
unchanged. The bounded-buffer backpressure is per-`channel_id` (was
per-`(channel_id, stream_type)`; now per-`channel_id` since there's
one reassembly buffer per channel). The 256-channel cap, 1 MiB
default, and monotonic-ID-with-wrap strategy are unchanged.
- **Two-pump shutdown-on-completion** (alknet ADR-078) — unchanged.
Tunnel/SSH handlers call `tokio::io::split(bidi)` for their two pump
halves; the shutdown-on-completion contract applies to the
`ReadHalf` / `WriteHalf` unchanged.
- **Hub relay** (alknet ADR-079) — unchanged in contract. The hub
translates `channel/open` on channel 0 and byte-forwards data
channels with `channel_id` rewrite. The relay reads 8-byte headers
(not 9-byte) and rewrites the `channel_id` field (a 4-byte rewrite
within the 8-byte header, not a 9-byte header). The relay does not
parse the payload.
- **`ChannelClient`** (alknet ADR-080) — unchanged in API.
`from_connection` primary, `open_channel` returns a `Channel`. The
`stream_types` field on `open_channel` and `Channel` is removed (the
channels layer doesn't negotiate per-stream-type sets; the handler
owns its sub-stream multiplexing). The
`channel:stream_type_unavailable` error code is removed (the
channels layer can't refuse a `stream_type` it doesn't know about).
- **Sub-crate decomposition** (alknet ADR-081) — unchanged.
`channels-core` (pure multiplexer, depends on alknet-core only) /
`channels-call` (channel 0 pre-negotiation + lifecycle op
registrations, depends on `channels-core` + alknet-call). The 8-byte
wire format, demux/mux, and `ChannelBidiStreamSource` are in
`channels-core`; the call-protocol coupling is in `channels-call`.
- **`BidiStreamSource` trait** (alknet ADR-070) — unchanged in shape.
`ChannelBidiStreamSource` implements it; `accept_bi` yields a
`BiStream` (per alknet ADR-092, already landed).
## Consequences
**Positive:**
- **Clean separation of concerns.** The channels layer has no
`stream_type` concept — not in its header, not in its code, not in
its mental model. The handler owns its framing entirely. This
dissolves the mod 2/mod 3/mod 4 question at the channels layer
(there's nothing to decompose) and fixes the "control isn't actually
bidirectional" TTY flaw at the TTY layer (where it lives, not the
channels layer).
- **Uniform across all handlers.** Tunnel, call, SSH, and TTY all
receive the same shape: a `BiStream`. No handler gets a
`stream_type` byte it doesn't use; no handler needs a second
accessor (`into_sub_streams`) to reach its sub-streams. The channels
layer's API surface is `accept_bi -> BiStream`, period.
- **Recursive composition is literal.** A `alk/channels` channel runs
another channels demux on its `BiStream`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from
the payload. Same code, same shape, each level. This is a property,
not a feature — the primary use case is one level of multiplexing,
but the add/strip composition makes the recursion cleaner than
alknet ADR-071's group framing did.
- **The `into_sub_streams()` accessor and its consuming handler code
are removed.** This is a net simplification: one accessor, one
handler path, no downcast / extension trait / "two paths" ergonomics
question (which alknet ADR-074 left as an implementation detail).
The handler crate destructures its `BiStream` via its own framing
(TTY's 5-byte format), not via a channels-crate-provided typed
accessor.
- **TTY's `wire.rs` runs unchanged in both modes.** Direct mode and
inside-channels mode use the same code; only the `BiStream` source
differs. ADR-007's `drive_session_direct` / `drive_session_channels`
split collapses to one `drive_session` function. The `channels`
feature on alktty (if added) becomes a thin wrapper that gets the
`BiStream` from a channels-backed `Connection` instead of a
top-level one. (In alktty as built, `drive_session` already takes a
generic `AsyncRead + AsyncWrite` pair, so both the direct
`TtyAdapter::handle` path and the channels `TtyOpenHandler` path
call the same function — see `src/adapter.rs` and
`src/channels.rs`.)
- **The channels layer is WASM-compatible by construction.** The
8-byte header's core is pure byte manipulation (the sync core
compiles under `wasm32-unknown-unknown`, validated by the POC). The
8-byte format is simpler than the 9-byte (one fewer field to parse),
strengthening the WASM-clean property.
**Negative:**
- **5 extra bytes per TTY chunk.** The total header for a TTY chunk
inside channels is 13 bytes (8 channels + 5 TTY), not 9. The two
length fields are close but not identical (`ch_len = tty_len + 5`).
For typical TTY chunks (4 KiB+), this is ~0.1% overhead. For extreme
multiplexing scenarios, the clean separation is worth the trade-off;
for high-throughput bulk transfer, the escape hatch is
multi-connection (one channels connection per leg), not stripping
the header. This is the documented cost of the clean separation; the
alternative (9-byte header with `stream_type` in the channels layer)
carries a concept the channels layer doesn't own, which is the root
cause this ADR addresses.
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
reads from an `AsyncRead` (the transport stream). Inside channels,
it reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the
bytes the channels layer handed it after stripping its 8-byte
header. The framing logic (stream_type constants, length validation,
control message parsing) is unchanged. This is a bounded,
well-scoped implementation change, not an architecture change. The
same adaptation applies to any handler that parses its own framing
from a payload buffer (call's `EventEnvelope` framing already reads
from a buffer; tunnel and SSH don't parse the payload, so no
adaptation). In alktty as built, `drive_session` already reads from
a `BiStream` whose `AsyncRead` yields the post-channels-strip
payload bytes — the same `wire.rs` code runs unchanged in both
modes.
- **`channel/open` loses the `stream_types` field.** alknet ADR-073's
`channel/open` input included `stream_types: [u8]` (the active
sub-stream set) and the response echoed the negotiated set. Under
this ADR, the channels layer doesn't negotiate sub-stream sets —
the handler owns its sub-stream multiplexing. The `stream_types`
field is removed from `channel/open` (and from the
`channel:stream_type_unavailable` error code). The `alpn` and
`params` fields remain; the handler's sub-stream set is implicit in
its ALPN's wire format. This is a small wire-format change to
`channel/open` (one field removed); since the channels crate isn't
implemented yet, there's no migration cost.
- **`ChannelState.streams: HashMap<u8, ReassemblyBuffer>` becomes
`ChannelState.reassembly: ReassemblyBuffer` (one per channel, not
per `(channel_id, stream_type)`).** This is an internal
simplification (fewer reassembly buffers, simpler drain logic) but
is an implementation change, not an architecture one. The
bounded-buffer backpressure (alknet ADR-076) is per-`channel_id`
now, not per-`(channel_id, stream_type)` — the 1 MiB default and
the 256-channel cap are unchanged; the per-channel memory ceiling is
1 MiB (was up to 5 MiB for a TTY channel with 5 active
stream_types). This is a net improvement (lower memory ceiling per
channel), not a regression.
## Door type
**One-way (wire format, accessor removal, two-mode reversal).** The
8-byte chunk header layout (`channel_id:u32 + length:u32`), the
removal of `stream_type` from the channels header, and the removal of
`into_sub_streams()` are wire-format and API commitments. Changing
them after the channels crate is implemented and handlers are written
against them requires a version migration. Since the channels crate
doesn't exist yet, the one-way door is being cast now, before
implementation — the right time to cast a one-way door.
The reversal of ADR-007 (TTY always uses its 5-byte format) is one-way
in the same sense: once TTY's `wire.rs` runs in both modes (direct and
inside-channels), re-introducing a separate inside-channels mode would
be a rewrite of TTY's session driver. The trade is one unified session
driver now vs. two-mode maintenance forever.
The add/strip API shape (alknet OQ-68) is a **two-way door** — whether
the header add/strip is built into the read/write path or exposed as a
standalone utility is an implementation detail that can change without
breaking the wire format or the handler contract.
## References
- alknet ADR-071: channels wire format (amended — wire format is 8
bytes, not 9; `stream_type` removed from the channels header; the
stream_type decomposition is removed from the channels layer)
- alknet ADR-074: ChannelBidiStreamSource (amended —
`into_sub_streams()` removed; `accept_bi` is the only accessor,
yields one `BiStream` per channel)
- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed
— TTY always uses its 5-byte format; the channels layer carries it
transparently in the payload; the two-mode design is preserved but
differs only in `BiStream` source, not in parsing)
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
layer this ADR builds on — `accept_bi` returns `BiStream`;
`from_bidi` is the only public stream constructor)
- alknet ADR-070: `BidiStreamSource` trait (the extension point
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged —
channel 0's chunks have `channel_id = 0` in the 8-byte header; the
call protocol's framing is the payload)
- alknet ADR-073: channel lifecycle operations (amended —
`stream_types` field removed from `channel/open`;
`channel:stream_type_unavailable` error code removed)
- alknet ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally
unchanged — demux reads 8-byte headers; one reassembly buffer per
channel)
- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged —
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
monotonic IDs)
- alknet ADR-078: two-pump shutdown-on-completion (unchanged — the
contract applies to `tokio::io::split(bidi)` halves)
- alknet ADR-079: hub relay (unchanged in contract — 8-byte header,
4-byte `channel_id` rewrite, payload byte-forwarded)
- alknet ADR-080: `ChannelClient` (amended — `stream_types` field
removed from `open_channel` and `Channel`)
- alknet ADR-081: sub-crate decomposition (unchanged — 8-byte wire
format in `channels-core`; call-protocol coupling in
`channels-call`)
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
(the 5-byte format carried transparently in the channels payload;
the control channel split from Phase 7 is TTY-internal)
- `src/channels.rs` — alktty's channels integration (the
`register_openable` helper + `TtyOpenHandler` that runs
`drive_session` on a channels-backed `BiStream`, the same code as
the direct path)
- Port origin: alknet ADR-093 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md`