Files
alktty/docs/architecture/decisions/007-tty-inside-channels.md
T
glm-5.2 b3f50d1836 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
2026-08-17 10:52:26 +00:00

14 KiB

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: 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, 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 (alkttyalknet-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: 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: 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: TtyBackend trait and TtyHandle (unchanged by this ADR)
  • ADR-004: exit-chunk-is-last (generalized by this ADR + alknet ADR-073)
  • ADR-006: 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