12 KiB
Project Setup Plan
Status: draft Last updated: 2026-08-14
Overview
Extract alknet-tty + alknet-tty-local from the alknet mono-repo into a
standalone alktty crate — a producer/consumer protocol crate on top of
alkcall channels. This is the first protocol crate built on alkcall and
establishes patterns that alktunnels and others will follow.
Crate Structure
Single crate (alktty) with the local backend gated behind a local
feature. This is a "tty-specific monorepo" — one crate, one concern.
alktty/
├── src/
│ ├── lib.rs # crate root, re-exports
│ ├── wire.rs # ChunkReader/ChunkWriter (5-byte format)
│ ├── negotiation.rs # NegotiateRequest, framing reader/writer
│ ├── control.rs # ControlMessage enum, signal_from_name
│ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError
│ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session
│ ├── session.rs # TtySession — typed consumer client
│ ├── channels.rs # OpenHandler factory, register_openable helper
│ └── local/ # (behind `local` feature)
│ ├── mod.rs
│ ├── backend.rs # LocalTtyBackend
│ ├── pty.rs # PTY mode (portable_pty)
│ └── pipe.rs # Pipe/runner mode (tokio::process::Command)
├── docs/
│ ├── architecture/
│ │ ├── README.md # architecture index
│ │ ├── crates/
│ │ │ ├── overview.md
│ │ │ ├── tty-wire.md
│ │ │ ├── tty-backend.md
│ │ │ ├── tty-adapter.md
│ │ │ └── tty-local.md
│ │ └── decisions/ # renumbered ADRs
│ └── plans/
│ └── project-setup.md # this document
├── Cargo.toml
├── LICENSE-APACHE
└── LICENSE-MIT
Key Design Decisions
1. Single crate, feature-gated local backend
The old split (alknet-tty + alknet-tty-local) had a cyclic dependency
problem: alknet-tty-local depends on alknet-tty for the trait, and
ADR-054 wanted alknet-tty to re-export LocalTtyBackend behind a
local feature — which cargo rejects. The assembly-layer workaround
(consumer depends on both crates directly) works but is awkward.
Single crate with local feature solves this cleanly. The portable-pty
dep is optional. A docker-only deployment doesn't pull in PTY code.
2. Dependency: alknet-core → alkcall
All types previously from alknet-core now come from alkcall::core:
ProtocolHandler,Connection,BiStream,BidiStreamSourceAuthContext,Identity,IdentityProviderAccessControl,OwnershipProviderHandlerError,StreamError
The TtyAdapter's impl ProtocolHandler changes which crate the trait
comes from. The trait shape is identical.
3. Channels integration (the new part)
This is where alktty becomes a proper producer/consumer on alkcall.
Producer half — two paths to expose TTY:
-
Direct ALPN (
alk/tty):TtyAdapterimplementsProtocolHandler. Existing code, just import migration. Used for direct-connect scenarios (browser terminal over WebTransport, standalone TTY endpoint). -
Through channels (
alk/channels): Register anOpenHandlerviaChannelCore::register_openable(). The handler receives aConnection(the channel's data plane with ALPNalk/tty), callsaccept_bi(), and runs the samedrive_sessionon the resultingBiStream.Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format. The channels layer strips its 8-byte header and hands TTY the payload transparently. The same
wire.rscode runs in both modes — only theBiStreamsource differs.The channels integration code is small: a
register_openablehelper function that takes aChannelCore,OperationRegistry, and backend map, and registers the per-ALPN open op. This lives insrc/channels.rs.
Consumer half — TtySession:
A typed client that wraps the wire protocol. Two constructors:
TtySession::connect_direct(connection)— for directalknet/ttyTtySession::open_via_channels(client, params)— opens a channel viaChannelClient, negotiates, returns a session handle
The session handle exposes:
send_stdin(bytes)/close_stdin()recv_stdout()/recv_stderr()— streamsresize(cols, rows),signal(name)wait()— awaits exit code
4. Negotiation framing — keep self-contained
ADR-057 says the negotiation framing is self-contained (~30 lines, format coincides with alkcall's by convention). Even though alkcall is now a dependency, we keep the self-contained framing because:
- The payload is
NegotiateRequest, notEventEnvelope— alkcall'sFrameFramedReaderis hardcoded to deserializeEventEnvelope - The framing is trivial (4-byte BE length prefix + body)
- Avoids coupling to alkcall's internal wire types for a TTY-specific payload
5. Producer/consumer framing
The architecture README in alkcall describes protocol crates as having two halves:
-
Producer half —
register_*()functions that take an&mut OperationRegistryand register ops with handlers. For channels, anOpenHandlerfactory. -
Consumer half — a typed client wrapper around
CallConnectionorChannelClientthat exposes the crate's ops as async methods.
We make this explicit in the module structure:
src/adapter.rs+src/channels.rs= producer halfsrc/session.rs= consumer half
6. Access control and auth
The old code had minimal access control (a scope-gate at negotiation). With alkcall, we get:
- Operation-level ACL:
AccessControlonOperationSpec— scopes, resource ownership checks - Channel lifecycle policy:
ChannelLifecyclePolicy— per-identity channel caps, open/close hooks - Ownership:
OwnershipProvider— runtime-spawned resource tracking (terminal sessions are resources per ADR-050)
The TtyAdapter's access control is reworked to use alkcall's primitives
rather than the ad-hoc scope check. The channels path gets this for free
via ChannelCore::register_openable which wires AccessControl into the
operation spec.
Migration Strategy
Phase 1: Scaffold and core types (no new logic)
- Create
Cargo.tomlwith alkcall dependency - Port
wire.rs— changealknet-coreimports toalkcall::core, rename crate references in docs - Port
control.rs— same - Port
negotiation.rs— same - Port
backend.rs— same;BoxFuturetype alias can usefutures::future::BoxFuturesince alkcall pulls infutures - Port
adapter.rs— changeProtocolHandlerimport,Connectionimport,AuthContextimport,HandlerErrorimport. Thehandle()method signature is identical.
Phase 2: Channels integration (new code)
- Create
src/channels.rs:register_openablehelper — takesChannelCore, backend map,OperationRegistry, registers thechannels/tty/subopTtyOpenHandler— theOpenHandlerthat receives a channelConnection, callsaccept_bi(), runsdrive_session
- Create
src/session.rs:TtySessionstruct — typed consumer clientTtySession::open_via_channels(client, params)constructor- Methods:
send_stdin,recv_stdout,resize,signal,wait
Phase 3: Local backend (behind local feature)
- Create
src/local/mod.rs,src/local/backend.rs,src/local/pty.rs,src/local/pipe.rs - Port from
alknet-tty-local— changealknet-ttyimports tocrate LocalTtyBackendimplementscrate::backend::TtyBackend
Phase 4: Architecture docs
- Port the 5 spec docs from
alknet/docs/architecture/crates/tty/ - Port relevant ADRs (052, 053, 054, 055, 056, 057, 077, 093) — renumber into alktty's ADR range, update cross-references
- Write
docs/architecture/README.mdindex
Phase 5: Tests
- Port existing unit tests from
alknet-tty(wire, negotiation, control, adapter) - Port integration tests from
alknet-tty-local(negotiation, pipe, pty) - Add channels integration tests — end-to-end
register_openable+ChannelClient::open_channel+drive_sessionround-trip
Open Questions
OQ-1: ALPN strings (RESOLVED)
Decision: Use alk/tty. The alknet/<name> convention is being
shortened to alk/<name> across all crates. This is a wire-format change
but alkcall is v0.1.0 and this is the first protocol crate built on it —
the right time to make the change.
Upstream change needed: alkcall's test at channels/client.rs:767 uses
ChannelOpenSpec::new("alknet/tty") — needs updating to "alk/tty".
Also any docs/ADRs in alkcall that reference alknet/<name> as the ALPN
convention.
OQ-2: Channel open operation name (RESOLVED)
The alkcall test uses channels/tty/sub as the operation name. The
convention from ADR-047 is channels/<alpn>/sub for subscribe (consumer
opens) and channels/<alpn>/pub for publish (producer opens). Since TTY
is consumer-opens (the client requests a shell), channels/tty/sub is
correct.
Decision: channels/tty/sub for consumer-opens, channels/tty/pub
reserved for future producer-opens use case.
OQ-3: TtySession API — stream vs callback (RESOLVED)
Decision: Stream-based (Stream<Item = Bytes>). Consistent with the
backend trait's TtyHandle shape.
OQ-4: drive_session — keep the three-pump pattern? (RESOLVED)
Decision: Keep it. It works, it's tested, and ADR-093 means the same code runs in both direct and channels modes.
OQ-5: BoxFuture type alias (RESOLVED)
Decision: Use futures::future::BoxFuture. alkcall already pulls in
futures, so no new dependency.
Upstream Changes Needed in alkcall
These are changes we need to push to alkcall before or alongside this work:
-
ALPN convention:
alknet/<name>→alk/<name>across docs, ADRs, and test code. The test atchannels/client.rs:767usesChannelOpenSpec::new("alknet/tty")— needs"alk/tty". -
CHANNELS_ALPNconstant:b"alknet/channels"→b"alk/channels". This is inalkcall/src/channels/adapter.rs. -
ADR-004 (ALPN convention): Update from
alknet/prefix toalk/. -
Architecture README: The dependency layering diagram and pattern section reference
alknet/ttyandalknet/channels— update toalk/ttyandalk/channels.
Risks and Concerns
Risk: alkcall is v0.1.0 — API stability
alkcall is published at 0.1.0. Breaking changes are expected. The
integration surface we use (ChannelCore::register_openable,
ChannelClient, ProtocolHandler, Connection) is the core API and
likely stable, but the channels module in particular may evolve.
Mitigation: We own alkcall and can push changes as needed. This is the first real consumer, so we'll find and fix issues upstream rather than working around them.
Risk: drive_session complexity
The three-pump driver is ~1500 lines with edge cases around exit ordering, cancel cleanup, and bidirectional control. Porting it is mechanical but needs careful review.
Mitigation: The existing code has extensive tests. Port tests first or alongside.
Risk: Local backend PTY bridge
The PTY mode uses 3 std threads feeding tokio channels. This is a well-understood pattern (wezterm uses it) but is inherently platform- specific (Unix only for PTY, pipe mode works cross-platform).
Mitigation: Gate PTY behind #[cfg(unix)] as the existing code does.
Pipe mode works on all platforms.
References
- alkcall architecture README
- alknet-tty architecture docs
- ADR-093 — TTY always uses 5-byte format
- ADR-077 — reversed, historical context
- alkcall channels client test — reference integration pattern