# Changelog All notable changes to this crate are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this crate adheres to [Semantic Versioning](https://semver.org/). ## [0.2.0] - 2026-09-06 The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the channels-path establishment phase. Breaking on the channels path's error surface; the wire format, the direct-ALPN path, and the `TtyBackend` trait are unchanged. ### Added - **Channels establishment phase (ADR-010; alkcall 0.5.0 ADR-049 / review 006 E-01).** `register_openable` now registers `channels/tty/sub` with an establisher (`ChannelCore::register_openable_with_establisher`): the semantic validation that used to be post-open in-band error frames — the full `NegotiateRequest` parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership check — runs before the open reply. Rejections resolve the open op with `channel:open_failed` carrying `details: { reason, message }` (`unknown_resource` for an unknown backend, `handler_error` for malformed-negotiation/ownership-denial, `timeout` on the establishment deadline) and no channel ever exists opener-side — the SSH contract. The phantom-channel workaround (allocate → succeed → in-band-fail) is retired. - **`channel:open_failed` ErrorDefinition on `tty_open_spec()` (ADR-016).** The establishment-failure contract (the reachable `details.reason` enum) is disclosed via `services/schema`; the spec also gains a `description` (review 006 E-02) disclosed via `services/list`. - New tests: the establisher unit gates (parse/carriage/cmd/backend/ ownership mappings), the end-to-end `channel:open_failed` surfaces (the review-006 verification gate), and a pinned test guarding the one failure class that stays in-band (below). ### Changed - **BREAKING — `TtySessionError::ChannelsOpen` carries alkcall's typed `ChannelOpenError` (`#[from]`) instead of a flattened `String`** (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at alktty's layer). Consumers branch on `ChannelOpenError::establishment_reason()` instead of parsing a debug string. - **BREAKING — channels-path semantic failures change shape.** An unknown backend (and schema-valid-but-unparseable params, ownership denial) previously surfaced as `TtySessionError::NegotiationRejected` (an in-band `0x00`-prefixed error frame on a channel the open op had already reported as succeeding); they now fail the open op itself as `TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed })` (ADR-010). The one failure class that still arrives in-band is `allocate_failed` (still `NegotiationRejected`): the establisher cannot carry the allocated `TtyHandle` across to the pump handler (`Establishment` is payloadless), and re-allocating would violate ADR-005's kill-on-`Drop` contract — revisit when alkcall gives `Establishment` a payload. The direct-ALPN path's failure surface is unchanged (two transports, two contracts). - `alkcall = "0.5.0"`. ### Fixed - **No more phantom channels on the channels path.** Ledger, policy count, and manager state balance on every establishment rejection (the wrapper's teardown runs before the reply), instead of allocating a channel that immediately EOFs with an error frame. ## [0.1.0] - 2026-09-05 Initial crates.io release: the `alk/tty` terminal-session protocol — producer/consumer protocol crate on top of alkcall channels, ported and consolidated from the `alknet-tty` + `alknet-tty-local` crates of the alknet mono-repo. ### Added - **Wire format (ADR-001).** Two-carriage protocol: a 4-byte big-endian length-prefixed JSON negotiation frame (`NegotiateRequest` — `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque `serde(flatten)` backend params), then raw chunks (`[stream_type: u8][length: u32 be][payload]`) with five stream types (`STREAM_STDIN`=0, `STREAM_STDOUT`=1, `STREAM_STDERR`=2, `STREAM_CTRL_IN`=3, `STREAM_CTRL_OUT`=4). Zero-length data chunks are sentinels (zero-length stdin = client EOF; zero-length stdout = server drained); control chunks are never zero-length. Payloads are capped at 16 MiB (`MAX_CHUNK_LEN`) — this keeps the length prefix's high byte `0x00`, which is the disambiguation invariant between an error frame and a raw chunk (ADR-001 §5). The framing is self-contained (ADR-006): not reused from alkcall's `EventEnvelope` framing. The control channel is split into `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` so it is genuinely bidirectional on the wire (the Phase 7 amendment inside ADR-001). TTY always uses its own 5-byte chunk format inside channels — the channels layer carries it transparently in the channel payload (ADR-008, reversing ADR-007). The machine-readable contract is the BAST document (`docs/architecture/tty-bast.md`); a unit test guards the BAST's `StreamType` enum against drift from the wire constants. - **`TtyBackend` trait (ADR-002).** The inversion point between the wire-format adapter and backend crates. `allocate(&TtyParams) -> TtyHandle` (stdin `AsyncWrite`, stdout/stderr `Stream`, `exit_code: BoxFuture`, `TtyControlHandle` for resize/signal); `resource_id(&TtyParams) -> Option<(kind, id)>` feeds the ADR-050 ownership check. `TtyError` is `#[non_exhaustive]`. The trait shape is a one-way door once backends exist. - **Local backend behind the `local` feature (ADR-003).** The `alknet-tty-local` crate folded in as a feature-gated module: PTY mode via `portable_pty` (real terminal semantics — resize, process-group signal forwarding, merged stdout/stderr) and pipe mode via `tokio::process::Command` (the runner case — separate stdout/stderr, no-op resize, pid-only signal), selected by `TtyParams::terminal`. The blocking→async bridge uses three dedicated std threads feeding tokio mpsc/oneshot channels. Non-wasm by design; the default crate (no features) stays `wasm32-unknown-unknown`-clean. - **`TtyAdapter` producer (direct `alk/tty` ALPN).** A `ProtocolHandler` holding a `HashMap>` keyed by the negotiation frame's `backend` string. Accepts a connection, loops `accept_bi`, and dispatches each bidi stream to a session. Per-stream flow (ADR-001/002/005): parse + validate the negotiation frame (`carriage == "raw"`, non-empty `cmd`), scope-gate via `tty:open`, optional `OwnershipProvider` resource check, `backend.allocate()`, then three concurrent pumps (stdin→backend, stdout/stderr→client, exit→exit chunk) with the **exit-chunk-is-last** invariant — `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004; negative = signal-terminated) is enqueued only after both stream pumps complete and `exit_code` resolves. A concurrent drainer task owns the client write half, so a burst of >64 chunks from the backend cannot deadlock the session (the drainer starts before the pumps join; the single FIFO preserves exit-chunk-last). On session cancel the `TtyHandle` drops without driving `exit_code` to completion, which triggers the backend's kill-on-`Drop` guard — the adapter has no separate kill path (the session-cancel contract, ADR-005: dropping the future kills the session target). - **`channels` integration (ADR-008, ADR-009).** `register_openable` + `tty_open_spec` register the `channels/tty/sub` open op on a per-connection `OperationRegistry` (per ADR-047's per-connection amendment): the registry's `AccessControl` carries the `tty:open` scope gate, the op's input schema validates the shared `NegotiateRequest` fields (`carriage`/`backend`/`cmd` required; alkcall 0.4 enforces at dispatch), and the `OpenHandler` spawns the session driver pre-negotiated — **the open op's params ARE the negotiation** (ADR-009), no second frame on the channel stream. Access control is enforced by the registry before the handler runs; the handler validates the backend exists and drives the session. - **`TtySession` consumer.** The typed client handle with two constructors — `connect_direct` (direct `alk/tty` connection: writes the negotiation frame, then typed methods) and `open_via_channels` (opens `channels/tty/sub` on a `ChannelClient`, adopts the channel, starts in raw-chunk mode). Typed methods: `send_stdin`/`close_stdin` (stdin chunks / EOF sentinel), `recv_stdout`/`recv_stderr` (`Stream`), `resize`, `signal`, and `wait` (awaits the `Exit` control chunk; watch-based, no lost wakeup). `recv_stdout` ends ON the zero-length drained sentinel (it is not yielded as an item). Both constructors disambiguate the first response frame (`0x00` prefix = negotiation error frame → `NegotiationRejected` with the server's error code; raw chunk = session proceeds); a refused session open surfaces as `TtySessionError::Open` carrying the upstream error type. `TtySessionError` is `#[non_exhaustive]`. Dropping the session aborts the read pump and closes the write half. - **Control messages.** `ControlMessage` (`Resize`/`Signal`/`Eof` on `ctrl_in`; `Exit` on `ctrl_out`) — JSON, tagged by `"type"`, unknown types ignored by policy. `signal_from_name` maps the common signal set (`HUP`/`INT`/`QUIT`/`TERM`/`KILL`/`USR1`/`USR2`/`TSTP`/`CONT`) to libc numbers (unix). - **Crate-root re-exports.** The primary types (`TtyAdapter`, `TtySession`, `TtyBackend`, `TtyHandle`, `TtyParams`, `Chunk`, `NegotiateRequest`, `ControlMessage`, the wire constants, and `LocalTtyBackend` under `local`) are re-exported at the crate root; the module paths remain the full surface. - **Wire write-path validation.** All `ChunkWriter` methods validate before touching the transport — a `stream_type` > 4 or a payload over `MAX_CHUNK_LEN` fails locally instead of writing a header the peer cannot frame (the length is validated before the `u32` cast, so an oversized payload cannot truncate past the check and corrupt the peer's framing). Empty-payload writes are one shape: `length == 0` with no payload bytes. - **Peek-safe `ChunkReader`.** The reader tracks peeked state: `read_chunk()` after `peek_stream_type()` completes the peeked chunk instead of consuming a second header byte (which silently desynchronized framing), and a second peek returns the peeked byte without reading. `read_chunk_after_peek` remains the explicit form. - **Backpressure regression test.** >64-chunk sessions are driven through the real adapter with a per-read timeout so a deadlock regression fails the test instead of hanging it, plus exact-boundary (`MAX_CHUNK_LEN`) round-trip and server-sends-client-direction- stream-types discard tests. - **PTY bridge robustness (`local` feature).** The stdin sink's EOF (`poll_shutdown`) parks an in-flight send on a full channel so the waker is registered — a stdin blast followed by EOF always delivers the EOF to the child. Lock-poisoning no longer cascades (the five lock sites adopt `into_inner()`), and thread-spawn failure maps to `TtyError::AllocFailed` instead of panicking. - **Producer hardening.** The `tty:open` scope gate runs before the backend lookup (an unscoped identity gets `forbidden` regardless of the request body — no backend-name enumeration differential), and the client→backend pump is aborted at session end instead of lingering until the client disconnects. [0.1.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.1.0