# 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.6.0] - 2026-09-28 The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing parse surfaces, with zero findings in the crate — every crash the campaigns produced was a harness-model gap, and every fix landed in the harness, not the crate. The `alkcall` requirement also updates to 0.8.1. No API, wire-format, or behavior change; this release ships no source change in `src/` at all — the deltas are the fuzz workspace, the dependency bump, and this changelog. Minor bump per the 0.x ecosystem-coordination rule (alktty's public signatures reference alkcall types, so downstream crates must unify on alkcall 0.8.1). ### Added - **cargo-fuzz workspace under `fuzz/`** (per `docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook): four targets over the wire-facing surfaces, with the invariant logic in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus replays on stable — the nightly pin is scoped to the subtree via `fuzz/rust-toolchain.toml`: - `chunk_frame` — the 5-byte chunk codec (`ChunkReader`/`ChunkWriter`), including the peek/disambiguation seam (`peek_stream_type` vs `read_chunk_after_peek` in both call orders must yield exactly one chunk). - `negotiation_frame` — the 4-byte BE length-prefixed negotiation framing + `NegotiateRequest` JSON parse + `error_response_bytes`, and the cross-codec invariant that an error-response frame's length-prefix high byte is `0x00` so a `ChunkReader` peek distinguishes it from a chunk (ADR-001 §5). - `control_json` — `ControlMessage` JSON parse + `signal_from_name`. - `session_opseq` — the stateful target: a `#[derive(Arbitrary)]` 12-op sequence drives the real producer session pump (`drive_session_pre_negotiated`) over a duplex pair against a fuzz-local mock backend, asserting the wire-contract invariants (kill-on-`Drop` per ADR-005, exit-chunk-is-last per ADR-004, lossless per-stream FIFO + single stdout sentinel, stdin prefix-losslessness, control dispatch bounds, take-once allocation, teardown termination). - **Committed corpus: 340 hand-made seeds** across the four targets (deterministic generator `fuzz/gen_fuzz_seeds.py`; the `session_opseq` seeds are hand-encoded against the `arbitrary` 1.4.x derive layout with the encoding pinned by a decode test). Grown corpus entries are gitignored and excised after campaigns. - **The standing fuzz gate**: `cargo test --manifest-path fuzz/shared/Cargo.toml` replays every committed seed through the same invariant functions the targets run, on stable. Added to the verification checklist. - **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh` (setsid/nohup, fork mode, bounded RSS) — never in the foreground of an agent session. ### Changed - **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.1.** alkcall 0.8.1 is a patch release fixing the duplicate adopt/open channel-state destruction found by alkcall's own fuzz campaign; it changed no type alktty touches. Downstream crates must unify on alkcall 0.8.x per the caret requirement. Minor bump per the 0.x ecosystem-coordination rule. - **Root `Cargo.toml` gains a `[workspace]` table** (`members = ["."]`, `exclude = ["fuzz"]`) so the fuzz subtree's nightly dev-deps never join the root workspace (an MSRV footgun), and `fuzz/` joins the publish exclude. Verified: the packaged file list is unchanged except for the exclusion (43 files; the fuzz workspace, seeds, plans, reviews, and `.opencode/` all stay out of the crate). ### Verification - 0 findings across all four targets: detached campaigns (10 min each on the three wire targets; 10 min on `session_opseq` at 42.3k execs, cov 3934 → 4075 and still growing at budget end) plus a 60s smoke run on target 4 — no crash/OOM/timeout/leak. - Corpus replay 9 tests green on stable (340 seeds); crate tests 113 default / 156 all-features; clippy stable + wasm; fmt; doc; wasm check — all pass. - The three crashes the harness-model smoke runs surfaced were harness gaps, each fixed in the fuzz harness (the alkcall §7.7 pattern): a failed client write can mean the session completed and dropped the server duplex half mid-write — the harness drains and requires the exit chunk on any write/shutdown error (a premature close is a finding). No adapter code changed. ## [0.5.0] - 2026-09-18 The alkcall 0.8.0 adoption: a dependency bump only — no source change in alktty. alkcall 0.8.0 landed review 008's remediation (the establisher reply projection, flavor-form open-op ids in discovery, the in-tree `ChannelRelay` + `HubLegImports`/`HubLegTemplate`) plus a post-landing hardening sweep; all new surfaces are additive on the call-plane JSON and the registry seams, and the channels data plane is untouched. The wire format, the direct-ALPN path, the `TtyBackend` trait, and alktty's public API surface are unchanged. ### Changed - **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.0.** alkcall 0.8.0 changed no type that alktty touches: the open-op machinery alktty registers through (`ChannelCore`/ `register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler` /`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`, `ChannelOpenError::CallFailed`) is shape-identical to 0.7.1 — the new `open_channel_with_reply` / reply-field / relay / hub-leg surfaces are additive and alktty exercises none of them. alktty's own open op is the standard-shape name `channels/tty/sub`, so the new flavor-form `channel_open_alpn` discovery path stays on the byte-stable standard derivation, and with no establisher reply-fields configured the open reply is byte-identical to pre-0.8.0. alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.8.0. Minor bump per the 0.x ecosystem-coordination rule. - **`alkcall` requirement updated to `0.8.0`; lockfile bumped from 0.7.1 to 0.8.0.** Verification counts are unchanged from the 0.7.1 baseline (113 default / 156 all-features) — the adoption required no source change in alktty. ## [0.4.1] - 2026-09-10 The MSRV floor becomes honest: `rust-version` raises from 1.85 to 1.88. The 1.85 claim was already false at the dependency level — the lockfile pins `alkcall 0.7.x`, and alkcall 0.7.1 raised its MSRV floor to 1.88 (the ecosystem MSRV audit: noq's 1.88 matching the QUIC path; iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0 regardless of what Cargo.toml declares. The raise breaks no downstream that could build the crate before. No API or wire-format change; downstreams on caret requirements pick this up on their next `cargo update`. ### Changed - **`rust-version` 1.85 → 1.88.** Verified on a real 1.88 toolchain (full test suite, clippy). alktty's own code is 1.85-clean — `cargo check` under a 1.85 toolchain passes with the pre-bump lockfile — so the raise is dependency-driven only and needed no clippy fixes. Behavior-identical. - **`alkcall` requirement updated to `0.7.1`; lockfile bumped from 0.7.0 to 0.7.1.** Version-requirement-only (0.7.x is already the resolved family); alkcall 0.7.1 changed no type alktty touches. ## [0.4.0] - 2026-09-07 The alkcall 0.7.0 adoption (the connect-side caller-identity seam, CF-005/006/007): a dependency bump plus one behavior-alignment change on the channels path. The wire format, the direct-ALPN path, the `TtyBackend` trait, and the public API surface are unchanged. ### Changed - **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.7.0.** alkcall 0.7.0 changed no type that alktty touches (`OpenEstablisher`/`OpenHandler`/`Establishment`/`ChannelPlan` are identical to 0.6.0; alktty never constructs `ServingConfig`), but alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.7.0. Minor bump per the 0.x ecosystem-coordination rule. - **The channels establisher and pump handler take their identity from the per-call `auth` (CF-006 alignment).** `register_openable` no longer captures the install-time identity and overrides the establisher/handler `auth.identity` with it — post-alkcall-0.7 the per-call context already carries the dispatch-resolved opener (the same identity view the registry's ACL gate checked; the end client, not the hub, on hub-forwarded opens). Behavior-identical in the per-connection-registry deployment (install-time identity was the caller's); the ownership check and the ACL gate now see the same subject in every deployment. Two factory parameters dropped (`make_tty_establisher`/`make_tty_open_handler` are private; no public-surface change). ## [0.3.0] - 2026-09-07 The alkcall 0.6.0 adoption (review 007 R-01 — the `Establishment` plan payload): backend allocation moves into the channels establisher. Breaking on the channels path's allocation-failure shape; the wire format, the direct-ALPN path, and the `TtyBackend` trait are unchanged. ### Changed - **BREAKING — `alkcall` bumped to 0.6.0; channels-path allocation failure changes shape (ADR-010 §2A).** alkcall 0.6.0 filled the reserved `Establishment` field (`plan: Option`, review 007 R-01), so `backend.allocate` moved from the pump handler into the establisher — the allocated `TtyHandle` crosses to the handler via the plan payload (a private per-open one-shot slot; the handle is not `Sync`). Allocation failure now fails the open op itself: `TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed, reason: "dial_failed" })` instead of the post-open in-band `NegotiationRejected{ "allocate_failed" }`. The SSH contract now covers every failure class: no channel ever exists opener-side, no `channel_id`, no `0x00`-prefixed in-band frame. The direct-ALPN path's in-band vocabulary (including `allocate_failed`) is unchanged. - `tty_open_spec()`'s `channel:open_failed` `ErrorDefinition` declares `dial_failed` in the `details.reason` enum (four reachable reasons: `dial_failed` / `unknown_resource` / `handler_error` / `timeout`). - Handler closures in `make_tty_open_handler` gain the `Option` parameter (alkcall 0.6 `OpenHandler` shape); a `None` plan (no-establisher registration) falls back to the pre-0.3.0 inline validate-and-allocate — defense-in-depth, keeping the in-band error-frame vocabulary for that registration shape. - New tests: establisher unit gate for allocate-in-establisher (plan slot carries the live handle; failure maps to `dial_failed`), the end-to-end `dial_failed` open-failure surface (flipped from the pinned in-band test), and the handler's `plan: None` fallback arm. ## [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.6.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.6.0 [0.5.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.5.0 [0.4.1]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.1 [0.4.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.4.0 [0.3.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.3.0 [0.2.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.2.0 [0.1.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.1.0