alktty 0.5.0

Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels.
Documentation
# 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.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<ChannelPlan>`,
  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<ChannelPlan>` 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<Item = Bytes>`,
  `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<String, Arc<dyn TtyBackend>>`
  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<Item = Bytes>`), `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.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