- BREAKING (ecosystem-coordination): alkcall bumped to 0.8.0. No type
alktty touches changed shape (ChannelCore/openable machinery,
OpenEstablisher/OpenHandler/Establishment/ChannelPlan,
ChannelClient::open_channel, ChannelOpenError::CallFailed are
identical to 0.7.1); the new reply-projection / 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 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. The minor bump is for
downstream lockstep, since alktty's public signatures reference
alkcall types.
- Verification counts unchanged from the 0.7.1 baseline (113 default /
156 all-features); the adoption required no source change in alktty.
Verification:
- cargo test: 113 passed (default), 156 passed (--all-features)
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean
- cargo check/clippy --target wasm32-unknown-unknown -D warnings: clean
- cargo publish --dry-run --allow-dirty: clean
The 1.85 claim is now false at the dependency level: the lockfile pins
alkcall 0.7.x, and alkcall 0.7.1 raised its MSRV floor to 1.88, so no
1.85 toolchain can resolve this dependency graph regardless of what
Cargo.toml declares. Raising the floor keeps the claim honest and
aligns with the ecosystem floor (noq's 1.88, matching the QUIC path;
iroh sits above at 1.91). alktty's own code is 1.85-clean (verified:
cargo check under 1.85 passes with the pre-bump lockfile) — the raise
is dependency-driven only, so no clippy fixes are needed.
- Cargo.toml: rust-version 1.85 -> 1.88 (version-line bump deferred to
release time)
Verification:
- 1.88: cargo test --locked (113 default / 156 all-features pass),
clippy --all-targets -D warnings
- stable 1.94: test --locked, clippy --all-targets -D warnings,
fmt, doc --no-deps, wasm32-unknown-unknown check + clippy clean
- BREAKING (ecosystem-coordination): alkcall bumped to 0.7.0. No type
alktty touches changed shape (OpenEstablisher/OpenHandler/
Establishment/ChannelPlan identical to 0.6.0); the minor bump is for
downstream lockstep, since alktty's public signatures reference
alkcall types.
- CF-006 alignment: the channels establisher and pump handler now take
their identity from the per-call auth (alkcall 0.7 derives the
dispatch-resolved opener — the same identity view the registry's ACL
gate checked) instead of the captured install-time override.
Behavior-identical in the per-connection-registry deployment; the
ownership check and the ACL gate see the same subject in every
deployment. make_tty_establisher / make_tty_open_handler drop their
captured-identity parameter (both private).
Verification:
- cargo test: 113 passed (default), 156 passed (--all-features)
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean
- cargo check/clippy --target wasm32-unknown-unknown -D warnings: clean
- cargo publish --dry-run --allow-dirty: clean
- Bump alkcall 0.5.0 -> 0.6.0 (Establishment plan payload, ADR-049
amendment 2); alktty 0.2.0 -> 0.3.0 (breaking channels-path
allocation-failure shape)
- make_tty_establisher runs backend.allocate; failure maps to
EstablishmentError::DialFailed (channel:open_failed reason
dial_failed); the TtyHandle crosses to the pump handler via a
private per-open AllocatedHandle one-shot slot (the handle is not
Sync — it cannot be the ChannelPlan directly)
- make_tty_open_handler gains the Option<ChannelPlan> parameter; a
plan drives the new drive_session_pre_allocated (pumps only), a
None plan falls back to drive_session_pre_negotiated (inline
validate-and-allocate — defense-in-depth for no-establisher
registrations)
- tty_open_spec's channel:open_failed ErrorDefinition declares
dial_failed (four reachable reasons)
- Pinned in-band allocate test flipped:
allocate_failure_fails_open_as_dial_failed (end-to-end, no channel
survives); establisher unit gate for the plan slot + failure
mapping
- ADR-010 amended (§2A; §2 kept as historical record), tty-adapter.md,
session/channels/adapter docs, CHANGELOG, AGENTS.md alkcall pin
updated
Verification: cargo test (113) + --all-features (137), clippy
-D warnings (native + wasm32, default + all-features), fmt, doc,
wasm32 check — all clean
Adopt alkcall 0.5.0's channel-open establishment phase (ADR-049 —
review 006 E-01 + N-1) and migrate the channels-path semantic failures
per its §5 sequencing (alktty ADR-010).
- `register_openable` registers `channels/tty/sub` with an establisher
(`register_openable_with_establisher`): full `NegotiateRequest`
parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`,
backend lookup, and the ADR-050 ownership check run before the open
reply; rejections are `channel:open_failed` with `details.reason`
(`unknown_resource` / `handler_error` / `timeout`) — no phantom
channel (the SSH contract holds consumer-visibly)
- `backend.allocate` deliberately stays in the pump handler:
`Establishment` is payloadless so the `TtyHandle` cannot cross the
establisher→handler boundary, and re-allocating would violate
ADR-005's kill-on-Drop contract — `allocate_failed` remains the one
in-band failure class on the channels path (pinned by test)
- `TtySessionError::ChannelsOpen` carries alkcall's typed
`ChannelOpenError` (`#[from]`) instead of a flattened `String` —
the N-1 fix at alktty's layer (breaking)
- channels-path semantic failures change shape from
`NegotiationRejected` in-band frames to `channel:open_failed` call
errors (breaking); the direct-ALPN path is unchanged
- `tty_open_spec()` gains a `description` (review 006 E-02) and an
ErrorDefinition for `channel:open_failed` (ADR-016 — disclosed via
services/schema)
- alkcall = "0.5.0"; version 0.2.0; ADR-010 + ADR-009 amendment +
tty-adapter.md + CHANGELOG
Verification: cargo test (112 lib + integration), cargo test
--all-features (136), clippy --all-targets -D warnings (host + wasm),
fmt --check, cargo doc --no-deps clean; wasm32-unknown-unknown check
confirms the default crate stays wasm-clean.
Closes review #003 (prepublish review for v0.1.0).
- P8: StdinSink::poll_shutdown parks an inflight reserve+send on a
full channel (waker registered) — a stdin blast followed by EOF
delivers the EOF instead of stranding it
- P9: five poisoned-lock .expect() sites -> unwrap_or_else(into_inner)
- P10: three thread-spawn .expect() sites -> TtyError::AllocFailed
- P5: tty:open scope gate runs before carriage/cmd/backend-lookup
checks (no backend-name enumeration differential for unscoped ids)
- P11: recv_stdout terminates on the zero-length drained sentinel;
the sentinel is no longer yielded as an item (doc was already the
contract); stderr has no sentinel (doc noted)
- P2: exclude AGENTS.md + docs/plans/, drop dead Cargo.lock and
docs/research/ entries (package list: 42 files, 659.2KiB)
- P3: AGENTS.md phase status (all five landed), ADR range 001..009
(+ alktty-native ADR-009 in the mapping), alkcall guidance
corrected to v0.4.x / pin "0.4.0"; architecture README ADR-009 row
+ landed-phase status
- P15: backend.rs doc typo; redundant tokio-stream dev-dep removed;
NegotiationError::Io arm logs; set_identity failure logs;
input_pump.abort() at session end; TtySessionError::Open carries
the accept_bi StreamError (no io::Error flattening); borrowing
deserialize in open_via_channels (no params.clone());
error_response_bytes guards an "error" key in fields; trivial
inline comments promoted/removed; plan-doc test counts + doc
front-matter refreshed; session tests that raced session teardown
under the abort change use a GatedBackend (exit held until
released)
Verification: cargo test 104 lib / --all-features 147; clippy
(all-targets + wasm32) -D warnings; fmt; wasm check; doc 0 warnings;
publish dry-run OK.
Review #003 Session 2 (P12 + P6 + P7 + P4 + P16):
- P12: primary types re-exported at the crate root (TtyAdapter,
TtySession, TtyBackend/TtyHandle/TtyParams, Chunk codec, wire
constants, ControlMessage, negotiation types, channels helpers;
LocalTtyBackend under `local`). signal_from_name re-export is
#[cfg(unix)]-gated to match its definition (wasm check caught it).
- P6: ChunkWriter validates before writing — stream_type > 4 or
payload > MAX_CHUNK_LEN fails locally (RawError) instead of
corrupting the peer's framing; length validated as u64 before the
u32 cast so oversized payloads cannot wrap past the check.
Empty-payload shape unified via a single write_validated helper.
- P7: ChunkReader tracks peeked state — read_chunk() after
peek_stream_type() completes the peeked chunk instead of consuming
a second header byte; second peek is idempotent;
read_chunk_after_peek debug-asserts a peek. Session read pump drops
its manual first_byte_peeked threading.
- P4: README.md (alkcall structure; quick-start examples per role
using the new root paths) + readme = "README.md" in [package].
- P16: CHANGELOG.md with the full [0.1.0] entry and tag link.
Verification: cargo test 104 lib (default) / 147 (--all-features);
clippy all-targets + wasm32 -D warnings; fmt; wasm check; doc 0
warnings; publish dry-run 44 files OK.
Session-1 resolution section records the concurrent-drainer fix shape,
the bug-validated regression test (fails with the old order
reinstated), the two P13 boundary/discard tests, and the deferred
accept-loop harness item. Verification counts updated (98 lib / 141
--all-features).
The stdout drainer started only after the pumps+exit join completed,
but the pumps share a bounded (64-slot) channel with it. Once the
channel filled, pump_stdout parked on send, the join never completed,
and the session hung with the client actively reading (review #003 P1;
reproduced empirically at N=62 ok / N=63 hang). Any real session (cat,
ls -R) exceeds 64 chunks instantly.
pump_session now spawns drain_chunks (owning writer_rx + client_write)
before the join; the exit chunk is still enqueued after the join and
writer_tx is dropped after it, so the single FIFO preserves the
exit-chunk-is-last invariant (ADR-005).
Tests (review #003 P13):
- backpressure regression: 80 chunks through a real drive_session,
in-order delivery + sentinel + exit asserted, 10s per-read timeout
so a regression fails instead of hanging (validated against the bug:
fails with the old order reinstated)
- wire: MAX_CHUNK_LEN exact-boundary round trip (limit is inclusive)
- session: stream_type 0/3 from the server ignored mid-stream without
desynchronizing framing or losing stdout/exit
Verification: cargo test 98 lib (default) / 141 --all-features,
clippy all-targets + wasm32 -D warnings, fmt --check, wasm check, doc
--no-deps all clean.
Two subagent reviews (code + packaging/docs vs alkcall 0.4.1) with every
finding manually re-verified against source at 918af40. 13 open findings:
P1 pump_session deadlock at >=63 stdout chunks (empirically reproduced,
N=62 ok / N=63 hangs), P2 AGENTS.md ships in the crate, P3 stale
AGENTS.md/architecture-README text, P4 no README, P5-P13 API-shape and
robustness items, P15-P16 polish/changelog. P14 closed as alkcall parity.
Includes suggested session breakdown for distributing the remediation.
Verification baseline on the reviewed tree: cargo test 95 lib (default) /
138 --all-features, clippy (all-targets + wasm32) clean, fmt clean, doc
clean, publish --dry-run OK, CJK scan clean.
Review #002 R5 — the open_via_channels fail-fast parse (a params value
that fails the local NegotiateRequest parse before a channel is
allocated) surfaced as NegotiationSerialize, whose name and doc
describe serializing the negotiation frame, not parsing open-op params.
- add TtySessionError::InvalidParams(String); the fail-fast path maps
to it (the serde_json::Error's From impl stays for
NegotiationSerialize's real users — the direct-path serialize)
- mark TtySessionError #[non_exhaustive] — the same two-way-door
pattern as TtyError (backend.rs) and alkcall's consumer-facing
AdapterError; the policy rationale is in the enum's doc
- NegotiationError / RawError stay exhaustive (deliberate — they
mirror fixed wire semantics; in-crate matchers keep exhaustiveness
checking)
- the two fail-fast tests assert InvalidParams(_) now
- review #002: R5 resolved; the superseded deferral rationale is
recorded (circular trigger — "first channels-path consumer exists"
fires after the change becomes expensive; misapplied citation —
ADR-009's version-skew note governs wire skew, not error enums;
the "unreachable" framing belonged to R4's arm, not R5's — the
fail-fast path is live today). The #[non_exhaustive] policy is
decided on principle, pre-publish, while the variant addition is
additive by construction
Verification: cargo test 95 lib (default) / 138 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
Review #002 R4 — a NegotiateRequest parse failure of the open op's
schema-validated input died silently (log + return, channel teardown,
consumer observed NoExitChunk — indistinguishable from a crashed
producer), while the other post-open failure classes (unknown backend,
allocate_failed, ownership denial) wrote the 0x00-prefixed error frame.
- make_tty_open_handler now accepts the channel's BiStream and writes
a malformed_negotiation frame via the shared
crate::adapter::send_negotiation_error (now pub(crate)) before
returning; the consumer's M1 peek surfaces NegotiationRejected
unchanged
- the frame type and layout are unchanged (ADR-001 wire-stable
contract); no new frame type, no wire change
- tests: make_tty_open_handler seam test with a hand-built
schema-bypassing input (cwd: 42) + a real-registry end-to-end test
via ChannelClient::open_channel (bypasses open_via_channels's local
fail-fast parse — R5's path — so it exercises the producer handler)
- docs: ADR-009 amended (Parse-failure error frame section);
tty-adapter.md malformed_negotiation row covers both paths;
session.rs post-open failure lists updated; review #002 R4 resolved
Note: the review's "unreachable end-to-end" premise was refined —
open_via_channels parses params locally (fail-fast) so a TtySession
consumer never hits the producer-side parse failure, but direct
ChannelClient callers do; the schema is deliberately partial so a
schema-valid value (cwd typed as a number) reaches the handler.
Verification: cargo test 95 lib (default) / 138 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
Documents the post-hoc review of the five resolution commits:
- R1 (ADR-009 missing) and R2 (stale L1-redesign docs) — resolved in
37ae07a
- R3 — install-time identity snapshot on the channels path, closed as
intended (hub-proxy design predating the alkcall split; constraint
recorded: registries must stay per-connection)
- R4 (silent death on the parse-failure path, no error frame) and R5
(NegotiationSerialize mislabel on the fail-fast path) — open,
deferred to the first post-1.0 error-surface decision
Also cross-links review #001's L1 resolution to ADR-009 and review #002.
Verification: cargo test 93 lib pass; fmt clean.
Review of the 2026-09-05 session commits:
- the ADR-009 decision cited by 96692d3 and docs/reviews/001 was never
written — added decisions/009-channels-open-op-is-the-negotiation.md
(context: the L1 two-gate disagreement, alkcall 0.4.0/0.4.1
prerequisites, producer/consumer design, consequences, door type)
- channels.rs module doc + register_openable doc still described the
old wire-frame negotiation read (drive_session) — aligned with
drive_session_pre_negotiated and the enforced input schema
- tty-adapter.md: session-driver section + ADR tables now reference
ADR-009 and the pre-negotiated driver; overview.md ADR index row
Verification: cargo test 93 lib (default) / 136 (--all-features);
clippy -D warnings native + wasm clean; fmt clean; doc 0 warnings.
- cargo +1.85 check passes (default + --all-features), plus wasm
target check and a full +1.85 test --all-features run (136 tests).
The declared MSRV is real, not aspirational.
- the lockfile pins the 1.85-compatible transitive set (jsonschema
0.46.9, idna_adapter 1.2.0, icu crates 2.0.x) — idna_adapter 1.2.2
requires rustc 1.86, icu 2.3 requires 1.88; stable still resolves
and all tests pass.
- review #001 is now fully resolved (status: fully-resolved). A CI
MSRV job can gate on 'cargo +1.85 check' once CI exists.
- signal tests use a marker-file readiness signal: the child's command
is 'echo ready > <marker>; exec sleep 60', the test polls
wait_for_file(marker, 5s) — marker exists = the shell exec'd, so the
signal lands on the real target regardless of machine load. Applied
in tests/pty.rs (both signal tests), tests/pipe.rs (SIGTERM), and
the src/local unit tests; wait_for_file lives in tests/common.
- cancel-cleanup post-action sleeps became bounded polls for the
child's death (kill(pid,0) -> ESRCH, 5s deadline) — faster and
flake-proof in both directions.
- resize/cat-stdin tests need no readiness signal at all: the adapter's
input pump processes chunks in order — the sleeps there were pure
latency (integration suites now ~40ms, was 200-270ms).
- signal_after_child_exit_takes_both_kill_fallbacks: a late signal
(after the child exited) takes kill(-pgid) fail -> kill(pid) fail ->
warn + return, with no panic — the reachable part of the
REQ-TTY-02 fallback chain.
- the remaining bridge error arms are documented-unreachable through
the public path (per the review's disposition), with per-arm
reasoning in the module doc: try_clone_reader (dup failure),
reader read error (EIO -> EOF mapped), take_writer (second-take
only), writer write/flush (externally-closed fd), waiter wait()
(already-reaped child). The ADR-055 §4 -1 sentinel is covered at
the adapter level (exit_error_sends_minus_one) — it also arises
when the oneshot drops on kill-on-cancel.
- local/pty.rs line coverage 80.85% -> 86.01%; total 94.48%.
The channels path no longer carries a second negotiation frame on the
channel's data stream (ADR-009). The open op's registry-validated
input IS the negotiation:
- producer: make_tty_open_handler parses the open op's input into a
NegotiateRequest and drives the new drive_session_pre_negotiated
(same three-pump driver as drive_session, minus the wire-frame
negotiation phase; validate/allocate factored into
validate_and_allocate, shared by both paths). Post-open failures
(unknown backend, allocate_failed, ownership denial) still go to the
client as a 0x00-prefixed negotiation error frame, so the consumer's
M1 disambiguation read applies unchanged. The tty:open scope gate is
enforced by the registry's AccessControl (not re-checked in the
handler).
- consumer: open_via_channels parses params locally (fail-fast before
a channel is allocated), opens the channel, and starts raw-chunk
mode directly (from_halves_raw — no negotiation write; the
0x00-error-frame peek retained).
- tty_open_spec's input schema is now the partial NegotiateRequest
shape (carriage/backend/cmd required; backend params stay free-form
— raw JSON Schema is permissive on unknown keys).
Prerequisites landed upstream: alkcall 0.4.0 enforces
OperationSpec.input_schema at dispatch (the registry check this design
leans on never existed before); alkcall 0.4.1 parks early-arrival
chunks for un-adopted channels instead of dropping them — the open
response / producer's-first-write race was silently losing the first
chunks (found by L3's test; the session never resolved).
L3: open_via_channels + from_bidi_stream_via now covered end-to-end
(5 session tests + pre-negotiated adapter test + shared-harness tests
in the new crate::testing module; the channels harness moved there so
session tests share it).
Verification: cargo test 93 lib (default), 116 lib + 19 integration
(--all-features); clippy -D warnings native + wasm clean; fmt clean;
doc 0 warnings; wasm check clean.
- alkcall 0.1.1 -> 0.3.1 (crates.io latest). No API breakage in the
surfaces alktty uses (core, channels, registry); all verification
gates pass unchanged.
- remove criterion + alktype dev-deps and the [[bench]] section: the
wire_vs_bast benchmark was extracted to the alktype project, leaving
this config dead.
Update the remediation plan with a resolution section and per-finding
status. Nine findings resolved in commit 9944153; L1, L3, L6, N4, N6
remain open.
- M1: TtySession now handles the negotiation-rejection error frame.
from_halves peeks the first response byte (ADR-052 §5 disambiguation)
and returns NegotiationRejected on a 0x00-prefixed error frame;
ChunkReader gains peek_stream_type/read_chunk_after_peek.
- M2: wait() now surfaces MalformedExitChunk instead of collapsing it
to NoExitChunk. The exit watch channel carries a cloneable
ExitOutcome enum; MalformedExitChunk carries a String.
- L2: add EmittingBackend + recv_stdout_and_stderr_route_backend_data
test covering the consumer read-pump stdout/stderr routing.
- L4: MockBackend/MockControl/MockStdinSink are now #[cfg(test)]
pub(crate), removing them from the public API.
- L5: cargo fmt (the BAST drift test was unformatted).
- N1: fix all 9 rustdoc intra-doc links.
- N2: fix stale doc paths (crates/tty/ and docs/research/).
- N3: amend AGENTS.md §14 to accurately describe the local module's
libc::kill unsafe blocks.
- N5: consolidate nanos_seed into tests/common/mod.rs.
Verification: cargo test (84), cargo test --all-features (107),
clippy clean (native + wasm), fmt clean, doc clean, wasm check clean.
Coverage: session.rs 79.71% -> 87.43%, total 90.74% -> 91.47%.
Parse docs/architecture/tty-bast.md at test time, extract the BAST
JSON block, and assert the StreamType enum values match wire.rs's
STREAM_STDIN/STREAM_STDOUT/STREAM_STDERR/STREAM_CTRL_IN/
STREAM_CTRL_OUT constants (the index of each name in the enum's
values array is the integer on the wire). Catches the common drift
case — a new stream_type added to wire.rs but not the BAST, or vice
versa — without requiring alktype as a dep, just serde_json which
alktty already has.
This is the mitigation listed in the project setup plan's "Risk: BAST
schema drift" section. The test lives in src/wire.rs mod tests alongside
the STREAM_* constants it asserts against, so a developer editing the
constants sees the test fail locally before the BAST doc and the wire
format drift apart.
Verified the test catches drift by temporarily renaming CtrlOut ->
CtrlOutX in the BAST: the test fails with a clear diagnostic
("StreamType[4] is 'CtrlOutX' in BAST but 'CtrlOut' in wire.rs"),
then restored.
Verification:
- cargo test --all-features -> 123 tests pass (104 lib + 19
integration; +1 from the new drift test)
- cargo clippy --all-targets --all-features -- -D warnings -> clean
- cargo clippy --target wasm32-unknown-unknown -- -D warnings -> clean
- cargo fmt --check -> clean
The previous BAST document modeled the JSON payloads (NegotiateRequest,
ControlMessage and its resize/signal/eof/exit variants, TerminalParams)
as BAST struct/union definitions with uint16/int32 fields. That was a
category error: BAST describes binary data layouts, and per the BAST
format spec itself, "a BAST document cannot validate a JSON payload."
The control and negotiation payloads on the wire are UTF-8 JSON text
serialized via serde_json, not struct-encoded binary — the uint16/int32
field widths implied a binary encoding that does not exist on the wire
and would have misled any generated validator.
The rewrite keeps only the genuinely-binary framing layer:
- ChunkHeader (5-byte: stream_type u8 + length u32 BE)
- StreamType enum (name->index table; documented deviation: on-wire
is uint8, not BAST's standard u32 enum index)
- Chunk (header + length-prefixed bytes payload)
- NegotiationFrame (4-byte BE length prefix + UTF-8 JSON body,
modeled as bytes since the body's JSON interpretation is above the
BAST layer)
The JSON shapes (NegotiateRequest, ControlMessage, TerminalParams)
remain specified in tty-wire.md and implemented by the Rust source
(src/negotiation.rs, src/control.rs), which are the source of truth
for those payloads. Cross-references in tty-wire.md, overview.md, and
README.md updated to reflect the simplified scope.
The drift-detection test (project plan "Risk: BAST schema drift")
still works unchanged — it asserts the StreamType enum values match
wire.rs's STREAM_* constants, and that enum is retained.
Docs-only change; no Rust source changes.
Verification:
- cargo test --all-features -> 122 tests pass (unchanged)
- cargo clippy --all-targets --all-features -- -D warnings -> clean
- cargo fmt --check -> clean
- cargo doc --no-deps -> no new warnings (9 pre-existing rustdoc link
warnings in src/, unchanged)
- BAST JSON block parses as valid JSON (4 : ChunkHeader,
StreamType, Chunk, NegotiationFrame)
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
Folds the alknet-tty-local crate into alktty as a feature-gated local
submodule (the single-crate + local-feature design from ADR-054; the
cyclic-dep workaround the mono-repo needed doesn't apply to one crate).
src/local/mod.rs:
- Module root, re-exports LocalTtyBackend, declares backend/pipe/pty
- Doc-commented as feature-gated + non-wasm by design (portable-pty +
tokio::process need a real OS; enabling local on a wasm target is a
build error by design)
src/local/backend.rs:
- LocalTtyBackend: implements crate::backend::TtyBackend
- allocate() dispatches on params.terminal: Some -> pty::allocate_pty,
None -> pipe::allocate_pipe (ADR-054)
- 7 tests: PTY/pipe dispatch, empty-cmd rejection (both modes),
resource_id returns None, new() constructs
src/local/pty.rs:
- portable_pty + 3 std threads (reader/writer/waiter) feeding tokio
mpsc/oneshot (REQ-TTY-01 blocking->async bridge)
- PtyControl: resize + REQ-TTY-02 process-group signal forwarding
(libc::kill(-pgid, sig) + kill(pid, sig) fallback + ChildKiller::kill
for unknown names)
- LocalExitFuture: ADR-056 kill-on-Drop guard (ChildKiller on cancel,
Option::take disarms on resolve)
- StdinSink: AsyncWrite over mpc::Sender<StdinCmd> with in-flight
reserve+send parking for full-channel backpressure
- 7 tests incl. process-group reach (bash -c sleep), cancel-cleanup,
unknown-signal fallback
src/local/pipe.rs:
- tokio::process::Command + tokio_util::io::ReaderStream
- PipeControl: no-op resize, libc::kill(pid, sig) with SIGKILL
fallback for unknown names (pid-only, no process group - documented
limitation of the runner case)
- PipeExitFuture: in-place Child::wait() poll (avoids self-referential
borrow) + ADR-056 kill-on-Drop guard (start_kill on cancel)
- BytesStream: wraps ReaderStream, strips io::Error to EOF
- 9 tests incl. separate stderr, SIGTERM=-15, SIGKILL=-9 fallback,
cancel-cleanup pid probe (kill(pid,0) returns ESRCH)
Import migration: alknet_tty::backend::{...} -> crate::backend::{...},
alknet_tty::control::signal_from_name -> crate::control::signal_from_name.
Cargo.toml: no changes needed (portable-pty + tokio-util optional, libc
under cfg(unix), and the local feature wiring tokio/process +
tokio/rt-multi-thread were already in the scaffold per Phase 0).
Also fixes pre-existing rustfmt drift in adapter.rs/channels.rs/
session.rs/lib.rs left by the phase 2 commit (cargo fmt --check without
--features local reported 28 diffs; cargo fmt does not accept
--features, so the earlier 'clean' check was a false negative — the
check errored on the unknown flag and grepped an empty stdout). Lesson:
run cargo fmt --check with no feature flags; cargo fmt doesn't gate on
features.
Verification:
- cargo test --features local -> 103/103 pass (was 80 at phase 2 end;
+23 new tests across the 3 local modules: 7 backend, 7 pty, 9 pipe)
- cargo test (no features) -> 80/80 pass (local module not compiled)
- cargo clippy --features local --all-targets -> clean
- cargo clippy --all-targets (no features) -> clean
- cargo check --target wasm32-unknown-unknown -> clean (default crate
stays wasm-clean; local is feature-gated and non-wasm by design)
- cargo fmt --check -> clean
New code (not a port) — the producer/consumer halves of the channels
integration per alkcall's protocol-crate pattern.
Producer half (src/channels.rs):
- register_openable helper: builds the OperationSpec for channels/tty/sub
(Sub-typed, channel_open marker for alk/tty, AccessControl with
tty:open scope gate) and calls ChannelCore::register_openable
- TtyOpenHandler factory: receives the channel Connection, calls
accept_bi(), runs drive_session on the BiStream — same code path as
the direct-ALPN TtyAdapter (ADR-093)
- 8 tests: spec shape, registration, end-to-end open op returns
channel_id, ACL denial without tty:open scope
Consumer half (src/session.rs):
- TtySession::connect_direct(connection, negotiate) — direct alk/tty
- TtySession::open_via_channels(client, params) — opens a channel via
ChannelClient::open_channel, builds a Connection from the reassembled
halves, runs the same negotiation + typed-methods flow
- Methods: send_stdin, close_stdin, resize, signal, recv_stdout,
recv_stderr, wait
- Read pump: spawns a task that reads chunks off the BiStream and
routes stdout/stderr to mpsc channels, parses Exit control chunks
and resolves a watch channel for wait()
- Drop aborts the read pump
- 6 tests: negotiation frame write, stdin round-trip, resize/signal,
stdout stream, NoExitChunk on early close, broken-stream error
Also: added Serialize to NegotiateRequest + TerminalParamsWire (was
Deserialize-only; the session client needs to serialize the negotiation
frame).
80/80 tests pass, wasm32-unknown-unknown clean, clippy clean.
Port wire.rs, control.rs, negotiation.rs, backend.rs, adapter.rs from
alknet-tty (the protocol half of the alknet mono-repo split) into the
single alktty crate. All 65 unit tests pass; cargo check on
wasm32-unknown-unknown is clean (no features); clippy is clean.
Migration changes:
- adapter.rs imports: alknet_core::{auth, ownership, types} →
alkcall::core::{auth, ownership, types + Connection/HandlerError/
ProtocolHandler/StreamError re-exported at the crate root}
- adapter.rs TtyAdapter::alpn(): b"alknet/tty" → b"alk/tty"
- backend.rs BoxFuture type alias: Pin<Box<dyn Future + Send +
'static>> → futures::future::BoxFuture<'static, T> (matches alkcall
convention; alktty declares futures = 0.3 directly)
- doc comments: alknet/tty → alk/tty, alknet-tty → alktty, the
alknet-tty-poc and findings.md cross-references trimmed to local
docs
Test shape: alkcall's Identity.resources is HashMap<String, Vec<String>>
(was HashMap<String, String> in alknet-core); the tests construct with
StdHashMap::new() and infer the new shape from the struct, so no test
edits were needed. alkcall's OwnershipStore::record is 3-arg (no
action) and OwnershipProvider::owns is 4-arg (with action) — both
already match what the ported code calls.
Cargo.lock committed (matches alkcall/alktype convention; still
excluded from the published package via Cargo.toml's exclude list).
The default crate (no features) must compile to wasm32-unknown-unknown
so the downstream TS/Python adapter story works — a wasm-compiled
alktty is the protocol layer for a sandboxed adapter. The local feature
is inherently non-wasm (portable-pty + tokio::process need a real OS)
and enabling it on wasm is a build error by design.
Cargo.toml:
- tokio: drop features = ["full"], use the wasm-clean subset alkcall
uses (rt, sync, io-util, macros) with default-features = false
- local feature adds tokio/process + tokio/rt-multi-thread
- document the wasm constraint in the [features] comment
Plan:
- Decision 1: add WASM target subsection recording the constraint
- Phase 0: mark the tokio feature split as done
- Risks: add WASM-target-regression risk with a cargo-check CI mitigation
Reflect what actually landed since the 2026-08-14 draft:
- alkcall 0.1.1: alk/tty + alk/channels ALPN rename (no public API change)
- alkcall Identity.resources now HashMap<String, Vec<String>>
- alkcall OwnershipProvider::owns gained action arg; OwnershipStore::record lost it
- alktype 0.2.0: kind-based BAST format (schema doc artifact, not a runtime dep)
Fold alknet-tty-local into the single crate as the feature-gated local
module (pty.rs + pipe.rs + backend.rs) — the cyclic-dep workaround it
required in the alknet mono-repo doesn't apply to a single crate.
Add Decision 7 (BAST schema as documentation artifact), Phase 0
(scaffold hygiene), Phase 4 BAST-schema step, OQ-6, and a BAST-drift
risk with a cheap test mitigation.
Cargo.toml: bump alkcall 0.1.0 -> 0.1.1; add futures = 0.3 as a direct
dep so the BoxFuture alias doesn't ride on a transitive.