21 Commits
Author SHA1 Message Date
glm-5.3-flash a701247ca6 chore: bump to 0.8.2, changelog for the PTY exit-signal fidelity fix
- version 0.8.1 -> 0.8.2 (patch: behavior-only bug fix in the local
  feature's PTY waiter from db0b1fc; ADR-004 decision 1's negative-code
  convention now holds on the PTY path; no API, wire-format, or
  dependency change)
- CHANGELOG entry for 0.8.2 (review #004 resolution: the waitpid reap
  mechanism, the signal round-trip notes, the tests, the docs)
- finish the em-dash sweep in prose 58da42a missed: doc comments in
  tests/ (negotiation, pipe, pty, common) and fuzz/shared
  (control_json, negotiation_frame, session_opseq); prose rewrites
  only, no code, JSON literals, or wire shapes touched
- README checked: no version pins, nothing to update

Verification: cargo test; cargo test --all-features; cargo clippy
--all-targets -D warnings (default and --all-features); cargo fmt
--check; cargo doc --no-deps; fuzz/shared corpus replay (9 seeds pass);
cargo check + clippy for wasm32-unknown-unknown -D warnings; cargo
publish --dry-run --allow-dirty (43 files, 194.4 KiB); detached
campaigns over the four targets (chunk_frame 632s, negotiation_frame
602s, control_json 610s, session_opseq 608s) all exiting 0 with
0 crash/oom/timeout findings.
2026-11-02 08:27:32 +00:00
glm-5.3-flash 62f04c6856 docs(agents): spawned subagents never commit; coordinator commits once
A subagent mid-dedash sweep read the 'commit and push when reasonable'
rule and pushed its own subset before the coordinator verified the
aggregate. State the assembly rule explicitly so spawned tasks report
instead.
2026-11-01 14:30:23 +00:00
glm-5.3-flash 58da42a021 docs: remove em dashes from repo prose and forbid them going forward
- Rewrote ~1090 em dashes across docs/ (excluding docs/reviews/
  history), README, CHANGELOG, and .opencode/agents prompts with varied
  restructures (colons, semicolons, parentheses, commas, sentence
  splits); ADR decisions, status lines, wire-shape examples, and code
  fences preserved
- Rewrote 234 em dashes in src doc comments plus a handful of existing
  inline comments and one test assert message; no code, JSON literals,
  or wire shapes touched
- Added Project Convention 15: no em dashes in prose (doc comments,
  markdown, agent prompts, commit messages); restructure instead; no
  spaced hyphens as a substitute
Verification: cargo test; cargo clippy --all-targets -- -D warnings;
cargo fmt --check; cargo doc --no-deps; cargo test --all-features;
cargo test --manifest-path fuzz/shared/Cargo.toml; cargo check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
src + docs + agent prompts returns no matches outside docs/reviews/
2026-11-01 13:11:32 +00:00
glm-5.3-flash e9e01c04e1 docs: remove em dashes from tty-local spec and ADRs 004/006/007
- Rewrite dash-joined clauses with colons, semicolons, commas, and
  parentheses, varied per instance
- No decision, status, ADR reference, heading structure, or code-fence
  content changed
Verification: grep -c '—' returns 0 for all four files; structure,
links, tables, and fences unchanged
2026-11-01 13:03:57 +00:00
glm-5.3-flash db0b1fce5c fix(local): PTY waiter reaps via libc::waitpid — ADR-004 exit encoding holds on the PTY path
- portable-pty 0.9.0's ExitStatus flattens a signal-terminated child to
  code 1 with the signal only as a strsignal name string, so a SIGTERM
  death reported {"code":1} — indistinguishable from a real exit 1 and
  a violation of ADR-004 decision 1's negative-code convention (review
  #004, consumer input from alkssh)
- on Unix the waiter now skips the portable-pty conversion entirely:
  libc::waitpid(pid) on the direct child, decoded WIFEXITED/WEXITSTATUS
  and WIFSIGNALED/-WTERMSIG (numeric, locale-independent); Child::wait()
  remains as the waitpid-failure fallback and the non-Unix primary
  (std::process::Child's Drop neither kills nor reaps, so the two paths
  never double-reap)
- signal features now round-trip: signal("TERM") -> -15, ADR-056 cancel
  kill (SIGHUP) -> -1 (the documented ADR-004 sentinel collision)
- tests: SIGINT -> -2, SIGTERM -> -15, SIGHUP fallback -> -1 pinned
- docs: ADR-004 decision 1, tty-local.md, ADR-005, tty-adapter.md,
  review #004 closed as resolved with disposition

Verification: cargo test (113), cargo test --all-features (138 + the
tests/pty.rs, tests/pipe.rs, tests/negotiation.rs integrations),
clippy --all-targets --all-features -D warnings, fmt, doc,
fuzz/shared corpus replay (9)
2026-11-01 12:06:23 +00:00
glm-5.3-flash d7a296d7bd docs(review): consumer input from alkssh — PTY-path exit-signal fidelity vs the ADR-004 negative-code convention 2026-10-31 12:56:00 +00:00
glm-5.3-flash 6e33320e69 docs: exit-chunk code semantics — the -N encoding is not ExitStatus::code(), and the -1 sentinel collides with SIGHUP
- ADR-004 §1: the negative-signal encoding is this crate's convention
  (Python subprocess.returncode-style, via ExitStatusExt::signal()); std's
  ExitStatus::code() returns None for signal termination. The old §1
  wrongly claimed the encoding "matches ExitStatus::code()'s behavior on
  Unix" and the Negative section then self-corrected two pages later.
- ADR-004 Negative: replace the impossible "real exit -1" conflation with
  the real one — SIGHUP (signal 1) encodes as exactly -1, the same value
  as the backend wait-failure sentinel; narrow in practice (the usual
  SIGHUP source is a client-sent Signal { name: "HUP" } the client knows
  it sent), real otherwise.
- Carried the same correction into tty-wire.md §Control Channel, the
  ControlMessage::Exit doc comment, TtySession::wait's doc comment, and
  pipe.rs::exit_code_from's doc comment.

Verification: cargo test (5/5 targets ok), cargo test --all-features,
cargo clippy --all-targets -- -D warnings, cargo fmt --check,
cargo doc --no-deps, cargo check/clippy --target wasm32-unknown-unknown -D warnings
2026-10-27 15:19:03 +00:00
glm-5.3-flash e3d7f6f3f2 chore: bump to 0.8.1, changelog for the alkcall 0.10.1 adoption
- alkcall requirement 0.10.0 -> 0.10.1 (same-family version-only bump:
  the adoption-window ordering fix, private fix surface, no type alktty
  touches); lockfiles updated in both the root and fuzz workspaces
- AGENTS.md pin refreshed from the stale 0.9.0 note

Verification: cargo test 113 default / 137 all-features (unchanged from
the 0.8.0 baseline); fuzz corpus replay 9 green; clippy stable + wasm
(-D warnings); fmt; doc; wasm32-unknown-unknown check; publish dry run
(43 files)
2026-10-22 06:22:33 +00:00
glm-5.3-flash 56d0556240 feat: alkcall 0.10.0 adoption — bump to 0.8.0
The gateway ownership seam release touches no surface alktty uses:
the loud unwired-spine default applies only to `resource_type`-gated
ops served through `GatewayDispatch`, and alktty consults the
OwnershipProvider seam directly (direct-ALPN scope gate + channels
establisher). Dependency bump only — no source change in alktty.

- alkcall requirement 0.9.0 -> 0.10.0; lockfile bumped
- version 0.7.0 -> 0.8.0 (0.x ecosystem-coordination minor; raising
  the minimum requirement is breaking)
- changelog entry for 0.8.0

Verification: cargo test (113 default), cargo test --all-features
(137), clippy --all-targets + wasm clippy (-D warnings), fmt --check,
cargo doc, wasm32-unknown-unknown check, fuzz corpus replay (11/11).
2026-10-19 03:39:55 +00:00
glm-5.3-flash 0170897bbb feat: alkcall 0.9.0 adoption — bump to 0.7.0
The stub-sweep release changes no type alktty touches: the four
implemented stubs, the register_openable Pub-typed refusal (alktty's
op is Sub-typed), the register_imported Result return, and the removed
RelayError::PubTypedOpen are all surfaces alktty never exercises.

- alkcall requirement 0.8.1 -> 0.9.0; lockfile follows
- alktty 0.6.0 -> 0.7.0 (minor: raising the minimum alkcall requirement
  is ecosystem-coordination breaking on a 0.x crate)
- one test fix: `establisher_ok_on_valid_params` formatted a
  Result<Establishment, _> with {:?} — Establishment no longer derives
  Debug in alkcall 0.9.0; the assert now branches on Err and formats
  only the error

Verification: 113 default / 156 all-features tests, clippy
--all-targets -D warnings, fmt --check, cargo doc, fuzz corpus replay
(9 tests / 340 seeds), wasm32-unknown-unknown check + clippy,
publish --dry-run — all pass.
2026-10-14 05:15:06 +00:00
glm-5.3-flash 3855bb1c99 chore: bump to 0.6.0, changelog for the fuzzing adoption (alkcall 0.8.1) 2026-09-28 10:18:50 +00:00
glm-5.3-flash fb7cfe3851 feat(fuzz): session_opseq target - stateful op-sequence campaign against the session pump
Target 4 from the fuzzing plan (the alkcall-bug-class hunt): a
#[derive(Arbitrary)] op sequence drives the producer session pump
(drive_session_pre_negotiated) over a duplex pair against a fuzz-local
mock backend.

- fuzz/shared/src/session_opseq.rs: op vocabulary (client writes incl.
  invalid-type/oversize-length barrier probes, backend production/EOF,
  exit resolve/fail/drop, control messages, bounded reads, yields),
  kill-guard exit future (ADR-005 observability), delivery model, and
  the invariants: kill-on-Drop (kill_fired == !exit_resolved), exit
  chunk is last with first-issued code -1 on wait-failure (ADR-004),
  lossless per-stream FIFO + single stdout sentinel, stdin
  prefix-losslessness (post-shutdown chunks never delivered), control
  dispatch bounds, take-once allocation, teardown termination.
- Harness-model correction caught by a 60s smoke run before the
  campaign (the alkcall §7.7 pattern, third time): a failed client
  write can mean the session completed and dropped the server duplex
  half while the write was in flight - the harness now drains and
  requires the exit chunk on any write/shutdown error (premature close
  without it is a finding). No adapter code changed.
- 12 committed seeds via the deterministic generator, hand-encoded
  against the arbitrary 1.4.x derive layout; the encoding is pinned by
  a seed-decode test. Existing chunk_frame/negotiation_frame/
  control_json seed corpora are byte-identical to before.
- fuzz/.gitignore: the per-target seed negations never matched (corpus/*
  excludes the parent dir; git cannot re-include beneath an excluded
  dir). Fixed with the !corpus/*/ + corpus/*/* + !corpus/*/seed-*
  recipe - new seed files were silently ignored until now.
- Campaign: 10 min detached via run-detached.sh, exited 0, artifact
  dir empty - 42.3k execs across 33 fork jobs, 0 crash/oom/timeout,
  cov 3934 -> 4075 edges. Grown corpus excised per the corpus policy.

Verification: corpus replay green on stable (9 tests), cargo test 113
+ --all-features 156, clippy stable/wasm/fuzz-shared, fmt, doc,
publish dry-run, wasm check - all pass.
2026-09-28 10:06:00 +00:00
glm-5.3-flash e72e1fc4c7 fix(fuzz): commit only hand-made seeds - grown corpus entries swept in by the campaign window are excised; corpus/* ignore tightened to seed-* 2026-09-28 08:08:17 +00:00
glm-5.3-flash 49f70d9efb feat(fuzz): cargo-fuzz workspace, 3 wire-surface targets; bump alkcall 0.8.1
- Bump alkcall 0.8.0 -> 0.8.1 (duplicate adopt/open channel-state
  destruction fix from alkcall's fuzz campaign; AGENTS.md stale-pin fix).
- Adopt fuzzing per docs/plans/fuzzing.md (mirrors alkcall's
  docs/research/fuzzing.md as-built layout): fuzz/ workspace with own
  [workspace] table, nightly pinned for the subtree only, invariant
  logic in a stable-toolchain shared crate.
- Targets: chunk_frame (5-byte chunk codec), negotiation_frame
  (negotiation framing + NegotiateRequest + error_response_bytes +
  the cross-codec peek-disambiguation seam), control_json
  (ControlMessage JSON + signal_from_name). src/local/ out of scope
  (not wire-attacker-shaped).
- 328 committed seeds (deterministic generator); grown corpora
  gitignored. Corpus replay on stable is the standing fuzz gate
  (cargo test --manifest-path fuzz/shared/Cargo.toml).
- Detached-runner rule (fuzz/run-detached.sh): campaigns never run in
  the foreground of an agent session - OOM in a target must cost the
  fuzzer, never the session host.
- Root Cargo.toml gained [workspace] members/exclude (MSRV vs fuzz
  nightly dev-deps footgun) and fuzz/ in the publish exclude.

Verification: cargo test (113), cargo test --all-features (156),
clippy stable + wasm32-unknown-unknown, fmt, doc, publish dry-run,
corpus replay 328 seeds - all green. 10-min detached campaigns on all
three targets: 0 crashes/hangs/OOMs/leaks (chunk_frame cov-saturated
at 710 edges; negotiation_frame 3.69M execs cov 3157; control_json
8.6M execs cov 2035). Corpus replay caught three harness-model
mismatches pre-campaign (payload-slice shape; the negotiation framing
layer is length-prefix-only - Json unreachable from read_frame;
zero-length frames admitted by the reader, rejected by the adapter's
parse). Campaigns recorded in docs/plans/fuzzing.md section 8.
2026-09-28 08:07:30 +00:00
glm-5.3-flash e95459c425 feat: alkcall 0.8.0 adoption — bump to 0.5.0
- 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
2026-09-18 13:55:53 +00:00
glm-5.3-flash d14941ffb1 chore: bump to 0.4.1, changelog for the MSRV floor raise (1.85 -> 1.88)
Verification: 113 default / 156 all-features tests on stable (1.94) and
on a real 1.88 toolchain, clippy (all-targets) clean, fmt clean, doc
clean, wasm32 check + clippy clean, publish dry-run OK.
2026-09-10 02:55:11 +00:00
glm-5.3-flash 01e94914c1 chore: raise rust-version floor to 1.88
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
2026-09-10 02:53:33 +00:00
glm-5.3-flash 32809af840 docs: fill changelog version link refs (0.4.0/0.3.0/0.2.0) 2026-09-07 18:07:13 +00:00
glm-5.3-flash 67cf253621 feat: alkcall 0.7.0 adoption (CF-006 identity alignment) — bump to 0.4.0
- 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
2026-09-07 17:57:36 +00:00
glm-5.3-flash bc4a9b6024 docs: align remaining alloc-failure references with ADR-010 §2A (prepublish review for v0.3.0)
- README.md ADR index: ADR-010 status notes the §2A amendment
- ADR-009: amendment note records the §2A supersession (in-band path
  shrinks to nothing from registered producers)
- ADR-010 Consequences: dial_failed replaces the stale in-band
  allocate_failed retry-policy bullet
- AllocFailed doc comment + tty-backend.md: describe both failure
  surfaces (direct-path frame / channels-path dial_failed)
2026-09-07 10:04:44 +00:00
glm-5.3-flash af9dbaedea feat: allocation moves into the channels establisher (alkcall 0.6.0 / review 007 R-01) — bump to 0.3.0
- 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
2026-09-07 09:53:57 +00:00
406 changed files with 6821 additions and 1504 deletions

No files matched your search

+44 -44
View File
@@ -37,10 +37,10 @@ docs/architecture/
The README is the entry point. It contains: The README is the entry point. It contains:
1. **Current State** — what phase the project is in, what's implemented 1. **Current State**: what phase the project is in, what's implemented
2. **Architecture Documents** — table linking to each spec doc with status 2. **Architecture Documents**: table linking to each spec doc with status
3. **ADR Table** — every decision with number, title, and status 3. **ADR Table**: every decision with number, title, and status
4. **Open Questions** — link to `open-questions.md` 4. **Open Questions**: link to `open-questions.md`
### Spec Documents ### Spec Documents
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs) (Links to related specs and ADRs)
``` ```
ADR numbering starts at 001 within each project. ADRs are stable — once ADR numbering starts at 001 within each project. ADRs are stable: once
Accepted, they don't revert. If a decision is superseded, create a new ADR and Accepted, they don't revert. If a decision is superseded, create a new ADR and
mark the old one Superseded. mark the old one Superseded.
@@ -97,12 +97,12 @@ don't need ADRs.
`open-questions.md` contains all unresolved questions across all spec documents, `open-questions.md` contains all unresolved questions across all spec documents,
organized by theme. Each question has: organized by theme. Each question has:
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference - **OQ-ID** (OQ-01, OQ-02, ...), a stable reference
- **Origin** — which spec doc(s) the question appeared in - **Origin**: which spec doc(s) the question appeared in
- **Status** — open, resolved, partially resolved - **Status**: open, resolved, partially resolved
- **Priority** — high, medium, low - **Priority**: high, medium, low
- **Resolution** — when resolved, what was decided and which ADR addresses it - **Resolution**: when resolved, what was decided and which ADR addresses it
- **Cross-references** — related OQs and ADRs - **Cross-references**: related OQs and ADRs
Spec documents reference OQs by number, not by repeating the question inline. Spec documents reference OQs by number, not by repeating the question inline.
When an OQ is resolved, leave a strikethrough + resolution note in the spec When an OQ is resolved, leave a strikethrough + resolution note in the spec
@@ -136,7 +136,7 @@ Before writing architecture:
- Understand the problem domain - Understand the problem domain
- Identify constraints and quality attributes - Identify constraints and quality attributes
- Research similar systems if needed - Research similar systems if needed
- Read downstream consumer architecture — if the project is a library, understand - Read downstream consumer architecture; if the project is a library, understand
what consumers need what consumers need
### 2. Identify Documentation Scope ### 2. Identify Documentation Scope
@@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by
number. As you identify open questions, add them to `open-questions.md` and number. As you identify open questions, add them to `open-questions.md` and
reference them by OQ-ID. reference them by OQ-ID.
Spec documents reference ADRs and OQs — they don't contain the full rationale Spec documents reference ADRs and OQs; they don't contain the full rationale
or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and
open questions tracked centrally. open questions tracked centrally.
@@ -180,7 +180,7 @@ Before requesting external review:
blocking condition (for `deferred(scope)`) or investigation target blocking condition (for `deferred(scope)`) or investigation target
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're (for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
deferring. If the blocker needs what you're deferring, you have a deferring. If the blocker needs what you're deferring, you have a
prerequisite inversion — either make the decision now (the pieces prerequisite inversion: either make the decision now (the pieces
exist) or reframe honestly (the shape isn't clear, here's what would exist) or reframe honestly (the shape isn't clear, here's what would
make it clear). make it clear).
@@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made:
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition 1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
2. Create a blocker task in `tasks/architecture/` naming the dependency 2. Create a blocker task in `tasks/architecture/` naming the dependency
3. Continue to decisions that *can* be made — do not stall on one question 3. Continue to decisions that *can* be made; do not stall on one question
### 6. Request Architecture Review ### 6. Request Architecture Review
@@ -216,12 +216,12 @@ task(
Address feedback: Address feedback:
- **Critical**: Must fix before stabilization — inline decisions not - **Critical**: Must fix before stabilization (inline decisions not
extracted, ADR references that point to nonexistent files, undefined extracted, ADR references that point to nonexistent files, undefined
terms, circular deferrals terms, circular deferrals)
- **Warning**: Should fix — missing cross-references, documents approaching - **Warning**: Should fix: missing cross-references, documents approaching
split threshold split threshold
- **Suggestion**: Consider — minor clarity improvements - **Suggestion**: Consider: minor clarity improvements
Iterate until zero critical issues. Iterate until zero critical issues.
@@ -264,7 +264,7 @@ last_updated: 2026-05-29
answer is resolved, not left "open" with hedging language like "v1 default" answer is resolved, not left "open" with hedging language like "v1 default"
or "can be revisited later." If the decision is made, mark it resolved. If or "can be revisited later." If the decision is made, mark it resolved. If
the decision genuinely can't be made yet (the use case isn't concrete, the decision genuinely can't be made yet (the use case isn't concrete,
the options aren't clear), mark it `deferred(scope)` — see Safe Exit below. the options aren't clear), mark it `deferred(scope)` (see Safe Exit below).
The architect's job is to make architecture decisions that *can* be made The architect's job is to make architecture decisions that *can* be made
and to clearly identify which decisions *can't* be made yet and why. and to clearly identify which decisions *can't* be made yet and why.
@@ -272,7 +272,7 @@ last_updated: 2026-05-29
Door type classifies **reversal cost** (one-way vs two-way), not Door type classifies **reversal cost** (one-way vs two-way), not
urgency. A two-way door is a decision you make now and can revert urgency. A two-way door is a decision you make now and can revert
later — not a decision to defer. Using "it's a two-way door" as a later; it is not a decision to defer. Using "it's a two-way door" as a
reason to leave a decision unmade conflates reversal cost with reason to leave a decision unmade conflates reversal cost with
decision-making. See ADR-009 §"What this framework is NOT" for the decision-making. See ADR-009 §"What this framework is NOT" for the
full rationale. full rationale.
@@ -281,17 +281,17 @@ Architecture decisions are the architect's, regardless of door type.
The implementation agent makes implementation decisions (variable The implementation agent makes implementation decisions (variable
names, loop order, which library to use for a concrete task). If a names, loop order, which library to use for a concrete task). If a
decision affects the system's structure, constraints, or API surface, decision affects the system's structure, constraints, or API surface,
it's an architecture decision — even if it's a two-way door. it's an architecture decision, even if it's a two-way door.
## Anti-Patterns to Avoid ## Anti-Patterns to Avoid
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs 1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs: extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections — 2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
centralize in `open-questions.md` centralize in `open-questions.md`
3. **Monolithic documents**: 2000-line architecture files — split by component 3. **Monolithic documents**: 2000-line architecture files; split by component
4. **Duplication across documents**: Cross-reference ADRs and OQs, don't 4. **Duplication across documents**: Cross-reference ADRs and OQs, don't
copy-paste rationale copy-paste rationale
5. **Historical comparison**: "Here's what the old code did" — specs describe 5. **Historical comparison**: "Here's what the old code did": specs describe
the current design, not the transition from before the current design, not the transition from before
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?", 6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
write an ADR write an ADR
@@ -310,7 +310,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
## Safe Exit: Deferred Decisions ## Safe Exit: Deferred Decisions
When a decision can't be made yet, the architect has a Safe Exit path. When a decision can't be made yet, the architect has a Safe Exit path.
This is not a failure — it's scope management. The architect's job is This is not a failure; it's scope management. The architect's job is
to make decisions that *can* be made and to clearly identify which to make decisions that *can* be made and to clearly identify which
decisions *can't* be made yet and why. decisions *can't* be made yet and why.
@@ -318,10 +318,10 @@ There are two kinds of deferral. The distinction matters because they
have different resolution paths, and confusing them is a source of have different resolution paths, and confusing them is a source of
circular reasoning. circular reasoning.
### `deferred(scope)` — the information is genuinely missing ### `deferred(scope)`: the information is genuinely missing
The decision can't be made because something the decision depends on The decision can't be made because something the decision depends on
doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC doesn't exist yet. Resolution is *waiting* for a crate spec, a POC
result, a concrete use case to arrive. result, a concrete use case to arrive.
A decision should be `deferred(scope)` when: A decision should be `deferred(scope)` when:
@@ -336,11 +336,11 @@ A decision should be `deferred(scope)` when:
current scope is core + call crates; this question is about the agent current scope is core + call crates; this question is about the agent
crate") crate")
### `deferred(unclear)` — the pieces exist but the shape isn't clear ### `deferred(unclear)`: the pieces exist but the shape isn't clear
The pieces of the decision exist (decided in other ADRs, existing The pieces of the decision exist (decided in other ADRs, existing
types, existing patterns) but the composition — how they fit together types, existing patterns) but the composition (how they fit together
into a coherent shape — isn't clear yet. Resolution is *investigation*, into a coherent shape) isn't clear yet. Resolution is *investigation*,
not waiting: work through example use cases, maybe build a POC, maybe not waiting: work through example use cases, maybe build a POC, maybe
just think through the composition until the shape surfaces. just think through the composition until the shape surfaces.
@@ -352,7 +352,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint
normalization) while the shape they compose into (the client config normalization) while the shape they compose into (the client config
type) is still unclear. Forcing a decision in that state produces a type) is still unclear. Forcing a decision in that state produces a
guess; forcing a `deferred(scope)` produces a false deferral (the guess; forcing a `deferred(scope)` produces a false deferral (the
information isn't missing — it's un-synthesized). `deferred(unclear)` information isn't missing; it's un-synthesized). `deferred(unclear)`
is the honest state: "I can see the pieces but I can't see the shape is the honest state: "I can see the pieces but I can't see the shape
yet, and I need to work through examples to see it." yet, and I need to work through examples to see it."
@@ -363,27 +363,27 @@ A decision should be `deferred(unclear)` when:
- Resolution requires *work* (thinking through examples, building a - Resolution requires *work* (thinking through examples, building a
POC), not *waiting* (for a spec or use case to arrive) POC), not *waiting* (for a spec or use case to arrive)
- The architect can articulate what investigation would help ("work - The architect can articulate what investigation would help ("work
through 2+ example outbound-dial use cases") — if you can't, that's a through 2+ example outbound-dial use cases"); if you can't, that's a
signal the deferral might be circular signal the deferral might be circular
### How to Defer ### How to Defer
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not 1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`**, not
`open` (implies it should be resolved now) and not `resolved` `open` (implies it should be resolved now) and not `resolved`
(implies it's decided). (implies it's decided).
2. **State the blocking condition** (`deferred(scope)`) or 2. **State the blocking condition** (`deferred(scope)`) or
**investigation target** (`deferred(unclear)`) — what specific thing **investigation target** (`deferred(unclear)`): what specific thing
would unblock this? Be concrete: "blocked on: alknet-agent crate spec would unblock this? Be concrete: "blocked on: alknet-agent crate spec
exists" or "investigation: work through 2+ example outbound-dial use exists" or "investigation: work through 2+ example outbound-dial use
cases (hub→worker, worker→hub) to see how verifier-selection + cases (hub→worker, worker→hub) to see how verifier-selection +
provider + connector compose." provider + connector compose."
3. **State the impacts** — what does this block downstream? Be 3. **State the impacts**: what does this block downstream? Be
specific: "blocks the first hub deployment because the hub dials specific: "blocks the first hub deployment because the hub dials
workers" not "blocks the hub crate." This is the triage signal that workers" not "blocks the hub crate." This is the triage signal that
makes the deferral's urgency visible. If the impact is significant, makes the deferral's urgency visible. If the impact is significant,
the deferral needs to be addressed soon; if it's a future feature, the deferral needs to be addressed soon; if it's a future feature,
it can wait. it can wait.
4. **Move on** — the architect continues to decisions that *can* be 4. **Move on**: the architect continues to decisions that *can* be
made. Deferred decisions are not failures; they're the input to the made. Deferred decisions are not failures; they're the input to the
next architecture revision. next architecture revision.
@@ -396,10 +396,10 @@ A decision should be `deferred(unclear)` when:
- **Status**: deferred(scope) | deferred(unclear) - **Status**: deferred(scope) | deferred(unclear)
- **Door type**: <one-way | two-way> - **Door type**: <one-way | two-way>
- **Priority**: <high | medium | low> - **Priority**: <high | medium | low>
- **Impacts**: <what this blocks downstream — be specific> - **Impacts**: <what this blocks downstream: be specific>
- **Blocked on**: <concrete dependency> (for deferred(scope)) - **Blocked on**: <concrete dependency> (for deferred(scope))
**Investigation**: <what work would make the shape clear> (for deferred(unclear)) **Investigation**: <what work would make the shape clear> (for deferred(unclear))
- **Resolution**: Not yet decidable. <Why — either the information - **Resolution**: Not yet decidable. <Why: either the information
doesn't exist yet (deferred(scope)) or the pieces exist but the doesn't exist yet (deferred(scope)) or the pieces exist but the
composition isn't clear (deferred(unclear), cite the pieces).> composition isn't clear (deferred(unclear), cite the pieces).>
- **Cross-references**: OQ-NN, ADR-NNN - **Cross-references**: OQ-NN, ADR-NNN
@@ -410,13 +410,13 @@ A decision should be `deferred(unclear)` when:
- Do not mark a deferred decision as `resolved` with caveats. "Resolved - Do not mark a deferred decision as `resolved` with caveats. "Resolved
with an escape hatch" is hedging. with an escape hatch" is hedging.
- Do not leave a deferred decision as `open` without a blocking - Do not leave a deferred decision as `open` without a blocking
condition. "Open" means "needs to be resolved now" — if it can't be condition. "Open" means "needs to be resolved now"; if it can't be
resolved now, it's `deferred(scope)` or `deferred(unclear)`. resolved now, it's `deferred(scope)` or `deferred(unclear)`.
- Do not confuse the two deferral kinds. If the information is missing, - Do not confuse the two deferral kinds. If the information is missing,
it's `deferred(scope)`. If the information exists but the shape isn't it's `deferred(scope)`. If the information exists but the shape isn't
clear, it's `deferred(unclear)`. Confusing them produces circular clear, it's `deferred(unclear)`. Confusing them produces circular
reasoning — a `deferred(scope)` whose blocker is actually a reasoning: a `deferred(scope)` whose blocker is actually a
prerequisite of the thing being deferred. prerequisite of the thing being deferred.
## When to Redirect ## When to Redirect
+15 -15
View File
@@ -84,7 +84,7 @@ Check coverage of:
#### F. Decision Quality and Deferral Honesty #### F. Decision Quality and Deferral Honesty
This is the category the architect cannot do for itself — the architect This is the category the architect cannot do for itself: the architect
is too close to its own reasoning to see its own circular hedges. A is too close to its own reasoning to see its own circular hedges. A
fresh context can see the whole picture and spot reasoning that folds fresh context can see the whole picture and spot reasoning that folds
back on itself. This is often the highest-value category on projects back on itself. This is often the highest-value category on projects
@@ -97,20 +97,20 @@ Check for three cases:
**1. Hedging on a resolved decision.** An OQ marked `resolved` whose **1. Hedging on a resolved decision.** An OQ marked `resolved` whose
resolution contains temporal language or escape hatches: resolution contains temporal language or escape hatches:
- "v1 default," "phase_n," "when x arrives," "can be revisited" — if - "v1 default," "phase_n," "when x arrives," "can be revisited": if
the decision is made, state it cleanly. Reserve temporal language the decision is made, state it cleanly. Reserve temporal language
for genuinely deferred decisions. for genuinely deferred decisions.
- "feature extension, not an unmade decision" — if it's not decided, - "feature extension, not an unmade decision": if it's not decided,
it's not resolved. it's not resolved.
- "additive, not blocking" — if it's not decided, don't claim it is. - "additive, not blocking": if it's not decided, don't claim it is.
- "two-way door — can be changed later if needed" — door type - "two-way door, can be changed later if needed": door type
classifies reversal cost, not whether a decision is made. classifies reversal cost, not whether a decision is made.
- "not a v1 blocker" — if it's not decided, it's deferred. Say what - "not a v1 blocker": if it's not decided, it's deferred. Say what
unblocks it. unblocks it.
- "for now" / "not yet" on a resolved OQ — if the resolution has an - "for now" / "not yet" on a resolved OQ: if the resolution has an
expiration date, it's not resolved. expiration date, it's not resolved.
- Resolution text primarily about how the decision can be changed - Resolution text primarily about how the decision can be changed
later ("X, but here's how we'd undo X") — the decision is made; drop later ("X, but here's how we'd undo X"): the decision is made; drop
the undo instructions. If it's "X for now, Y later," the decision is the undo instructions. If it's "X for now, Y later," the decision is
not made. not made.
@@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or
**2. False deferral / circular hedge.** A deferred OQ whose blocking **2. False deferral / circular hedge.** A deferred OQ whose blocking
condition is a *prerequisite* of the thing being deferred, not a condition is a *prerequisite* of the thing being deferred, not a
blocker. This is the most damaging pattern — it creates a circular blocker. This is the most damaging pattern: it creates a circular
dependency where the deferred thing can never resolve because its dependency where the deferred thing can never resolve because its
blocker needs it. blocker needs it.
@@ -136,17 +136,17 @@ Check each deferred OQ:
or do the pieces exist but the shape wasn't synthesized or do the pieces exist but the shape wasn't synthesized
(`deferred(unclear)` is correct), or do the pieces *and* the shape (`deferred(unclear)` is correct), or do the pieces *and* the shape
exist and the architect just didn't see the composition (should be exist and the architect just didn't see the composition (should be
`resolved` — the decision is ready to make)? `resolved`; the decision is ready to make)?
Flag as **critical**: the deferral is circular or inverted. Suggest Flag as **critical**: the deferral is circular or inverted. Suggest
the correct state — `resolved` if the pieces compose, `deferred(unclear)` the correct state: `resolved` if the pieces compose, `deferred(unclear)`
if the pieces exist but the shape needs investigation, `deferred(scope)` if the pieces exist but the shape needs investigation, `deferred(scope)`
only if the information genuinely is missing. only if the information genuinely is missing.
**3. Legitimate deferral.** A deferred OQ where the information **3. Legitimate deferral.** A deferred OQ where the information
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
but the shape genuinely needs investigation (`deferred(unclear)`). but the shape genuinely needs investigation (`deferred(unclear)`).
Leave these — they're honest. The distinction from case 2 is that Leave these; they're honest. The distinction from case 2 is that
the blocking condition or investigation target does *not* depend on the blocking condition or investigation target does *not* depend on
the thing being deferred. the thing being deferred.
@@ -156,12 +156,12 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`,
`partially resolved`) should have an **impacts** field stating what it `partially resolved`) should have an **impacts** field stating what it
blocks downstream. Check: blocks downstream. Check:
- Is the impacts field present? Absence is a warning — without it, the - Is the impacts field present? Absence is a warning; without it, the
deferral's urgency is invisible and triage is guesswork. deferral's urgency is invisible and triage is guesswork.
- Is it specific? "Blocks the first hub deployment because the hub - Is it specific? "Blocks the first hub deployment because the hub
dials workers" is useful. "Blocks the hub crate" is boilerplate. dials workers" is useful. "Blocks the hub crate" is boilerplate.
- Does it match the priority? A `deferred(unclear)` with high priority - Does it match the priority? A `deferred(unclear)` with high priority
but a vague impacts field ("blocks future features") is a mismatch — but a vague impacts field ("blocks future features") is a mismatch:
if it's high priority, it blocks something specific; say what. if it's high priority, it blocks something specific; say what.
Flag missing impacts fields as **warning**, vague ones as Flag missing impacts fields as **warning**, vague ones as
@@ -259,5 +259,5 @@ section 2"
- Critical issues must block stabilization - Critical issues must block stabilization
- The Decision Quality (F) category is often the highest-value check on - The Decision Quality (F) category is often the highest-value check on
projects creating new protocols or solving poorly-defined problems. projects creating new protocols or solving poorly-defined problems.
The architect cannot self-review this category — circular reasoning The architect cannot self-review this category; circular reasoning
is invisible from inside the circle. Prioritize it. is invisible from inside the circle. Prioritize it.
+5 -5
View File
@@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task.
## Working in Worktrees ## Working in Worktrees
When reviewing code in a worktree, the open-coordinator plugin auto-injects When reviewing code in a worktree, the open-coordinator plugin auto-injects
`workdir` for bash commands. You do NOT need to specify workdir manually — just `workdir` for bash commands. You do NOT need to specify workdir manually, just
run commands as usual. run commands as usual.
```text ```text
@@ -111,10 +111,10 @@ cargo fmt --check # Format check
For this project, also verify: For this project, also verify:
- No comments in code (per project convention) - No comments in code (per project convention)
- Error handling uses `anyhow::Result` (application) / `thiserror` (library) — no - Error handling uses `anyhow::Result` (application) / `thiserror` (library);
panics in library code no panics in library code
- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles - Feature flags are used correctly (`tls`, `iroh`, `acme`): the base crate
lean compiles lean
- Public API is well-documented with `///` doc comments where appropriate - Public API is well-documented with `///` doc comments where appropriate
- Module structure follows Rust conventions (`mod.rs`, `lib.rs`) - Module structure follows Rust conventions (`mod.rs`, `lib.rs`)
- No unnecessary `unwrap()` or `expect()` in library code - No unnecessary `unwrap()` or `expect()` in library code
+20 -20
View File
@@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs:
## The `worktree` Tool (via @alkimiadev/open-coordinator) ## The `worktree` Tool (via @alkimiadev/open-coordinator)
You use the **worktree** tool with `{action, args}` dispatch. Role is You use the **worktree** tool with `{action, args}` dispatch. Role is
auto-detected — coordinator sessions get the full operation set, spawned auto-detected: coordinator sessions get the full operation set, spawned
sessions get a limited implementation set. sessions get a limited implementation set.
### Coordinator Operations ### Coordinator Operations
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
If merge conflicts occur: If merge conflicts occur:
- **Source code conflicts between parallel tasks** that modify the same file: - **Source code conflicts between parallel tasks** that modify the same file:
Resolve them yourself. Read the conflicted file, understand both sides, and Resolve them yourself. Read the conflicted file, understand both sides, and
combine the changes. Both sets of changes are valid — they were just combine the changes. Both sets of changes are valid; they were just
developed in parallel. developed in parallel.
- **Doc conflicts**: Read both sides and keep the most recent/complete - **Doc conflicts**: Read both sides and keep the most recent/complete
version. Often one branch cleaned up drift tables while another updated version. Often one branch cleaned up drift tables while another updated
@@ -112,7 +112,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
```text ```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}}) worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
``` ```
The `remote: true` flag tells the plugin to also delete the remote branch — The `remote: true` flag tells the plugin to also delete the remote branch,
no separate `git push origin --delete` needed. If you need to force-remove a no separate `git push origin --delete` needed. If you need to force-remove a
dirty worktree, add `force: true`. dirty worktree, add `force: true`.
@@ -132,12 +132,12 @@ in this order:
latest main) latest main)
If two tasks modify the same source files and were developed in parallel, you If two tasks modify the same source files and were developed in parallel, you
WILL get merge conflicts. This is expected — resolve them. WILL get merge conflicts. This is expected; resolve them.
### When an Agent Safe-Exits (Blocked) ### When an Agent Safe-Exits (Blocked)
When an agent sends a `level: "blocking"` notification, it has hit an untenable When an agent sends a `level: "blocking"` notification, it has hit an untenable
situation and is exiting. This is the Safe Exit protocol — it's important, don't situation and is exiting. This is the Safe Exit protocol; it's important, don't
ignore it. ignore it.
1. **Read the blocking message carefully.** The agent should have included what 1. **Read the blocking message carefully.** The agent should have included what
@@ -162,7 +162,7 @@ ignore it.
``` ```
4. **Try to resolve the blocker:** 4. **Try to resolve the blocker:**
- Missing context? Send it via `worktree({action: "message", ...})` — but - Missing context? Send it via `worktree({action: "message", ...})`, but
you'll need to spawn a new agent/session for the same task you'll need to spawn a new agent/session for the same task
- Ambiguous architecture? Ask the user to clarify - Ambiguous architecture? Ask the user to clarify
- Scope too large? Decompose into smaller tasks - Scope too large? Decompose into smaller tasks
@@ -179,14 +179,14 @@ ignore it.
The `prompt` parameter supports `{{task}}` template substitution. Use it, but The `prompt` parameter supports `{{task}}` template substitution. Use it, but
also include: also include:
1. **Task identification** — How to find their task file in `tasks/` 1. **Task identification**: How to find their task file in `tasks/`
2. **Merge from main** — Tell them to 2. **Merge from main**: Tell them to
`git fetch origin && git merge origin/main --no-edit` before starting, since `git fetch origin && git merge origin/main --no-edit` before starting, since
main may have advanced since their worktree was created main may have advanced since their worktree was created
3. **Key references** — Which source files and architecture docs to read 3. **Key references**: Which source files and architecture docs to read
4. **Project constraints** — Important rules from the repo (no comments, 4. **Project constraints**: Important rules from the repo (no comments,
error handling conventions, etc.) error handling conventions, etc.)
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete 5. **Done signal**: Use `worktree({action: "notify", ...})` when complete
Example prompt template: Example prompt template:
@@ -200,7 +200,7 @@ Your task: {{task}}
3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit
4. Implement the changes, following all acceptance criteria. 4. Implement the changes, following all acceptance criteria.
5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures. 5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures.
6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main. 6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main.
7. Push: git push origin $(git branch --show-current) 7. Push: git push origin $(git branch --show-current)
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}}) 8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
@@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
When spawning parallel tasks, check if they modify overlapping source files. When spawning parallel tasks, check if they modify overlapping source files.
Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to
cause merge conflicts. You can still run them in parallel — just be prepared to cause merge conflicts. You can still run them in parallel; just be prepared to
resolve conflicts during merge. resolve conflicts during merge.
If you want to avoid conflicts, make overlapping tasks sequential. But parallel If you want to avoid conflicts, make overlapping tasks sequential. But parallel
@@ -255,7 +255,7 @@ worktree({action: "spawn", args: {
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details." prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
}}) }})
# Review tasks — often handle yourself # Review tasks: often handle yourself
# If level: review, verify the acceptance criteria against the codebase # If level: review, verify the acceptance criteria against the codebase
# directly instead of spawning a new agent # directly instead of spawning a new agent
``` ```
@@ -266,7 +266,7 @@ worktree({action: "spawn", args: {
The notification system works well. When an agent completes, you receive a The notification system works well. When an agent completes, you receive a
notification in your session. When an anomaly is detected, you receive an alert. notification in your session. When an anomaly is detected, you receive an alert.
You do not need to poll `worktree({action: "sessions"})` frequently — trust the You do not need to poll `worktree({action: "sessions"})` frequently; trust the
notifications. notifications.
Check `worktree({action: "sessions"})` when: Check `worktree({action: "sessions"})` when:
@@ -312,7 +312,7 @@ Use these when:
When a task has `level: review`, verify the acceptance criteria yourself instead When a task has `level: review`, verify the acceptance criteria yourself instead
of spawning a new agent. Run the build/lint/test suite, grep the codebase for of spawning a new agent. Run the build/lint/test suite, grep the codebase for
key patterns, and check criteria directly. Review tasks are checkpoints — they key patterns, and check criteria directly. Review tasks are checkpoints; they
don't produce code changes. don't produce code changes.
Only spawn a review task as an agent if the review requires extensive manual Only spawn a review task as an agent if the review requires extensive manual
@@ -321,7 +321,7 @@ inspection of many files.
## Task File Handling ## Task File Handling
Task files (`tasks/*.md`) are coordination state. They live in the repo for Task files (`tasks/*.md`) are coordination state. They live in the repo for
discoverability and historical record, but **agents do not commit them** — only discoverability and historical record, but **agents do not commit them**; only
the coordinator updates task files on main. the coordinator updates task files on main.
### Why Agents Don't Commit Task Files ### Why Agents Don't Commit Task Files
@@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires:
what happened what happened
2. Send guidance via `worktree({action: "message", ...})` if you can help 2. Send guidance via `worktree({action: "message", ...})` if you can help
3. Abort via `worktree({action: "abort", ...})` if unrecoverable 3. Abort via `worktree({action: "abort", ...})` if unrecoverable
4. Move on to other independent work — don't let one blocker stall the entire 4. Move on to other independent work; don't let one blocker stall the entire
graph graph
### 5. Resolve Merge Conflicts Yourself (Usually) ### 5. Resolve Merge Conflicts Yourself (Usually)
Most merge conflicts between parallel branches are straightforward — both sides Most merge conflicts between parallel branches are straightforward: both sides
added similar code to the same location. Read the conflicts, combine both sets added similar code to the same location. Read the conflicts, combine both sets
of changes, validate, and commit. Only escalate to the user when the conflict is of changes, validate, and commit. Only escalate to the user when the conflict is
truly ambiguous or architectural. truly ambiguous or architectural.
@@ -418,7 +418,7 @@ After merging and pushing:
```text ```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}}) worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
``` ```
The `remote: true` flag handles remote branch deletion automatically — no The `remote: true` flag handles remote branch deletion automatically; no
separate `git push origin --delete` needed. separate `git push origin --delete` needed.
Don't let stale branches accumulate. Don't let stale branches accumulate.
+33 -33
View File
@@ -10,7 +10,7 @@ graph.
## Your Environment ## Your Environment
**You are in a worktree.** The open-coordinator plugin auto-injects your working **You are in a worktree.** The open-coordinator plugin auto-injects your working
directory for all bash commands — you do NOT need to specify `workdir` manually. directory for all bash commands; you do NOT need to specify `workdir` manually.
**Verify your worktree (optional):** **Verify your worktree (optional):**
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
worktree. This means: worktree. This means:
```bash ```bash
# ✅ CORRECT — workdir is auto-injected # ✅ CORRECT: workdir is auto-injected
cargo test cargo test
# ✅ ALSO CORRECT — explicit workdir still works # ✅ ALSO CORRECT: explicit workdir still works
bash({ command: "cargo test", workdir: "/path/to/worktree" }) bash({ command: "cargo test", workdir: "/path/to/worktree" })
``` ```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles **Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
routing. routing.
## Workflow ## Workflow
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
### 5. Commit and Notify ### 5. Commit and Notify
```bash ```bash
# Stage only source code — NOT task files # Stage only source code (NOT task files)
git add src/ test/ docs/ # or specific files as appropriate git add src/ test/ docs/ # or specific files as appropriate
git commit -m "feat(<task-id>): <description>" git commit -m "feat(<task-id>): <description>"
git push origin $(git branch --show-current) git push origin $(git branch --show-current)
@@ -178,7 +178,7 @@ When task becomes untendable:
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}}) worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
``` ```
3. **Commit any partial source code progress** if it's coherent (you may not 3. **Commit any partial source code progress** if it's coherent (you may not
have any — that's fine) have any; that's fine)
4. **Push your branch** so the coordinator can inspect your work if needed 4. **Push your branch** so the coordinator can inspect your work if needed
5. **Exit** - coordinator handles escalation 5. **Exit** - coordinator handles escalation
@@ -194,13 +194,13 @@ If NOT in worktree:
When available, use memory tools to manage your context: When available, use memory tools to manage your context:
- `memory({tool: "context"})` — check context window usage, especially during - `memory({tool: "context"})`: check context window usage, especially during
long implementations long implementations
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous - `memory({tool: "messages", args: {sessionId: "..."}})`: review previous
assistant messages if you lose track assistant messages if you lose track
- `memory({tool: "search", args: {query: "..."}})` — search past conversations - `memory({tool: "search", args: {query: "..."}})`: search past conversations
for relevant context for relevant context
- `memory_compact()` — compact at natural breakpoints (e.g., after completing a - `memory_compact()`: compact at natural breakpoints (e.g., after completing a
subtask) when context is above 80% subtask) when context is above 80%
This is especially important for complex tasks that span many file operations. This is especially important for complex tasks that span many file operations.
@@ -209,34 +209,34 @@ This is especially important for complex tasks that span many file operations.
Read `AGENTS.md` at project root for full details. Key rules: Read `AGENTS.md` at project root for full details. Key rules:
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`) 1. **No comments in code**. Per project convention. Doc comments (`///`, `//!`)
are fine and expected on public API. Inline `//` comments only when the user are fine and expected on public API. Inline `//` comments only when the user
asks or when a non-obvious safety/correctness constraint would otherwise be asks or when a non-obvious safety/correctness constraint would otherwise be
missed (e.g., "zero-length stdin is the EOF sentinel — the codec does not missed (e.g., "zero-length stdin is the EOF sentinel: the codec does not
special-case it; the adapter interprets `length == 0` chunks"). special-case it; the adapter interprets `length == 0` chunks").
2. **Error handling** — `thiserror` for library error types (`TtyError`, 2. **Error handling**: `thiserror` for library error types (`TtyError`,
`NegotiateError`, `WireError`; `HandlerError`/`StreamError` come from `NegotiateError`, `WireError`; `HandlerError`/`StreamError` come from
alkcall::core). No panics in library code. No `unwrap()` or `expect()` alkcall::core). No panics in library code. No `unwrap()` or `expect()`
outside tests. For poisoned `RwLock`/`Mutex`, use outside tests. For poisoned `RwLock`/`Mutex`, use
`unwrap_or_else(|e| e.into_inner())`. `unwrap_or_else(|e| e.into_inner())`.
3. **`tokio` is the async runtime** — all I/O is async. The adapter's 3. **`tokio` is the async runtime**: all I/O is async. The adapter's
per-session pumps, the channels integration, and `TtySession` are all async. per-session pumps, the channels integration, and `TtySession` are all async.
Use `tokio::sync` primitives (`oneshot`, `mpsc`) for request/exit Use `tokio::sync` primitives (`oneshot`, `mpsc`) for request/exit
correlation and stdout/stderr streams. The PTY bridge in `src/local/pty.rs` correlation and stdout/stderr streams. The PTY bridge in `src/local/pty.rs`
is the one exception: three dedicated std threads feeding tokio mpsc/oneshot is the one exception: three dedicated std threads feeding tokio mpsc/oneshot
channels (the `portable_pty` API is blocking) — behind the `local` feature. channels (the `portable_pty` API is blocking). Behind the `local` feature.
4. **WASM target is load-bearing** — the default crate (no `local` feature) 4. **WASM target is load-bearing**: the default crate (no `local` feature)
MUST compile to `wasm32-unknown-unknown`. This is what makes the downstream MUST compile to `wasm32-unknown-unknown`. This is what makes the downstream
TS/Python adapter story work. `Cargo.toml` uses TS/Python adapter story work. `Cargo.toml` uses
`tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }` `tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }`
(the wasm-clean subset alkcall uses); `local` adds `tokio/process` + (the wasm-clean subset alkcall uses); `local` adds `tokio/process` +
`tokio/rt-multi-thread`. **Do NOT use `features = ["full"]`** — it pulls in `tokio/rt-multi-thread`. **Do NOT use `features = ["full"]`**: it pulls in
`signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The adapter's `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The adapter's
`tokio::spawn` is fine on wasm; the `local` module's std threads, `tokio::spawn` is fine on wasm; the `local` module's std threads,
`tokio::process::Command`, and `libc::kill` are the non-wasm parts, and `tokio::process::Command`, and `libc::kill` are the non-wasm parts, and
they're feature-gated. they're feature-gated.
5. **Wire formats are stable** — two one-way doors: 5. **Wire formats are stable**: two one-way doors:
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE][payload]`) — - **5-byte chunk header** (`[stream_type: u8][length: u32 BE][payload]`),
the raw chunk codec in `wire.rs`. Five stream types: `STREAM_STDIN=0`, the raw chunk codec in `wire.rs`. Five stream types: `STREAM_STDIN=0`,
`STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_CTRL_IN=3`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_CTRL_IN=3`,
`STREAM_CTRL_OUT=4`. Zero-length data chunks are sentinels (zero-length `STREAM_CTRL_OUT=4`. Zero-length data chunks are sentinels (zero-length
@@ -244,14 +244,14 @@ Read `AGENTS.md` at project root for full details. Key rules:
control chunks are never zero-length. Changing the header, the control chunks are never zero-length. Changing the header, the
stream-type values, or the sentinel semantics breaks all peers. stream-type values, or the sentinel semantics breaks all peers.
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON - **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
`NegotiateRequest` body) — self-contained, not reused from alkcall's `NegotiateRequest` body): self-contained, not reused from alkcall's
`EventEnvelope` framing. The `NegotiateRequest` JSON shape is `EventEnvelope` framing. The `NegotiateRequest` JSON shape is
wire-stable once consumers exist. wire-stable once consumers exist.
Per ADR-093: TTY always uses its 5-byte format, even inside channels. The Per ADR-093: TTY always uses its 5-byte format, even inside channels. The
channels layer strips its 8-byte header transparently — the same `wire.rs` channels layer strips its 8-byte header transparently; the same `wire.rs`
code runs in both direct (`alk/tty`) and channels (`alk/channels`) modes; code runs in both direct (`alk/tty`) and channels (`alk/channels`) modes;
only the `BiStream` source differs. only the `BiStream` source differs.
6. **`TtyBackend` trait shape is a one-way door** — the trait in 6. **`TtyBackend` trait shape is a one-way door**: the trait in
`src/backend.rs` is the inversion point between the wire-format adapter and `src/backend.rs` is the inversion point between the wire-format adapter and
the backend crates (alktty's own `local` feature module, future the backend crates (alktty's own `local` feature module, future
`alknet-docker`, `alknet-ssh`). alktty defines the trait; the backends `alknet-docker`, `alknet-ssh`). alktty defines the trait; the backends
@@ -259,10 +259,10 @@ Read `AGENTS.md` at project root for full details. Key rules:
keyed by the negotiation frame's `backend` string; backends produce keyed by the negotiation frame's `backend` string; backends produce
`TtyHandle`s, they do not write to the wire. `TtyError` is `TtyHandle`s, they do not write to the wire. `TtyError` is
`#[non_exhaustive]` so new variants are additive. `#[non_exhaustive]` so new variants are additive.
7. **Producer/consumer, not server/client** — both sides of a `alk/tty` or 7. **Producer/consumer, not server/client**: both sides of a `alk/tty` or
`alk/channels` connection can initiate. Use "producer"/"consumer" or `alk/channels` connection can initiate. Use "producer"/"consumer" or
"accept side"/"connect side," not "server"/"client." "accept side"/"connect side," not "server"/"client."
8. **`local` feature isolation** — the `local` module is the only non-wasm 8. **`local` feature isolation**: the `local` module is the only non-wasm
part of the crate. The shared/producer/consumer modules (`wire`, `control`, part of the crate. The shared/producer/consumer modules (`wire`, `control`,
`negotiation`, `backend`, `adapter`, `channels`, `session`) MUST NOT import `negotiation`, `backend`, `adapter`, `channels`, `session`) MUST NOT import
from `crate::local` or reference any `local`-only type (`LocalTtyBackend`, from `crate::local` or reference any `local`-only type (`LocalTtyBackend`,
@@ -272,36 +272,36 @@ Read `AGENTS.md` at project root for full details. Key rules:
`std::thread`, or `libc` (outside the existing `cfg(unix)` calls in `std::thread`, or `libc` (outside the existing `cfg(unix)` calls in
`control.rs`/`pipe.rs`) silently breaks the downstream TS/Python adapter `control.rs`/`pipe.rs`) silently breaks the downstream TS/Python adapter
story. story.
9. **Module structure** — one module per file under `src/`, re-exported from 9. **Module structure**: one module per file under `src/`, re-exported from
`src/lib.rs`. Producer half: `src/adapter.rs` + `src/channels.rs`. Consumer `src/lib.rs`. Producer half: `src/adapter.rs` + `src/channels.rs`. Consumer
half: `src/session.rs`. Shared: `src/wire.rs`, `src/control.rs`, half: `src/session.rs`. Shared: `src/wire.rs`, `src/control.rs`,
`src/negotiation.rs`, `src/backend.rs`. The `src/local/` module is behind `src/negotiation.rs`, `src/backend.rs`. The `src/local/` module is behind
the `local` feature and never imported from the shared/producer/consumer the `local` feature and never imported from the shared/producer/consumer
modules — only re-exported from `lib.rs` under `#[cfg(feature = "local")]`. modules, only re-exported from `lib.rs` under `#[cfg(feature = "local")]`.
10. **BAST document, not alktype dependency** — `docs/architecture/tty-bast.md` 10. **BAST document, not alktype dependency**: `docs/architecture/tty-bast.md`
(Phase 4, not yet written) will be the machine-readable spec for the (Phase 4, not yet written) will be the machine-readable spec for the
`alk/tty` wire format, conforming to the BAST meta-schema at `alk/tty` wire format, conforming to the BAST meta-schema at
`https://alk.dev/bast/v1/schema`. alktty does **not** depend on alktype; `https://alk.dev/bast/v1/schema`. alktty does **not** depend on alktype;
the hand-rolled `ChunkReader`/`ChunkWriter` in `wire.rs` is the runtime the hand-rolled `ChunkReader`/`ChunkWriter` in `wire.rs` is the runtime
codec, the BAST document is the human-readable contract. If runtime codec, the BAST document is the human-readable contract. If runtime
validation against the BAST becomes desirable later, alktype becomes an validation against the BAST becomes desirable later, alktype becomes an
optional dep — do not roll your own offset map or validator for complex optional dep; do not roll your own offset map or validator for complex
formats. formats.
11. **Feature flags** — the only feature is `local` (default = `[]`). `local` 11. **Feature flags**: the only feature is `local` (default = `[]`). `local`
is inherently non-wasm; enabling it on wasm is a build error by design. is inherently non-wasm; enabling it on wasm is a build error by design.
Verify both `cargo test` (default) and `cargo test --all-features` pass. Verify both `cargo test` (default) and `cargo test --all-features` pass.
Do not add a feature flag that pulls non-wasm deps into the default crate. Do not add a feature flag that pulls non-wasm deps into the default crate.
12. **Access control** — the direct-ALPN path keeps the ad-hoc scope check 12. **Access control**: the direct-ALPN path keeps the ad-hoc scope check
(`has_scope(identity, TTY_OPEN_SCOPE)`) and optionally consults (`has_scope(identity, TTY_OPEN_SCOPE)`) and optionally consults
`OwnershipProvider` (`provider.owns(id_ref, kind, &id, "tty")` — the 4-arg `OwnershipProvider` (`provider.owns(id_ref, kind, &id, "tty")`, the 4-arg
shape). The channels path gets both for free via shape). The channels path gets both for free via
`ChannelCore::register_openable`, which wires `AccessControl` into the `ChannelCore::register_openable`, which wires `AccessControl` into the
operation spec. `OwnershipStore::record(&self, identity, resource_type, operation spec. `OwnershipStore::record(&self, identity, resource_type,
resource_id)` is the 3-arg shape (alkcall dropped the old `action` arg). resource_id)` is the 3-arg shape (alkcall dropped the old `action` arg).
13. **Naming conventions** — Rust standard: `snake_case` for functions/variables/ 13. **Naming conventions**: Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`). constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
14. **No `unsafe`** — the crate has zero `unsafe` blocks. The PTY bridge's 14. **No `unsafe`**: the crate has zero `unsafe` blocks. The PTY bridge's
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls are safe `libc` `libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls are safe `libc`
crate APIs (not `unsafe` blocks in this crate). Do not introduce `unsafe` crate APIs (not `unsafe` blocks in this crate). Do not introduce `unsafe`
for performance. for performance.
+4 -4
View File
@@ -10,10 +10,10 @@ approaches.
## Your Environment ## Your Environment
**You are in a research worktree.** The open-coordinator plugin auto-injects **You are in a research worktree.** The open-coordinator plugin auto-injects
your working directory for all bash commands — you do NOT need to specify your working directory for all bash commands; you do NOT need to specify
`workdir` manually. `workdir` manually.
- The current directory IS the worktree — do NOT navigate elsewhere - The current directory IS the worktree; do NOT navigate elsewhere
- You are on branch `research/<task-id>` - You are on branch `research/<task-id>`
- Use relative paths for all file operations - Use relative paths for all file operations
@@ -56,11 +56,11 @@ session is mapped to a worktree. This means you can just run commands without
specifying workdir: specifying workdir:
```bash ```bash
# ✅ CORRECT — workdir is auto-injected # ✅ CORRECT: workdir is auto-injected
cargo test cargo test
``` ```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles **Do NOT use `cd` in commands**: it doesn't persist and the plugin handles
routing. routing.
## When You Are Spawned ## When You Are Spawned
+89 -62
View File
@@ -17,7 +17,7 @@ The workflow:
1. Make the change 1. Make the change
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`, 2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check`, `cargo doc --no-deps` if docs changed `cargo fmt --check`, `cargo doc --no-deps` if docs changed
3. Inspect `git status` and `git diff` before staging — stage only the 3. Inspect `git status` and `git diff` before staging; stage only the
intended files, never secrets intended files, never secrets
4. Write a concise commit message matching the repo style (see `git log 4. Write a concise commit message matching the repo style (see `git log
--oneline -10`). For multi-point changes, use a summary line plus a --oneline -10`). For multi-point changes, use a summary line plus a
@@ -25,13 +25,18 @@ The workflow:
5. `git push origin main` 5. `git push origin main`
6. Report the commit hash and the verification summary 6. Report the commit hash and the verification summary
Exceptions — **do not** commit or push without asking: Spawned subagents (task-tool launches) never commit or push on their
own: they finish their assigned edits and report, and the session that
spawned them commits once, after combined verification, so history
stays reviewable as one logical change.
Exceptions (**do not** commit or push without asking):
- The change is exploratory / speculative (you're not sure the user wants - The change is exploratory / speculative (you're not sure the user wants
it kept) it kept)
- The user is actively reviewing the diff and may ask for changes - The user is actively reviewing the diff and may ask for changes
- The change touches the wire format or the `TtyBackend` trait shape - The change touches the wire format or the `TtyBackend` trait shape
(one-way doors — see "Wire formats are stable" and "`TtyBackend` trait (one-way doors; see "Wire formats are stable" and "`TtyBackend` trait
shape is a one-way door" below; the 5-byte chunk header, the shape is a one-way door" below; the 5-byte chunk header, the
negotiation-frame layout, the `NegotiateRequest` JSON shape, and the negotiation-frame layout, the `NegotiateRequest` JSON shape, and the
trait signature are wire-stable contracts once consumers exist) trait signature are wire-stable contracts once consumers exist)
@@ -39,7 +44,7 @@ Exceptions — **do not** commit or push without asking:
commit, or skipping hooks commit, or skipping hooks
Never commit secrets, keys, or credentials. If a commit fails or hooks Never commit secrets, keys, or credentials. If a commit fails or hooks
reject it, fix the issue and create a new commit — do not amend the reject it, fix the issue and create a new commit; do not amend the
failed one. failed one.
Git identity is preconfigured (`glm-5.2 <glm-5.2@alk.dev>`). Do not Git identity is preconfigured (`glm-5.2 <glm-5.2@alk.dev>`). Do not
@@ -47,7 +52,7 @@ change `git config`, skip hooks, or use `git commit -i`.
## Project Conventions (Rust / TTY protocol crate) ## Project Conventions (Rust / TTY protocol crate)
This is the `alk/tty` terminal-session protocol crate — a This is the `alk/tty` terminal-session protocol crate, a
producer/consumer protocol crate on top of alkcall channels. It folds producer/consumer protocol crate on top of alkcall channels. It folds
the old `alknet-tty` (wire/adapter/backend-trait half) and the old `alknet-tty` (wire/adapter/backend-trait half) and
`alknet-tty-local` (`portable_pty` + `tokio::process` half) from the `alknet-tty-local` (`portable_pty` + `tokio::process` half) from the
@@ -61,46 +66,46 @@ implementation agents.
project-wide convention. Doc comments (`///`, `//!`) are fine and project-wide convention. Doc comments (`///`, `//!`) are fine and
expected on public API. Inline `//` comments only when the user asks expected on public API. Inline `//` comments only when the user asks
or when a non-obvious safety/correctness constraint would otherwise be or when a non-obvious safety/correctness constraint would otherwise be
missed (e.g., "zero-length stdin is the EOF sentinel — the codec does missed (e.g., "zero-length stdin is the EOF sentinel: the codec does
not special-case it; the adapter interprets `length == 0` chunks"). not special-case it, and the adapter interprets `length == 0` chunks").
2. **Error handling** — `thiserror` for library error types 2. **Error handling**: `thiserror` for library error types
(`TtyError`, `NegotiateError`, `WireError`, `HandlerError`/`StreamError` (`TtyError`, `NegotiateError`, `WireError`, `HandlerError`/`StreamError`
come from alkcall::core). No panics in library code. No `unwrap()` or come from alkcall::core). No panics in library code. No `unwrap()` or
`expect()` outside tests. If you reach for `unwrap`, the error path `expect()` outside tests. If you reach for `unwrap`, the error path
wasn't specified — stop and decide what should actually happen. For wasn't specified; stop and decide what should actually happen. For
poisoned `RwLock`/`Mutex`, use poisoned `RwLock`/`Mutex`, use
`unwrap_or_else(|e| e.into_inner())` so a panic in one operation does `unwrap_or_else(|e| e.into_inner())` so a panic in one operation does
not cascade to other operations. not cascade to other operations.
3. **`tokio` is the async runtime** — all I/O is async. The adapter's 3. **`tokio` is the async runtime**: all I/O is async. The adapter's
per-session pumps, the channels integration, and `TtySession` are all per-session pumps, the channels integration, and `TtySession` are all
async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
request/exit correlation and stdout/stderr streams. The PTY bridge in request/exit correlation and stdout/stderr streams. The PTY bridge in
`src/local/pty.rs` is the one exception: it uses three dedicated std `src/local/pty.rs` is the one exception: it uses three dedicated std
threads feeding tokio mpsc/oneshot channels (the `portable_pty` API threads feeding tokio mpsc/oneshot channels (the `portable_pty` API
is blocking) — and it's behind the `local` feature. is blocking), and it's behind the `local` feature.
4. **WASM target is load-bearing** — the default crate (no `local` 4. **WASM target is load-bearing**: the default crate (no `local`
feature) MUST compile to `wasm32-unknown-unknown`. This is what makes feature) MUST compile to `wasm32-unknown-unknown`. This is what makes
the downstream TS/Python adapter story work (a wasm-compiled alktty the downstream TS/Python adapter story work (a wasm-compiled alktty
is the protocol layer for a sandboxed adapter). `Cargo.toml` uses is the protocol layer for a sandboxed adapter). `Cargo.toml` uses
`tokio = { default-features = false, features = ["rt", "sync", `tokio = { default-features = false, features = ["rt", "sync",
"io-util", "macros"] }` (the wasm-clean subset alkcall uses) and "io-util", "macros"] }` (the wasm-clean subset alkcall uses) and
`local` adds `tokio/process` + `tokio/rt-multi-thread`. **Do NOT use `local` adds `tokio/process` + `tokio/rt-multi-thread`. **Do NOT use
`features = ["full"]`** — it pulls in `signal`/`fs`/`net` which break `features = ["full"]`**, because it pulls in `signal`/`fs`/`net`, which break
`wasm32-unknown-unknown`. The adapter's `tokio::spawn` for per-session `wasm32-unknown-unknown`. The adapter's `tokio::spawn` for per-session
pumps is fine on wasm (the wasm tokio runtime supports `spawn`); the pumps is fine on wasm (the wasm tokio runtime supports `spawn`); the
`local` module's std threads, `tokio::process::Command`, and `local` module's std threads, `tokio::process::Command`, and
`libc::kill` are the non-wasm parts, and they're feature-gated. `libc::kill` are the non-wasm parts, and they're feature-gated.
`libc` stays under `cfg(unix)` (it's already there for `libc` stays under `cfg(unix)` (it's already there for
`signal_from_name` and the pipe-mode `kill` path — neither runs on `signal_from_name` and the pipe-mode `kill` path, neither of which runs on
wasm because the only callers are in `local`). wasm because the only callers are in `local`).
5. **Wire formats are stable** — two wire formats live in this crate, 5. **Wire formats are stable**. Two wire formats live in this crate,
both one-way doors: both one-way doors:
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE] - **5-byte chunk header** (`[stream_type: u8][length: u32 BE]
[payload]`) — the raw chunk codec in `wire.rs`. Five stream types: [payload]`), the raw chunk codec in `wire.rs`. Five stream types:
`STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`,
`STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. Zero-length data chunks are `STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. Zero-length data chunks are
sentinels (zero-length stdin = EOF from client; zero-length stdout sentinels (zero-length stdin = EOF from client; zero-length stdout
@@ -108,7 +113,7 @@ implementation agents.
Changing the header, the stream-type values, or the sentinel Changing the header, the stream-type values, or the sentinel
semantics breaks all peers. See ADR-052 (ported as alktty ADR-001). semantics breaks all peers. See ADR-052 (ported as alktty ADR-001).
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON - **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
`NegotiateRequest` body) — self-contained per ADR-057 (ported as `NegotiateRequest` body), self-contained per ADR-057 (ported as
alktty ADR-006), not reused from alkcall's `EventEnvelope` framing alktty ADR-006), not reused from alkcall's `EventEnvelope` framing
(the payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's (the payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope`). `FrameFramedReader` is hardcoded to deserialize `EventEnvelope`).
@@ -117,11 +122,11 @@ implementation agents.
Per ADR-093 (ported as alktty ADR-008): TTY always uses its 5-byte Per ADR-093 (ported as alktty ADR-008): TTY always uses its 5-byte
format, even inside channels. The channels layer strips its 8-byte format, even inside channels. The channels layer strips its 8-byte
header and hands TTY the payload transparently — the same `wire.rs` header and hands TTY the payload transparently; the same `wire.rs`
code runs in both direct (`alk/tty`) and channels (`alk/channels`) code runs in both direct (`alk/tty`) and channels (`alk/channels`)
modes; only the `BiStream` source differs. modes; only the `BiStream` source differs.
6. **`TtyBackend` trait shape is a one-way door** — the trait 6. **`TtyBackend` trait shape is a one-way door**: the trait
(`src/backend.rs`) is the inversion point between the wire-format (`src/backend.rs`) is the inversion point between the wire-format
adapter and the backend crates (alktty's own `local` feature module, adapter and the backend crates (alktty's own `local` feature module,
future `alknet-docker`, `alknet-ssh`). alktty defines the trait; the future `alknet-docker`, `alknet-ssh`). alktty defines the trait; the
@@ -134,89 +139,90 @@ implementation agents.
(two-way-door extension within the one-way trait shape). See ADR-053 (two-way-door extension within the one-way trait shape). See ADR-053
(ported as alktty ADR-002). (ported as alktty ADR-002).
7. **Producer/consumer, not server/client** — inherited from alkcall. 7. **Producer/consumer, not server/client** (inherited from alkcall).
Both sides of a `alk/tty` or `alk/channels` connection can initiate. Both sides of a `alk/tty` or `alk/channels` connection can initiate.
A producer exposes TTY (direct ALPN via `TtyAdapter::ProtocolHandler`, A producer exposes TTY (direct ALPN via `TtyAdapter::ProtocolHandler`,
or via `channels::register_openable`); a consumer opens a session or via `channels::register_openable`); a consumer opens a session
(`TtySession::connect_direct` / `open_via_channels`). Both sides can (`TtySession::connect_direct` / `open_via_channels`). Both sides can
be both simultaneously — connection direction (who opened it) is be both simultaneously: connection direction (who opened it) is
independent of TTY direction (who's the shell, who's the client). independent of TTY direction (who's the shell, who's the client).
Avoid "server" and "client" framing in docs and API names; use Avoid "server" and "client" framing in docs and API names; use
"producer" and "consumer," or "accept side" / "connect side" for the "producer" and "consumer," or "accept side" / "connect side" for the
connection-establishment half specifically. connection-establishment half specifically.
8. **Module structure** — one module per file under `src/`, re-exported 8. **Module structure**: one module per file under `src/`, re-exported
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
crate has three concerns: crate has three concerns:
- **Producer half** — `src/adapter.rs` (`TtyAdapter` + `drive_session` - **Producer half**: `src/adapter.rs` (`TtyAdapter` + `drive_session`
for direct `alk/tty`) and `src/channels.rs` (`register_openable` for direct `alk/tty`) and `src/channels.rs` (`register_openable`
helper + `TtyOpenHandler` for the `alk/channels` multiplexed path). helper + `TtyOpenHandler` for the `alk/channels` multiplexed path).
- **Consumer half** — `src/session.rs` (`TtySession` typed client - **Consumer half**: `src/session.rs` (`TtySession` typed client
with `connect_direct` and `open_via_channels` constructors). with `connect_direct` and `open_via_channels` constructors).
- **Shared** — `src/wire.rs` (chunk codec), `src/control.rs` - **Shared**: `src/wire.rs` (chunk codec), `src/control.rs`
(control-message enum + `signal_from_name`), `src/negotiation.rs` (control-message enum + `signal_from_name`), `src/negotiation.rs`
(negotiation frame + `NegotiateRequest`), `src/backend.rs` (negotiation frame + `NegotiateRequest`), `src/backend.rs`
(`TtyBackend` trait, `TtyHandle`, `TtyParams`, `TtyError`). (`TtyBackend` trait, `TtyHandle`, `TtyParams`, `TtyError`).
The `src/local/` module (the folded `alknet-tty-local`) is behind the The `src/local/` module (the folded `alknet-tty-local`) is behind the
`local` feature and never imported from the shared/producer/consumer `local` feature and never imported from the shared/producer/consumer
modules — only re-exported from `lib.rs` under modules; it is only re-exported from `lib.rs` under
`#[cfg(feature = "local")]`. `#[cfg(feature = "local")]`.
9. **`local` feature isolation** — the `local` module is the only 9. **`local` feature isolation**: the `local` module is the only
non-wasm part of the crate. The shared/producer/consumer modules non-wasm part of the crate. The shared/producer/consumer modules
(`wire`, `control`, `negotiation`, `backend`, `adapter`, `channels`, (`wire`, `control`, `negotiation`, `backend`, `adapter`, `channels`,
`session`) MUST NOT import from `crate::local` or reference any `session`) MUST NOT import from `crate::local` or reference any
`local`-only type (`LocalTtyBackend`, `portable_pty`, `local`-only type (`LocalTtyBackend`, `portable_pty`,
`tokio::process::Command`). The producer's `TtyAdapter` takes a `tokio::process::Command`). The producer's `TtyAdapter` takes a
`HashMap<String, Arc<dyn TtyBackend>>` — the local backend is `HashMap<String, Arc<dyn TtyBackend>>`; the local backend is
injected at the assembly layer, not wired in by the adapter. A injected at the assembly layer, not wired in by the adapter. A
regression where a non-`local` module pulls in `portable_pty`, regression where a non-`local` module pulls in `portable_pty`,
`tokio::process`, `std::thread`, or `libc` (outside the existing `tokio::process`, `std::thread`, or `libc` (outside the existing
`cfg(unix)` calls in `control.rs`/`pipe.rs`) silently breaks the `cfg(unix)` calls in `control.rs`/`pipe.rs`) silently breaks the
downstream TS/Python adapter story. downstream TS/Python adapter story.
10. **BAST document for the wire format** — `docs/architecture/tty-bast.md` 10. **BAST document for the wire format**: `docs/architecture/tty-bast.md`
(Phase 4, not yet written) will be the machine-readable spec for the (Phase 4, not yet written) will be the machine-readable spec for the
`alk/tty` wire format, conforming to the BAST meta-schema at `alk/tty` wire format, conforming to the BAST meta-schema at
`https://alk.dev/bast/v1/schema`. BAST is plain JSON — no `https://alk.dev/bast/v1/schema`. BAST is plain JSON; no
dependency required to author or consume it. alktty does **not** dependency is required to author or consume it. alktty does **not**
depend on alktype; the hand-rolled `ChunkReader`/`ChunkWriter` in depend on alktype; the hand-rolled `ChunkReader`/`ChunkWriter` in
`wire.rs` is the runtime codec, the BAST document is the `wire.rs` is the runtime codec, the BAST document is the
human-readable contract that describes what those types round-trip. human-readable contract that describes what those types round-trip.
If runtime validation against the BAST becomes desirable later, If runtime validation against the BAST becomes desirable later,
alktype becomes an optional dep and the BAST document is already alktype becomes an optional dep and the BAST document is already
there to feed it. Do not roll your own offset map or validator for there to feed it. Do not roll your own offset map or validator for
complex formats — use alktype (as an optional dep) when that complex formats; use alktype (as an optional dep) when that
surfaces. surfaces.
11. **Access control** — the producer's direct-ALPN path keeps the 11. **Access control**. The producer's direct-ALPN path keeps the
existing ad-hoc scope check (`has_scope(identity, TTY_OPEN_SCOPE)`) existing ad-hoc scope check (`has_scope(identity, TTY_OPEN_SCOPE)`)
for the scope gate and optionally consults `OwnershipProvider` for for the scope gate and optionally consults `OwnershipProvider` for
the resource-ownership check (`provider.owns(id_ref, kind, &id, the resource-ownership check (`provider.owns(id_ref, kind, &id,
"tty")` — the 4-arg shape alkcall adopted). The channels path gets "tty")`, the 4-arg shape alkcall adopted). The channels path gets
the scope gate for free via `ChannelCore::register_openable` the scope gate for free via `ChannelCore::register_openable`
(which wires `AccessControl` into the operation spec — the (which wires `AccessControl` into the operation spec, so the
registry runs the ACL before the wrapper) and runs the ownership registry runs the ACL before the wrapper) and runs the ownership
check in the establisher (ADR-010 — it is part of the check in the establisher (ADR-010: it is part of the
establishment phase since alkcall 0.5.0 ADR-049, so a denial is establishment phase since alkcall 0.5.0 ADR-049, so a denial is
`channel:open_failed`, not an in-band frame; the pump handler `channel:open_failed`, not an in-band frame; since alkcall 0.6.0
only allocates and pumps). Terminal sessions are resources per ADR-010 §2A the establisher also allocates, and the pump handler
only pumps). Terminal sessions are resources per
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self, ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
identity, resource_type, resource_id)` is the 3-arg shape (alkcall identity, resource_type, resource_id)` is the 3-arg shape (alkcall
dropped the old `action` arg). dropped the old `action` arg).
12. **Feature flags** — the only feature is `local` (default = `[]`). 12. **Feature flags**: the only feature is `local` (default = `[]`).
`local` is inherently non-wasm (`portable-pty` + `tokio::process` `local` is inherently non-wasm (`portable-pty` + `tokio::process`
need a real OS); enabling `local` on wasm is a build error by need a real OS); enabling `local` on wasm is a build error by
design. Verify both `cargo test` (default) and design. Verify both `cargo test` (default) and
`cargo test --all-features` pass. Do not add a feature flag that `cargo test --all-features` pass. Do not add a feature flag that
pulls non-wasm deps into the default crate. pulls non-wasm deps into the default crate.
13. **Naming** — Rust standard: `snake_case` for functions/variables/ 13. **Naming** follows the Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`). constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
14. **No `unsafe`** — the crate has zero `unsafe` blocks outside the 14. **No `unsafe`**: the crate has zero `unsafe` blocks outside the
`local` feature module's signal-forwarding calls. The PTY bridge's `local` feature module's signal-forwarding calls. The PTY bridge's
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls (and the `libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls (and the
pipe-mode `libc::kill(pid, sig)` / `libc::kill(pid, SIGKILL)` pipe-mode `libc::kill(pid, sig)` / `libc::kill(pid, SIGKILL)`
@@ -227,6 +233,17 @@ implementation agents.
the pattern. Do not introduce `unsafe` for performance, and do not the pattern. Do not introduce `unsafe` for performance, and do not
add `unsafe` outside the `local` module's `libc::kill` calls. add `unsafe` outside the `local` module's `libc::kill` calls.
15. **No em dashes in prose.** Do not use the em dash in source
doc comments, markdown docs, agent prompts under `.opencode/agents/`,
or commit messages. Restructure instead: split the sentence, use
parentheses, commas, a colon, or a semicolon, whichever reads
naturally, and vary the choice rather than converting every instance
the same way. A spaced hyphen (` - `) is not an acceptable
substitute; it reads as a different but equally recognizable writing
tell. The same rule covers en dashes used as clause separators,
though en dashes inside numeric ranges keep their normal meaning.
Hyphens in compound words (`wasm-clean`, `tty-wire`) are untouched.
## Verification Commands ## Verification Commands
Run these before committing. All must pass. Run these before committing. All must pass.
@@ -237,19 +254,27 @@ cargo clippy --all-targets -- -D warnings
cargo fmt --check cargo fmt --check
cargo doc --no-deps cargo doc --no-deps
cargo test --all-features cargo test --all-features
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay: the standing fuzz gate
cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo publish --dry-run --allow-dirty # before a release cargo publish --dry-run --allow-dirty # before a release
``` ```
The wasm check is the structural guard for the "default crate is The wasm check is the structural guard for the "default crate is
wasm-clean" invariant — run it whenever a non-`local` module changes. wasm-clean" invariant; run it whenever a non-`local` module changes.
`cargo test --all-features` exercises the `local` backend (PTY mode is `cargo test --all-features` exercises the `local` backend (PTY mode is
`#[cfg(unix)]`; pipe mode is cross-platform). `#[cfg(unix)]`; pipe mode is cross-platform).
The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
seed through the same invariant functions the cargo-fuzz targets run,
on stable, no nightly needed. Run detached campaigns
(`fuzz/run-detached.sh <target>`, never foreground fuzzing) before a
release and after touching `src/wire.rs`, `src/negotiation.rs`,
`src/control.rs`, the adapter, or the session pump.
## Architecture Context ## Architecture Context
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All - `docs/plans/project-setup.md`: the current plan (phases 0–5). All
five phases are landed: Phase 0 (scaffold hygiene), Phase 1 (port five phases are landed: Phase 0 (scaffold hygiene), Phase 1 (port
core types), Phase 2 (channels integration + `TtySession`), Phase 3 core types), Phase 2 (channels integration + `TtySession`), Phase 3
(`local` backend), Phase 4 (architecture docs + BAST schema + (`local` backend), Phase 4 (architecture docs + BAST schema +
@@ -259,46 +284,46 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
referenced below (052, 053, 054, 055, 056, 057, 077, 093) are ported referenced below (052, 053, 054, 055, 056, 057, 077, 093) are ported
and renumbered into alktty's ADR range (001..009) in and renumbered into alktty's ADR range (001..009) in
`docs/architecture/decisions/`. ADR-009 (the channels open op's `docs/architecture/decisions/`. ADR-009 (the channels open op's
`input` is the negotiation) is alktty-native — it resolves review `input` is the negotiation) is alktty-native; it resolves review
#001 L1 and has no alknet original. The alknet originals at #001 L1 and has no alknet original. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
authoritative source for any ADR not yet ported — read them before authoritative source for any ADR not yet ported; read them before
non-trivial changes to the wire format, trait, or adapter. non-trivial changes to the wire format, trait, or adapter.
- Key ADRs that inform this crate's design (alknet numbers → ported - Key ADRs that inform this crate's design (alknet numbers → ported
alktty numbers, 1:1 in the order the plan lists them): alktty numbers, 1:1 in the order the plan lists them):
- ADR-052 → 001 — two-carriage wire format (JSON negotiation + raw - ADR-052 → 001, the two-carriage wire format (JSON negotiation + raw
chunks); the 5-byte chunk header. The Phase 7 control-channel chunks); the 5-byte chunk header. The Phase 7 control-channel
split (`STREAM_CTRL_IN` = 3 client→server, `STREAM_CTRL_OUT` = 4 split (`STREAM_CTRL_IN` = 3 client→server, `STREAM_CTRL_OUT` = 4
server→client) is an amendment inside this ADR, mirroring alknet server→client) is an amendment inside this ADR, mirroring alknet
(it was not a standalone ADR there either). (it was not a standalone ADR there either).
- ADR-053 → 002 — `TtyBackend` trait (the inversion point between - ADR-053 → 002, the `TtyBackend` trait (the inversion point between
wire-format adapter and backend crates) wire-format adapter and backend crates)
- ADR-054 → 003 — local backend placement. Ported with the - ADR-054 → 003, local backend placement. Ported with the
single-crate rewrite: alktty folds the local backend in behind a single-crate rewrite: alktty folds the local backend in behind a
`local` feature (resolves the alknet cyclic-dep workaround that `local` feature (resolves the alknet cyclic-dep workaround that
motivated the original sibling-crate decision; the ADR records both motivated the original sibling-crate decision; the ADR records both
the original alknet decision and the alktty consolidation). the original alknet decision and the alktty consolidation).
- ADR-055 → 004 — exit-code reporting (`{"type":"exit","code":N}` on - ADR-055 → 004: exit-code reporting (`{"type":"exit","code":N}` on
`ctrl_out`; `code: -1` on wait-failure) `ctrl_out`; `code: -1` on wait-failure)
- ADR-056 → 005 — backend cleanup on session cancel (drop of - ADR-056 → 005: backend cleanup on session cancel (drop of
`exit_code` future kills the session target; the `exit_code` future kills the session target; the
kill-on-`Drop` contract on the `TtyBackend` trait) kill-on-`Drop` contract on the `TtyBackend` trait)
- ADR-057 → 006 — negotiation framing is self-contained (not reused - ADR-057 → 006, negotiation framing is self-contained (not reused
from alkcall's `EventEnvelope` framing) from alkcall's `EventEnvelope` framing)
- ADR-077 → 007 — TTY inside channels (reversed by ADR-093/008; kept - ADR-077 → 007, TTY inside channels (reversed by ADR-093/008; kept
for historical context with its reversal notice pointing to 008) for historical context with its reversal notice pointing to 008)
- ADR-093 → 008 — TTY always uses its 5-byte format inside channels - ADR-093 → 008, TTY always uses its 5-byte format inside channels
(reverses ADR-077; channels strips its 8-byte header transparently) (reverses ADR-077; channels strips its 8-byte header transparently)
- (alktty-native) ADR-009 — the channels open op's `input` IS the - (alktty-native) ADR-009, the channels open op's `input` IS the
negotiation (no alknet original; resolves review #001 L1) negotiation (no alknet original; resolves review #001 L1)
- ADR-050 (dynamic resource ownership) is an alkcall/alknet-core ADR, - ADR-050 (dynamic resource ownership) is an alkcall/alknet-core ADR,
not a tty-specific one — it is not ported into alktty's ADR range. not a tty-specific one; it is not ported into alktty's ADR range.
The access-control work that declares against the ADR-050 model The access-control work that declares against the ADR-050 model
(scope-gate at negotiation, backend-driven `resource_id()` ownership (scope-gate at negotiation, backend-driven `resource_id()` ownership
check) is described in `tty-adapter.md` and the ADR-001/002 ported check) is described in `tty-adapter.md` and the ADR-001/002 ported
docs, which reference ADR-050 by its alknet number. docs, which reference ADR-050 by its alknet number.
- If a TODO references a "Phase 7" note or a design direction that an - If a TODO references a "Phase 7" note or a design direction that an
ADR has since decided against, the TODO is stale — remove it and ADR has since decided against, the TODO is stale; remove it and
align with the ADR. Do not implement the rejected design. align with the ADR. Do not implement the rejected design.
- The crate was ported from `/workspace/@alkdev/alknet/crates/ - The crate was ported from `/workspace/@alkdev/alknet/crates/
alknet-tty/src/` (wire, control, negotiation, backend, adapter) and alknet-tty/src/` (wire, control, negotiation, backend, adapter) and
@@ -311,9 +336,11 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`, `BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
`IdentityProvider`, `AccessControl`, `OwnershipProvider`, `IdentityProvider`, `AccessControl`, `OwnershipProvider`,
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`, `OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x — `StreamError`) come from `alkcall::core`. alkcall is v0.10.x;
breaking changes are expected at this major-zero stage; this is the breaking changes are expected at this major-zero stage; this is the
first real consumer, so we find and fix issues upstream rather than first real consumer, so we find and fix issues upstream rather than
working around them. Pin `alkcall = "0.5.0"` (the working around them. Pin `alkcall = "0.10.1"` (0.10.1 is the
channel-open establishment phase — ADR-049, review 006 E-01/N-1; adoption-window ordering fix: the `adopt_channel` install→drain
adopted by alktty ADR-010) and bump deliberately. window is atomic, so a routed chunk can no longer jump ahead of
parked early arrivals or get parked forever past the drain), and bump
deliberately.
+383 -24
View File
@@ -4,9 +4,358 @@ 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 based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/). this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.8.2] - 2026-11-02
The PTY-path exit-signal fidelity fix (review #004, consumer input from
alkssh 0.1.0): a behavior-only bug fix in the `local` feature's PTY
waiter. The pipe path already conformed; the wire format, the
`TtyBackend` trait, the adapter, the session, and alktty's public API
surface are unchanged. Patch release per the bug-fix rule: the fix
brings the PTY path into conformance with the convention ADR-004
decision 1 already documents (consumers that read the exit chunk,
notably alkssh, already consume negative codes verbatim, with no
contract change needed).
The finding: portable-pty 0.9.0's `ExitStatus` flattens a
signal-terminated child to `code = 1` with the signal surviving only as
a strsignal name string, so the waiter's `status.exit_code() as i32`
reported a SIGTERM death as `{"type":"exit","code":1}`,
indistinguishable from a real `exit 1`, and ADR-004's own `-N`
encoding (`-15` for SIGTERM) was unrecoverable on the PTY path. The
crate's own signal features made this internally inconsistent: REQ-TTY-02's
`signal("TERM")` (the `libc::kill(-pgid, sig)` forward) and the ADR-056
cancel cleanup's `ChildKiller::kill()` (SIGHUP) both ended in
signal-terminated reaps the waiter reported as plain `1`.
### Fixed
- **PTY-path exit-status encoding now honors ADR-004 decision 1: on
Unix a signal-terminated child reports the negative signal number
(e.g. `-15` for SIGTERM), Python's `Popen.returncode` parity.** The
waiter (`wait_exit_code` in `src/local/pty.rs`) skips portable-pty's
status conversion entirely on Unix and reaps the direct child via
`libc::waitpid(pid, ..)`, decoding `WIFEXITED`/`WEXITSTATUS` (normal
exit) and `WIFSIGNALED`/`WTERMSIG` (signal death → `-N`). Fully
numeric, locale-independent, and exhaustive: no name→signum table, no
`strsignal` parsing, no upstream ask, no fork. This relies on
portable-pty's unix backend returning a `std::process::Child` whose
`Drop` neither kills nor reaps, so the two reap paths never
double-reap (`Child::wait()` remains as the waitpid-failure fallback
and the non-Unix primary). The pipe path's encodings are unchanged.
- **Signal features now round-trip through the exit report.**
`signal("TERM")` resolves as `-15` on the exit chunk; the ADR-056
cancel cleanup (SIGHUP) resolves as `-1`, which is the documented
ADR-004 sentinel collision (that collision is now common on the
cancel path, not just the wait-failure path; the `-1` →
"could not determine / exit-status(255)" consumer reading stands).
- **Tests strengthened:** the PTY-path pinning now covers `SIGINT` →
`-2`, `SIGTERM` → `-15`, and the SIGHUP fallback → `-1` collision.
- **Docs aligned:** ADR-004 decision 1 now references both backends'
encodings, and `tty-local.md`, ADR-005, and `tty-adapter.md` carry
the mechanism wording (review #004 closed as resolved).
## [0.8.1] - 2026-10-22
The alkcall 0.10.1 adoption: a dependency bump only; no source change
in alktty. alkcall 0.10.1 is the adoption-window ordering fix (review
013, routed from alkgit's wave-2 gate): a chunk routed by the demux
inside the `adopt_channel` install→drain window could be delivered
ahead of earlier-parked early arrivals (inverting the channel's
byte-stream order) and a related chunk could sit parked forever past
the drain. alktty adopts channels only as a consumer-side client
(`TtySession::open_via_channels`, which opens and then adopts the
session channel), so the fix applies to every channels-path session
under multi-process load: the reassembled chunk stream now preserves
wire-arrival order through the adoption window. The surface of the fix
is private to alkcall (`ChannelState` and the `install_locked` path):
no API, wire-format, or documented-cap change; the behavior only
strengthens. The direct-ALPN path does not touch the adopt machinery
at all.
### Changed
- **`alkcall` requirement updated to `0.10.1`; lockfile bumped from
0.10.0 to 0.10.1.** Version-requirement-only (0.10.1 is inside the
`0.10` caret family; alkcall 0.10.1 changed no type alktty touches,
and the fix surface is private). alktty's public signatures reference
alkcall types, so downstream crates composing alktty with the fixed
alkcall pick this patch bump up on their next `cargo update`.
## [0.8.0] - 2026-10-19
The alkcall 0.10.0 adoption: a dependency bump only; no source change
in alktty. alkcall 0.10.0 is the gateway ownership seam release
(`GatewayDispatch::with_ownership_provider`, the declared
static-fallback opt-in, and the loud unwired-spine default). All of it
touches surfacing alktty never uses: alktty consults the
`OwnershipProvider` seam directly on its own paths (the direct-ALPN
scope gate and the channels establisher), not through the gateway
dispatch spine, and the wire surface and `OperationContext` shape are
unchanged. 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.10.0.**
alkcall 0.10.0 changed no type that alktty touches: the seam alktty
exercises (`ChannelCore`/`register_openable_with_establisher`/
`OpenEstablisher`/`OpenHandler`/`Establishment`, `ChannelClient`,
`OwnershipProvider`/`OwnershipStore`/`InMemoryOwnershipStore`) is
shape-identical to 0.9.0; the loud unwired-spine default applies
only to `resource_type`-gated ops served through `GatewayDispatch`,
which alktty never registers against. alktty's public signatures
reference alkcall types, so downstream crates must now unify on
alkcall 0.10.0. Minor bump per the 0.x ecosystem-coordination rule
(raising the minimum requirement is breaking, hence 0.8.0, not a
patch release).
- **`alkcall` requirement updated to `0.10.0`; lockfile bumped from
0.9.0 to 0.10.0.** Verification counts are unchanged from the 0.7.0
baseline (113 default / 137 all-features); the adoption required no
source change in alktty.
## [0.7.0] - 2026-10-14
The alkcall 0.9.0 adoption: a dependency bump only, with no source change
in alktty. alkcall 0.9.0 is the stub-sweep release (the marked-pub open
contract, `channel/control`, `channel/resources/subscribe`, and
`op/register` forward-through are real implementations). All of
alkcall's semver-relevant changes (`ManagerError::InvalidAdopt`, the
`RelayError::PubTypedOpen` removal, the `register_imported`
`Result` return, `Establishment` losing `Debug`, the new
`register_openable*` refusal of `Pub`-typed specs) touch surfacing
alktty never uses: alktty's open op is `Sub`-typed and it only
constructs `Establishment` (whose `new` is unchanged). 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.9.0.**
alkcall 0.9.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.8.1: the
four stub implementations are net-new surfaces on ops alktty does
not exercise, the established-wrapper refusal of `Pub`-typed specs
does not apply to alktty's `Sub`-typed open op, and the removed
`RelayError::PubTypedOpen` was never referenced. alktty's public
signatures reference alkcall types, so downstream crates must now
unify on alkcall 0.9.0. Minor bump per the 0.x
ecosystem-coordination rule (raising the minimum requirement is
breaking, hence 0.7.0, not a patch release).
- **`alkcall` requirement updated to `0.9.0`; lockfile bumped from
0.8.1 to 0.9.0.**
## [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<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 ## [0.2.0] - 2026-09-06
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the 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 channels-path establishment phase. Breaking on the channels path's
error surface; the wire format, the direct-ALPN path, and the error surface; the wire format, the direct-ALPN path, and the
`TtyBackend` trait are unchanged. `TtyBackend` trait are unchanged.
@@ -17,15 +366,15 @@ error surface; the wire format, the direct-ALPN path, and the
review 006 E-01).** `register_openable` now registers review 006 E-01).** `register_openable` now registers
`channels/tty/sub` with an establisher `channels/tty/sub` with an establisher
(`ChannelCore::register_openable_with_establisher`): the semantic (`ChannelCore::register_openable_with_establisher`): the semantic
validation that used to be post-open in-band error frames — the validation that used to be post-open in-band error frames (the
full `NegotiateRequest` parse of schema-valid `input`, `carriage == full `NegotiateRequest` parse of schema-valid `input`, `carriage ==
"raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership
check — runs before the open reply. Rejections resolve the open op check) runs before the open reply. Rejections resolve the open op
with `channel:open_failed` carrying `details: { reason, message }` with `channel:open_failed` carrying `details: { reason, message }`
(`unknown_resource` for an unknown backend, `handler_error` for (`unknown_resource` for an unknown backend, `handler_error` for
malformed-negotiation/ownership-denial, `timeout` on the malformed-negotiation/ownership-denial, `timeout` on the
establishment deadline) and no channel ever exists opener-side — establishment deadline) and no channel ever exists opener-side (the
the SSH contract. The phantom-channel workaround SSH contract). The phantom-channel workaround
(allocate → succeed → in-band-fail) is retired. (allocate → succeed → in-band-fail) is retired.
- **`channel:open_failed` ErrorDefinition on `tty_open_spec()` - **`channel:open_failed` ErrorDefinition on `tty_open_spec()`
(ADR-016).** The establishment-failure contract (the reachable (ADR-016).** The establishment-failure contract (the reachable
@@ -39,13 +388,13 @@ error surface; the wire format, the direct-ALPN path, and the
### Changed ### Changed
- **BREAKING — `TtySessionError::ChannelsOpen` carries alkcall's - **BREAKING: `TtySessionError::ChannelsOpen` carries alkcall's
typed `ChannelOpenError` (`#[from]`) instead of a flattened typed `ChannelOpenError` (`#[from]`) instead of a flattened
`String`** (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at `String`** (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at
alktty's layer). Consumers branch on alktty's layer). Consumers branch on
`ChannelOpenError::establishment_reason()` instead of parsing a `ChannelOpenError::establishment_reason()` instead of parsing a
debug string. debug string.
- **BREAKING — channels-path semantic failures change shape.** An - **BREAKING: channels-path semantic failures change shape.** An
unknown backend (and schema-valid-but-unparseable params, unknown backend (and schema-valid-but-unparseable params,
ownership denial) previously surfaced as ownership denial) previously surfaced as
`TtySessionError::NegotiationRejected` (an in-band `0x00`-prefixed `TtySessionError::NegotiationRejected` (an in-band `0x00`-prefixed
@@ -56,8 +405,8 @@ error surface; the wire format, the direct-ALPN path, and the
`allocate_failed` (still `NegotiationRejected`): the establisher `allocate_failed` (still `NegotiationRejected`): the establisher
cannot carry the allocated `TtyHandle` across to the pump handler cannot carry the allocated `TtyHandle` across to the pump handler
(`Establishment` is payloadless), and re-allocating would violate (`Establishment` is payloadless), and re-allocating would violate
ADR-005's kill-on-`Drop` contract — revisit when alkcall gives ADR-005's kill-on-`Drop` contract (revisit when alkcall gives
`Establishment` a payload. The direct-ALPN path's failure surface `Establishment` a payload). The direct-ALPN path's failure surface
is unchanged (two transports, two contracts). is unchanged (two transports, two contracts).
- `alkcall = "0.5.0"`. - `alkcall = "0.5.0"`.
@@ -70,7 +419,7 @@ error surface; the wire format, the direct-ALPN path, and the
## [0.1.0] - 2026-09-05 ## [0.1.0] - 2026-09-05
Initial crates.io release: the `alk/tty` terminal-session protocol — Initial crates.io release: the `alk/tty` terminal-session protocol, a
producer/consumer protocol crate on top of alkcall channels, ported and producer/consumer protocol crate on top of alkcall channels, ported and
consolidated from the `alknet-tty` + `alknet-tty-local` crates of the consolidated from the `alknet-tty` + `alknet-tty-local` crates of the
alknet mono-repo. alknet mono-repo.
@@ -79,21 +428,21 @@ alknet mono-repo.
- **Wire format (ADR-001).** Two-carriage protocol: a 4-byte - **Wire format (ADR-001).** Two-carriage protocol: a 4-byte
big-endian length-prefixed JSON negotiation frame (`NegotiateRequest` big-endian length-prefixed JSON negotiation frame (`NegotiateRequest`
— `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque with `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque
`serde(flatten)` backend params), then raw chunks `serde(flatten)` backend params), then raw chunks
(`[stream_type: u8][length: u32 be][payload]`) with five stream types (`[stream_type: u8][length: u32 be][payload]`) with five stream types
(`STREAM_STDIN`=0, `STREAM_STDOUT`=1, `STREAM_STDERR`=2, (`STREAM_STDIN`=0, `STREAM_STDOUT`=1, `STREAM_STDERR`=2,
`STREAM_CTRL_IN`=3, `STREAM_CTRL_OUT`=4). Zero-length data chunks are `STREAM_CTRL_IN`=3, `STREAM_CTRL_OUT`=4). Zero-length data chunks are
sentinels (zero-length stdin = client EOF; zero-length stdout = sentinels (zero-length stdin = client EOF; zero-length stdout =
server drained); control chunks are never zero-length. Payloads are server drained); control chunks are never zero-length. Payloads are
capped at 16 MiB (`MAX_CHUNK_LEN`) — this keeps the length prefix's capped at 16 MiB (`MAX_CHUNK_LEN`); this keeps the length prefix's
high byte `0x00`, which is the disambiguation invariant between an high byte `0x00`, which is the disambiguation invariant between an
error frame and a raw chunk (ADR-001 §5). The framing is error frame and a raw chunk (ADR-001 §5). The framing is
self-contained (ADR-006): not reused from alkcall's `EventEnvelope` self-contained (ADR-006): not reused from alkcall's `EventEnvelope`
framing. The control channel is split into `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` 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 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 inside ADR-001). TTY always uses its own 5-byte chunk format inside
channels — the channels layer carries it transparently in the channel channels; the channels layer carries it transparently in the channel
payload (ADR-008, reversing ADR-007). The machine-readable contract payload (ADR-008, reversing ADR-007). The machine-readable contract
is the BAST document (`docs/architecture/tty-bast.md`); a unit test is the BAST document (`docs/architecture/tty-bast.md`); a unit test
guards the BAST's `StreamType` enum against drift from the wire guards the BAST's `StreamType` enum against drift from the wire
@@ -107,9 +456,9 @@ alknet mono-repo.
is a one-way door once backends exist. is a one-way door once backends exist.
- **Local backend behind the `local` feature (ADR-003).** The - **Local backend behind the `local` feature (ADR-003).** The
`alknet-tty-local` crate folded in as a feature-gated module: `alknet-tty-local` crate folded in as a feature-gated module:
PTY mode via `portable_pty` (real terminal semantics — resize, PTY mode via `portable_pty` (real terminal semantics: resize,
process-group signal forwarding, merged stdout/stderr) and pipe mode process-group signal forwarding, merged stdout/stderr) and pipe mode
via `tokio::process::Command` (the runner case — separate via `tokio::process::Command` (the runner case: separate
stdout/stderr, no-op resize, pid-only signal), selected by stdout/stderr, no-op resize, pid-only signal), selected by
`TtyParams::terminal`. The blocking→async bridge uses three dedicated `TtyParams::terminal`. The blocking→async bridge uses three dedicated
std threads feeding tokio mpsc/oneshot channels. Non-wasm by design; std threads feeding tokio mpsc/oneshot channels. Non-wasm by design;
@@ -123,14 +472,14 @@ alknet mono-repo.
via `tty:open`, optional `OwnershipProvider` resource check, via `tty:open`, optional `OwnershipProvider` resource check,
`backend.allocate()`, then three concurrent pumps (stdin→backend, `backend.allocate()`, then three concurrent pumps (stdin→backend,
stdout/stderr→client, exit→exit chunk) with the **exit-chunk-is-last** stdout/stderr→client, exit→exit chunk) with the **exit-chunk-is-last**
invariant — `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004; invariant: `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004;
negative = signal-terminated) is enqueued only after both stream negative = signal-terminated) is enqueued only after both stream
pumps complete and `exit_code` resolves. A concurrent drainer task pumps complete and `exit_code` resolves. A concurrent drainer task
owns the client write half, so a burst of >64 chunks from the backend owns the client write half, so a burst of >64 chunks from the backend
cannot deadlock the session (the drainer starts before the pumps cannot deadlock the session (the drainer starts before the pumps
join; the single FIFO preserves exit-chunk-last). On session cancel join; the single FIFO preserves exit-chunk-last). On session cancel
the `TtyHandle` drops without driving `exit_code` to completion, the `TtyHandle` drops without driving `exit_code` to completion,
which triggers the backend's kill-on-`Drop` guard — the adapter has which triggers the backend's kill-on-`Drop` guard; the adapter has
no separate kill path (the session-cancel contract, ADR-005: dropping no separate kill path (the session-cancel contract, ADR-005: dropping
the future kills the session target). the future kills the session target).
- **`channels` integration (ADR-008, ADR-009).** `register_openable` + - **`channels` integration (ADR-008, ADR-009).** `register_openable` +
@@ -140,12 +489,12 @@ alknet mono-repo.
scope gate, the op's input schema validates the shared scope gate, the op's input schema validates the shared
`NegotiateRequest` fields (`carriage`/`backend`/`cmd` required; `NegotiateRequest` fields (`carriage`/`backend`/`cmd` required;
alkcall 0.4 enforces at dispatch), and the `OpenHandler` spawns the alkcall 0.4 enforces at dispatch), and the `OpenHandler` spawns the
session driver pre-negotiated — **the open op's params ARE the session driver pre-negotiated; **the open op's params ARE the
negotiation** (ADR-009), no second frame on the channel stream. negotiation** (ADR-009), with no second frame on the channel stream.
Access control is enforced by the registry before the handler runs; Access control is enforced by the registry before the handler runs;
the handler validates the backend exists and drives the session. the handler validates the backend exists and drives the session.
- **`TtySession` consumer.** The typed client handle with two - **`TtySession` consumer.** The typed client handle with two
constructors — `connect_direct` (direct `alk/tty` connection: constructors, `connect_direct` (direct `alk/tty` connection:
writes the negotiation frame, then typed methods) and writes the negotiation frame, then typed methods) and
`open_via_channels` (opens `channels/tty/sub` on a `ChannelClient`, `open_via_channels` (opens `channels/tty/sub` on a `ChannelClient`,
adopts the channel, starts in raw-chunk mode). Typed methods: adopts the channel, starts in raw-chunk mode). Typed methods:
@@ -161,7 +510,7 @@ alknet mono-repo.
`TtySessionError` is `#[non_exhaustive]`. Dropping the session `TtySessionError` is `#[non_exhaustive]`. Dropping the session
aborts the read pump and closes the write half. aborts the read pump and closes the write half.
- **Control messages.** `ControlMessage` (`Resize`/`Signal`/`Eof` on - **Control messages.** `ControlMessage` (`Resize`/`Signal`/`Eof` on
`ctrl_in`; `Exit` on `ctrl_out`) — JSON, tagged by `"type"`, unknown `ctrl_in`; `Exit` on `ctrl_out`): JSON, tagged by `"type"`, with unknown
types ignored by policy. `signal_from_name` maps the common signal types ignored by policy. `signal_from_name` maps the common signal
set (`HUP`/`INT`/`QUIT`/`TERM`/`KILL`/`USR1`/`USR2`/`TSTP`/`CONT`) set (`HUP`/`INT`/`QUIT`/`TERM`/`KILL`/`USR1`/`USR2`/`TSTP`/`CONT`)
to libc numbers (unix). to libc numbers (unix).
@@ -171,7 +520,7 @@ alknet mono-repo.
`LocalTtyBackend` under `local`) are re-exported at the crate root; `LocalTtyBackend` under `local`) are re-exported at the crate root;
the module paths remain the full surface. the module paths remain the full surface.
- **Wire write-path validation.** All `ChunkWriter` methods validate - **Wire write-path validation.** All `ChunkWriter` methods validate
before touching the transport — a `stream_type` > 4 or a payload over before touching the transport; a `stream_type` > 4 or a payload over
`MAX_CHUNK_LEN` fails locally instead of writing a header the peer `MAX_CHUNK_LEN` fails locally instead of writing a header the peer
cannot frame (the length is validated before the `u32` cast, so an cannot frame (the length is validated before the `u32` cast, so an
oversized payload cannot truncate past the check and corrupt the oversized payload cannot truncate past the check and corrupt the
@@ -189,14 +538,24 @@ alknet mono-repo.
stream-types discard tests. stream-types discard tests.
- **PTY bridge robustness (`local` feature).** The stdin sink's EOF - **PTY bridge robustness (`local` feature).** The stdin sink's EOF
(`poll_shutdown`) parks an in-flight send on a full channel so the (`poll_shutdown`) parks an in-flight send on a full channel so the
waker is registered — a stdin blast followed by EOF always delivers waker is registered, and a stdin blast followed by EOF always delivers
the EOF to the child. Lock-poisoning no longer cascades (the five the EOF to the child. Lock-poisoning no longer cascades (the five
lock sites adopt `into_inner()`), and thread-spawn failure maps to lock sites adopt `into_inner()`), and thread-spawn failure maps to
`TtyError::AllocFailed` instead of panicking. `TtyError::AllocFailed` instead of panicking.
- **Producer hardening.** The `tty:open` scope gate runs before the - **Producer hardening.** The `tty:open` scope gate runs before the
backend lookup (an unscoped identity gets `forbidden` regardless of backend lookup (an unscoped identity gets `forbidden` regardless of
the request body — no backend-name enumeration differential), and the request body, with no backend-name enumeration differential), and
the client→backend pump is aborted at session end instead of the client→backend pump is aborted at session end instead of
lingering until the client disconnects. lingering until the client disconnects.
[0.8.2]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.8.2
[0.8.1]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.8.1
[0.8.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.8.0
[0.7.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.7.0
[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 [0.1.0]: https://git.alk.dev/alkdev/alktty/releases/tag/v0.1.0
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]] [[package]]
name = "alkcall" name = "alkcall"
version = "0.5.0" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bbaeb718c370b74f9136d1d59b4608249bd7d1dd2332c0259f98351d709737f" checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]] [[package]]
name = "alktty" name = "alktty"
version = "0.2.0" version = "0.8.2"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"async-trait", "async-trait",
+8 -4
View File
@@ -1,15 +1,19 @@
[package] [package]
name = "alktty" name = "alktty"
version = "0.2.0" version = "0.8.2"
edition = "2021" edition = "2021"
rust-version = "1.85" rust-version = "1.88"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
description = "Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels." description = "Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels."
repository = "https://git.alk.dev/alkdev/alktty" repository = "https://git.alk.dev/alkdev/alktty"
readme = "README.md" readme = "README.md"
keywords = ["tty", "terminal", "pty", "channels", "alkcall"] keywords = ["tty", "terminal", "pty", "channels", "alkcall"]
categories = ["network-programming", "asynchronous"] categories = ["network-programming", "asynchronous"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md"] exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md", "fuzz/"]
[workspace]
members = ["."]
exclude = ["fuzz"]
[lib] [lib]
name = "alktty" name = "alktty"
@@ -25,7 +29,7 @@ default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"] local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
[dependencies] [dependencies]
alkcall = "0.5.0" alkcall = "0.10.1"
# Minimal, wasm-clean tokio features. `local` adds `process` + # Minimal, wasm-clean tokio features. `local` adds `process` +
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it # `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. # pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
+14 -14
View File
@@ -6,7 +6,7 @@ be][payload]`), a backend-agnostic `TtyBackend` trait, a
backend-agnostic producer adapter, and a typed consumer client. backend-agnostic producer adapter, and a typed consumer client.
A producer/consumer protocol crate on top of A producer/consumer protocol crate on top of
[alkcall](https://crates.io/crates/alkcall) channels — no networking, no [alkcall](https://crates.io/crates/alkcall) channels: no networking, no
transport dependencies. Downstream crates (a `alknet-docker` or transport dependencies. Downstream crates (a `alknet-docker` or
`alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of `alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of
it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html) it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html)
@@ -14,7 +14,7 @@ for your execution environment, and the adapter handles the wire.
## Quick start ## Quick start
### Producer — register backends and run the adapter ### Producer: register backends and run the adapter
```rust ```rust
use std::collections::HashMap; use std::collections::HashMap;
@@ -27,11 +27,11 @@ backends.insert("local".into(), Arc::new(alktty::LocalTtyBackend::new()));
let tty_adapter = alktty::TtyAdapter::new(backends); let tty_adapter = alktty::TtyAdapter::new(backends);
// tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty` // tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty`
// ALPN — hand it to your transport accept loop: // ALPN; hand it to your transport accept loop:
// tty_adapter.handle(connection, &auth).await // tty_adapter.handle(connection, &auth).await
``` ```
### Producer — via `alk/channels` instead of a dedicated ALPN ### Producer via `alk/channels` instead of a dedicated ALPN
```rust ```rust
use std::collections::HashMap; use std::collections::HashMap;
@@ -44,10 +44,10 @@ let backends: HashMap<String, Arc<dyn alktty::TtyBackend>> =
// In your channels acceptor's `install_channel_zero` hook (per-connection): // In your channels acceptor's `install_channel_zero` hook (per-connection):
// alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth) // alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth)
// Consumers open `channels/tty/sub`; the open op's params ARE the // Consumers open `channels/tty/sub`; the open op's params ARE the
// negotiation (ADR-009) — no second frame on the channel stream. // negotiation (ADR-009); no second frame on the channel stream.
``` ```
### Consumer — open a session over the direct ALPN ### Consumer: open a session over the direct ALPN
```rust ```rust
use alkcall::core::Connection; use alkcall::core::Connection;
@@ -73,14 +73,14 @@ session.close_stdin().await?;
let mut stdout = session.recv_stdout().await; let mut stdout = session.recv_stdout().await;
while let Some(bytes) = futures::StreamExt::next(&mut stdout).await { while let Some(bytes) = futures::StreamExt::next(&mut stdout).await {
// The stream ends on the zero-length stdout sentinel ("drained") — // The stream ends on the zero-length stdout sentinel ("drained");
// it is never yielded as an item. // it is never yielded as an item.
// ... render bytes ... // ... render bytes ...
} }
let code = session.wait().await?; let code = session.wait().await?;
``` ```
### Consumer — open a session via `alk/channels` ### Consumer: open a session via `alk/channels`
```rust ```rust
use alkcall::channels::client::ChannelClient; use alkcall::channels::client::ChannelClient;
@@ -106,14 +106,14 @@ both sides of a connection can be either):
| Role | Direct `alk/tty` | Via `alk/channels` | | Role | Direct `alk/tty` | Via `alk/channels` |
|------|------------------|--------------------| |------|------------------|--------------------|
| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler` — negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html) — the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated | | **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler`: negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html): the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated |
| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct) — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels) — the open op's params are the negotiation | | **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct), which writes the negotiation frame and exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels), where the open op's params are the negotiation |
One connection hosts many sessions (one per bidi stream on the direct One connection hosts many sessions (one per bidi stream on the direct
path, one per channel on the channels path); sessions are independent. path, one per channel on the channels path); sessions are independent.
The wire format (ADR-001) is two carriages: a 4-byte The wire format (ADR-001) is two carriages: a 4-byte
length-prefixed JSON negotiation frame, then raw chunks — length-prefixed JSON negotiation frame, then raw chunks of the form
`[stream_type: u8][length: u32 be][payload]` with five stream types `[stream_type: u8][length: u32 be][payload]` with five stream types
(`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see (`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see
[`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)). [`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)).
@@ -126,7 +126,7 @@ document at `docs/architecture/tty-bast.md`.
### WASM target ### WASM target
The default crate (no features) compiles to `wasm32-unknown-unknown` — The default crate (no features) compiles to `wasm32-unknown-unknown`;
this is what makes the downstream TS/Python adapter story work: a this is what makes the downstream TS/Python adapter story work: a
wasm-compiled alktty is the protocol layer for a sandboxed adapter. The wasm-compiled alktty is the protocol layer for a sandboxed adapter. The
`local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html) `local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html)
@@ -135,10 +135,10 @@ real OS and is non-wasm by design.
## Documentation ## Documentation
- [Architecture docs](docs/architecture/README.md) — the authoritative - [Architecture docs](docs/architecture/README.md), the authoritative
spec: ADRs (001–009), the wire format, the backend trait contract, spec: ADRs (001–009), the wire format, the backend trait contract,
and the local backend's cancel-cleanup semantics. and the local backend's cancel-cleanup semantics.
- [API docs](https://docs.rs/alktty) — full crate documentation on - [API docs](https://docs.rs/alktty): full crate documentation on
docs.rs. docs.rs.
## License ## License
+16 -16
View File
@@ -1,4 +1,4 @@
# alktty — Architecture # alktty: Architecture
Terminal session protocol for the `alk/tty` ALPN: a Terminal session protocol for the `alk/tty` ALPN: a
producer/consumer protocol crate on top of alkcall channels. This producer/consumer protocol crate on top of alkcall channels. This
@@ -12,7 +12,7 @@ Syntax Tree) document for the wire format, and the ADRs.
| [overview.md](overview.md) | draft | Crate purpose, the two-carriage model in brief, dependencies, ALPN, backend location map, feature gates | | [overview.md](overview.md) | draft | Crate purpose, the two-carriage model in brief, dependencies, ALPN, backend location map, feature gates |
| [tty-wire.md](tty-wire.md) | draft | The wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel split into `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves, sentinels | | [tty-wire.md](tty-wire.md) | draft | The wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel split into `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves, sentinels |
| [tty-bast.md](tty-bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the binary framing layer of the `alk/tty` wire format (5-byte chunk header + negotiation length prefix); a normative JSON spec conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`. JSON payloads are specified in `tty-wire.md` and the Rust source, not in the BAST | | [tty-bast.md](tty-bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the binary framing layer of the `alk/tty` wire format (5-byte chunk header + negotiation length prefix); a normative JSON spec conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`. JSON payloads are specified in `tty-wire.md` and the Rust source, not in the BAST |
| [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl` — the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) | | [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl`: the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) |
| [tty-adapter.md](tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alk/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-004), access control, session-cancel cleanup (ADR-005) | | [tty-adapter.md](tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alk/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-004), access control, session-cancel cleanup (ADR-005) |
| [tty-local.md](tty-local.md) | draft | `LocalTtyBackend` (the `local` feature module): `portable_pty` (PTY) and `tokio::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) | | [tty-local.md](tty-local.md) | draft | `LocalTtyBackend` (the `local` feature module): `portable_pty` (PTY) and `tokio::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) |
@@ -28,16 +28,16 @@ tty-specific and therefore not ported into alktty's ADR range).
| ADR | Title | Origin | Status | | ADR | Title | Origin | Status |
|-----|-------|--------|--------| |-----|-------|--------|--------|
| [001](decisions/001-wire-format-and-two-carriage.md) | alktty Wire Format and Two-Carriage Model | alknet ADR-052 | Accepted (amended 2026-07-18 — Phase 7 control-channel split) | | [001](decisions/001-wire-format-and-two-carriage.md) | alktty Wire Format and Two-Carriage Model | alknet ADR-052 | Accepted (amended 2026-07-18, Phase 7 control-channel split) |
| [002](decisions/002-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle — the Backend Inversion Point | alknet ADR-053 | Accepted | | [002](decisions/002-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle: the Backend Inversion Point | alknet ADR-053 | Accepted |
| [003](decisions/003-local-backend-placement.md) | Local TTY Backend Placement (Single Crate with `local` Feature) | alknet ADR-054 | Accepted (records both the alknet sibling-crate decision and the alktty single-crate consolidation) | | [003](decisions/003-local-backend-placement.md) | Local TTY Backend Placement (Single Crate with `local` Feature) | alknet ADR-054 | Accepted (records both the alknet sibling-crate decision and the alktty single-crate consolidation) |
| [004](decisions/004-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk (the Last Chunk Before Stream Close) | alknet ADR-055 | Accepted | | [004](decisions/004-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk (the Last Chunk Before Stream Close) | alknet ADR-055 | Accepted |
| [005](decisions/005-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel (Drop of `exit_code` Kills) | alknet ADR-056 | Accepted | | [005](decisions/005-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel (Drop of `exit_code` Kills) | alknet ADR-056 | Accepted |
| [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted | | [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted |
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) | | [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels (Sub-Streams, Not Wire Format) | alknet ADR-077 | Accepted (**reversed by ADR-008**; kept for historical context) |
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) | | [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) | | [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 (parse failure is a client-visible error frame); amended by ADR-010 (semantic failures move into the establisher)) |
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment — in-band frames shrink to `allocate_failed`) | | [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload, with allocation moves into the establisher and no in-band frame from a registered producer) |
## Key Design Principles ## Key Design Principles
@@ -75,7 +75,7 @@ tty-specific and therefore not ported into alktty's ADR range).
depend on any backend. This preserves alknet ADR-003's depend on any backend. This preserves alknet ADR-003's
no-handler-depends-on-another-handler rule. alktty does not depend no-handler-depends-on-another-handler rule. alktty does not depend
on alkcall's internal wire types either (the negotiation framing is on alkcall's internal wire types either (the negotiation framing is
self-contained — [ADR-006](decisions/006-negotiation-framing-self-contained.md)). self-contained ([ADR-006](decisions/006-negotiation-framing-self-contained.md)).
See [tty-backend.md](tty-backend.md) and See [tty-backend.md](tty-backend.md) and
[ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md). [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md).
@@ -92,7 +92,7 @@ tty-specific and therefore not ported into alktty's ADR range).
([ADR-004](decisions/004-exit-code-on-control-chunk.md)).** ([ADR-004](decisions/004-exit-code-on-control-chunk.md)).**
`{"type":"exit","code":N}` rides on `STREAM_CTRL_OUT` (stream_type 4) `{"type":"exit","code":N}` rides on `STREAM_CTRL_OUT` (stream_type 4)
and is the last chunk before the server closes the write half. This and is the last chunk before the server closes the write half. This
gives coordinators deterministic completion notification — no gives coordinators deterministic completion notification (no
polling, no plugin state. The adapter owns the ordering; backends polling, no plugin state. The adapter owns the ordering; backends
resolve `exit_code` and the adapter awaits, sends the chunk, closes. resolve `exit_code` and the adapter awaits, sends the chunk, closes.
See [tty-adapter.md](tty-adapter.md). See [tty-adapter.md](tty-adapter.md).
@@ -103,14 +103,14 @@ tty-specific and therefore not ported into alktty's ADR range).
the `TtyHandle`, which drops the `exit_code` future without driving the `TtyHandle`, which drops the `exit_code` future without driving
it to completion. The backend's `exit_code` future's `Drop`-on-cancel it to completion. The backend's `exit_code` future's `Drop`-on-cancel
MUST kill the child/container/SSH process. This is a behavioral MUST kill the child/container/SSH process. This is a behavioral
contract on the `TtyBackend` trait — the adapter has no separate kill contract on the `TtyBackend` trait: the adapter has no separate kill
method; the cleanup is wired into the `exit_code` future's `Drop` by method; the cleanup is wired into the `exit_code` future's `Drop` by
the backend. See [tty-adapter.md](tty-adapter.md) and the backend. See [tty-adapter.md](tty-adapter.md) and
[tty-local.md](tty-local.md). [tty-local.md](tty-local.md).
8. **The runner pattern is preserved, not specialized.** The local 8. **The runner pattern is preserved, not specialized.** The local
backend in pipe mode (`terminal: None`) is a process-streaming backend in pipe mode (`terminal: None`) is a process-streaming
endpoint — the same shape as GitHub/Gitea Actions runners, just over endpoint, the same shape as GitHub/Gitea Actions runners, just over
alk's transport instead of HTTP polling. alktty provides the alk's transport instead of HTTP polling. alktty provides the
*mechanism* (framed byte stream + exit code); runner *policy* (job *mechanism* (framed byte stream + exit code); runner *policy* (job
management, log persistence, task graph) is a downstream crate's management, log persistence, task graph) is a downstream crate's
@@ -130,7 +130,7 @@ tty-specific and therefore not ported into alktty's ADR range).
| OQ | Title | Status | Relevance | | OQ | Title | Status | Relevance |
|----|-------|--------|-----------| |----|-------|--------|-----------|
| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; the trait is NOT `Clone` (not object-safe) — the newtype carries `Clone`-ability | | OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; the trait is NOT `Clone` (not object-safe); the newtype carries `Clone`-ability |
| OQ-44 | Terminal modes (TTY modes) | deferred(scope) | `TerminalParams.modes` reserved; default terminal modes suffice for current scope; blocked on a concrete mode-control use case | | OQ-44 | Terminal modes (TTY modes) | deferred(scope) | `TerminalParams.modes` reserved; default terminal modes suffice for current scope; blocked on a concrete mode-control use case |
| OQ-45 | Flow control for high-throughput stdout | resolved | QUIC per-stream flow control is the backpressure mechanism (chain complete by construction); no application-level windowing. Reversal is an additive `ControlMessage` variant, not a wire-format change | | OQ-45 | Flow control for high-throughput stdout | resolved | QUIC per-stream flow control is the backpressure mechanism (chain complete by construction); no application-level windowing. Reversal is an additive `ControlMessage` variant, not a wire-format change |
| OQ-46 | Runner API surface | deferred(scope) | The runner mechanism (pipe mode) is in alktty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope here | | OQ-46 | Runner API surface | deferred(scope) | The runner mechanism (pipe mode) is in alktty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope here |
@@ -138,20 +138,20 @@ tty-specific and therefore not ported into alktty's ADR range).
## References ## References
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All - `docs/plans/project-setup.md`: the current plan (phases 0–5). All
five phases are landed: Phase 4 landed this directory (architecture five phases are landed: Phase 4 landed this directory (architecture
docs + BAST schema + renumbered ADRs); Phase 5 landed the tests, docs + BAST schema + renumbered ADRs); Phase 5 landed the tests,
including the integration tests in `tests/` at the crate root. including the integration tests in `tests/` at the crate root.
- alknet originals of the ported ADRs (alknet ADR-052, 053, 054, 055, - alknet originals of the ported ADRs (alknet ADR-052, 053, 054, 055,
056, 057, 077, 093) at 056, 057, 077, 093) at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` — the `/workspace/@alkdev/alknet/docs/architecture/decisions/`: the
authoritative source for any ADR not yet ported, and for the alknet authoritative source for any ADR not yet ported, and for the alknet
ADRs referenced by alknet number in the docs above (which are not ADRs referenced by alknet number in the docs above (which are not
tty-specific and therefore not ported into alktty's ADR range). tty-specific and therefore not ported into alktty's ADR range).
- [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the - [alktype BAST format spec](https://alk.dev/bast/v1/schema): the
normative format spec for BAST documents (the meta-schema normative format spec for BAST documents (the meta-schema
`tty-bast.md` conforms to); see also `tty-bast.md` conforms to); see also
`/workspace/@alkdev/alktype/docs/architecture/bast-format.md`. `/workspace/@alkdev/alktype/docs/architecture/bast-format.md`.
- alkcall architecture README at - alkcall architecture README at
`/workspace/@alkdev/alkcall/docs/architecture/README.md` — the `/workspace/@alkdev/alkcall/docs/architecture/README.md`: the
producer/consumer protocol-crate pattern alktty follows. producer/consumer protocol-crate pattern alktty follows.
@@ -2,11 +2,11 @@
## Status ## Status
Accepted (amended 2026-07-18 — Phase 7: the control channel is split Accepted (amended 2026-07-18, Phase 7: the control channel is split
into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4`
(server→client) halves; see §"Control channel split" below. Ported (server→client) halves; see §"Control channel split" below. Ported
from alknet ADR-052 2026-08-17; cross-references renumbered to from alknet ADR-052 2026-08-17; cross-references renumbered to
alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003, alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003,
ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008.
Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012, Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012,
044, 049, 050) are not ported into alktty's ADR range because they are 044, 049, 050) are not ported into alktty's ADR range because they are
@@ -16,15 +16,15 @@ authoritative.)
## Context ## Context
The alknet-docker POC validated that interactive attach — bidirectional The alknet-docker POC validated that interactive attach (bidirectional
byte pumping over a framed bidi stream with a 1-byte stream-type byte pumping over a framed bidi stream with a 1-byte stream-type
multiplexer — is the same problem regardless of whether the backend is multiplexer) is the same problem regardless of whether the backend is
`bollard::attach_container()` or russh's `pty_request`. The POC's raw `bollard::attach_container()` or russh's `pty_request`. The POC's raw
chunk format (`[stream_type: u8][length: u32 be][payload bytes]`, chunk format (`[stream_type: u8][length: u32 be][payload bytes]`,
stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished
version of SSH's channel multiplexer: fixed set of channel types, no version of SSH's channel multiplexer: fixed set of channel types, no
negotiation, no open/close handshake, no windowing (QUIC provides flow negotiation, no open/close handshake, no windowing (QUIC provides flow
control on the bidi stream). That impoverishment is the feature — a control on the bidi stream). That impoverishment is the feature: a
terminal session needs exactly those channels and no more. terminal session needs exactly those channels and no more.
The alknet-tty POC (built 2026-07-05) extended that format with a 4th The alknet-tty POC (built 2026-07-05) extended that format with a 4th
@@ -44,8 +44,8 @@ Three load-bearing questions are decided here:
rejected: JSON-encoding every byte chunk is wasteful (base64 for rejected: JSON-encoding every byte chunk is wasteful (base64 for
binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams
partial bytes with no message boundary that maps to a JSON object). partial bytes with no message boundary that maps to a JSON object).
The two-carriage model — a single JSON negotiation frame, then raw The two-carriage model (a single JSON negotiation frame, then raw
chunks — keeps the call protocol's JSON-RPC shape for the *request* chunks) keeps the call protocol's JSON-RPC shape for the *request*
and switches to bytes for the *body*, which is the part that is and switches to bytes for the *body*, which is the part that is
actually bytes. This is the pattern the docker POC validated and the actually bytes. This is the pattern the docker POC validated and the
SSH research independently arrived at for PTY. SSH research independently arrived at for PTY.
@@ -53,7 +53,7 @@ Three load-bearing questions are decided here:
2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary 2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary
services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with
string-named types negotiated per channel. alktty multiplexes one string-named types negotiated per channel. alktty multiplexes one
service — a terminal session — with a fixed `u8` set and no service, a terminal session, with a fixed `u8` set and no
negotiation. Adding a 6th channel type is a wire-format change negotiation. Adding a 6th channel type is a wire-format change
(one-way door). The ALPN model handles extensibility at the protocol (one-way door). The ALPN model handles extensibility at the protocol
level: a genuinely new sideband (e.g., file transfer alongside the level: a genuinely new sideband (e.g., file transfer alongside the
@@ -64,7 +64,7 @@ Three load-bearing questions are decided here:
(`[control_type: u8][params...]`) would be faster but harder to (`[control_type: u8][params...]`) would be faster but harder to
extend and inconsistent with the negotiation layer. Control messages extend and inconsistent with the negotiation layer. Control messages
are rare (resize on window drag, signal on Ctrl-C, one eof, one exit are rare (resize on window drag, signal on Ctrl-C, one eof, one exit
per session) — serialization cost is negligible against the data per session), so serialization cost is negligible against the data
chunks. If a hot control path appears, a binary `control_type` can chunks. If a hot control path appears, a binary `control_type` can
be added without breaking the chunk format (additive within the be added without breaking the chunk format (additive within the
control channel). control channel).
@@ -79,7 +79,7 @@ implements `ProtocolHandler` (alknet ADR-002, revised by alknet ADR-007
to receive a `Connection`). The handler owns the entire connection to receive a `Connection`). The handler owns the entire connection
lifecycle and accepts one bidi stream per terminal session. This is a lifecycle and accepts one bidi stream per terminal session. This is a
separate ALPN, not a set of operations in the call protocol's separate ALPN, not a set of operations in the call protocol's
`OperationRegistry` — the raw-carriage byte pump is not a `OperationRegistry`: the raw-carriage byte pump is not a
`StreamingHandler` (alknet ADR-049); it is its own wire format after a `StreamingHandler` (alknet ADR-049); it is its own wire format after a
single JSON negotiation frame. single JSON negotiation frame.
@@ -88,7 +88,7 @@ single JSON negotiation frame.
The bidi stream has two phases: The bidi stream has two phases:
- **Negotiation (JSON carriage).** The client opens a bidi stream and - **Negotiation (JSON carriage).** The client opens a bidi stream and
writes a single length-prefixed JSON frame — a 4-byte big-endian writes a single length-prefixed JSON frame: a 4-byte big-endian
length prefix + UTF-8 JSON body. The frame carries the terminal length prefix + UTF-8 JSON body. The frame carries the terminal
parameters and backend selector the server needs to allocate the parameters and backend selector the server needs to allocate the
session: session:
@@ -113,13 +113,13 @@ The bidi stream has two phases:
The `carriage` field is `"raw"` for terminal sessions (the only The `carriage` field is `"raw"` for terminal sessions (the only
carriage in v1). The `tty` block is `null` for the pipe/runner case carriage in v1). The `tty` block is `null` for the pipe/runner case
(no PTY — see ADR-003). The `backend` field selects the `TtyBackend` (no PTY; see ADR-003). The `backend` field selects the `TtyBackend`
(ADR-002); backend-specific fields (e.g., `container` for docker) (ADR-002); backend-specific fields (e.g., `container` for docker)
ride alongside. ride alongside.
- **Raw carriage.** After the negotiation frame, the stream switches to - **Raw carriage.** After the negotiation frame, the stream switches to
the chunk format below for the life of the session. There is no the chunk format below for the life of the session. There is no
`call.responded`/`call.completed` — this is not the call protocol. `call.responded`/`call.completed`; this is not the call protocol.
### 3. Chunk format ### 3. Chunk format
@@ -127,24 +127,24 @@ The bidi stream has two phases:
[stream_type: u8][length: u32 be][payload bytes] [stream_type: u8][length: u32 be][payload bytes]
``` ```
- `stream_type` — fixed set, no negotiation: - `stream_type` (fixed set, no negotiation):
| stream_type | channel | direction | payload | | stream_type | channel | direction | payload |
|---|---|---|---| |---|---|---|---|
| 0 | data-in (stdin) | client→server | raw bytes | | 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes | | 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes | | 2 | data-err (stderr) | server→client | raw bytes |
| 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) | | 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`; see §4a) |
| 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) | | 4 | ctrl-out | server→client | JSON control message (`Exit`; see §4a) |
`stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7 `stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7
amendment, §4a: the original `3 = control (bidirectional)` is split amendment, §4a: the original `3 = control (bidirectional)` is split
into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was
`> 3`.) There is no extension escape hatch in the byte — a 6th `> 3`.) There is no extension escape hatch in the byte: a 6th
channel is a wire-format change requiring a new ALPN (`alk/tty/v2` channel is a wire-format change requiring a new ALPN (`alk/tty/v2`
per alknet ADR-006), not a negotiated addition to this format. per alknet ADR-006), not a negotiated addition to this format.
- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A - `length`: payload length in bytes, u32 big-endian, max 16 MiB. A
chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The
16 MiB bound accommodates large paste operations and large `env` 16 MiB bound accommodates large paste operations and large `env`
blocks while bounding memory per chunk (a reader can allocate a blocks while bounding memory per chunk (a reader can allocate a
@@ -166,20 +166,20 @@ Control chunks carry a small JSON payload, tagged by `type`:
| client→server | eof | `{"type":"eof"}` | | client→server | eof | `{"type":"eof"}` |
| server→client | exit | `{"type":"exit","code":0}` | | server→client | exit | `{"type":"exit","code":0}` |
- **resize** — window-size change. Maps to SSH `window-change`, docker - **resize**: window-size change. Maps to SSH `window-change`, docker
exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY. exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY.
- **signal** — signal forwarding. `name` is an uppercase string - **signal**: signal forwarding. `name` is an uppercase string
(`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`, (`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`,
`"USR1"`, `"USR2"`). Unknown names fall back to the backend's default `"USR1"`, `"USR2"`). Unknown names fall back to the backend's default
kill (see ADR-002 / `tty-local.md` REQ-TTY-02). kill (see ADR-002 / `tty-local.md` REQ-TTY-02).
- **eof** — client signals no more stdin. Maps to SSH channel EOF, - **eof**: client signals no more stdin. Maps to SSH channel EOF,
docker stdin close, or `ChildStdin::drop` / PTY writer close. This is docker stdin close, or `ChildStdin::drop` / PTY writer close. This is
one of two canonical "stdin done" signals; the other is a zero-length one of two canonical "stdin done" signals; the other is a zero-length
stdin chunk (the docker POC's sentinel). Both are accepted as stdin chunk (the docker POC's sentinel). Both are accepted as
equivalent — `eof` is recommended for explicitness (it's a control equivalent: `eof` is recommended for explicitness (it's a control
message, not a data-length hack); the zero-length stdin chunk is kept message, not a data-length hack); the zero-length stdin chunk is kept
for compatibility with the docker POC's pattern. See OQ-47. for compatibility with the docker POC's pattern. See OQ-47.
- **exit** — server signals process exit with code. This is the last - **exit**: server signals process exit with code. This is the last
control chunk before stream close (see ADR-004). control chunk before stream close (see ADR-004).
The `type` tag is the extensibility seam: new control message types are The `type` tag is the extensibility seam: new control message types are
@@ -187,7 +187,7 @@ added by extending the tagged enum. Unknown `type` values are ignored
(not a protocol error) so that a newer client sending a control message (not a protocol error) so that a newer client sending a control message
an older server doesn't recognize degrades gracefully rather than an older server doesn't recognize degrades gracefully rather than
tearing down the session. This is a two-way-door extension point within tearing down the session. This is a two-way-door extension point within
the one-way-door wire format — adding a control message type is the one-way-door wire format: adding a control message type is
additive; changing the chunk header is not. additive; changing the chunk header is not.
### 4a. Control channel split (Phase 7 amendment, 2026-07-18) ### 4a. Control channel split (Phase 7 amendment, 2026-07-18)
@@ -207,7 +207,7 @@ The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length).
**Why the split.** The original §4 documented stream_type 3 as **Why the split.** The original §4 documented stream_type 3 as
"bidirectional" and listed the four control messages with their "bidirectional" and listed the four control messages with their
directions. But the adapter had no way to distinguish the two directions. But the adapter had no way to distinguish the two
directions on the same stream_type — `Exit` from the client was always directions on the same stream_type: `Exit` from the client was always
ignored (the adapter's `pump_client_to_backend` matched `Exit` and ignored (the adapter's `pump_client_to_backend` matched `Exit` and
logged "ignoring Exit control from client (server→client only)"). The logged "ignoring Exit control from client (server→client only)"). The
spec said "bidirectional"; the code was half-duplex. The split makes spec said "bidirectional"; the code was half-duplex. The split makes
@@ -220,7 +220,7 @@ exit vs. control without parsing the JSON `type` tag first.
**Door type.** One-way, same as the original §3 / §4. The stream_type **Door type.** One-way, same as the original §3 / §4. The stream_type
set is bytes clients and servers parse. A client written against the set is bytes clients and servers parse. A client written against the
old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as
`InvalidStreamType (> 3)` and tear down the session — the split is a `InvalidStreamType (> 3)` and tear down the session; the split is a
wire-format change, not an additive extension. The reversal path is wire-format change, not an additive extension. The reversal path is
the same as the original: a new ALPN (`alk/tty/v2`), which coexists the same as the original: a new ALPN (`alk/tty/v2`), which coexists
rather than replaces. The trade is one new stream_type byte now vs. rather than replaces. The trade is one new stream_type byte now vs.
@@ -231,7 +231,7 @@ the half-duplex-in-disguise flaw forever.
direction table is unchanged in content (the four messages keep their direction table is unchanged in content (the four messages keep their
directions and shapes) but the direction is now encoded in the directions and shapes) but the direction is now encoded in the
stream_type, not just in the adapter's behavior. ADR-004's "exit chunk stream_type, not just in the adapter's behavior. ADR-004's "exit chunk
is last" invariant is unchanged — the exit chunk still rides the is last" invariant is unchanged: the exit chunk still rides the
control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of
the old single `STREAM_CONTROL` (stream_type 3). the old single `STREAM_CONTROL` (stream_type 3).
@@ -243,7 +243,7 @@ response in the same 4-byte length-prefixed framing as the negotiation
frame and closes the stream without entering raw mode. The error frame and closes the stream without entering raw mode. The error
response shape is `{"error":"<code>","message":"..."}` (see response shape is `{"error":"<code>","message":"..."}` (see
[tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the [tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the
codes). This is not `call.error` — this is not the call protocol; the codes). This is not `call.error` (this is not the call protocol); the
error is a JSON response in the negotiation framing, and the stream error is a JSON response in the negotiation framing, and the stream
closes after it. closes after it.
@@ -252,11 +252,11 @@ allocation (raw chunks) and a failed allocation (JSON error frame)
begin with bytes the client must read before knowing which framing begin with bytes the client must read before knowing which framing
applies. The disambiguation is by the first byte: a JSON error frame's applies. The disambiguation is by the first byte: a JSON error frame's
4-byte big-endian length prefix always starts with `0x00` (error frames 4-byte big-endian length prefix always starts with `0x00` (error frames
MUST be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this MUST be under 16 MiB, the `MAX_CHUNK_LEN` cap, so the high byte is
is a wire-format invariant, not an assumption), while a raw chunk's zero; this is a wire-format invariant, not an assumption), while a raw chunk's
first byte is a `stream_type` in `{0, 1, 2, 3, 4}`. A stream_type of first byte is a `stream_type` in `{0, 1, 2, 3, 4}`. A stream_type of
`0` (stdin from server) is invalid — the server never sends stdin `0` (stdin from server) is invalid, because the server never sends
chunks — so the client distinguishes: read the first byte; if it is stdin chunks; the client distinguishes: read the first byte; if it is
`0x00`, interpret the next 4 bytes as a big-endian length prefix and `0x00`, interpret the next 4 bytes as a big-endian length prefix and
read that many bytes as a JSON error frame; otherwise interpret it as read that many bytes as a JSON error frame; otherwise interpret it as
a `stream_type` byte and continue reading the raw chunk header. This is a `stream_type` byte and continue reading the raw chunk header. This is
@@ -265,7 +265,7 @@ framing (length prefix) and MUST be under 16 MiB; success uses the raw
chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs
`0x00`-as-invalid-stream_type disambiguation is what makes the two `0x00`-as-invalid-stream_type disambiguation is what makes the two
distinguishable on the wire. (With the Phase 7 split, the server-sent distinguishable on the wire. (With the Phase 7 split, the server-sent
set is `{1, 2, 4}` — stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is set is `{1, 2, 4}`: stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is
still unambiguous.) still unambiguous.)
### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency) ### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency)
@@ -274,7 +274,7 @@ The 4-byte length prefix + JSON body is implemented in alktty as a
small, self-contained module (`src/negotiation.rs`, ~30 lines: read small, self-contained module (`src/negotiation.rs`, ~30 lines: read
4-byte BE length, bounds-check, read N bytes; write the inverse). The 4-byte BE length, bounds-check, read N bytes; write the inverse). The
format coincides with alkcall's `EventEnvelope` framing by convention format coincides with alkcall's `EventEnvelope` framing by convention
(both are length-prefixed JSON) — not by code reuse. alktty does not (both are length-prefixed JSON), not by code reuse. alktty does not
depend on alkcall's internal wire types: the negotiation payload is a depend on alkcall's internal wire types: the negotiation payload is a
tty-specific struct (`NegotiateRequest`), not a `call.requested` tty-specific struct (`NegotiateRequest`), not a `call.requested`
`EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is `EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is
@@ -294,7 +294,7 @@ clarification.
- The wire format is POC-validated twice (docker POC for stream_type - The wire format is POC-validated twice (docker POC for stream_type
0/1/2 + bidirectional pump; tty POC for stream_type 3 + control 0/1/2 + bidirectional pump; tty POC for stream_type 3 + control
messages + local PTY). No new wire-format invention in Phase 1. messages + local PTY). No new wire-format invention in Phase 1.
- The fixed channel set is a `match`, not a hash lookup — fast on the - The fixed channel set is a `match`, not a hash lookup, fast on the
hot path where every chunk is data. hot path where every chunk is data.
- The two-carriage model keeps the call protocol's JSON-RPC shape for - The two-carriage model keeps the call protocol's JSON-RPC shape for
the structured request while letting the body be raw bytes, which is the structured request while letting the body be raw bytes, which is
@@ -312,21 +312,21 @@ clarification.
**Negative:** **Negative:**
- A 6th channel type is a wire-format change (one-way door). The - A 6th channel type is a wire-format change (one-way door). The
justification is that the use cases are bounded — a terminal session justification is that the use cases are bounded: a terminal session
has stdin, stdout, stderr, and control (now split into ctrl-in / has stdin, stdout, stderr, and control (now split into ctrl-in /
ctrl-out). New sideband needs are different ALPNs, not 6th channels. ctrl-out). New sideband needs are different ALPNs, not 6th channels.
If this proves wrong, the reversal is a new ALPN string If this proves wrong, the reversal is a new ALPN string
(`alk/tty/v2`), which coexists with the old one rather than replacing (`alk/tty/v2`), which coexists with the old one rather than replacing
it — but every client and server implementing the old format would it, but every client and server implementing the old format would
need updating to speak the new one. need updating to speak the new one.
- Control as JSON means a `serde_json` deserialize per control chunk. - Control as JSON means a `serde_json` deserialize per control chunk.
Control chunks are rare (one per resize, one per signal, one eof, one Control chunks are rare (one per resize, one per signal, one eof, one
exit), so this is negligible. A hot control path would warrant a exit), so this is negligible. A hot control path would warrant a
binary format — the `type`-tagged enum leaves that door open without binary format; the `type`-tagged enum leaves that door open without
a wire-format change. a wire-format change.
- The negotiation frame is a custom JSON shape, not a `call.requested` - The negotiation frame is a custom JSON shape, not a `call.requested`
event, so a client library can't reuse its call-protocol client to event, so a client library can't reuse its call-protocol client to
open a tty session — it speaks the tty wire format directly. This is open a tty session; it speaks the tty wire format directly. This is
intentional (the tty session is not a call-protocol operation) but intentional (the tty session is not a call-protocol operation) but
means the tty client is a separate small client, not a `CallClient` means the tty client is a separate small client, not a `CallClient`
method. method.
@@ -337,8 +337,8 @@ clarification.
construction (see §Assumptions 1). If a workload ever requires construction (see §Assumptions 1). If a workload ever requires
sub-QUIC-window backpressure signaling, the reversal is an additive sub-QUIC-window backpressure signaling, the reversal is an additive
`ControlMessage::WindowUpdate` (or similar) variant on the control `ControlMessage::WindowUpdate` (or similar) variant on the control
channel — a two-way-door extension to the control channel, not a channel (a two-way-door extension to the control channel, not a
wire-format header change. The `type`-tagged enum's "unknown types wire-format header change). The `type`-tagged enum's "unknown types
ignored" rule means older peers degrade gracefully if such a variant ignored" rule means older peers degrade gracefully if such a variant
is introduced. is introduced.
@@ -348,7 +348,7 @@ clarification.
stream_type set (0-4), and the two-carriage sequence (JSON frame → raw stream_type set (0-4), and the two-carriage sequence (JSON frame → raw
chunks) are bytes clients and servers parse. Changing any of them breaks chunks) are bytes clients and servers parse. Changing any of them breaks
every client and server implementing the format. The reversal path is a every client and server implementing the format. The reversal path is a
new ALPN (`alk/tty/v2`), which coexists rather than replaces — but the new ALPN (`alk/tty/v2`), which coexists rather than replaces, but the
cost of migrating every consumer is the one-way-door cost. cost of migrating every consumer is the one-way-door cost.
The control message `type` enum is a two-way-door extension point within The control message `type` enum is a two-way-door extension point within
@@ -362,14 +362,14 @@ not.
backpressure mechanism.** The chunk format carries no window field. backpressure mechanism.** The chunk format carries no window field.
This is decided (OQ-45 resolved): the backpressure chain from a slow This is decided (OQ-45 resolved): the backpressure chain from a slow
client read all the way back to the child process's stdout write is client read all the way back to the child process's stdout write is
complete by construction — QUIC flow control → bounded drainer complete by construction: QUIC flow control → bounded drainer
channel → bounded stdout channel → OS pipe/PTY buffer → process channel → bounded stdout channel → OS pipe/PTY buffer → process
`write()` blocks. Every link awaits its producer; no unbounded `write()` blocks. Every link awaits its producer; no unbounded
buffer breaks the chain. The reversal path, if a workload ever buffer breaks the chain. The reversal path, if a workload ever
surfaces a problem QUIC's defaults cannot handle, is an additive surfaces a problem QUIC's defaults cannot handle, is an additive
window-update `ControlMessage` variant (a two-way-door extension to window-update `ControlMessage` variant (a two-way-door extension to
the control channel, not a wire-format header change) — noted in the control channel, not a wire-format header change), noted in
§Consequences. See OQ-45. §Consequences. See OQ-45.
2. **The negotiation frame fits in one chunk of the underlying stream's 2. **The negotiation frame fits in one chunk of the underlying stream's
initial flow-control window.** The frame is small (terminal params + initial flow-control window.** The frame is small (terminal params +
@@ -386,26 +386,26 @@ not.
## References ## References
- alknet ADR-001 — ALPN-based dispatch - alknet ADR-001: ALPN-based dispatch
- alknet ADR-002 — ProtocolHandler trait - alknet ADR-002: ProtocolHandler trait
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per - alknet ADR-006: `alk/<name>` ALPN convention; one ALPN per
connection; new ALPN for incompatible versions connection; new ALPN for incompatible versions
- alknet ADR-007 — handler receives a `Connection`, accepts bidi streams - alknet ADR-007: handler receives a `Connection`, accepts bidi streams
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call; - alknet ADR-003 Amendment 2: alktty does not depend on alknet-call;
self-contained negotiation framing self-contained negotiation framing
- [ADR-006](006-negotiation-framing-self-contained.md) — the - [ADR-006](006-negotiation-framing-self-contained.md): the
dependency-edge decision this ADR's §6 reflects dependency-edge decision this ADR's §6 reflects
- alknet ADR-012 — the call protocol's stream model (which tty is *not* - alknet ADR-012 (the call protocol's stream model, which tty is *not*
using for the body, by design) using for the body, by design)
- alknet ADR-049 — the `StreamingHandler` path tty explicitly does not - alknet ADR-049: the `StreamingHandler` path tty explicitly does not
use for the byte body use for the byte body
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend trait - [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the backend trait
the negotiation frame's `backend` field dispatches to the negotiation frame's `backend` field dispatches to
- [ADR-003](003-local-backend-placement.md) — the local backend's - [ADR-003](003-local-backend-placement.md): the local backend's
module placement (the `tty: null` pipe-mode case) module placement (the `tty: null` pipe-mode case)
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk - [ADR-004](004-exit-code-on-control-chunk.md): the exit-chunk
ordering this ADR's control channel carries ordering this ADR's control channel carries
- [ADR-008](008-channels-pure-channel-multiplexing.md) — TTY always - [ADR-008](008-channels-pure-channel-multiplexing.md): TTY always
uses its 5-byte format, including inside channels (the channels layer uses its 5-byte format, including inside channels (the channels layer
carries this wire format transparently in its payload) carries this wire format transparently in its payload)
- Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md) - Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md)
@@ -1,9 +1,9 @@
# ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point # ADR-002: TtyBackend Trait and TtyHandle: the Backend Inversion Point
## Status ## Status
Accepted (ported from alknet ADR-053 2026-08-17; cross-references Accepted (ported from alknet ADR-053 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, renumbered to alktty's ADR range: ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017, ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017,
050) are not ported into alktty's ADR range because they are not 050) are not ported into alktty's ADR range because they are not
@@ -13,7 +13,7 @@ authoritative.)
## Context ## Context
alktty's wire format (ADR-001) is backend-agnostic — the chunk codec alktty's wire format (ADR-001) is backend-agnostic: the chunk codec
pumps bytes and JSON control messages without knowing whether the pumps bytes and JSON control messages without knowing whether the
backend is a docker container, an SSH session channel, or a local backend is a docker container, an SSH session channel, or a local
process. The question this ADR answers: **what is the seam between the process. The question this ADR answers: **what is the seam between the
@@ -22,7 +22,7 @@ wire-format adapter and the backends?**
The alknet-tty research identified the `TtyBackend` trait as the The alknet-tty research identified the `TtyBackend` trait as the
inversion point. The guiding insight: inversion point. The guiding insight:
> A terminal session is not an SSH concern, or a Docker concern — it is > A terminal session is not an SSH concern, or a Docker concern; it is
> a terminal concern. SSH and Docker are just two backends that can > a terminal concern. SSH and Docker are just two backends that can
> allocate a PTY. > allocate a PTY.
@@ -32,7 +32,7 @@ alknet-ssh, alktty's `local` feature module) implement the trait. This
preserves alknet ADR-003's no-handler-depends-on-another-handler rule: preserves alknet ADR-003's no-handler-depends-on-another-handler rule:
alktty depends on alkcall; backend crates depend on alktty for the alktty depends on alkcall; backend crates depend on alktty for the
trait; alktty does not depend on any backend (and, per ADR-006, does trait; alktty does not depend on any backend (and, per ADR-006, does
not depend on alkcall's internal wire types either — the negotiation not depend on alkcall's internal wire types either; the negotiation
framing is self-contained). framing is self-contained).
### What the local-PTY POC discovered about the trait shape ### What the local-PTY POC discovered about the trait shape
@@ -46,7 +46,7 @@ in the alknet findings doc):
`portable_pty` is a blocking `std::io::{Read, Write}` API with a `portable_pty` is a blocking `std::io::{Read, Write}` API with a
blocking `Child::wait()`. The POC bridges it to async via three blocking `Child::wait()`. The POC bridges it to async via three
dedicated std threads (reader, writer, waiter) feeding tokio dedicated std threads (reader, writer, waiter) feeding tokio
mpsc/oneshot channels — the same pattern wezterm (portable_pty's mpsc/oneshot channels, the same pattern wezterm (portable_pty's
primary consumer) uses. The trait's adapter-facing types primary consumer) uses. The trait's adapter-facing types
(`AsyncWrite`, `Stream<Item = Bytes>`, `BoxFuture`) are the (`AsyncWrite`, `Stream<Item = Bytes>`, `BoxFuture`) are the
*adapter's* contract; a backend may expose blocking handles *adapter's* contract; a backend may expose blocking handles
@@ -71,9 +71,9 @@ The POC used a separate cloneable `PtyControl` struct for resize/signal,
not a trait object. The research noted this worked cleanly because the not a trait object. The research noted this worked cleanly because the
control-chunk dispatcher needs to be `Clone` to hand off to the spawned control-chunk dispatcher needs to be `Clone` to hand off to the spawned
pump task. Phase 1 confirms the `control` field as a separate pump task. Phase 1 confirms the `control` field as a separate
`TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct `TtyControlHandle` newtype: a concrete `#[derive(Clone)]` struct
wrapping `Arc<dyn TtyControl + Send + Sync>` (the trait is NOT `Clone`; wrapping `Arc<dyn TtyControl + Send + Sync>` (the trait is NOT `Clone`;
`Clone` is not object-safe — see OQ-43). The newtype carries the `Clone` is not object-safe; see OQ-43). The newtype carries the
`Clone`-ability; the trait stays object-safe. `Clone`-ability; the trait stays object-safe.
## Decision ## Decision
@@ -90,7 +90,7 @@ pub trait TtyBackend: Send + Sync {
/// The pre-existing resource this session targets, for ownership /// The pre-existing resource this session targets, for ownership
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session /// checks (alknet ADR-050). `None` = no pre-existing resource (the session
/// creates its own — local process, SSH channel). `Some((kind, id))` /// creates its own: local process, SSH channel). `Some((kind, id))`
/// = the session targets an existing resource the caller must own /// = the session targets an existing resource the caller must own
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The /// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
/// adapter calls this at negotiation to gate access; the backend /// adapter calls this at negotiation to gate access; the backend
@@ -106,11 +106,11 @@ The assembly layer registers backends at startup; the adapter dispatches
by the `backend` field. A backend is the *thing that allocates a by the `backend` field. A backend is the *thing that allocates a
session*; the wire-format pump is backend-agnostic. session*; the wire-format pump is backend-agnostic.
### 2. `TtyParams` — the allocation request ### 2. `TtyParams`: the allocation request
```rust ```rust
pub struct TtyParams { pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case, /// Terminal parameters. `None` = pipe mode (no PTY; the runner case,
/// ADR-003). `Some` = allocate a PTY with these dimensions. /// ADR-003). `Some` = allocate a PTY with these dimensions.
pub terminal: Option<TerminalParams>, pub terminal: Option<TerminalParams>,
/// Command vector (argv[0] + args). /// Command vector (argv[0] + args).
@@ -133,12 +133,12 @@ pub struct TerminalParams {
pub rows: u16, pub rows: u16,
pub pixel_width: u16, pub pixel_width: u16,
pub pixel_height: u16, pub pixel_height: u16,
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1
} }
``` ```
`terminal: None` is the pipe/runner case — no PTY, separate `terminal: None` is the pipe/runner case: no PTY, separate
stdout/stderr (ADR-003). `terminal: Some` is the PTY case — stdout/stderr (ADR-003). `terminal: Some` is the PTY case:
stdout/stderr merged into the single stdout stream stdout/stderr merged into the single stdout stream
(`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal (`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal
delivery to process group). delivery to process group).
@@ -149,7 +149,7 @@ delivery to process group).
a typed enum. The adapter passes the negotiation frame's a typed enum. The adapter passes the negotiation frame's
backend-specific fields through verbatim; the backend deserializes its backend-specific fields through verbatim; the backend deserializes its
own strongly-typed params struct. alktty has zero knowledge of any own strongly-typed params struct. alktty has zero knowledge of any
backend's params shape — not docker's `container`, not an SSH host backend's params shape: not docker's `container`, not an SSH host
selector, not anything. selector, not anything.
Each backend defines its own params struct: Each backend defines its own params struct:
@@ -164,7 +164,7 @@ struct DockerBackendParams { container: String }
struct SshBackendParams { /* host selector if multi-host; else empty */ } struct SshBackendParams { /* host selector if multi-host; else empty */ }
// in alktty's local feature module // in alktty's local feature module
// no backend-specific params — backend_params is empty // no backend-specific params; backend_params is empty
``` ```
And deserializes from `params.backend_params` inside `allocate()`: And deserializes from `params.backend_params` inside `allocate()`:
@@ -183,7 +183,7 @@ impl TtyBackend for DockerTtyBackend {
This is a complete inversion: the *trait* is inverted (backends This is a complete inversion: the *trait* is inverted (backends
implement, alktty doesn't depend on them) and the *params* are inverted implement, alktty doesn't depend on them) and the *params* are inverted
(backends define their own typed shape, alktty doesn't carry it). A new (backends define their own typed shape, alktty doesn't carry it). A new
backend crate requires zero changes to alktty — no enum variant to add, backend crate requires zero changes to alktty: no enum variant to add,
no forward-reference type to place, no dependency edge. no forward-reference type to place, no dependency edge.
**Why not a typed enum.** The earlier draft of this ADR defined **Why not a typed enum.** The earlier draft of this ADR defined
@@ -203,7 +203,7 @@ problems:
`ChannelId` and session reference." If it lives in alknet-ssh, `ChannelId` and session reference." If it lives in alknet-ssh,
alktty depends on alknet-ssh (violates the inversion). If it lives in alktty depends on alknet-ssh (violates the inversion). If it lives in
alktty, alktty pulls in russh types (same violation). Opaque params alktty, alktty pulls in russh types (same violation). Opaque params
dissolve the contradiction — there is no `SshChannelRef` type in dissolve the contradiction: there is no `SshChannelRef` type in
alktty at all. alktty at all.
The earlier draft rejected `serde_json::Value` because "it loses type The earlier draft rejected `serde_json::Value` because "it loses type
@@ -212,40 +212,40 @@ shouldn't interpret." The first concern doesn't apply (each backend has
its own strongly-typed struct via serde; type safety moves from alktty its own strongly-typed struct via serde; type safety moves from alktty
to the backend where it belongs). The second was already inconsistent to the backend where it belongs). The second was already inconsistent
with the adapter, which hardcoded extraction of docker's `container` with the adapter, which hardcoded extraction of docker's `container`
field for the ownership check — the adapter *was* parsing field for the ownership check; the adapter *was* parsing
backend-specific JSON. The opaque approach removes that: the adapter backend-specific JSON. The opaque approach removes that: the adapter
delegates the resource-id extraction to the backend via `resource_id()` delegates the resource-id extraction to the backend via `resource_id()`
below. below.
### 3. `TtyHandle` — what a backend produces ### 3. `TtyHandle`: what a backend produces
```rust ```rust
pub struct TtyHandle { pub struct TtyHandle {
/// Stdin writer — bytes the adapter pumps from client stdin chunks. /// Stdin writer: bytes the adapter pumps from client stdin chunks.
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io` /// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
/// one — they are incompatible traits; the tokio stack is the /// one; they are incompatible traits; the tokio stack is the
/// adapter's runtime). /// adapter's runtime).
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>, pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// Stdout stream — bytes the adapter pumps to client stdout chunks. /// Stdout stream: bytes the adapter pumps to client stdout chunks.
/// Ends when the backend's stdout reaches EOF (process exited, /// Ends when the backend's stdout reaches EOF (process exited,
/// container output stream ended, SSH channel closed). /// container output stream ended, SSH channel closed).
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by /// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods). /// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>, pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged /// Stderr stream: `None` for PTY backends (stdout/stderr merged
/// into `stdout`). `Some` for pipe backends (separate streams). /// into `stdout`). `Some` for pipe backends (separate streams).
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>, pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
/// Exit code — a `Future` the adapter awaits. Resolves when the /// Exit code: a `Future` the adapter awaits. Resolves when the
/// process/container/SSH exec exits. The adapter sends the result /// process/container/SSH exec exits. The adapter sends the result
/// as the `{"type":"exit","code":N}` control chunk (ADR-004) and /// as the `{"type":"exit","code":N}` control chunk (ADR-004) and
/// closes the stream. This is `BoxFuture`, not a method on /// closes the stream. This is `BoxFuture`, not a method on
/// `TtyHandle`, so the adapter can `select` between exit and /// `TtyHandle`, so the adapter can `select` between exit and
/// stream-close without coupling to the other fields. (REQ-TTY-01.) /// stream-close without coupling to the other fields. (REQ-TTY-01.)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>, pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can /// Control handle (resize, signal). `Clone` so the adapter can
/// hand it to the spawned control-chunk dispatcher. `None` only /// hand it to the spawned control-chunk dispatcher. `None` only
/// when the backend genuinely has no control path (e.g., a pipe /// when the backend genuinely has no control path (e.g., a pipe
/// backend with no PTY — signal still works via `kill(pid, sig)`, /// backend with no PTY; signal still works via `kill(pid, sig)`,
/// but resize is a no-op). See OQ-43 for the `TtyControlHandle` /// but resize is a no-op). See OQ-43 for the `TtyControlHandle`
/// newtype rationale. /// newtype rationale.
pub control: Option<TtyControlHandle>, pub control: Option<TtyControlHandle>,
@@ -265,7 +265,7 @@ pub trait TtyControl: Send + Sync {
} }
/// The `Clone`-able handle to a backend's control path. The `TtyControl` /// The `Clone`-able handle to a backend's control path. The `TtyControl`
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) -> /// trait is NOT `Clone` (`Clone` is not object-safe: `fn clone(&self) ->
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability /// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
/// lives on this concrete newtype, which holds the trait object behind an /// lives on this concrete newtype, which holds the trait object behind an
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned /// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
@@ -279,9 +279,9 @@ pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`, The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`BoxFuture`, the `TtyControl` trait object) are the **adapter's `BoxFuture`, the `TtyControl` trait object) are the **adapter's
contract**. A backend may expose blocking handles internally and bridge contract**. A backend may expose blocking handles internally and bridge
them to these async-facing types. The bridging pattern — blocking them to these async-facing types. The bridging pattern (blocking
`std::io` on dedicated std threads or `tokio::task::spawn_blocking`, `std::io` on dedicated std threads or `tokio::task::spawn_blocking`,
feeding tokio mpsc/oneshot channels — is a **documented, supported feeding tokio mpsc/oneshot channels) is a **documented, supported
implementation strategy**, not a workaround. implementation strategy**, not a workaround.
The local backend (ADR-003) uses this pattern: `portable_pty` is a The local backend (ADR-003) uses this pattern: `portable_pty` is a
@@ -293,7 +293,7 @@ the threading. See `tty-local.md` for the bridge details.
### 5. Backend registration and the assembly layer ### 5. Backend registration and the assembly layer
The `TtyAdapter` does not know the set of backends at compile time — it The `TtyAdapter` does not know the set of backends at compile time; it
holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at
construction. The assembly layer (the CLI binary) constructs backends construction. The assembly layer (the CLI binary) constructs backends
with their dependencies (a `DockerTtyBackend` wraps a `bollard::Docker` with their dependencies (a `DockerTtyBackend` wraps a `bollard::Docker`
@@ -326,21 +326,21 @@ layer chooses what's available.
`OperationAdapter` (alknet ADR-017): the trait lives where the types `OperationAdapter` (alknet ADR-017): the trait lives where the types
live; the implementations live where their transport dependencies live; the implementations live where their transport dependencies
live. live.
- Blocking-API backends (portable_pty) are first-class — the trait - Blocking-API backends (portable_pty) are first-class: the trait
accommodates them by making the adapter-facing types the contract and accommodates them by making the adapter-facing types the contract and
the bridging pattern a documented strategy. No re-spec required when the bridging pattern a documented strategy. No re-spec required when
a future backend is also blocking. a future backend is also blocking.
- `exit_code` as a `Future` (not a method on the handle) lets the - `exit_code` as a `Future` (not a method on the handle) lets the
adapter `select` between exit and stream-close — the load-bearing adapter `select` between exit and stream-close, the load-bearing
composition the session lifecycle needs (the exit chunk is sent after composition the session lifecycle needs (the exit chunk is sent after
the child is reaped, then the stream closes — ADR-004). the child is reaped, then the stream closes; ADR-004).
**Negative:** **Negative:**
- **Backend params are opaque (`serde_json::Map`), not a typed enum.** - **Backend params are opaque (`serde_json::Map`), not a typed enum.**
Each backend deserializes its own params struct; the adapter passes Each backend deserializes its own params struct; the adapter passes
the JSON through verbatim. The cost is one serde deserialize per the JSON through verbatim. The cost is one serde deserialize per
`allocate()` call (negligible — allocation is once per session, not on `allocate()` call (negligible; allocation is once per session, not on
the hot path). The benefit is a complete inversion: alktty has zero the hot path). The benefit is a complete inversion: alktty has zero
knowledge of any backend's params shape, and a new backend crate knowledge of any backend's params shape, and a new backend crate
requires zero changes to alktty (no enum variant, no forward-reference requires zero changes to alktty (no enum variant, no forward-reference
@@ -372,7 +372,7 @@ layer chooses what's available.
`TtyHandle` field set, and the `TtyControl` trait are the API surface `TtyHandle` field set, and the `TtyControl` trait are the API surface
every backend crate implements and the adapter consumes. Changing them every backend crate implements and the adapter consumes. Changing them
after backends exist is a rewrite across crates. `backend_params` as an after backends exist is a rewrite across crates. `backend_params` as an
opaque `serde_json::Map` is part of the one-way `TtyParams` shape — the opaque `serde_json::Map` is part of the one-way `TtyParams` shape: the
*carrier* is fixed (opaque JSON), but the *contents* are *carrier* is fixed (opaque JSON), but the *contents* are
backend-defined and require no alktty change for new backends. The backend-defined and require no alktty change for new backends. The
`resource_id()` default method is additive (a new method with a default `resource_id()` default method is additive (a new method with a default
@@ -384,7 +384,7 @@ impl doesn't break existing implementors); its return type
1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone` 1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone`
on it; the `TtyControlHandle` newtype holds the trait object behind on it; the `TtyControlHandle` newtype holds the trait object behind
an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used
a concrete `PtyControl` struct (inherently `Clone` — it held a concrete `PtyControl` struct (inherently `Clone`: it held
`Arc<Mutex<...>>` fields). The newtype generalizes the POC's shape so `Arc<Mutex<...>>` fields). The newtype generalizes the POC's shape so
a backend produces its own control type via a backend produces its own control type via
`TtyControlHandle::new(Arc::new(MyControl))` without the adapter `TtyControlHandle::new(Arc::new(MyControl))` without the adapter
@@ -393,43 +393,43 @@ impl doesn't break existing implementors); its return type
2. **Backends produce a single session per `allocate()` call.** The 2. **Backends produce a single session per `allocate()` call.** The
adapter calls `allocate()` once per accepted bidi stream (one session adapter calls `allocate()` once per accepted bidi stream (one session
per stream — ADR-001). A backend that multiplexed multiple sessions per stream; ADR-001). A backend that multiplexed multiple sessions
over one `allocate()` would not fit the trait; no such backend is over one `allocate()` would not fit the trait; no such backend is
contemplated. contemplated.
3. **The adapter, not the backend, owns the exit-chunk ordering.** The 3. **The adapter, not the backend, owns the exit-chunk ordering.** The
backend resolves `exit_code`; the adapter awaits it, sends the exit backend resolves `exit_code`; the adapter awaits it, sends the exit
control chunk, and closes the stream (ADR-004). The backend does not control chunk, and closes the stream (ADR-004). The backend does not
write to the wire — it produces handles; the adapter pumps. This write to the wire: it produces handles; the adapter pumps. This
keeps the wire-format logic in one place (the adapter) and the keeps the wire-format logic in one place (the adapter) and the
backend focused on its allocation target (docker, ssh, local backend focused on its allocation target (docker, ssh, local
process). process).
## References ## References
- alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another- - alknet ADR-003 + Amendments 1 & 2: no-handler-depends-on-another-
handler; alktty depends on alkcall (no alkcall-internal-wire-types per handler; alktty depends on alkcall (no alkcall-internal-wire-types per
Am. 2 / ADR-006); backends depend on alktty for the trait Am. 2 / ADR-006); backends depend on alktty for the trait
- [ADR-006](006-negotiation-framing-self-contained.md) — alktty does not - [ADR-006](006-negotiation-framing-self-contained.md): alktty does not
depend on alkcall's internal wire types (self-contained negotiation depend on alkcall's internal wire types (self-contained negotiation
framing) framing)
- alknet ADR-007 — `Connection`, `SendStream`, `RecvStream` (the adapter - alknet ADR-007: `Connection`, `SendStream`, `RecvStream` (the adapter
receives a `Connection`, accepts bidi streams, pumps per-session) receives a `Connection`, accepts bidi streams, pumps per-session)
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format this - [ADR-001](001-wire-format-and-two-carriage.md): the wire format this
trait's backends feed trait's backends feed
- [ADR-003](003-local-backend-placement.md) — the local backend's - [ADR-003](003-local-backend-placement.md): the local backend's
module placement (folded into alktty behind a `local` feature) module placement (folded into alktty behind a `local` feature)
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit chunk ordering - [ADR-004](004-exit-code-on-control-chunk.md): the exit chunk ordering
this trait's `exit_code` field feeds into this trait's `exit_code` field feeds into
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel- - [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel-
cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel
kills the session target) kills the session target)
- alknet ADR-017 — the adapter-location-map pattern (trait where types - alknet ADR-017: the adapter-location-map pattern (trait where types
live, implementation where deps live) this ADR follows live, implementation where deps live) this ADR follows
- alknet ADR-050 — the ownership model the `resource_id()` default - alknet ADR-050: the ownership model the `resource_id()` default
declares against declares against
- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed) - OQ-43: `TtyControl` as `Clone` trait object (resolved: confirmed)
- OQ-44 — terminal modes (deferred(scope): not needed for current scope) - OQ-44: terminal modes (deferred(scope): not needed for current scope)
- Spec: [tty-backend.md](../tty-backend.md) - Spec: [tty-backend.md](../tty-backend.md)
- Port origin: alknet ADR-053 at - Port origin: alknet ADR-053 at
`/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md` `/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md`
@@ -4,7 +4,7 @@
Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate
consolidation recorded as the alktty resolution. Cross-references consolidation recorded as the alktty resolution. Cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, renumbered to alktty's ADR range: ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. Alknet ADRs referenced by alknet number (003, 009, 017) ADR-093→008. Alknet ADRs referenced by alknet number (003, 009, 017)
are not ported into alktty's ADR range because they are not tty-specific; are not ported into alktty's ADR range because they are not tty-specific;
@@ -29,15 +29,15 @@ local-process backend (`std::process::Command` with piped stdio, or
The local backend is the simplest backend and the one that enables the The local backend is the simplest backend and the one that enables the
runner pattern (a process whose stdin/stdout/stderr/exit-code stream runner pattern (a process whose stdin/stdout/stderr/exit-code stream
over a framed bidi connection — the same shape as GitHub/Gitea Actions over a framed bidi connection, the same shape as GitHub/Gitea Actions
runners, just over alk's transport instead of HTTP polling). It has no runners, just over alk's transport instead of HTTP polling). It has no
heavy dependencies in the pipe case — just `std` — but the PTY case heavy dependencies in the pipe case (just `std`), but the PTY case
pulls in `portable_pty` (a non-trivial native dependency that builds on pulls in `portable_pty` (a non-trivial native dependency that builds on
Unix `openpty`/`ioctl` and Windows ConPTY). Unix `openpty`/`ioctl` and Windows ConPTY).
The relevant constraint from ADR-002: alktty itself depends only on The relevant constraint from ADR-002: alktty itself depends only on
alkcall and the wire-format codec (ADR-001). The `portable_pty` alkcall and the wire-format codec (ADR-001). The `portable_pty`
dependency does not belong in the core tty crate — a docker-only dependency does not belong in the core tty crate: a docker-only
deployment or an SSH-PTY-only deployment should not pull in PTY deployment or an SSH-PTY-only deployment should not pull in PTY
allocation code. This is the same inversion as `OperationAdapter` allocation code. This is the same inversion as `OperationAdapter`
(alknet ADR-017): the trait lives where the types live; the (alknet ADR-017): the trait lives where the types live; the
@@ -56,7 +56,7 @@ in `alknet-tty-local`; alknet-tty itself never depends on
The sibling-crate placement was motivated by a cyclic-dependency The sibling-crate placement was motivated by a cyclic-dependency
workaround: `alknet-tty-local` depends on `alknet-tty` for the trait, workaround: `alknet-tty-local` depends on `alknet-tty` for the trait,
and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
behind a `local` feature — which cargo rejects (a crate cannot re-export behind a `local` feature, which cargo rejects (a crate cannot re-export
from a sibling crate it depends on via an optional dep AND have that from a sibling crate it depends on via an optional dep AND have that
sibling depend back on it). The assembly-layer workaround (consumer sibling depend back on it). The assembly-layer workaround (consumer
depends on both crates directly) worked but was awkward. depends on both crates directly) worked but was awkward.
@@ -74,7 +74,7 @@ ssh-only deployment enables neither feature and never pulls in
This ADR records both decisions: the original alknet sibling-crate This ADR records both decisions: the original alknet sibling-crate
decision (preserved as the historical context for why the local backend decision (preserved as the historical context for why the local backend
is a separable concern), and the alktty consolidation (the operative is a separable concern), and the alktty consolidation (the operative
decision — the local backend is folded into alktty behind a `local` decision: the local backend is folded into alktty behind a `local`
feature). feature).
## Decision ## Decision
@@ -100,8 +100,8 @@ tokio-util = { version = "0.7", features = ["io"], optional = true }
A consumer that wants the local backend enables `features = ["local"]` A consumer that wants the local backend enables `features = ["local"]`
on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that
only wants docker/ssh backends uses the default features and depends on only wants docker/ssh backends uses the default features and depends on
`alknet-docker` / `alknet-ssh` (or their own backend crate) directly — `alknet-docker` / `alknet-ssh` (or their own backend crate) directly,
no `portable_pty` in the dependency tree. with no `portable_pty` in the dependency tree.
This is the same feature-re-export pattern the Rust ecosystem uses for This is the same feature-re-export pattern the Rust ecosystem uses for
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
@@ -118,15 +118,15 @@ coupled to the trait.
alktty (local feature enabled) alktty (local feature enabled)
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types) ├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alkcall::core (via alktty's re-export; not direct) ├── alkcall::core (via alktty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, here not in alktty default) ├── portable_pty (PTY allocation, the heavy dep, not in alktty default)
├── libc (signal forwarding — REQ-TTY-02, Unix only) ├── libc (signal forwarding, REQ-TTY-02, Unix only)
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite) └── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
``` ```
The `local` feature is inherently non-wasm (`portable-pty` + The `local` feature is inherently non-wasm (`portable-pty` +
`tokio::process` need a real OS); enabling `local` on `tokio::process` need a real OS); enabling `local` on
`wasm32-unknown-unknown` is a build error by design. The default crate `wasm32-unknown-unknown` is a build error by design. The default crate
(no features) stays wasm-clean — this is what makes the downstream (no features) stays wasm-clean; this is what makes the downstream
TS/Python adapter story work (a wasm-compiled alktty is the protocol TS/Python adapter story work (a wasm-compiled alktty is the protocol
layer for a sandboxed adapter; the local-process backend runs on a real layer for a sandboxed adapter; the local-process backend runs on a real
OS, never in a sandbox). OS, never in a sandbox).
@@ -135,19 +135,19 @@ OS, never in a sandbox).
`TtyParams.terminal: Option<TerminalParams>` (ADR-002) selects the mode: `TtyParams.terminal: Option<TerminalParams>` (ADR-002) selects the mode:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY - **`terminal: Some(TerminalParams { ... })`**: allocate a real PTY
via `portable_pty`. Terminal semantics: resize (via via `portable_pty`. Terminal semantics: resize (via
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group `ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling (via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
(the kernel PTY's line discipline). stdout and stderr are merged (the kernel PTY's line discipline). stdout and stderr are merged
(kernel PTY property — one output stream from the slave), so (kernel PTY property: one output stream from the slave), so
`TtyHandle.stderr` is `None`. `TtyHandle.stderr` is `None`.
- **`terminal: None`** — pipe mode, no PTY. `tokio::process::Command` - **`terminal: None`**: pipe mode, no PTY. `tokio::process::Command`
with `Stdio::piped()` for stdin/stdout/stderr. No resize, no with `Stdio::piped()` for stdin/stdout/stderr. No resize, no
escape-sequence handling, but `kill(pid, sig)` still works for signal escape-sequence handling, but `kill(pid, sig)` still works for signal
forwarding. stdout and stderr are separate streams, so forwarding. stdout and stderr are separate streams, so
`TtyHandle.stderr` is `Some`. This is the **runner** case — a `TtyHandle.stderr` is `Some`. This is the **runner** case, a
command-streaming endpoint with no terminal semantics. command-streaming endpoint with no terminal semantics.
The same `LocalTtyBackend` serves both; the `allocate()` call branches The same `LocalTtyBackend` serves both; the `allocate()` call branches
on `params.terminal`. A deployment that only does terminals always on `params.terminal`. A deployment that only does terminals always
@@ -169,7 +169,7 @@ polling:
chunks back, sends `{"type":"exit","code":N}` when it finishes chunks back, sends `{"type":"exit","code":N}` when it finishes
(ADR-004). (ADR-004).
- The coordinator gets reliable completion notification (the exit - The coordinator gets reliable completion notification (the exit
control chunk + stream close) — no polling. control chunk + stream close), no polling.
The runner-specific API surface (job management, log persistence, task The runner-specific API surface (job management, log persistence, task
graph integration) is **out of scope for alktty**. alktty provides the graph integration) is **out of scope for alktty**. alktty provides the
@@ -186,7 +186,7 @@ alktty. See OQ-46.
wire-format codec). A docker-only or ssh-only deployment never pulls wire-format codec). A docker-only or ssh-only deployment never pulls
in `portable_pty`. in `portable_pty`.
- The local backend is a one-feature opt-in (`features = ["local"]`) - The local backend is a one-feature opt-in (`features = ["local"]`)
for the common case — a consumer that wants a terminal/runner for the common case: a consumer that wants a terminal/runner
endpoint with no docker or SSH gets it with one feature flag, not a endpoint with no docker or SSH gets it with one feature flag, not a
separate dependency. separate dependency.
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals - PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
@@ -198,8 +198,8 @@ alktty. See OQ-46.
no-handler-depends-on-another-handler rule: the local backend is in no-handler-depends-on-another-handler rule: the local backend is in
the same crate as the trait, so there is no cross-crate dependency the same crate as the trait, so there is no cross-crate dependency
edge to worry about. The docker and SSH backends remain separate edge to worry about. The docker and SSH backends remain separate
crates (real external deps — `bollard`, `russh` — and their own crates with real external deps (`bollard`, `russh`) and their own
resource models). resource models.
- The alktty consolidation resolves the alknet cyclic-dep workaround - The alktty consolidation resolves the alknet cyclic-dep workaround
that motivated the original sibling-crate decision. No assembly-layer that motivated the original sibling-crate decision. No assembly-layer
"depend on both crates directly" pattern; no cargo-rejected "depend on both crates directly" pattern; no cargo-rejected
@@ -209,11 +209,11 @@ alktty. See OQ-46.
- A consumer that wants the local backend must enable a feature flag - A consumer that wants the local backend must enable a feature flag
(`features = ["local"]`). Forgetting the flag results in the (`features = ["local"]`). Forgetting the flag results in the
`alktty::local` module not existing — a compile error, not a silent `alktty::local` module not existing (a compile error, not a silent
miss. This is the standard Rust feature-flag trade and is miss). This is the standard Rust feature-flag trade and is
self-documenting. self-documenting.
- The `local` feature is non-wasm by design. A consumer that targets - The `local` feature is non-wasm by design. A consumer that targets
`wasm32-unknown-unknown` must not enable `local` — the build error is `wasm32-unknown-unknown` must not enable `local`; the build error is
intentional (the local-process backend cannot run in a sandbox). The intentional (the local-process backend cannot run in a sandbox). The
default crate stays wasm-clean so the downstream TS/Python adapter default crate stays wasm-clean so the downstream TS/Python adapter
story works. story works.
@@ -230,7 +230,7 @@ reversible: if the local backend ever grows large enough to warrant
splitting out (e.g., a future docker or SSH backend shares enough splitting out (e.g., a future docker or SSH backend shares enough
portable-pty bridge code to motivate a shared "blocking-backend bridge" portable-pty bridge code to motivate a shared "blocking-backend bridge"
crate), extracting `src/local/` into a sibling crate behind the same crate), extracting `src/local/` into a sibling crate behind the same
`local` feature is mechanical — the trait is the seam, and the feature `local` feature is mechanical: the trait is the seam, and the feature
gate already exists. The cost of reversal is low (the module becomes a gate already exists. The cost of reversal is low (the module becomes a
crate; the feature gate switches from `dep:portable-pty` to crate; the feature gate switches from `dep:portable-pty` to
`dep:alktty-local`), and no downstream consumer breaks (the `dep:alktty-local`), and no downstream consumer breaks (the
@@ -240,28 +240,28 @@ re-exports it).
This is a two-way door that is **decided** (single crate + `local` This is a two-way door that is **decided** (single crate + `local`
feature), not deferred. The decision is made now; the reversal is cheap feature), not deferred. The decision is made now; the reversal is cheap
if a future split warrants it. See alknet ADR-009 §"What this framework if a future split warrants it. See alknet ADR-009 §"What this framework
is NOT" — door type classifies reversal cost, not urgency. is NOT"; door type classifies reversal cost, not urgency.
## References ## References
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the - alknet ADR-003 + Amendments 1 & 2: crate decomposition rule (the
single-crate placement preserves it; the local backend is in the same single-crate placement preserves it; the local backend is in the same
crate as the trait, not a separate crate that depends back) crate as the trait, not a separate crate that depends back)
- alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's - alknet ADR-009: door-type-as-deferral anti-pattern (this ADR's
two-way-door classification is reversal cost, not a deferral) two-way-door classification is reversal cost, not a deferral)
- alknet ADR-017 — the adapter-location-map pattern (trait where types - alknet ADR-017: the adapter-location-map pattern (trait where types
live, implementation where deps live) this ADR follows (the `local` live, implementation where deps live) this ADR follows (the `local`
feature is where the deps live) feature is where the deps live)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend` - [ADR-002](002-ttybackend-trait-and-ttyhandle.md), the `TtyBackend`
trait this module implements trait this module implements
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the - [ADR-001](001-wire-format-and-two-carriage.md), the wire format the
adapter pumps to/from this backend adapter pumps to/from this backend
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk - [ADR-004](004-exit-code-on-control-chunk.md): the exit-chunk
ordering the local backend's waiter thread feeds ordering the local backend's waiter thread feeds
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel- - [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel-
cleanup contract the local backend's `exit_code` future's `Drop` cleanup contract the local backend's `exit_code` future's `Drop`
implements implements
- OQ-46 — runner API surface (deferred(scope): mechanism in alktty, - OQ-46, runner API surface (deferred(scope): mechanism in alktty,
policy is a downstream crate) policy is a downstream crate)
- Spec: [tty-local.md](../tty-local.md) - Spec: [tty-local.md](../tty-local.md)
- Port origin: alknet ADR-054 at - Port origin: alknet ADR-054 at
@@ -3,10 +3,10 @@
## Status ## Status
Accepted (ported from alknet ADR-055 2026-08-17; cross-references Accepted (ported from alknet ADR-055 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, renumbered to alktty's ADR range: ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. The alknet ADR referenced by alknet number (052, 053) is ADR-093→008. The alknet ADR referenced by alknet number (052, 053) is
not ported into alktty's ADR range as a separate ADR — it is ADR-001 not ported into alktty's ADR range as a separate ADR; it is ADR-001
and ADR-002 here. The alknet originals at and ADR-002 here. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative for any ADR not yet ported.) authoritative for any ADR not yet ported.)
@@ -16,11 +16,11 @@ authoritative for any ADR not yet ported.)
The alknet-docker POC validated exit-code propagation for the JSON The alknet-docker POC validated exit-code propagation for the JSON
carriage path: exec with an exit code rides on a final `call.responded` carriage path: exec with an exit code rides on a final `call.responded`
frame `{ "exitCode": N }` before `call.completed`. That works because frame `{ "exitCode": N }` before `call.completed`. That works because
the JSON carriage path is the call protocol — `call.responded` and the JSON carriage path is the call protocol: `call.responded` and
`call.completed` exist and carry the result. `call.completed` exist and carry the result.
The raw-carriage path (ADR-001) has no `call.responded` and no The raw-carriage path (ADR-001) has no `call.responded` and no
`call.completed` — after the negotiation frame, the stream is raw `call.completed`; after the negotiation frame, the stream is raw
chunks. The exit code must ride on the chunk format itself. Two options: chunks. The exit code must ride on the chunk format itself. Two options:
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last control - **(a) Control chunk**: `{"type":"exit","code":N}` as the last control
@@ -28,7 +28,7 @@ chunks. The exit code must ride on the chunk format itself. Two options:
explicit, carries the code as structured data on the channel that explicit, carries the code as structured data on the channel that
already exists for control metadata. already exists for control metadata.
- **(b) Final data chunk with exit code**: a special stdout chunk with - **(b) Final data chunk with exit code**: a special stdout chunk with
an exit-code payload. Overloads the data channel for metadata — a an exit-code payload. Overloads the data channel for metadata: a
client parsing stdout chunks would have to special-case "this stdout client parsing stdout chunks would have to special-case "this stdout
chunk is actually an exit code," conflating data and control. chunk is actually an exit code," conflating data and control.
@@ -45,7 +45,7 @@ the drainer writes it to the client before the writer closes.
Clients will depend on the **"exit chunk is last"** invariant: after Clients will depend on the **"exit chunk is last"** invariant: after
the exit control chunk, no more data chunks follow, and the stream the exit control chunk, no more data chunks follow, and the stream
closes. This is the deterministic completion notification the docker closes. This is the deterministic completion notification the docker
POC identified as the stopgap coordination property — a coordinator POC identified as the stopgap coordination property: a coordinator
spawns a process, streams its output, and gets a reliable "it exited spawns a process, streams its output, and gets a reliable "it exited
with code N" signal without polling or plugin state. Changing the with code N" signal without polling or plugin state. Changing the
ordering after clients exist would break every consumer that reads ordering after clients exist would break every consumer that reads
@@ -63,10 +63,19 @@ not data (process output). It rides on the control channel
{"type":"exit","code":0} {"type":"exit","code":0}
``` ```
The `code` is an `i32` (matches `std::process::ExitStatus::code()` and The `code` is an `i32` in this crate's exit-status convention:
Unix wait-status convention; negative values are signal-terminated, `ExitStatus::code()` on normal exit; on Unix, the negative signal
e.g., `-9` for SIGKILL, matching `ExitStatus::code()`'s behavior on number on signal-terminated exit (e.g., `-9` for SIGKILL), which is the same
Unix). The chunk is the last control chunk before stream close. convention as Python's `subprocess.Popen.returncode`. It is **not**
`ExitStatus::code()`'s behavior, which returns `None` for signal
termination. Both local backends encode it: the pipe path via
`ExitStatusExt::signal()` (`src/local/pipe.rs::exit_code_from`), the
PTY path via a direct `libc::waitpid` reap decoded to `WEXITSTATUS` /
`-WTERMSIG` (`src/local/pty.rs::wait_exit_code`); portable-pty 0.9.0's
`ExitStatus` would flatten a signal death to `code = 1` with the signal
only as a strsignal name string, so the waiter reaps by pid and skips
the conversion (review #004). The chunk is the last control chunk
before stream close.
### 2. The "exit chunk is last" invariant ### 2. The "exit chunk is last" invariant
@@ -85,7 +94,7 @@ session end. The exit chunk is the deterministic completion signal.
Per ADR-002 assumption 3, the backend resolves `exit_code` (a Per ADR-002 assumption 3, the backend resolves `exit_code` (a
`BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it, `BoxFuture<'static, Result<i32, TtyError>>`); the adapter awaits it,
sends the exit control chunk, and closes the stream. The backend does sends the exit control chunk, and closes the stream. The backend does
not write to the wire — it produces handles; the adapter pumps. This not write to the wire: it produces handles; the adapter pumps. This
keeps the wire-format logic (including the "exit is last" invariant) in keeps the wire-format logic (including the "exit is last" invariant) in
one place (the adapter's session driver) and the backend focused on its one place (the adapter's session driver) and the backend focused on its
allocation target. allocation target.
@@ -107,14 +116,14 @@ resolve, and the exit chunk is enqueued only after the exit resolves.
The drainer writes chunks in arrival order; the exit chunk is last The drainer writes chunks in arrival order; the exit chunk is last
because the exit is the last thing to resolve (the child must exit because the exit is the last thing to resolve (the child must exit
before its stdout drains, but the exit chunk is sent only after before its stdout drains, but the exit chunk is sent only after
`exit_code` resolves, which is after `Child::wait()` returns — i.e., `exit_code` resolves, which is after `Child::wait()` returns, i.e.,
after the child is reaped). after the child is reaped).
### 4. Error exit codes ### 4. Error exit codes
A backend `TtyError` during `allocate()` (the PTY couldn't be A backend `TtyError` during `allocate()` (the PTY couldn't be
allocated, the docker exec failed to start, the SSH channel request was allocated, the docker exec failed to start, the SSH channel request was
rejected) is handled before the raw-carriage phase begins — the adapter rejected) is handled before the raw-carriage phase begins; the adapter
sends a JSON error response to the negotiation frame and closes the sends a JSON error response to the negotiation frame and closes the
stream without entering raw mode. See `tty-adapter.md` §"Negotiation stream without entering raw mode. See `tty-adapter.md` §"Negotiation
errors". errors".
@@ -135,36 +144,40 @@ the completion notification.
overload of the data channel. Clients parse it as a overload of the data channel. Clients parse it as a
`ControlMessage::Exit`, not as a special-cased stdout chunk. `ControlMessage::Exit`, not as a special-cased stdout chunk.
- The "exit chunk is last" invariant gives coordinators deterministic - The "exit chunk is last" invariant gives coordinators deterministic
completion notification — the same stopgap property the docker POC completion notification: the same stopgap property the docker POC
validated for logs subscriptions. No polling, no plugin state; the validated for logs subscriptions. No polling, no plugin state; the
process exiting is the signal. process exiting is the signal.
- The adapter owns the ordering, so the invariant is enforced in one - The adapter owns the ordering, so the invariant is enforced in one
place; backends don't have to know the wire format's completion place; backends don't have to know the wire format's completion
semantics. semantics.
- The error-exit `-1` fallback keeps the completion notification - The error-exit `-1` fallback keeps the completion notification
reliable even when the backend can't determine the real code — the reliable even when the backend can't determine the real code, so the
client still knows the session ended. client still knows the session ended.
**Negative:** **Negative:**
- The "exit chunk is last" invariant is a one-way door — clients depend - The "exit chunk is last" invariant is a one-way door: clients depend
on it. Reversing it (allowing data chunks after the exit chunk, or on it. Reversing it (allowing data chunks after the exit chunk, or
moving the exit code to a data chunk) would break every consumer. moving the exit code to a data chunk) would break every consumer.
This is the intended commitment: the invariant is the value. This is the intended commitment: the invariant is the value.
- A client that doesn't read until the exit chunk (e.g., a runner that - A client that doesn't read until the exit chunk (e.g., a runner that
cancels mid-stream by closing the write half) won't see the exit cancels mid-stream by closing the write half) won't see the exit
code. That's correct — a cancelled stream doesn't have a code. That's correct: a cancelled stream doesn't have a
deterministic exit; the client that cancels already knows it deterministic exit; the client that cancels already knows it
cancelled. The exit chunk is for the client that reads to completion. cancelled. The exit chunk is for the client that reads to completion.
- The `-1` error-exit code conflates "the backend couldn't determine - The `-1` sentinel collides with a real SIGHUP termination on the
the exit" with "the process exited with code -1" (which doesn't Unix pipe path: signal-terminated children are encoded `-N`, so
happen on Unix — `ExitStatus::code()` returns `None` for signal SIGHUP (signal 1) encodes as exactly `-1`, the same value the
termination, not -1; the POC's waiter thread sends -1 only on adapter sends for a backend wait failure. A client can't
`wait()` failure, not on signal termination — signal termination distinguish a real SIGHUP termination from a backend wait failure
sends the negative signal number, e.g., -9 for SIGKILL). A client from the code alone. The collision is narrow in practice (the usual
that needs to distinguish "real exit -1" from "backend error" can't way a pipe-mode child receives SIGHUP is a client-sent
from the code alone. This is a documented edge case; if it becomes `Signal { name: "HUP" }` control chunk, which the client knows it
load-bearing, a future control message type sent), but it is real for any other SIGHUP source. Note
`std::process::ExitStatus::code()` cannot itself cause the
collision (it returns `None` for signal termination), so this
conflation exists only through this crate's `-N` encoding. If
distinguishing becomes load-bearing, a future control message type
(`{"type":"exit_error","message":"..."}`) can carry the distinction (`{"type":"exit_error","message":"..."}`) can carry the distinction
additively (the `type`-tagged enum is the extension seam per additively (the `type`-tagged enum is the extension seam per
ADR-001). ADR-001).
@@ -201,16 +214,16 @@ additive.
## References ## References
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format - [ADR-001](001-wire-format-and-two-carriage.md): the wire format
(control channel, `STREAM_CTRL_OUT` stream_type 4) this ADR's exit (control channel, `STREAM_CTRL_OUT` stream_type 4) this ADR's exit
chunk rides on chunk rides on
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the - [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the
`TtyHandle.exit_code` field (the `Future` the adapter awaits) this `TtyHandle.exit_code` field (the `Future` the adapter awaits) this
ADR's ordering consumes ADR's ordering consumes
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel path - [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel path
that bypasses this ADR's happy-path ordering (no exit chunk is sent that bypasses this ADR's happy-path ordering (no exit chunk is sent
on cancel — the stream is gone) on cancel, since the stream is gone)
- [ADR-008](008-channels-pure-channel-multiplexing.md) — the - [ADR-008](008-channels-pure-channel-multiplexing.md): the
exit-chunk-is-last invariant generalizes to channels mode (the exit exit-chunk-is-last invariant generalizes to channels mode (the exit
chunk is the last `STREAM_CTRL_OUT` chunk before the channel closes) chunk is the last `STREAM_CTRL_OUT` chunk before the channel closes)
- Spec: [tty-adapter.md](../tty-adapter.md) (the session driver that - Spec: [tty-adapter.md](../tty-adapter.md) (the session driver that
@@ -3,10 +3,10 @@
## Status ## Status
Accepted (ported from alknet ADR-056 2026-08-17; cross-references Accepted (ported from alknet ADR-056 2026-08-17; cross-references
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, renumbered to alktty's ADR range: ADR-052→001, ADR-053→002,
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
ADR-093→008. The alknet ADR referenced by alknet number (052, 053, 054, ADR-093→008. The alknet ADR referenced by alknet number (052, 053, 054,
055) is not ported into alktty's ADR range as a separate ADR — it is 055) is not ported into alktty's ADR range as a separate ADR; it is
ADR-001, 002, 003, 004 here. The alknet originals at ADR-001, 002, 003, 004 here. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative for any ADR not yet ported.) authoritative for any ADR not yet ported.)
@@ -17,30 +17,31 @@ A `alk/tty` session can be cancelled before the child process exits
naturally. Three cancel paths exist (see `tty-adapter.md` §"Connection naturally. Three cancel paths exist (see `tty-adapter.md` §"Connection
and Stream Lifecycle"): and Stream Lifecycle"):
1. **Connection drop** — the QUIC connection closes; all in-flight 1. **Connection drop**: the QUIC connection closes; all in-flight
sessions on it are cancelled. sessions on it are cancelled.
2. **Stream reset** — the client (or transport) resets the bidi stream 2. **Stream reset**: the client (or transport) resets the bidi stream
mid-session. mid-session.
3. **Task panic / adapter shutdown** — the session pump task exits 3. **Task panic / adapter shutdown**: the session pump task exits
without completing the normal exit-chunk sequence. without completing the normal exit-chunk sequence.
In all three, the adapter's per-session pump tasks are dropped (Rust In all three, the adapter's per-session pump tasks are dropped (Rust
`Drop`). The pump tasks hold the `TtyHandle`, whose fields — `stdin`, `Drop`). The pump tasks hold the `TtyHandle`, whose fields (`stdin`,
`stdout`, `stderr`, `exit_code`, `control` — are dropped too. `stdout`, `stderr`, `exit_code`, `control`) are dropped too.
The problem the local-PTY POC surfaced: **`Drop` alone is not sufficient The problem the local-PTY POC surfaced: **`Drop` alone is not sufficient
cleanup for a backend whose child may outlive the session.** The local cleanup for a backend whose child may outlive the session.** The local
backend's three std threads illustrate the gap: backend's three std threads illustrate the gap:
- **Reader thread** — blocking `MasterPty::try_clone_reader()` reads. - **Reader thread** (blocking `MasterPty::try_clone_reader()` reads).
On `mpsc::Sender::drop` (the stdout channel's sender side drops when On `mpsc::Sender::drop` (the stdout channel's sender side drops when
the handle drops), the reader's next `blocking_send` returns a the handle drops), the reader's next `blocking_send` returns a
`SendError`; the thread exits. ✓ `Drop` works. `SendError`; the thread exits. ✓ `Drop` works.
- **Writer thread** — drains an `mpsc::Receiver<StdinCmd>`. On - **Writer thread** (drains an `mpsc::Receiver<StdinCmd>`). On
`Receiver::drop`, the writer's `recv()` returns `None`; the thread `Receiver::drop`, the writer's `recv()` returns `None`; the thread
drops the master writer (EOF to the slave's stdin) and exits. ✓ drops the master writer (EOF to the slave's stdin) and exits. ✓
`Drop` works. `Drop` works.
- **Waiter thread** — blocking `Child::wait()`. This is a syscall that - **Waiter thread** (blocking wait; `Child::wait()`, on Unix now the
`libc::waitpid` reap, ADR-004). This is a syscall that
returns **only when the child is reaped**. It does not observe returns **only when the child is reaped**. It does not observe
channel close. If the child ignores stdin EOF (a daemon, a channel close. If the child ignores stdin EOF (a daemon, a
long-lived process with no stdin reader, a process in an long-lived process with no stdin reader, a process in an
@@ -57,7 +58,7 @@ in it.**
The earlier spec text (`tty-adapter.md` §"Connection and Stream The earlier spec text (`tty-adapter.md` §"Connection and Stream
Lifecycle" pre-this-ADR) asserted: Lifecycle" pre-this-ADR) asserted:
> No explicit cleanup is needed — `Drop` is the cleanup. > No explicit cleanup is needed: `Drop` is the cleanup.
That is wrong for the waiter thread and any backend in the same shape. That is wrong for the waiter thread and any backend in the same shape.
This ADR corrects the claim and commits a cleanup contract that closes This ADR corrects the claim and commits a cleanup contract that closes
@@ -70,7 +71,7 @@ contract (ADR-002), not a backend-internal detail, for two reasons:
1. **The adapter depends on the property.** The adapter's session 1. **The adapter depends on the property.** The adapter's session
driver holds the `TtyHandle` and, on cancel, drops it. The adapter driver holds the `TtyHandle` and, on cancel, drops it. The adapter
cannot itself call a backend-specific kill — it doesn't know the cannot itself call a backend-specific kill; it doesn't know the
child's pid (the local backend owns it; the adapter never sees it). child's pid (the local backend owns it; the adapter never sees it).
The kill must be wired into the backend's handle shape, specifically The kill must be wired into the backend's handle shape, specifically
into the `exit_code` future the adapter drops on cancel. The into the `exit_code` future the adapter drops on cancel. The
@@ -89,9 +90,9 @@ contract (ADR-002), not a backend-internal detail, for two reasons:
### 1. The `TtyBackend` cleanup contract: cancelling the `exit_code` future kills the session target ### 1. The `TtyBackend` cleanup contract: cancelling the `exit_code` future kills the session target
When the adapter cancels a session (drops the pump tasks, which drops When the adapter cancels a session (drops the pump tasks, which drops
the `TtyHandle`), the backend's `exit_code` future — the the `TtyHandle`), the backend's `exit_code` future (the
`BoxFuture<'static, Result<i32, TtyError>>` field of `TtyHandle` `BoxFuture<'static, Result<i32, TtyError>>` field of `TtyHandle`,
(ADR-002) — is dropped *without being driven to completion*. The ADR-002) is dropped *without being driven to completion*. The
cleanup contract: cleanup contract:
> **Dropping the `exit_code` future MUST kill the session target (the > **Dropping the `exit_code` future MUST kill the session target (the
@@ -103,7 +104,7 @@ cleanup contract:
> whose process ignores channel close). > whose process ignores channel close).
`exit_code`'s `Drop` is the cancel path. The adapter drives the future `exit_code`'s `Drop` is the cancel path. The adapter drives the future
to completion (the happy path — the child exits, the future resolves, to completion (the happy path: the child exits, the future resolves,
the adapter sends the exit chunk); on cancel, the adapter drops the the adapter sends the exit chunk); on cancel, the adapter drops the
future (their `Drop`), which runs the kill. future (their `Drop`), which runs the kill.
@@ -117,8 +118,8 @@ contract is backend-agnostic.
If the future resolved normally (the adapter awaited it, got the exit If the future resolved normally (the adapter awaited it, got the exit
code, sent the exit chunk), the `Drop` runs on an already-resolved code, sent the exit chunk), the `Drop` runs on an already-resolved
future. The kill MUST be a no-op in that case — the child is already future. The kill MUST be a no-op in that case (the child is already
reaped, the kill is delivered to a nonexistent pid, etc. This is the reaped, the kill is delivered to a nonexistent pid, etc.). This is the
"best-effort" qualifier: a kill on an already-exited child is not an "best-effort" qualifier: a kill on an already-exited child is not an
error. Backends implement this with a guard that distinguishes error. Backends implement this with a guard that distinguishes
"resolved" from "cancelled" (a flag, an `Option` taken on resolve, an "resolved" from "cancelled" (a flag, an `Option` taken on resolve, an
@@ -127,7 +128,7 @@ error. Backends implement this with a guard that distinguishes
### 3. Local backend mechanism: `ChildKiller` held in the `exit_code` future's `Drop` guard ### 3. Local backend mechanism: `ChildKiller` held in the `exit_code` future's `Drop` guard
The local backend (ADR-003) implements the contract using The local backend (ADR-003) implements the contract using
`portable_pty::ChildKiller` — the kill handle `portable_pty` exposes `portable_pty::ChildKiller`, the kill handle `portable_pty` exposes
alongside `Child::wait()`. The pattern: alongside `Child::wait()`. The pattern:
- `allocate()` spawns the child via `portable_pty`, obtaining a `Child` - `allocate()` spawns the child via `portable_pty`, obtaining a `Child`
@@ -138,13 +139,13 @@ alongside `Child::wait()`. The pattern:
- The `exit_code` future's `poll` delegates to the inner - The `exit_code` future's `poll` delegates to the inner
`oneshot::Receiver::poll` (resolves when the waiter thread sends the `oneshot::Receiver::poll` (resolves when the waiter thread sends the
exit code). exit code).
- The `exit_code` future's `Drop` (runs on cancel only — on resolve, - The `exit_code` future's `Drop` (runs on cancel only; on resolve,
the `Drop` is a no-op via the guard) calls `ChildKiller::kill(SIGHUP)` the `Drop` is a no-op via the guard) calls `ChildKiller::kill(SIGHUP)`
(or the backend's configured cancel signal). The kill causes the (or the backend's configured cancel signal). The kill causes the
child to exit; the waiter thread's `wait()` returns; the waiter child to exit; the waiter thread's blocking wait returns; the waiter
thread's `oneshot::send` fails silently (the receiver was dropped thread's `oneshot::send` fails silently (the receiver was dropped
with the future). The waiter thread then exits. The child is reaped with the future). The waiter thread then exits. The child is reaped
by the waiter thread's `wait()`; no zombie. by the waiter thread; no zombie.
For pipe mode (`terminal: None`), the same pattern applies with For pipe mode (`terminal: None`), the same pattern applies with
`tokio::process::Child::start_kill()` (or `Child::kill()`) instead of `tokio::process::Child::start_kill()` (or `Child::kill()`) instead of
@@ -154,13 +155,13 @@ the waiter task).
### 4. Future backends (docker, SSH) follow the same contract ### 4. Future backends (docker, SSH) follow the same contract
- **Docker (`DockerTtyBackend`)** — `bollard`'s exec stream is - **Docker (`DockerTtyBackend`)**: `bollard`'s exec stream is
cancelled by dropping the `AttachContainer` / `start_exec` stream cancelled by dropping the `AttachContainer` / `start_exec` stream
and calling `bollard::container::kill_container` (or `exec::kill_exec` and calling `bollard::container::kill_container` (or `exec::kill_exec`
if available). The `exit_code` future's `Drop` holds the if available). The `exit_code` future's `Drop` holds the
container/exec id and the `bollard::Docker` client; on cancel, it container/exec id and the `bollard::Docker` client; on cancel, it
issues the kill. issues the kill.
- **SSH (`SshTtyBackend`)** — russh's `Channel::close()` and/or - **SSH (`SshTtyBackend`)**: russh's `Channel::close()` and/or
`Channel::signal(SIGHUP)` terminate the remote process. The `Channel::signal(SIGHUP)` terminate the remote process. The
`exit_code` future's `Drop` holds the russh channel handle; on `exit_code` future's `Drop` holds the russh channel handle; on
cancel, it closes the channel. cancel, it closes the channel.
@@ -168,23 +169,23 @@ the waiter task).
The docker and SSH backends are future work (out of scope for this The docker and SSH backends are future work (out of scope for this
spec set); the contract is what they implement. A future backend that spec set); the contract is what they implement. A future backend that
does NOT fit the contract (e.g., a "recorded session replay" backend does NOT fit the contract (e.g., a "recorded session replay" backend
with no live process) implements a no-op `Drop`-on-cancel — the with no live process) implements a no-op `Drop`-on-cancel (the
contract is "kill if there is a killable target; no-op if not." contract is "kill if there is a killable target; no-op if not.").
### 5. The adapter does not call a backend kill method ### 5. The adapter does not call a backend kill method
The adapter has no `TtyBackend::cancel()` or `TtyHandle::kill()` method The adapter has no `TtyBackend::cancel()` or `TtyHandle::kill()` method
to call — the cleanup is wired into the `exit_code` future's `Drop`, to call; the cleanup is wired into the `exit_code` future's `Drop`,
which the adapter triggers by dropping the future. This keeps the which the adapter triggers by dropping the future. This keeps the
trait surface unchanged (no new method) and the cleanup in the backend trait surface unchanged (no new method) and the cleanup in the backend
(where the kill handle lives). The adapter's only responsibility is to (where the kill handle lives). The adapter's only responsibility is to
drop the `TtyHandle` (and therefore the `exit_code` future) when the drop the `TtyHandle` (and therefore the `exit_code` future) when the
session is cancelled — which it already does by virtue of dropping the session is cancelled, which it already does by virtue of dropping the
pump tasks. pump tasks.
The `TtyControl::signal("HUP")` path (ADR-002) is the The `TtyControl::signal("HUP")` path (ADR-002) is the
*client-initiated* signal forwarding path — a client sends a *client-initiated* signal forwarding path (a client sends a
`{"type":"signal","name":"HUP"}` control chunk. It is NOT the `{"type":"signal","name":"HUP"}` control chunk). It is NOT the
cancel-cleanup path. The cancel-cleanup path is server-internal (the cancel-cleanup path. The cancel-cleanup path is server-internal (the
adapter drops the handle) and does not involve the wire format. These adapter drops the handle) and does not involve the wire format. These
are two different signal paths; both end in the child receiving SIGHUP are two different signal paths; both end in the child receiving SIGHUP
@@ -198,18 +199,18 @@ different actors (client vs. server cancel).
- A backend that conforms to the contract cannot orphan a process on - A backend that conforms to the contract cannot orphan a process on
cancel. The local-PTY POC's waiter-thread gap is closed at the cancel. The local-PTY POC's waiter-thread gap is closed at the
contract level, not left to each backend to rediscover. contract level, not left to each backend to rediscover.
- The cleanup is idiomatic Rust — `Drop`-on-cancel of a `Future` is - The cleanup is idiomatic Rust: `Drop`-on-cancel of a `Future` is
the standard pattern for resource cleanup in async Rust (the same the standard pattern for resource cleanup in async Rust (the same
pattern `tokio::process::Child` uses; the same pattern `tokio::io` pattern `tokio::process::Child` uses; the same pattern `tokio::io`
AsyncRead guards use). No new trait method; no adapter-side kill AsyncRead guards use). No new trait method; no adapter-side kill
call. call.
- The contract is backend-agnostic — the mechanism (`ChildKiller` for - The contract is backend-agnostic: the mechanism (`ChildKiller` for
local, `kill_container` for docker, `channel::close` for SSH) lives local, `kill_container` for docker, `channel::close` for SSH) lives
in the backend; the contract ("drop the future, the target dies") in the backend; the contract ("drop the future, the target dies")
lives at the seam. lives at the seam.
- The happy path (the child exits, the adapter drives `exit_code` to - The happy path (the child exits, the adapter drives `exit_code` to
completion, sends the exit chunk, then drops the resolved future) is completion, sends the exit chunk, then drops the resolved future) is
unaffected — the `Drop`-on-resolve is a no-op via the guard. unaffected (the `Drop`-on-resolve is a no-op via the guard).
**Negative:** **Negative:**
@@ -239,8 +240,8 @@ different actors (client vs. server cancel).
**One-way.** The cleanup contract is part of the `TtyBackend` **One-way.** The cleanup contract is part of the `TtyBackend`
behavioral contract. Clients (the adapter) depend on "drop the handle, behavioral contract. Clients (the adapter) depend on "drop the handle,
the session is cleaned up." Changing the contract after backends exist the session is cleaned up." Changing the contract after backends exist
— e.g., adding a separate `TtyBackend::cancel()` method and migrating (for example, adding a separate `TtyBackend::cancel()` method and migrating
the cleanup out of `exit_code`'s `Drop` — would require every backend the cleanup out of `exit_code`'s `Drop`) would require every backend
to change. The `exit_code`-future-`Drop`-on-cancel mechanism is the to change. The `exit_code`-future-`Drop`-on-cancel mechanism is the
seam. seam.
@@ -260,36 +261,36 @@ seam.
that needs a different cancel signal configures it internally. that needs a different cancel signal configures it internally.
3. **The waiter thread (local backend) reaps the killed child.** After 3. **The waiter thread (local backend) reaps the killed child.** After
the `Drop`-on-cancel calls `ChildKiller::kill(SIGHUP)`, the child the `Drop`-on-cancel calls `ChildKiller::kill(SIGHUP)`, the child
exits; the waiter thread's `wait()` returns and reaps it (no exits; the waiter thread's blocking wait returns and reaps it (no
zombie). The waiter thread then exits. The `oneshot::send` from the zombie). The waiter thread then exits. The `oneshot::send` from the
waiter thread fails silently (the receiver was dropped with the waiter thread fails silently (the receiver was dropped with the
future) — this is expected and not an error. future), which is expected and not an error.
## References ## References
- [tty-adapter.md](../tty-adapter.md) §"Connection and Stream - [tty-adapter.md](../tty-adapter.md) §"Connection and Stream
Lifecycle" — the cancel paths (connection drop, stream reset) that Lifecycle": the cancel paths (connection drop, stream reset) that
trigger the contract trigger the contract
- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)" — the - [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)": the
local backend's three-thread bridge and the waiter-thread gap this local backend's three-thread bridge and the waiter-thread gap this
ADR closes ADR closes
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the - [ADR-001](001-wire-format-and-two-carriage.md): the wire format the
cancel does not involve (the cleanup is server-internal) cancel does not involve (the cleanup is server-internal)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend` - [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the `TtyBackend`
trait this contract is part of; the `exit_code` field the cleanup trait this contract is part of; the `exit_code` field the cleanup
wires into; the `TtyControl::signal()` path the cancel-cleanup path wires into; the `TtyControl::signal()` path the cancel-cleanup path
is distinct from is distinct from
- [ADR-003](003-local-backend-placement.md) — the local backend this - [ADR-003](003-local-backend-placement.md): the local backend this
ADR's reference mechanism (`ChildKiller`) is for ADR's reference mechanism (`ChildKiller`) is for
- [ADR-004](004-exit-code-on-control-chunk.md) — the happy-path - [ADR-004](004-exit-code-on-control-chunk.md): the happy-path
exit-chunk sequence (the cancel path bypasses it; no exit chunk is exit-chunk sequence (the cancel path bypasses it; no exit chunk is
sent on cancel — see `tty-adapter.md` §"Stream reset") sent on cancel, see `tty-adapter.md` §"Stream reset")
- `src/local/pty.rs` — the local backend's `LocalExitFuture` (the - `src/local/pty.rs`: the local backend's `LocalExitFuture` (the
`Future` + `Drop` guard this ADR specifies, with the `ChildKiller` `Future` + `Drop` guard this ADR specifies, with the `ChildKiller`
held in the guard) held in the guard)
- `src/local/pipe.rs` — the pipe-mode equivalent - `src/local/pipe.rs`: the pipe-mode equivalent
(`tokio::process::Child::start_kill()` on `Drop`-on-cancel) (`tokio::process::Child::start_kill()` on `Drop`-on-cancel)
- `portable-pty` 0.9 `ChildKiller` — the kill handle the local - `portable-pty` 0.9 `ChildKiller`: the kill handle the local
backend's cancel-cleanup uses backend's cancel-cleanup uses
- Spec: [tty-backend.md](../tty-backend.md), - Spec: [tty-backend.md](../tty-backend.md),
[tty-adapter.md](../tty-adapter.md), [tty-adapter.md](../tty-adapter.md),
@@ -3,14 +3,14 @@
## Status ## Status
Accepted (ported from alknet ADR-057 2026-08-17; the dependency-edge Accepted (ported from alknet ADR-057 2026-08-17; the dependency-edge
decision carries over unchanged — alktty depends on alkcall (for the decision carries over unchanged: alktty depends on alkcall (for the
`ProtocolHandler` trait, auth, ownership types) but not on alkcall's `ProtocolHandler` trait, auth, ownership types) but not on alkcall's
internal wire types. The framing is self-contained in internal wire types. The framing is self-contained in
`src/negotiation.rs`. Cross-references renumbered to alktty's ADR `src/negotiation.rs`. Cross-references renumbered to alktty's ADR
range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADR ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADR
referenced by alknet number (003) is not ported into alktty's ADR referenced by alknet number (003) is not ported into alktty's ADR
range — it is an alknet-core ADR. The alknet originals at range; it is an alknet-core ADR. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.) authoritative.)
@@ -18,7 +18,7 @@ authoritative.)
The alknet-tty specs (alknet ADR-052, 053, 054) and alknet ADR-003 The alknet-tty specs (alknet ADR-052, 053, 054) and alknet ADR-003
Amendment 1 previously stated that alknet-tty depends on alknet-call for Amendment 1 previously stated that alknet-tty depends on alknet-call for
the `FrameFramedReader`/`FrameFramedWriter` "framing utility" — the the `FrameFramedReader`/`FrameFramedWriter` "framing utility": the
4-byte big-endian length prefix + UTF-8 JSON body framing the 4-byte big-endian length prefix + UTF-8 JSON body framing the
negotiation frame uses. The claim was "reuse the framing utility, not negotiation frame uses. The claim was "reuse the framing utility, not
the `EventEnvelope` type" (alknet ADR-052 §6). the `EventEnvelope` type" (alknet ADR-052 §6).
@@ -39,7 +39,7 @@ pub async fn read_frame(&mut self) -> Result<EventEnvelope, FrameError> {
The framing logic (read 4 bytes → length, read N bytes) and the The framing logic (read 4 bytes → length, read N bytes) and the
`EventEnvelope` deserialization are entangled in the same method. The `EventEnvelope` deserialization are entangled in the same method. The
"framing utility" the spec claimed to reuse does not exist as a "framing utility" the spec claimed to reuse does not exist as a
separable thing — the length-prefix read and the type-specific separable thing: the length-prefix read and the type-specific
deserialize are one call. alktty's negotiation frame is a deserialize are one call. alktty's negotiation frame is a
`NegotiateRequest`, not an `EventEnvelope`, so `read_frame()` cannot `NegotiateRequest`, not an `EventEnvelope`, so `read_frame()` cannot
return what alktty needs. return what alktty needs.
@@ -53,13 +53,13 @@ comparison):
2. **Promote a generic length-prefixed framing utility to alkcall::core.** 2. **Promote a generic length-prefixed framing utility to alkcall::core.**
Makes the spec's claim true (a reusable utility exists), but Makes the spec's claim true (a reusable utility exists), but
accretes a framing module to the foundation crate for the sake of accretes a framing module to the foundation crate for the sake of
two consumers — a shared utility pays for itself at ≥2 consumers, two consumers. A shared utility pays for itself at ≥2 consumers,
but the framing is trivial enough that the cost of the shared but the framing is trivial enough that the cost of the shared
abstraction (a new module, a new type, a refactor of alkcall) abstraction (a new module, a new type, a refactor of alkcall)
exceeds the cost of the duplication. exceeds the cost of the duplication.
3. **Actually use alkcall** (e.g., model the tty control channel as 3. **Actually use alkcall** (e.g., model the tty control channel as
call-protocol operations). A different architecture, not a call-protocol operations). A different architecture, not a
dependency-edge fix — the current `ControlMessage` tagged enum dependency-edge fix: the current `ControlMessage` tagged enum
(ADR-001) is the two-way-door seam; replacing it with the call (ADR-001) is the two-way-door seam; replacing it with the call
protocol is a v2 ALPN decision, not a v1 dependency choice. protocol is a v2 ALPN decision, not a v1 dependency choice.
@@ -74,7 +74,7 @@ dependency edge is:
``` ```
alktty alktty
└── alkcall (ProtocolHandler, Connection, AuthContext, Identity, └── alkcall (ProtocolHandler, Connection, AuthContext, Identity,
AccessControl, OwnershipProvider — alknet ADR-050) AccessControl, OwnershipProvider; alknet ADR-050)
``` ```
No alkcall-internal-wire-types dependency. The crate depends on alkcall No alkcall-internal-wire-types dependency. The crate depends on alkcall
@@ -99,10 +99,10 @@ alktty implements the negotiation framing as a small module
(`src/negotiation.rs`, ~30 lines for the framing reader/writer: read (`src/negotiation.rs`, ~30 lines for the framing reader/writer: read
4-byte BE length, bounds-check, read N bytes; write the inverse). The 4-byte BE length, bounds-check, read N bytes; write the inverse). The
module's types (`NegotiationReader`/`NegotiationWriter`) are private to module's types (`NegotiationReader`/`NegotiationWriter`) are private to
the crate — they are not a reusable utility for other crates. If a the crate; they are not a reusable utility for other crates. If a
future crate wants length-prefixed JSON framing, it implements its own future crate wants length-prefixed JSON framing, it implements its own
(the idiom is trivial) or a future ADR promotes a generic utility to (the idiom is trivial) or a future ADR promotes a generic utility to
alkcall::core at that point (deferred — not needed for the current alkcall::core at that point (deferred: not needed for the current
scope; two consumers is the threshold but the second consumer does not scope; two consumers is the threshold but the second consumer does not
yet exist). yet exist).
@@ -126,7 +126,7 @@ yet exist).
internal wire types" rule is preserved without the Amendment 1 internal wire types" rule is preserved without the Amendment 1
exception for alktty. (alknet ADR-003 Amendment 1's exception exception for alktty. (alknet ADR-003 Amendment 1's exception
remains for alknet-http/agent/napi, which use alkcall's remains for alknet-http/agent/napi, which use alkcall's
`OperationSpec`/`Handler`/`OperationAdapter` types — actual type `OperationSpec`/`Handler`/`OperationAdapter` types: actual type
reuse, not framing glue. alktty does not need that exception.) reuse, not framing glue. alktty does not need that exception.)
**Negative:** **Negative:**
@@ -164,7 +164,7 @@ wire-format change), but the dependency edge is one-way.
2. **The format coincidence (both use 4-byte BE length + JSON body) is 2. **The format coincidence (both use 4-byte BE length + JSON body) is
stable.** Both crates use the same length-prefix convention. If stable.** Both crates use the same length-prefix convention. If
alkcall's framing changes (e.g., a different max-frame-size, a alkcall's framing changes (e.g., a different max-frame-size, a
different prefix width), alktty's is unaffected — they are different prefix width), alktty's is unaffected; they are
independent implementations that happen to share a format today. independent implementations that happen to share a format today.
## References ## References
@@ -175,12 +175,12 @@ wire-format change), but the dependency edge is one-way.
- [ADR-001](001-wire-format-and-two-carriage.md) §2 (negotiation frame - [ADR-001](001-wire-format-and-two-carriage.md) §2 (negotiation frame
format), §6 (revised: format coincides by convention, not by code format), §6 (revised: format coincides by convention, not by code
reuse) reuse)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the crate - [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the crate
decomposition this ADR's dependency edge affects decomposition this ADR's dependency edge affects
- `src/negotiation.rs` — the self-contained framing reader/writer this - `src/negotiation.rs`: the self-contained framing reader/writer this
ADR commits (`NegotiationReader`, `NegotiationWriter`, ADR commits (`NegotiationReader`, `NegotiationWriter`,
`error_response_bytes`) `error_response_bytes`)
- alkcall's `FrameFramedReader`/`FrameFramedWriter` — the - alkcall's `FrameFramedReader`/`FrameFramedWriter`: the
`EventEnvelope`-bound methods that are NOT reused (the entangled `EventEnvelope`-bound methods that are NOT reused (the entangled
length-prefix-read + `EventEnvelope`-deserialize that motivated this length-prefix-read + `EventEnvelope`-deserialize that motivated this
ADR) ADR)
@@ -1,16 +1,16 @@
# ADR-007: TTY Inside Channels — Sub-Streams, Not Wire Format # ADR-007: TTY Inside Channels (Sub-Streams, Not Wire Format)
## Status ## Status
Accepted (**reversed 2026-07-18 by alknet ADR-093, ported here as Accepted (**reversed 2026-07-18 by alknet ADR-093, ported here as
[ADR-008](008-channels-pure-channel-multiplexing.md): TTY always uses [ADR-008](008-channels-pure-channel-multiplexing.md): TTY always uses
its 5-byte format; the channels layer carries it transparently in the its 5-byte format; the channels layer carries it transparently in the
payload — see "Reversal (ADR-008, 2026-07-18)" below.** Ported from payload (see "Reversal (ADR-008, 2026-07-18)" below).** Ported from
alknet ADR-077 2026-08-17; cross-references renumbered to alktty's alknet ADR-077 2026-08-17; cross-references renumbered to alktty's
ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004,
ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADRs ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADRs
referenced by alknet number (052, 053, 055, 057, 061, 071, 073, 074, referenced by alknet number (052, 053, 055, 057, 061, 071, 073, 074,
092) are not ported into alktty's ADR range — they are alknet-core / 092) are not ported into alktty's ADR range; they are alknet-core /
alknet-channels ADRs. The alknet originals at alknet-channels ADRs. The alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.) authoritative.)
@@ -28,14 +28,14 @@ modes differ only in *where the `BiStream` comes from* (a top-level
When TTY is inside channels, the channels layer strips its 8-byte header When TTY is inside channels, the channels layer strips its 8-byte header
(alknet ADR-093 / ADR-008) and hands TTY the payload bytes. TTY parses (alknet ADR-093 / ADR-008) and hands TTY the payload bytes. TTY parses
its 5-byte header from the payload. The channels layer carries TTY's its 5-byte header from the payload. The channels layer carries TTY's
5-byte chunks transparently in its payload — no shared fields, no 5-byte chunks transparently in its payload: no shared fields, no
leaked abstraction, no double-chunking concern (the 13-byte total leaked abstraction, no double-chunking concern (the 13-byte total
header is 8 channels + 5 TTY, not 8 + 9; the channels `length` is header is 8 channels + 5 TTY, not 8 + 9; the channels `length` is
always `tty_len + 5`). always `tty_len + 5`).
The `channels` feature on alktty (if such a feature were added) becomes The `channels` feature on alktty (if such a feature were added) becomes
"run TTY's sub-demux on a channels-backed `BiStream`" — the same code "run TTY's sub-demux on a channels-backed `BiStream`" (the same code
as direct mode, different `BiStream` source. The control channel split as direct mode, a different `BiStream` source). The control channel split
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the (`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
channels layer doesn't know about it. alknet ADR-074's channels layer doesn't know about it. alknet ADR-074's
`into_sub_streams()` (the accessor this ADR's two-mode design relied `into_sub_streams()` (the accessor this ADR's two-mode design relied
@@ -66,7 +66,7 @@ integration question the research hand-waved.
The problem: the `TtyAdapter`'s current `handle()` loops `accept_bi()`, The problem: the `TtyAdapter`'s current `handle()` loops `accept_bi()`,
spawning a `drive_session` task per bidi stream that parses 5-byte TTY spawning a `drive_session` task per bidi stream that parses 5-byte TTY
chunks off the stream. Inside a channels connection, the stream is chunks off the stream. Inside a channels connection, the stream is
*already de-chunked* by the channels layer's 9-byte format — the *already de-chunked* by the channels layer's 9-byte format: the
handler sees an `AsyncRead + AsyncWrite` pair, not a chunk-encoded handler sees an `AsyncRead + AsyncWrite` pair, not a chunk-encoded
stream. If the TTY adapter tries to parse 5-byte chunks off an stream. If the TTY adapter tries to parse 5-byte chunks off an
already-de-chunked stream, it breaks. already-de-chunked stream, it breaks.
@@ -80,21 +80,21 @@ its `Connection`:
| Mode | When | Wire format | How the adapter gets sub-streams | | Mode | When | Wire format | How the adapter gets sub-streams |
|------|------|-------------|---------------------------------| |------|------|-------------|---------------------------------|
| **Direct (`alk/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alk/tty` | TTY's 5-byte format (ADR-001, amended — see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 | | **Direct (`alk/tty` ALPN)** | Top-level QUIC/TCP connection with ALPN `alk/tty` | TTY's 5-byte format (ADR-001, amended; see below) | `accept_bi()` → parse 5-byte chunks → split into stream_types 0-4 |
| **Inside channels** | `channel/open` with ALPN `alk/tty` on a channels connection | Channels' 9-byte format (alknet ADR-071) — the channels layer de-chunks | `into_sub_streams()` (alknet ADR-074) → five named handles for stream_types 0-4 | | **Inside channels** | `channel/open` with ALPN `alk/tty` on a channels connection | Channels' 9-byte format (alknet ADR-071); the channels layer de-chunks | `into_sub_streams()` (alknet ADR-074) → five named handles for stream_types 0-4 |
In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged In both modes, the `TtyBackend` trait and `TtyHandle` are unchanged
(ADR-002). The backend allocates a PTY and returns a `TtyHandle`; the (ADR-002). The backend allocates a PTY and returns a `TtyHandle`; the
adapter pumps data between the handle and the sub-streams. The adapter pumps data between the handle and the sub-streams. The
difference is only in how the adapter gets the sub-streams — 5-byte difference is only in how the adapter gets the sub-streams: 5-byte
chunk parsing (direct) vs. `into_sub_streams()` (channels). chunk parsing (direct) vs. `into_sub_streams()` (channels).
### The control channel is now properly bidirectional ### The control channel is now properly bidirectional
The phase-0 findings flagged that the TTY control channel "isn't The phase-0 findings flagged that the TTY control channel "isn't
actually bidirectional… the adapter ignores Exit from the client." The actually bidirectional… the adapter ignores Exit from the client." The
root cause: `stream_type 3` was "bidirectional" — one stream both sides root cause: `stream_type 3` was "bidirectional" (one stream both sides
wrote to, which is not properly multiplexed. wrote to, which is not properly multiplexed).
alknet ADR-071's stream_type decomposition fixes this: **every alknet ADR-071's stream_type decomposition fixes this: **every
stream_type is unidirectional.** Control is now two halves: stream_type is unidirectional.** Control is now two halves:
@@ -107,8 +107,8 @@ stream_type is unidirectional.** Control is now two halves:
The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3) The TTY adapter writes resize/signal/eof to `ctrl_in` (stream_type 3)
and reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its and reads exit/keepalive from `ctrl_out` (stream_type 4). Each has its
own reassembly buffer, its own flow control, its own EOF. The control own reassembly buffer, its own flow control, its own EOF. The control
channel is *actually* bidirectional — two unidirectional streams, not channel is *actually* bidirectional (two unidirectional streams, not
one shared stream both sides write to. one shared stream both sides write to).
This amends ADR-001's stream_type assignments for direct mode too: This amends ADR-001's stream_type assignments for direct mode too:
direct `alk/tty` connections now use stream_types [0, 1, 2, 3, 4] (data direct `alk/tty` connections now use stream_types [0, 1, 2, 3, 4] (data
@@ -121,11 +121,11 @@ splits from 3 into 3+4).
1. **The adapter's session-driving code splits into two entry 1. **The adapter's session-driving code splits into two entry
points:** points:**
- `drive_session_direct(send, recv, backends, ...)` — the existing - `drive_session_direct(send, recv, backends, ...)` (the existing
path: parse 5-byte chunks, split into stream_types 0-4, pump. Used path): parse 5-byte chunks, split into stream_types 0-4, pump. Used
for direct `alk/tty` connections. for direct `alk/tty` connections.
- `drive_session_channels(sub_streams, backends, ...)` — the new - `drive_session_channels(sub_streams, backends, ...)` (the new
path: receive `ChannelSubStreams` (five named handles: stdin= path): receive `ChannelSubStreams` (five named handles: stdin=
SendStream, stdout=RecvStream, stderr=Option<RecvStream>, SendStream, stdout=RecvStream, stderr=Option<RecvStream>,
ctrl_in=SendStream, ctrl_out=RecvStream), pump directly without ctrl_in=SendStream, ctrl_out=RecvStream), pump directly without
chunk parsing. Used when the channel's `Connection` is backed by chunk parsing. Used when the channel's `Connection` is backed by
@@ -135,7 +135,7 @@ splits from 3 into 3+4).
type.** The channels crate's `ChannelBidiStreamSource` is a type.** The channels crate's `ChannelBidiStreamSource` is a
`BidiStreamSource` (alknet ADR-070); the `Connection` wraps it. The `BidiStreamSource` (alknet ADR-070); the `Connection` wraps it. The
adapter detects whether the `Connection` is channels-backed (via a adapter detects whether the `Connection` is channels-backed (via a
downcast or a channels-crate extension trait — exact ergonomics per downcast or a channels-crate extension trait; exact ergonomics per
alknet ADR-074's implementation detail) and calls alknet ADR-074's implementation detail) and calls
`drive_session_channels` instead of `drive_session_direct`. `drive_session_channels` instead of `drive_session_direct`.
@@ -147,16 +147,16 @@ splits from 3 into 3+4).
3. **The 5-byte wire format (ADR-001) is unchanged for direct 3. **The 5-byte wire format (ADR-001) is unchanged for direct
connections.** ADR-001's scope is now "the wire format for direct connections.** ADR-001's scope is now "the wire format for direct
`alk/tty` connections." The channels path does not use it. This `alk/tty` connections." The channels path does not use it. This
amends ADR-001's scope — the format is not replaced, it's scoped. amends ADR-001's scope: the format is not replaced, it's scoped.
4. **The control channel works the same in both modes, now properly 4. **The control channel works the same in both modes, now properly
bidirectional.** In direct mode, control-in JSON rides in 5-byte bidirectional.** In direct mode, control-in JSON rides in 5-byte
chunks with `stream_type=3` and control-out rides with chunks with `stream_type=3` and control-out rides with
`stream_type=4`. In channels mode, control-in rides in 9-byte chunks `stream_type=4`. In channels mode, control-in rides in 9-byte chunks
with `stream_type=3` (write) and control-out with `stream_type=4` with `stream_type=3` (write) and control-out with `stream_type=4`
(read) — but the channels layer de-chunks them, so the adapter (read), but the channels layer de-chunks them, so the adapter
reads raw JSON bytes from `ctrl_in`/`ctrl_out` in both cases. The reads raw JSON bytes from `ctrl_in`/`ctrl_out` in both cases. The
`ControlMessage` enum (resize, signal, eof, exit) is unchanged — `ControlMessage` enum (resize, signal, eof, exit) is unchanged:
the JSON shape is the same; only the stream_type assignments change the JSON shape is the same; only the stream_type assignments change
(3 splits into 3+4). (3 splits into 3+4).
@@ -166,19 +166,19 @@ splits from 3 into 3+4).
amended). In channels mode, the exit control message is the last amended). In channels mode, the exit control message is the last
data on `stream_type=4` before `channel/close` is sent on channel 0 data on `stream_type=4` before `channel/close` is sent on channel 0
(alknet ADR-073 §channel/close). The ordering invariant is the same (alknet ADR-073 §channel/close). The ordering invariant is the same
— exit before close — but the mechanism differs: 5-byte chunk (exit before close), but the mechanism differs: 5-byte chunk
ordering on stream_type 4 (direct) vs. `stream_type 4` ordering + ordering on stream_type 4 (direct) vs. `stream_type 4` ordering +
`channel/close` after pump completion (channels). `channel/close` after pump completion (channels).
### What does NOT change ### What does NOT change
- **`TtyBackend` trait, `TtyHandle`, `TtyControl`** (ADR-002) — - **`TtyBackend` trait, `TtyHandle`, `TtyControl`** (ADR-002):
unchanged. Backends don't know about channels or direct mode. unchanged. Backends don't know about channels or direct mode.
- **`DockerTtyBackend`, `LocalTtyBackend`** — unchanged. They implement - **`DockerTtyBackend`, `LocalTtyBackend`**: unchanged. They implement
`TtyBackend::allocate()` and return a `TtyHandle`. `TtyBackend::allocate()` and return a `TtyHandle`.
- **`ControlMessage` enum** — unchanged. The JSON shape is the same in - **`ControlMessage` enum**: unchanged. The JSON shape is the same in
both modes. both modes.
- **The `alk/tty` ALPN string** — unchanged. Direct connections use it; - **The `alk/tty` ALPN string**: unchanged. Direct connections use it;
channels `channel/open` requests it. channels `channel/open` requests it.
### Crate dependency ### Crate dependency
@@ -195,16 +195,16 @@ build; the feature-gated dependency is opt-in, same as `alknet-docker`'s
## Consequences ## Consequences
**Positive:** **Positive:**
- The TTY crate's direct mode is unchanged — existing `alk/tty` - The TTY crate's direct mode is unchanged: existing `alk/tty`
deployments (browser terminals over WebSocket, direct QUIC TTY) keep deployments (browser terminals over WebSocket, direct QUIC TTY) keep
working with the 5-byte format. working with the 5-byte format.
- The channels path uses the channels layer's de-chunking — no - The channels path uses the channels layer's de-chunking, so there is
double-chunking (5-byte inside 9-byte). The TTY adapter sees clean no double-chunking (5-byte inside 9-byte). The TTY adapter sees clean
sub-streams. sub-streams.
- The `TtyBackend` trait is insulated — backends don't know which mode - The `TtyBackend` trait is insulated: backends don't know which mode
the adapter is in. Docker, SSH, and local backends work in both the adapter is in. Docker, SSH, and local backends work in both
modes without changes. modes without changes.
- The control channel and exit-chunk invariant carry forward cleanly — - The control channel and exit-chunk invariant carry forward cleanly:
the `ControlMessage` enum and ordering semantics are the `ControlMessage` enum and ordering semantics are
mode-independent. mode-independent.
@@ -220,17 +220,17 @@ build; the feature-gated dependency is opt-in, same as `alknet-docker`'s
`alknet-docker`'s `tty` feature (alknet ADR-061) and is opt-in. `alknet-docker`'s `tty` feature (alknet ADR-061) and is opt-in.
- ADR-001's scope is amended (from "the TTY wire format" to "the TTY - ADR-001's scope is amended (from "the TTY wire format" to "the TTY
wire format for direct connections"). This is a scope clarification, wire format for direct connections"). This is a scope clarification,
not a format change — the 5-byte format itself is unchanged. not a format change; the 5-byte format itself is unchanged.
## Door type ## Door type
**One-way (scope amendment) + two-way (feature gate).** ADR-001's scope **One-way (scope amendment) + two-way (feature gate).** ADR-001's scope
amendment (direct-only) is one-way — once the channels path exists, amendment (direct-only) is one-way: once the channels path exists,
re-merging the formats would require unifying 5-byte and 9-byte chunk re-merging the formats would require unifying 5-byte and 9-byte chunk
handling, which is a rewrite. The `channels` feature gate is two-way — handling, which is a rewrite. The `channels` feature gate is two-way
it can be removed if channels integration is no longer needed. (it can be removed if channels integration is no longer needed).
**Reversed by ADR-008 (2026-07-18):** the two-mode design is reversed — **Reversed by ADR-008 (2026-07-18):** the two-mode design is reversed:
TTY always uses its 5-byte format, carried transparently in the TTY always uses its 5-byte format, carried transparently in the
channels payload. The one-way door is re-cast (the channels crate is channels payload. The one-way door is re-cast (the channels crate is
not yet implemented, so this is the right time). See ADR-008 for the not yet implemented, so this is the right time). See ADR-008 for the
@@ -239,29 +239,29 @@ amended door-type discussion.
## References ## References
- **[ADR-008](008-channels-pure-channel-multiplexing.md)**: channels - **[ADR-008](008-channels-pure-channel-multiplexing.md)**: channels
pure channel multiplexing (reverses this ADR — TTY always uses its pure channel multiplexing (reverses this ADR: TTY always uses its
5-byte format; the channels layer carries it transparently; the 5-byte format; the channels layer carries it transparently; the
two-mode design is preserved but differs only in `BiStream` source, two-mode design is preserved but differs only in `BiStream` source,
not in parsing) not in parsing)
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format - [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
(amended — scoped to direct connections by this ADR; **re-amended by (amended; scoped to direct connections by this ADR; **re-amended by
ADR-008 — TTY always uses its 5-byte format, in both direct and ADR-008: TTY always uses its 5-byte format, in both direct and
inside-channels modes**) inside-channels modes**)
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): TtyBackend trait - [ADR-002](002-ttybackend-trait-and-ttyhandle.md): TtyBackend trait
and TtyHandle (unchanged by this ADR) and TtyHandle (unchanged by this ADR)
- [ADR-004](004-exit-code-on-control-chunk.md): exit-chunk-is-last - [ADR-004](004-exit-code-on-control-chunk.md): exit-chunk-is-last
(generalized by this ADR + alknet ADR-073) (generalized by this ADR + alknet ADR-073)
- [ADR-006](006-negotiation-framing-self-contained.md): alktty does not - [ADR-006](006-negotiation-framing-self-contained.md): alktty does not
depend on alkcall's internal wire types (preserved — the channels depend on alkcall's internal wire types (preserved: the channels
feature is on alknet-channels, not alkcall's internal wire types) feature is on alknet-channels, not alkcall's internal wire types)
- alknet ADR-071: channels wire format (the 9-byte format the channels - alknet ADR-071: channels wire format (the 9-byte format the channels
path uses; **amended by alknet ADR-093 / ADR-008 — 8-byte format, no path uses; **amended by alknet ADR-093 / ADR-008: 8-byte format, no
`stream_type`**) `stream_type`**)
- alknet ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the - alknet ADR-074: ChannelBidiStreamSource / `into_sub_streams` (the
accessor the channels path uses; **amended by alknet ADR-093 / accessor the channels path uses; **amended by alknet ADR-093 /
ADR-008 — `into_sub_streams()` removed**) ADR-008: `into_sub_streams()` removed**)
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf - alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
decision that enables the reversal — `accept_bi` returns `BiStream`) decision that enables the reversal: `accept_bi` returns `BiStream`)
- alknet ADR-061: DockerTtyBackend in alknet-docker (the feature-gated - alknet ADR-061: DockerTtyBackend in alknet-docker (the feature-gated
dependency pattern this ADR mirrors) dependency pattern this ADR mirrors)
- alknet channels phase-0 findings §DP-3, §OQ-CH-02, §Relationship to - alknet channels phase-0 findings §DP-3, §OQ-CH-02, §Relationship to
@@ -2,32 +2,33 @@
## Status ## Status
Accepted (amends alknet ADR-071 — wire format is 8 bytes, not 9, and Accepted (amends alknet ADR-071: wire format is 8 bytes, not 9, and
the channels layer has no `stream_type` concept; amends alknet ADR-074 the channels layer has no `stream_type` concept; amends alknet ADR-074
— `into_sub_streams()` removed, `accept_bi` is the only accessor and (`into_sub_streams()` removed, `accept_bi` is the only accessor and
yields one `BiStream` per channel; reverses [ADR-007](007-tty-inside-channels.md) yields one `BiStream` per channel); reverses
— TTY always uses its 5-byte format, the channels layer carries it [ADR-007](007-tty-inside-channels.md): TTY always uses its 5-byte
transparently in the payload. Ported from alknet ADR-093 2026-08-17; format, the channels layer carries it transparently in the payload.
cross-references renumbered to alktty's ADR range — ADR-052→001, Ported from alknet ADR-093 2026-08-17; cross-references renumbered to
ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003,
ADR-077→007, ADR-093→008. The alknet ADRs referenced by alknet number ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The
(052, 070, 071, 072, 073, 074, 075, 076, 078, 079, 080, 081, 092) are alknet ADRs referenced by alknet number (052, 070, 071, 072, 073, 074,
not ported into alktty's ADR range — they are alknet-core / 075, 076, 078, 079, 080, 081, 092) are not ported into alktty's ADR
alknet-channels ADRs. The alknet originals at range; they are alknet-core / alknet-channels ADRs. The alknet
originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
authoritative.) authoritative.)
## Context ## Context
alknet ADR-071 committed the channels wire format as a 9-byte chunk alknet ADR-071 committed the channels wire format as a 9-byte chunk
header (`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte header (`[channel_id:u32][stream_type:u8][length:u32]`), a 4-byte
extension of TTY's 5-byte format, with `stream_type` carried in the extension of TTY's 5-byte format, with `stream_type` carried in the
channels header and decomposed into unidirectional halves (0/1/2 = channels header and decomposed into unidirectional halves (0/1/2 =
data write/read/err, 3/4/5 = control write/read/err, `% 3` formula). data write/read/err, 3/4/5 = control write/read/err, `% 3` formula).
alknet ADR-074 added a second accessor (`into_sub_streams()`) alknet ADR-074 added a second accessor (`into_sub_streams()`)
alongside `accept_bi` for handlers that need typed sub-streams (TTY's alongside `accept_bi` for handlers that need typed sub-streams (TTY's
stdin/stdout/stderr/ctrl-in/ctrl-out). [ADR-007](007-tty-inside-channels.md) stdin/stdout/stderr/ctrl-in/ctrl-out). [ADR-007](007-tty-inside-channels.md)
split TTY's wire format into two modes — direct (5-byte) and split TTY's wire format into two modes: direct (5-byte) and
inside-channels (the channels layer de-chunks and the adapter inside-channels (the channels layer de-chunks and the adapter
destructures via `into_sub_streams()`). destructures via `into_sub_streams()`).
@@ -39,18 +40,18 @@ because the channels layer reassembles per-`stream_type` and needs to
expose the result. The two-mode TTY design exists because the channels expose the result. The two-mode TTY design exists because the channels
layer's `stream_type` overlaps with TTY's own `stream_type`. The mod layer's `stream_type` overlaps with TTY's own `stream_type`. The mod
2/mod 3/mod 4 numbering question (settled as mod 3 in alknet ADR-071 2/mod 3/mod 4 numbering question (settled as mod 3 in alknet ADR-071
revised) was a symptom of this overlap — a numbering convention for a revised) was a symptom of this overlap: a numbering convention for a
concept the channels layer shouldn't carry. concept the channels layer shouldn't carry.
### The structural question ### The structural question
The channels layer has two objectives in tension: The channels layer has two objectives in tension:
1. **"Pass a stream to/from any ALPN"** — every channel is a 1. **"Pass a stream to/from any ALPN"**: every channel is a
`BiStream`; any handler gets `accept_bi()` and treats the channel `BiStream`; any handler gets `accept_bi()` and treats the channel
as a duplex stream. Uniform, transport-agnostic, as a duplex stream. Uniform, transport-agnostic,
recursive-composition-friendly. recursive-composition-friendly.
2. **"Channels carry N sub-streams"** — a TTY channel carries 2. **"Channels carry N sub-streams"**: a TTY channel carries
stdin/stdout/stderr/control; the handler destructures via stdin/stdout/stderr/control; the handler destructures via
`into_sub_streams()`. Carries what the source produces. `into_sub_streams()`. Carries what the source produces.
@@ -61,7 +62,7 @@ symmetry; you can't make the channel a single `BiStream` without
losing the stdout/stderr distinction. losing the stdout/stderr distinction.
alknet ADR-074's two-accessor design resolves this by making the "pass alknet ADR-074's two-accessor design resolves this by making the "pass
a stream to/from any ALPN" objective *qualified* — it applies to a stream to/from any ALPN" objective *qualified*: it applies to
single-stream channels (tunnel, SSH, call), not multi-stream channels single-stream channels (tunnel, SSH, call), not multi-stream channels
(TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified (TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified
design. design.
@@ -82,19 +83,19 @@ sub-multiplexing on the `BiStream` the channels layer gives them.
SSH runs its own channel protocol. The channels layer carries the SSH runs its own channel protocol. The channels layer carries the
bytes transparently. bytes transparently.
- **The mod 2/mod 3/mod 4 question dissolves at the channels layer.** - **The mod 2/mod 3/mod 4 question dissolves at the channels layer.**
The channels layer has no `stream_type` concept — not in its header, The channels layer has no `stream_type` concept: not in its header,
not in its code, not in its mental model. `stream_type` is the inner not in its code, not in its mental model. `stream_type` is the inner
layer's framing byte, carried transparently. layer's framing byte, carried transparently.
- **The control channel is handler-internal.** TTY sub-demuxes control - **The control channel is handler-internal.** TTY sub-demuxes control
from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`, from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`,
`STREAM_CTRL_OUT = 4` — ADR-001 amended by Phase 7). The channels `STREAM_CTRL_OUT = 4`; ADR-001 amended by Phase 7). The channels
layer doesn't carry control. The "control isn't actually layer doesn't carry control. The "control isn't actually
bidirectional" flaw is fixed at the TTY layer, not the channels bidirectional" flaw is fixed at the TTY layer, not the channels
layer. layer.
- **Recursive composition is literal.** A channel with ALPN - **Recursive composition is literal.** A channel with ALPN
`alk/channels` runs another channels demux on its `BiStream`. The `alk/channels` runs another channels demux on its `BiStream`. The
outer layer strips its 8-byte header; the inner layer parses its outer layer strips its 8-byte header; the inner layer parses its
own 8-byte header from the payload. Each level is the same shape — own 8-byte header from the payload. Each level is the same shape:
`BiStream → accept_bi → N BiStreams`. `BiStream → accept_bi → N BiStreams`.
### The wire format decision: 8 bytes ### The wire format decision: 8 bytes
@@ -109,7 +110,7 @@ was considered and rejected. The 9-byte format puts `stream_type` in the
channels header, which means the channels layer carries a concept it channels header, which means the channels layer carries a concept it
doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's
5-byte header with `channel_id` prepended), but for non-TTY handlers 5-byte header with `channel_id` prepended), but for non-TTY handlers
(tunnel, call, SSH) the `stream_type` byte is dead weight — the (tunnel, call, SSH) the `stream_type` byte is dead weight: the
channels layer carries a byte it doesn't understand, and the handler channels layer carries a byte it doesn't understand, and the handler
ignores a byte in a header it doesn't control. ignores a byte in a header it doesn't control.
@@ -133,11 +134,11 @@ payload). The handler layer (e.g. TTY) parses its own framing from the
payload bytes per its existing `wire.rs`. The handler doesn't know or payload bytes per its existing `wire.rs`. The handler doesn't know or
care that a `channel_id` was stripped before it saw the bytes. care that a `channel_id` was stripped before it saw the bytes.
The composition is uniform — the same shape at every level. This is The composition is uniform: the same shape at every level. This is
SSH's model (layered headers, each layer strips its own at its SSH's model (layered headers, each layer strips its own at its
boundary), applied to channels. A `alk/channels`-inside-`alk/channels` boundary), applied to channels. A `alk/channels`-inside-`alk/channels`
recursive composition is the outer layer stripping its 8-byte header, recursive composition is the outer layer stripping its 8-byte header,
the inner layer parsing its own 8-byte header from the payload — same the inner layer parsing its own 8-byte header from the payload; same
code, same shape, each level. code, same shape, each level.
### Why this can land now ### Why this can land now
@@ -150,13 +151,13 @@ Three things changed since alknet ADR-071/074/077 were accepted:
The join moves into core's quinn/iroh/stream impls (once per The join moves into core's quinn/iroh/stream impls (once per
source, invisible to handlers). This ADR's "every channel is a source, invisible to handlers). This ADR's "every channel is a
`BiStream`" is the channels-layer consequence of alknet ADR-092's `BiStream`" is the channels-layer consequence of alknet ADR-092's
handler-leaf decision — the research-then-sync pattern applied: handler-leaf decision (the research-then-sync pattern applied:
alknet ADR-092 settled the transport leaf, this ADR settles the alknet ADR-092 settled the transport leaf, this ADR settles the
multiplexing layer above it. multiplexing layer above it).
2. **Phase 7 fixed the TTY control channel at the TTY layer.** The 2. **Phase 7 fixed the TTY control channel at the TTY layer.** The
`STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it `STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it
into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4`, *inside TTY's
5-byte format*, not at the channels layer. This removed the 5-byte format*, not at the channels layer. This removed the
load-bearing reason for the channels layer to carry `stream_type`: load-bearing reason for the channels layer to carry `stream_type`:
the control bidirectionality fix is a TTY-internal concern, not a the control bidirectionality fix is a TTY-internal concern, not a
channels-layer concern. ADR-007's two-mode TTY design was motivated channels-layer concern. ADR-007's two-mode TTY design was motivated
@@ -175,19 +176,19 @@ Three things changed since alknet ADR-071/074/077 were accepted:
- **The add/strip API shape** (built into read/write vs. a separate - **The add/strip API shape** (built into read/write vs. a separate
utility): the stream-unification research proposed `add_channel_id` utility): the stream-unification research proposed `add_channel_id`
/ `strip_channel_id` as standalone functions. Ideally the header is / `strip_channel_id` as standalone functions. Ideally the header is
built into the read/write path so the utility isn't needed at the built into the read/write path so the utility isn't needed at the
handler boundary — but there may be a generalized reason to expose handler boundary, but there may be a generalized reason to expose
it (recursive composition, test helpers, the hub relay's it (recursive composition, test helpers, the hub relay's
`channel_id` rewrite). The exact API shape is an implementation `channel_id` rewrite). The exact API shape is an implementation
detail for the channels crate. The *contract* — the channels layer detail for the channels crate. The *contract* (the channels layer
strips its 8-byte header on read and the handler parses its own strips its 8-byte header on read and the handler parses its own
framing from the payload — is decided here; the *function surface* framing from the payload) is decided here; the *function surface*
is not. is not.
- **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads - **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads
from an `AsyncRead`. Adapting it to read from a payload buffer from an `AsyncRead`. Adapting it to read from a payload buffer
(`&[u8]` or `Cursor<Bytes>`) is a small, well-scoped change (the (`&[u8]` or `Cursor<Bytes>`) is a small, well-scoped change; the
framing logic — stream_type constants, length validation, control framing logic (stream_type constants, length validation, control
message parsing — is unchanged). This is an implementation concern message parsing) is unchanged. This is an implementation concern
for the channels + TTY integration, not an architecture decision. for the channels + TTY integration, not an architecture decision.
In alktty, `drive_session` already runs against a `BiStream` whose In alktty, `drive_session` already runs against a `BiStream` whose
`AsyncRead` yields the post-channels-strip payload bytes; the same `AsyncRead` yields the post-channels-strip payload bytes; the same
@@ -212,11 +213,11 @@ handler's framing, carried transparently.
| field | offset | width | meaning | | field | offset | width | meaning |
|-------|--------|-------|---------| |-------|--------|-------|---------|
| `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (alknet ADR-072). Channels 1..N are opened dynamically via `channel/open` (alknet ADR-073). | | `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (alknet ADR-072). Channels 1..N are opened dynamically via `channel/open` (alknet ADR-073). |
| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-001 §5). | | `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap; ADR-001 §5). |
The `stream_type` byte is **removed** from the channels header. The The `stream_type` byte is **removed** from the channels header. The
channels layer has no `stream_type` concept — not in its header, not in channels layer has no `stream_type` concept (nothing in its header,
its code, not in its mental model. What was the channels header's nothing in its code, nothing in its mental model). What was the channels header's
`stream_type` byte is now the first byte of the payload, owned by the `stream_type` byte is now the first byte of the payload, owned by the
handler's framing (TTY's 5-byte format, call's length-prefixed JSON, handler's framing (TTY's 5-byte format, call's length-prefixed JSON,
tunnel's raw bytes, SSH's channel protocol). tunnel's raw bytes, SSH's channel protocol).
@@ -232,7 +233,7 @@ layer carries transparently.
alknet ADR-074's `into_sub_streams()` / `ChannelSubStreams` / alknet ADR-074's `into_sub_streams()` / `ChannelSubStreams` /
`SubStreamHandle` are removed. The channels layer exposes one accessor: `SubStreamHandle` are removed. The channels layer exposes one accessor:
`accept_bi()`, which yields one `BiStream` per channel (per alknet `accept_bi()`, which yields one `BiStream` per channel (per alknet
ADR-092). Every handler — TTY, tunnel, SSH, call — receives a ADR-092). Every handler (TTY, tunnel, SSH, call) receives a
`Connection`, calls `accept_bi()` once, gets a `BiStream`, and `Connection`, calls `accept_bi()` once, gets a `BiStream`, and
sub-multiplexes it however it wants. sub-multiplexes it however it wants.
@@ -240,7 +241,7 @@ This amends alknet ADR-074: the two-accessor design (`accept_bi` for
generic handlers, `into_sub_streams` for typed handlers) collapses to generic handlers, `into_sub_streams` for typed handlers) collapses to
one accessor. The "typed handler path" (alknet ADR-074's motivating one accessor. The "typed handler path" (alknet ADR-074's motivating
case for TTY) is replaced by TTY sub-demuxing its `BiStream` via its case for TTY) is replaced by TTY sub-demuxing its `BiStream` via its
own 5-byte format — the same code TTY runs in direct mode. alknet own 5-byte format, the same code TTY runs in direct mode. alknet
ADR-074's yield-once `accept_bi` contract is preserved; the ADR-074's yield-once `accept_bi` contract is preserved; the
`into_sub_streams()` accessor is the amended part. `into_sub_streams()` accessor is the amended part.
@@ -257,16 +258,16 @@ same `wire.rs` code runs in both modes.
When TTY is inside channels, the channels layer strips its 8-byte When TTY is inside channels, the channels layer strips its 8-byte
header and hands TTY the payload bytes. TTY parses its 5-byte header header and hands TTY the payload bytes. TTY parses its 5-byte header
from the payload. The channels layer carries TTY's 5-byte chunks from the payload. The channels layer carries TTY's 5-byte chunks
transparently in its payload — no shared fields, no leaked transparently in its payload: no shared fields, no leaked
abstraction, no double-chunking concern (the 13-byte total header is abstraction, no double-chunking concern (the 13-byte total header is
8 channels + 5 TTY, not 8 + 9; the channels `length` is always 8 channels + 5 TTY, not 8 + 9; the channels `length` is always
`tty_len + 5`). `tty_len + 5`).
This reverses ADR-007: the 5-byte format is NOT scoped to direct — This reverses ADR-007: the 5-byte format is NOT scoped to direct;
it's TTY's internal format, carried transparently in the channels it's TTY's internal format, carried transparently in the channels
payload. The `channels` feature on alktty (if added) becomes "run payload. The `channels` feature on alktty (if added) becomes "run
TTY's sub-demux on a channels-backed `BiStream`" — the same code as TTY's sub-demux on a channels-backed `BiStream`" (the same code as
direct mode, different `BiStream` source. The control channel split direct mode, different `BiStream` source). The control channel split
(`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the (`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the
channels layer doesn't know about it. channels layer doesn't know about it.
@@ -291,65 +292,65 @@ TTY inside channels:
(8 bytes) carried as channels payload (8 bytes) carried as channels payload
``` ```
The composition is uniform — the same shape at every level. A The composition is uniform: the same shape at every level. A
`alk/channels`-inside-`alk/channels` recursive composition is the `alk/channels`-inside-`alk/channels` recursive composition is the
outer layer stripping its 8-byte header, the inner layer parsing its outer layer stripping its 8-byte header, the inner layer parsing its
own 8-byte header from the payload — same code, same shape, each own 8-byte header from the payload; same code, same shape, each
level. level.
### 5. What does NOT change ### 5. What does NOT change
- **alknet ADR-092's `BiStream` leaf** — unchanged. This ADR is the - **alknet ADR-092's `BiStream` leaf** is unchanged. This ADR is the
channels-layer consequence of alknet ADR-092: `accept_bi` yields a channels-layer consequence of alknet ADR-092: `accept_bi` yields a
`BiStream`, handlers sub-multiplex it. The two ADRs compose (alknet `BiStream`, handlers sub-multiplex it. The two ADRs compose (alknet
ADR-092 settles the transport leaf; this ADR settles the ADR-092 settles the transport leaf; this ADR settles the
multiplexing layer above it). multiplexing layer above it).
- **`ProtocolHandler` trait shape** (alknet ADR-002) — unchanged. - **`ProtocolHandler` trait shape** (alknet ADR-002): unchanged.
Handlers receive a `Connection` and call `accept_bi()`. Handlers receive a `Connection` and call `accept_bi()`.
- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072) — - **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072):
unchanged. Channel 0's chunks have `channel_id = 0` in the 8-byte unchanged. Channel 0's chunks have `channel_id = 0` in the 8-byte
header. The call protocol's `EventEnvelope` framing is the payload; header. The call protocol's `EventEnvelope` framing is the payload;
the channels layer carries it transparently. the channels layer carries it transparently.
- **Channel lifecycle operations** (alknet ADR-073) — unchanged. The - **Channel lifecycle operations** (alknet ADR-073): unchanged. The
four operations (`channel/open`/`close`/`control`/`resources/subscribe`) four operations (`channel/open`/`close`/`control`/`resources/subscribe`)
and their `direction` semantics are call-protocol operations on and their `direction` semantics are call-protocol operations on
channel 0, not channels-wire-format concerns. channel 0, not channels-wire-format concerns.
- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075) — - **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075):
structurally unchanged. The demux loop reads 8-byte headers (not structurally unchanged. The demux loop reads 8-byte headers (not
9-byte); the `ChannelManager` is ALPN-blind, auth-blind, 9-byte); the `ChannelManager` is ALPN-blind, auth-blind,
transport-blind. The `stream_types` field on `channel/open` and transport-blind. The `stream_types` field on `channel/open` and
`ChannelState` is removed (the channels layer doesn't track `ChannelState` is removed (the channels layer doesn't track
per-stream-type reassembly buffers; it tracks one reassembly buffer per-stream-type reassembly buffers; it tracks one reassembly buffer
per `channel_id`, yielding a `BiStream`). per `channel_id`, yielding a `BiStream`).
- **Backpressure, channel limits, ID reuse** (alknet ADR-076) — - **Backpressure, channel limits, ID reuse** (alknet ADR-076):
unchanged. The bounded-buffer backpressure is per-`channel_id` (was unchanged. The bounded-buffer backpressure is per-`channel_id` (was
per-`(channel_id, stream_type)`; now per-`channel_id` since there's per-`(channel_id, stream_type)`; now per-`channel_id` since there's
one reassembly buffer per channel). The 256-channel cap, 1 MiB one reassembly buffer per channel). The 256-channel cap, 1 MiB
default, and monotonic-ID-with-wrap strategy are unchanged. default, and monotonic-ID-with-wrap strategy are unchanged.
- **Two-pump shutdown-on-completion** (alknet ADR-078) — unchanged. - **Two-pump shutdown-on-completion** (alknet ADR-078): unchanged.
Tunnel/SSH handlers call `tokio::io::split(bidi)` for their two pump Tunnel/SSH handlers call `tokio::io::split(bidi)` for their two pump
halves; the shutdown-on-completion contract applies to the halves; the shutdown-on-completion contract applies to the
`ReadHalf` / `WriteHalf` unchanged. `ReadHalf` / `WriteHalf` unchanged.
- **Hub relay** (alknet ADR-079) — unchanged in contract. The hub - **Hub relay** (alknet ADR-079): unchanged in contract. The hub
translates `channel/open` on channel 0 and byte-forwards data translates `channel/open` on channel 0 and byte-forwards data
channels with `channel_id` rewrite. The relay reads 8-byte headers channels with `channel_id` rewrite. The relay reads 8-byte headers
(not 9-byte) and rewrites the `channel_id` field (a 4-byte rewrite (not 9-byte) and rewrites the `channel_id` field (a 4-byte rewrite
within the 8-byte header, not a 9-byte header). The relay does not within the 8-byte header, not a 9-byte header). The relay does not
parse the payload. parse the payload.
- **`ChannelClient`** (alknet ADR-080) — unchanged in API. - **`ChannelClient`** (alknet ADR-080): unchanged in API.
`from_connection` primary, `open_channel` returns a `Channel`. The `from_connection` primary, `open_channel` returns a `Channel`. The
`stream_types` field on `open_channel` and `Channel` is removed (the `stream_types` field on `open_channel` and `Channel` is removed (the
channels layer doesn't negotiate per-stream-type sets; the handler channels layer doesn't negotiate per-stream-type sets; the handler
owns its sub-stream multiplexing). The owns its sub-stream multiplexing). The
`channel:stream_type_unavailable` error code is removed (the `channel:stream_type_unavailable` error code is removed (the
channels layer can't refuse a `stream_type` it doesn't know about). channels layer can't refuse a `stream_type` it doesn't know about).
- **Sub-crate decomposition** (alknet ADR-081) — unchanged. - **Sub-crate decomposition** (alknet ADR-081): unchanged.
`channels-core` (pure multiplexer, depends on alknet-core only) / `channels-core` (pure multiplexer, depends on alknet-core only) /
`channels-call` (channel 0 pre-negotiation + lifecycle op `channels-call` (channel 0 pre-negotiation + lifecycle op
registrations, depends on `channels-core` + alknet-call). The 8-byte registrations, depends on `channels-core` + alknet-call). The 8-byte
wire format, demux/mux, and `ChannelBidiStreamSource` are in wire format, demux/mux, and `ChannelBidiStreamSource` are in
`channels-core`; the call-protocol coupling is in `channels-call`. `channels-core`; the call-protocol coupling is in `channels-call`.
- **`BidiStreamSource` trait** (alknet ADR-070) — unchanged in shape. - **`BidiStreamSource` trait** (alknet ADR-070): unchanged in shape.
`ChannelBidiStreamSource` implements it; `accept_bi` yields a `ChannelBidiStreamSource` implements it; `accept_bi` yields a
`BiStream` (per alknet ADR-092, already landed). `BiStream` (per alknet ADR-092, already landed).
@@ -358,7 +359,7 @@ level.
**Positive:** **Positive:**
- **Clean separation of concerns.** The channels layer has no - **Clean separation of concerns.** The channels layer has no
`stream_type` concept — not in its header, not in its code, not in `stream_type` concept: not in its header, not in its code, not in
its mental model. The handler owns its framing entirely. This its mental model. The handler owns its framing entirely. This
dissolves the mod 2/mod 3/mod 4 question at the channels layer dissolves the mod 2/mod 3/mod 4 question at the channels layer
(there's nothing to decompose) and fixes the "control isn't actually (there's nothing to decompose) and fixes the "control isn't actually
@@ -373,7 +374,7 @@ level.
another channels demux on its `BiStream`. The outer layer strips its another channels demux on its `BiStream`. The outer layer strips its
8-byte header; the inner layer parses its own 8-byte header from 8-byte header; the inner layer parses its own 8-byte header from
the payload. Same code, same shape, each level. This is a property, the payload. Same code, same shape, each level. This is a property,
not a feature — the primary use case is one level of multiplexing, not a feature: the primary use case is one level of multiplexing,
but the add/strip composition makes the recursion cleaner than but the add/strip composition makes the recursion cleaner than
alknet ADR-071's group framing did. alknet ADR-071's group framing did.
- **The `into_sub_streams()` accessor and its consuming handler code - **The `into_sub_streams()` accessor and its consuming handler code
@@ -392,7 +393,7 @@ level.
top-level one. (In alktty as built, `drive_session` already takes a top-level one. (In alktty as built, `drive_session` already takes a
generic `AsyncRead + AsyncWrite` pair, so both the direct generic `AsyncRead + AsyncWrite` pair, so both the direct
`TtyAdapter::handle` path and the channels `TtyOpenHandler` path `TtyAdapter::handle` path and the channels `TtyOpenHandler` path
call the same function — see `src/adapter.rs` and call the same function (see `src/adapter.rs` and
`src/channels.rs`.) `src/channels.rs`.)
- **The channels layer is WASM-compatible by construction.** The - **The channels layer is WASM-compatible by construction.** The
8-byte header's core is pure byte manipulation (the sync core 8-byte header's core is pure byte manipulation (the sync core
@@ -415,9 +416,9 @@ level.
cause this ADR addresses. cause this ADR addresses.
- **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently - **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently
reads from an `AsyncRead` (the transport stream). Inside channels, reads from an `AsyncRead` (the transport stream). Inside channels,
it reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`) — the it reads from a payload buffer (`&[u8]` or `Cursor<Bytes>`), the
bytes the channels layer handed it after stripping its 8-byte bytes the channels layer handed it after stripping its 8-byte
header. The framing logic (stream_type constants, length validation, header. The framing logic (stream_type constants, length validation,
control message parsing) is unchanged. This is a bounded, control message parsing) is unchanged. This is a bounded,
well-scoped implementation change, not an architecture change. The well-scoped implementation change, not an architecture change. The
same adaptation applies to any handler that parses its own framing same adaptation applies to any handler that parses its own framing
@@ -425,13 +426,13 @@ level.
from a buffer; tunnel and SSH don't parse the payload, so no from a buffer; tunnel and SSH don't parse the payload, so no
adaptation). In alktty as built, `drive_session` already reads from adaptation). In alktty as built, `drive_session` already reads from
a `BiStream` whose `AsyncRead` yields the post-channels-strip a `BiStream` whose `AsyncRead` yields the post-channels-strip
payload bytes — the same `wire.rs` code runs unchanged in both payload bytes; the same `wire.rs` code runs unchanged in both
modes. modes.
- **`channel/open` loses the `stream_types` field.** alknet ADR-073's - **`channel/open` loses the `stream_types` field.** alknet ADR-073's
`channel/open` input included `stream_types: [u8]` (the active `channel/open` input included `stream_types: [u8]` (the active
sub-stream set) and the response echoed the negotiated set. Under sub-stream set) and the response echoed the negotiated set. Under
this ADR, the channels layer doesn't negotiate sub-stream sets — this ADR, the channels layer doesn't negotiate sub-stream sets; the
the handler owns its sub-stream multiplexing. The `stream_types` handler owns its sub-stream multiplexing. The `stream_types`
field is removed from `channel/open` (and from the field is removed from `channel/open` (and from the
`channel:stream_type_unavailable` error code). The `alpn` and `channel:stream_type_unavailable` error code). The `alpn` and
`params` fields remain; the handler's sub-stream set is implicit in `params` fields remain; the handler's sub-stream set is implicit in
@@ -444,7 +445,7 @@ level.
simplification (fewer reassembly buffers, simpler drain logic) but simplification (fewer reassembly buffers, simpler drain logic) but
is an implementation change, not an architecture one. The is an implementation change, not an architecture one. The
bounded-buffer backpressure (alknet ADR-076) is per-`channel_id` bounded-buffer backpressure (alknet ADR-076) is per-`channel_id`
now, not per-`(channel_id, stream_type)` — the 1 MiB default and now, not per-`(channel_id, stream_type)`: the 1 MiB default and
the 256-channel cap are unchanged; the per-channel memory ceiling is the 256-channel cap are unchanged; the per-channel memory ceiling is
1 MiB (was up to 5 MiB for a TTY channel with 5 active 1 MiB (was up to 5 MiB for a TTY channel with 5 active
stream_types). This is a net improvement (lower memory ceiling per stream_types). This is a net improvement (lower memory ceiling per
@@ -459,7 +460,7 @@ removal of `stream_type` from the channels header, and the removal of
them after the channels crate is implemented and handlers are written them after the channels crate is implemented and handlers are written
against them requires a version migration. Since the channels crate against them requires a version migration. Since the channels crate
doesn't exist yet, the one-way door is being cast now, before doesn't exist yet, the one-way door is being cast now, before
implementation — the right time to cast a one-way door. implementation, the right time to cast a one-way door.
The reversal of ADR-007 (TTY always uses its 5-byte format) is one-way The reversal of ADR-007 (TTY always uses its 5-byte format) is one-way
in the same sense: once TTY's `wire.rs` runs in both modes (direct and in the same sense: once TTY's `wire.rs` runs in both modes (direct and
@@ -467,53 +468,53 @@ inside-channels), re-introducing a separate inside-channels mode would
be a rewrite of TTY's session driver. The trade is one unified session be a rewrite of TTY's session driver. The trade is one unified session
driver now vs. two-mode maintenance forever. driver now vs. two-mode maintenance forever.
The add/strip API shape (alknet OQ-68) is a **two-way door** — whether The add/strip API shape (alknet OQ-68) is a **two-way door**: whether
the header add/strip is built into the read/write path or exposed as a the header add/strip is built into the read/write path or exposed as a
standalone utility is an implementation detail that can change without standalone utility is an implementation detail that can change without
breaking the wire format or the handler contract. breaking the wire format or the handler contract.
## References ## References
- alknet ADR-071: channels wire format (amended — wire format is 8 - alknet ADR-071: channels wire format (amended: wire format is 8
bytes, not 9; `stream_type` removed from the channels header; the bytes, not 9; `stream_type` removed from the channels header; the
stream_type decomposition is removed from the channels layer) stream_type decomposition is removed from the channels layer)
- alknet ADR-074: ChannelBidiStreamSource (amended — - alknet ADR-074: ChannelBidiStreamSource (amended:
`into_sub_streams()` removed; `accept_bi` is the only accessor, `into_sub_streams()` removed; `accept_bi` is the only accessor,
yields one `BiStream` per channel) yields one `BiStream` per channel)
- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed - [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed:
— TTY always uses its 5-byte format; the channels layer carries it TTY always uses its 5-byte format; the channels layer carries it
transparently in the payload; the two-mode design is preserved but transparently in the payload; the two-mode design is preserved but
differs only in `BiStream` source, not in parsing) differs only in `BiStream` source, not in parsing)
- alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf - alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf
layer this ADR builds on — `accept_bi` returns `BiStream`; layer this ADR builds on: `accept_bi` returns `BiStream`;
`from_bidi` is the only public stream constructor) `from_bidi` is the only public stream constructor)
- alknet ADR-070: `BidiStreamSource` trait (the extension point - alknet ADR-070: `BidiStreamSource` trait (the extension point
`ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`) `ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`)
- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged — - alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged:
channel 0's chunks have `channel_id = 0` in the 8-byte header; the channel 0's chunks have `channel_id = 0` in the 8-byte header; the
call protocol's framing is the payload) call protocol's framing is the payload)
- alknet ADR-073: channel lifecycle operations (amended — - alknet ADR-073: channel lifecycle operations (amended:
`stream_types` field removed from `channel/open`; `stream_types` field removed from `channel/open`;
`channel:stream_type_unavailable` error code removed) `channel:stream_type_unavailable` error code removed)
- alknet ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally - alknet ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally
unchanged — demux reads 8-byte headers; one reassembly buffer per unchanged: demux reads 8-byte headers; one reassembly buffer per
channel) channel)
- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged — - alknet ADR-076: backpressure, channel limits, ID reuse (unchanged:
bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default, bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default,
monotonic IDs) monotonic IDs)
- alknet ADR-078: two-pump shutdown-on-completion (unchanged — the - alknet ADR-078: two-pump shutdown-on-completion (unchanged: the
contract applies to `tokio::io::split(bidi)` halves) contract applies to `tokio::io::split(bidi)` halves)
- alknet ADR-079: hub relay (unchanged in contract — 8-byte header, - alknet ADR-079: hub relay (unchanged in contract: 8-byte header,
4-byte `channel_id` rewrite, payload byte-forwarded) 4-byte `channel_id` rewrite, payload byte-forwarded)
- alknet ADR-080: `ChannelClient` (amended — `stream_types` field - alknet ADR-080: `ChannelClient` (amended: `stream_types` field
removed from `open_channel` and `Channel`) removed from `open_channel` and `Channel`)
- alknet ADR-081: sub-crate decomposition (unchanged — 8-byte wire - alknet ADR-081: sub-crate decomposition (unchanged: 8-byte wire
format in `channels-core`; call-protocol coupling in format in `channels-core`; call-protocol coupling in
`channels-call`) `channels-call`)
- [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format - [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format
(the 5-byte format carried transparently in the channels payload; (the 5-byte format carried transparently in the channels payload;
the control channel split from Phase 7 is TTY-internal) the control channel split from Phase 7 is TTY-internal)
- `src/channels.rs` — alktty's channels integration (the - `src/channels.rs`: alktty's channels integration (the
`register_openable` helper + `TtyOpenHandler` that runs `register_openable` helper + `TtyOpenHandler` that runs
`drive_session` on a channels-backed `BiStream`, the same code as `drive_session` on a channels-backed `BiStream`, the same code as
the direct path) the direct path)
@@ -8,26 +8,33 @@ Accepted (2026-09-05). Resolves review #001 L1. Prerequisites: alkcall
Amended 2026-09-05 (review #002 R4): a `NegotiateRequest` parse failure Amended 2026-09-05 (review #002 R4): a `NegotiateRequest` parse failure
of the schema-validated `input` is now a client-visible of the schema-validated `input` is now a client-visible
`malformed_negotiation` error frame, not a silent teardown — see `malformed_negotiation` error frame, not a silent teardown; see
§"Parse-failure error frame (R4 amendment, 2026-09-05)". §"Parse-failure error frame (R4 amendment, 2026-09-05)".
Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006 Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049, review 006
E-01), the semantic-failure classes the R4 amendment reported in-band E-01), the semantic-failure classes the R4 amendment reported in-band
(malformed negotiation, unknown backend, ownership denial) move into (malformed negotiation, unknown backend, ownership denial) move into
the producer's establisher and resolve as `channel:open_failed` call the producer's establisher and resolve as `channel:open_failed` call
errors — no phantom channel. The in-band error-frame path on this errors; no phantom channel. The in-band error-frame path on this
channel shrinks to one class: `allocate_failed` (the establisher channel shrinks to one class: `allocate_failed` (the establisher
cannot carry the allocated handle across — see ADR-010 §2). The R4 cannot carry the allocated handle across; see ADR-010 §2). The R4
tests that asserted the in-band frames for those classes now assert tests that asserted the in-band frames for those classes now assert
the call-error shape. the call-error shape.
Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0, review 007 R-01):
`Establishment` gained its plan payload, so `allocate` also moved
into the establisher; the in-band error-frame path on this channel
shrinks to nothing from a registered producer (the frames remain the
defense-in-depth fallback for no-establisher registrations and the
direct path). See ADR-010 §2A.
## Context ## Context
Before this ADR, the channels path carried the negotiation twice. The Before this ADR, the channels path carried the negotiation twice. The
consumer opened a channel via the `channels/tty/sub` open op, passing consumer opened a channel via the `channels/tty/sub` open op, passing
the `NegotiateRequest` params as the op's `input`; the producer-side the `NegotiateRequest` params as the op's `input`; the producer-side
open handler then ignored that `input` (`let _ = input;`) and read a open handler then ignored that `input` (`let _ = input;`) and read a
second, full negotiation frame from the channel's data stream — the second, full negotiation frame from the channel's data stream, the
same frame the direct-ALPN path reads. The registry validated `input` same frame the direct-ALPN path reads. The registry validated `input`
against the op's schema, but the backend selection actually came from against the op's schema, but the backend selection actually came from
the unvalidated wire frame. Two independent gates (the registry's the unvalidated wire frame. Two independent gates (the registry's
@@ -38,19 +45,19 @@ divergent frame.
Review #001 (L1) flagged this as a latent inconsistency and asked for Review #001 (L1) flagged this as a latent inconsistency and asked for
a publisher decision: either drop the `input` parameter or pass it a publisher decision: either drop the `input` parameter or pass it
through as the authoritative negotiation. Investigation of alkcall through as the authoritative negotiation. Investigation of alkcall
found the review's premise wrong in an important way — alkcall (≤0.3.1) found the review's premise wrong in an important way: alkcall (≤0.3.1)
never enforced `input_schema` on any dispatch path at all: the schema never enforced `input_schema` on any dispatch path at all: the schema
was advertise-only (metadata in discovery). The registry gate this was advertise-only (metadata in discovery). The registry gate this
design leaned on did not exist. design leaned on did not exist.
Two upstream changes landed to make the design sound: Two upstream changes landed to make the design sound:
1. **alkcall 0.4.0** — `OperationSpec.input_schema` is enforced at 1. **alkcall 0.4.0**: `OperationSpec.input_schema` is enforced at
call time by all three registry dispatch entry points (`invoke`, call time by all three registry dispatch entry points (`invoke`,
`invoke_streaming`, `invoke_sink`). The schema is compiled once at `invoke_streaming`, `invoke_sink`). The schema is compiled once at
registration (fail-closed, the same rule as `publish_schema` / registration (fail-closed, the same rule as `publish_schema` /
CF-003). Violations return `INVALID_INPUT` before the handler runs. CF-003). Violations return `INVALID_INPUT` before the handler runs.
2. **alkcall 0.4.1** — the demux parks early-arrival chunks for a 2. **alkcall 0.4.1**: the demux parks early-arrival chunks for a
not-yet-adopted channel (bounded FIFO, 64 payloads per channel) and not-yet-adopted channel (bounded FIFO, 64 payloads per channel) and
`adopt_channel` drains them into the new receiver. Before this, the `adopt_channel` drains them into the new receiver. Before this, the
open-op-response / producer's-first-write race silently dropped the open-op-response / producer's-first-write race silently dropped the
@@ -63,30 +70,30 @@ Two upstream changes landed to make the design sound:
The open op's registry-validated `input` **is** the negotiation The open op's registry-validated `input` **is** the negotiation
(design 2 of the L1 decision). The channels path carries **no second (design 2 of the L1 decision). The channels path carries **no second
negotiation frame** on the channel's data stream — the stream starts negotiation frame** on the channel's data stream; the stream starts
directly in raw-chunk mode (TTY's 5-byte format, ADR-001/ADR-008). directly in raw-chunk mode (TTY's 5-byte format, ADR-001/ADR-008).
### Producer side ### Producer side
`make_tty_open_handler` parses the open op's `input` into a `make_tty_open_handler` parses the open op's `input` into a
`NegotiateRequest` and drives the new `drive_session_pre_negotiated` `NegotiateRequest` and drives the new `drive_session_pre_negotiated`
(`src/adapter.rs`) — the same three-pump session driver as the direct (`src/adapter.rs`), the same three-pump session driver as the direct
path, minus the wire-frame negotiation phase. Validation is factored path, minus the wire-frame negotiation phase. Validation is factored
into `validate_and_allocate`, shared by both paths: `carriage == into `validate_and_allocate`, shared by both paths: `carriage ==
"raw"`, non-empty `cmd`, backend lookup, the ADR-050 ownership check, "raw"`, non-empty `cmd`, backend lookup, the ADR-050 ownership check,
and `backend.allocate`. On the channels path the `tty:open` scope gate and `backend.allocate`. On the channels path the `tty:open` scope gate
is NOT re-checked in the handler — the registry's `AccessControl` is NOT re-checked in the handler; the registry's `AccessControl`
enforced it before the handler ran (the identity the registry checked, enforced it before the handler ran (the identity the registry checked,
resolved from the connection, is the authoritative one; the resolved from the connection, is the authoritative one; the
handler-side identity is only the ownership-check subject). handler-side identity is only the ownership-check subject).
Post-open failures (unknown backend, `allocate_failed`, ownership Post-open failures (unknown backend, `allocate_failed`, ownership
denial) still go to the client as a `0x00`-prefixed negotiation error denial) still go to the client as a `0x00`-prefixed negotiation error
frame on the channel stream — the same framing the direct path uses — frame on the channel stream (the same framing the direct path uses),
so the consumer's ADR-001 §5 disambiguation read applies unchanged. so the consumer's ADR-001 §5 disambiguation read applies unchanged.
A `NegotiateRequest` parse failure of the schema-validated `input` A `NegotiateRequest` parse failure of the schema-validated `input`
(the schema is deliberately partial — `carriage`/`backend`/`cmd` (the schema is deliberately partial: `carriage`/`backend`/`cmd`
required, `tty`/`cwd`/`env`/backend-params free-form so the opaque required, `tty`/`cwd`/`env`/backend-params free-form so the opaque
ADR-053 params pass through) was originally a handler-side failure ADR-053 params pass through) was originally a handler-side failure
logged and returned without an error frame. The R4 amendment below logged and returned without an error frame. The R4 amendment below
@@ -99,7 +106,7 @@ failure.
a `NegotiateRequest` locally first (fail-fast before a channel is a `NegotiateRequest` locally first (fail-fast before a channel is
allocated), opens the channel via `ChannelClient::open_channel`, allocated), opens the channel via `ChannelClient::open_channel`,
builds the channel `Connection`, and starts raw-chunk mode directly builds the channel `Connection`, and starts raw-chunk mode directly
(`from_halves_raw` — no negotiation frame is written). The (`from_halves_raw`; no negotiation frame is written). The
`0x00`-error-frame peek is retained: the first byte of the producer's `0x00`-error-frame peek is retained: the first byte of the producer's
response disambiguates a post-open negotiation error frame (length response disambiguates a post-open negotiation error frame (length
prefix starting `0x00`) from a raw chunk (`stream_type` in `{1, 2, 4}`). prefix starting `0x00`) from a raw chunk (`stream_type` in `{1, 2, 4}`).
@@ -125,9 +132,9 @@ client-visible through the same `0x00` error-frame peek
(`from_halves_raw`): (`from_halves_raw`):
1. **Semantic validation failures** (`carriage != "raw"`, empty `cmd`, 1. **Semantic validation failures** (`carriage != "raw"`, empty `cmd`,
unknown backend, `allocate_failed`, ownership denial) — error unknown backend, `allocate_failed`, ownership denial); error
frames from `validate_and_allocate` (unchanged). frames from `validate_and_allocate` (unchanged).
2. **`NegotiateRequest` parse failure of schema-valid `input`** — the 2. **`NegotiateRequest` parse failure of schema-valid `input`**: the
open handler accepts the channel's `BiStream` and writes a open handler accepts the channel's `BiStream` and writes a
`malformed_negotiation` frame `malformed_negotiation` frame
(`{"error":"malformed_negotiation","message":"..."}`) via the shared (`{"error":"malformed_negotiation","message":"..."}`) via the shared
@@ -136,12 +143,12 @@ client-visible through the same `0x00` error-frame peek
deliberately partial: e.g. `cwd` typed as a number passes the schema deliberately partial: e.g. `cwd` typed as a number passes the schema
(unknown-key/type fields pass through for the opaque ADR-053 (unknown-key/type fields pass through for the opaque ADR-053
params) but fails the typed parse. params) but fails the typed parse.
3. **Schema-invalid `input`** — rejected at dispatch by the registry 3. **Schema-invalid `input`**: rejected at dispatch by the registry
(alkcall 0.4) before any handler runs: a `CallError` on the open (alkcall 0.4) before any handler runs: a `CallError` on the open
op, no channel allocated, no error frame (unchanged). op, no channel allocated, no error frame (unchanged).
This replaces the original behavior (log + return, channel teardown, This replaces the original behavior (log + return, channel teardown,
consumer observes `NoExitChunk` — indistinguishable from a crashed consumer observes `NoExitChunk`, indistinguishable from a crashed
producer). The error-frame layout is unchanged (ADR-001's wire-stable producer). The error-frame layout is unchanged (ADR-001's wire-stable
contract); no new frame type, no new stream type. Tests: the contract); no new frame type, no new stream type. Tests: the
`make_tty_open_handler` seam test (hand-built `input` bypassing the `make_tty_open_handler` seam test (hand-built `input` bypassing the
@@ -154,8 +161,8 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test
**Positive:** **Positive:**
- **One negotiation per session.** The registry's schema check and the - **One negotiation per session.** The registry's schema check and the
semantic validation run on the same value — the two-gate semantic validation run on the same value; the two-gate
disagreement is structurally impossible. disagreement is structurally impossible.
- **Fewer bytes and one less round trip** on the channels path: no - **Fewer bytes and one less round trip** on the channels path: no
4-byte length prefix + JSON frame on the data stream, and the 4-byte length prefix + JSON frame on the data stream, and the
consumer needs no negotiation write before its first read. consumer needs no negotiation write before its first read.
@@ -183,7 +190,7 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test
- **Consumer/producer version skew.** A consumer that writes the old - **Consumer/producer version skew.** A consumer that writes the old
second negotiation frame to a producer built on this ADR will have second negotiation frame to a producer built on this ADR will have
that frame parsed as a raw chunk (stream_type byte = first JSON that frame parsed as a raw chunk (stream_type byte = first JSON
byte, garbage length) — the session fails confusingly. Acceptable byte, garbage length), so the session fails confusingly. Acceptable
pre-1.0 (no released channels-path consumers); the direct path is pre-1.0 (no released channels-path consumers); the direct path is
unaffected. unaffected.
@@ -191,7 +198,7 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test
**Two-way for now, one-way once a released consumer exists.** The **Two-way for now, one-way once a released consumer exists.** The
channels-path negotiation location (open-op `input` vs. a wire frame) channels-path negotiation location (open-op `input` vs. a wire frame)
is reversible pre-1.0 — the `NegotiateRequest` shape, the 5-byte chunk is reversible pre-1.0: the `NegotiateRequest` shape, the 5-byte chunk
format, and the error-frame layout (the wire-stable contracts) are format, and the error-frame layout (the wire-stable contracts) are
untouched. Once a released consumer depends on "open op, then raw untouched. Once a released consumer depends on "open op, then raw
chunks," removing or re-adding the wire-frame negotiation on this path chunks," removing or re-adding the wire-frame negotiation on this path
@@ -200,19 +207,19 @@ public API is additive; `drive_session` is unchanged.
## References ## References
- [ADR-001](001-wire-format-and-two-carriage.md) — the two-carriage - [ADR-001](001-wire-format-and-two-carriage.md), the two-carriage
model (JSON negotiation + raw chunks) and the §5 framing model (JSON negotiation + raw chunks) and the §5 framing
disambiguation this ADR reuses for the post-open error frame disambiguation this ADR reuses for the post-open error frame
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend - [ADR-002](002-ttybackend-trait-and-ttyhandle.md), the backend
selection and allocation this ADR's validation runs selection and allocation this ADR's validation runs
- [ADR-006](006-negotiation-framing-self-contained.md) — the - [ADR-006](006-negotiation-framing-self-contained.md): the
negotiation frame layout (unchanged; the direct path still uses it) negotiation frame layout (unchanged; the direct path still uses it)
- [ADR-008](008-channels-pure-channel-multiplexing.md) — the channels - [ADR-008](008-channels-pure-channel-multiplexing.md): the channels
layer strips its 8-byte header transparently; TTY owns its framing layer strips its 8-byte header transparently; TTY owns its framing
(this ADR decides what TTY's framing on the channels path contains) (this ADR decides what TTY's framing on the channels path contains)
- `docs/reviews/001-code-review.md` §"Resolution (2026-09-05, L1 + L3)" - `docs/reviews/001-code-review.md` §"Resolution (2026-09-05, L1 + L3)"
- `src/adapter.rs` — `drive_session`, `drive_session_pre_negotiated`, - `src/adapter.rs`: `drive_session`, `drive_session_pre_negotiated`,
`validate_and_allocate` `validate_and_allocate`
- `src/channels.rs` — `make_tty_open_handler`, `tty_open_spec` - `src/channels.rs`: `make_tty_open_handler`, `tty_open_spec`
- `src/session.rs` — `open_via_channels`, `from_halves_raw` - `src/session.rs`: `open_via_channels`, `from_halves_raw`
- `src/testing.rs` — the shared producer/consumer channels harness - `src/testing.rs`: the shared producer/consumer channels harness
@@ -3,13 +3,26 @@
## Status ## Status
Accepted (2026-09-06). Migrates the channels-path semantic-failure Accepted (2026-09-06). Migrates the channels-path semantic-failure
surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1) — the surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1): the
sequencing ADR-049 §5 names ("alktty migration — channels-path sequencing ADR-049 §5 names ("alktty migration: channels-path
semantic failures move into an establisher; the direct-ALPN in-band semantic failures move into an establisher; the direct-ALPN in-band
error frame is retained — two transports, two contracts"). error frame is retained (two transports, two contracts)").
Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`, Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`,
`channel:open_failed`, typed `ChannelOpenError`). `channel:open_failed`, typed `ChannelOpenError`).
**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2, review
007 R-01):** `Establishment` gained its plan payload
(`ChannelPlan = Arc<dyn Any + Send + Sync>`), removing §2's
payloadless-`Establishment` blocker. Backend `allocate` moves into
the establisher; the allocated `TtyHandle` crosses to the pump
handler via the plan; `allocate_failed` joins the call-error surface
as `details.reason == "dial_failed"` (the target refused or the
backend lacked capacity). No failure class arrives in-band on the
channels path from a registered producer; the in-band error-frame
fallback stays for no-establisher registrations (defense-in-depth)
and the direct-ALPN path. See §2A below; §2 is retained as
historical record.
Amends ADR-009's R4 amendment: the in-band error-frame path on the Amends ADR-009's R4 amendment: the in-band error-frame path on the
channels path shrinks to one failure class (`allocate_failed`). channels path shrinks to one failure class (`allocate_failed`).
@@ -22,7 +35,7 @@ semantic-failure classes had nowhere to go but the channel stream:
the handler wrote a `0x00`-prefixed negotiation error frame the handler wrote a `0x00`-prefixed negotiation error frame
(`malformed_negotiation`, `unknown_backend`, `allocate_failed`, (`malformed_negotiation`, `unknown_backend`, `allocate_failed`,
ownership-denial `forbidden`) on a channel the open op had already ownership-denial `forbidden`) on a channel the open op had already
reported as succeeding. The consumer observed success-then-frame — reported as succeeding. The consumer observed success-then-frame,
a per-crate workaround ADR-049 explicitly retires: a per-crate workaround ADR-049 explicitly retires:
> alktty's per-crate in-band error vocabulary is retired on the > alktty's per-crate in-band error vocabulary is retired on the
@@ -30,7 +43,8 @@ a per-crate workaround ADR-049 explicitly retires:
> for free. > for free.
alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded
establisher (`OpenEstablisher` — `Fn(Value, AuthContext) -> BoxFuture< establisher (`OpenEstablisher`:
`Fn(Value, AuthContext) -> BoxFuture<
Result<Establishment, EstablishmentError>>`) between channel Result<Establishment, EstablishmentError>>`) between channel
allocation and the open reply; on rejection it tears the channel down allocation and the open reply; on rejection it tears the channel down
and replies `channel:open_failed` with `details: { reason, message }` and replies `channel:open_failed` with `details: { reason, message }`
@@ -47,19 +61,19 @@ branchable end-to-end.
`register_openable` (alktty's helper, signature unchanged) now calls `register_openable` (alktty's helper, signature unchanged) now calls
`ChannelCore::register_openable_with_establisher` with `ChannelCore::register_openable_with_establisher` with
`make_tty_establisher` — the establishment phase runs the semantic `make_tty_establisher`; the establishment phase runs the semantic
validation that used to be post-open error frames: validation that used to be post-open error frames:
- Full `NegotiateRequest` parse of the registry-schema-validated - Full `NegotiateRequest` parse of the registry-schema-validated
`input` (the schema stays deliberately partial — the opaque ADR-053 `input` (the schema stays deliberately partial: the opaque ADR-053
backend params pass through; the establisher is the typed gate). backend params pass through; the establisher is the typed gate).
- `carriage == "raw"`, non-empty `cmd`. - `carriage == "raw"`, non-empty `cmd`.
- Backend lookup against the adapter's backend map. - Backend lookup against the adapter's backend map.
- The ADR-050 ownership check (moved from the pump handler's - The ADR-050 ownership check (moved from the pump handler's
`validate_and_allocate`; the scope gate stays the registry's — `validate_and_allocate`; the scope gate stays the registry's,
pre-allocation, unchanged). pre-allocation and unchanged).
Rejections map into ADR-049's fixed reason vocabulary (no new codes — Rejections map into ADR-049's fixed reason vocabulary (no new codes;
the vocabulary is alkcall's one-way wire surface): the vocabulary is alkcall's one-way wire surface):
| TTY failure class | `EstablishmentError` variant | `details.reason` | | TTY failure class | `EstablishmentError` variant | `details.reason` |
@@ -68,17 +82,25 @@ the vocabulary is alkcall's one-way wire surface):
| `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` | | `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` |
| unknown backend | `UnknownResource` | `unknown_resource` | | unknown backend | `UnknownResource` | `unknown_resource` |
| ownership denial | `HandlerError` | `handler_error` | | ownership denial | `HandlerError` | `handler_error` |
| **allocation failure** (amended, §2A) | `DialFailed` | `dial_failed` |
| establishment deadline | (wrapper) | `timeout` | | establishment deadline | (wrapper) | `timeout` |
`dial_failed` and `resource_shortage` have no TTY producer path (TTY `resource_shortage` has no TTY producer path (allocation-capacity
dials nothing at open time; `allocate` capacity failures are the failures map to `dial_failed`: the target refused or the backend
in-band class below) — the spec's `ErrorDefinition` for lacked capacity; §2A). `HandlerError` covers both the
`channel:open_failed` declares only the three reachable reasons. malformed-negotiation semantics (the request shape is wrong) and the
`HandlerError` covers both the malformed-negotiation semantics (the ACL-outcome ownership denial (an authorization failure, not a dial
request shape is wrong) and the ACL-outcome ownership denial (an failure).
authorization failure, not a dial failure).
### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band ### 2. Backend allocation stays in the pump handler: `allocate_failed` stays in-band
> **Amended away (2026-09-07; see §2A).** This section is the
> historical record of the pre-0.3.0 shape; its blocker 1
> (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049
> amendment 2, review 007 R-01). Blocker 2 (double-`allocate`
> unsoundness) is avoided by construction in the amended shape: the
> establisher's handle *is* the handle the handler pumps; there is no
> second allocate to be unsound about.
The one failure class that cannot migrate: `backend.allocate`. Two The one failure class that cannot migrate: `backend.allocate`. Two
reasons, both structural: reasons, both structural:
@@ -92,12 +114,12 @@ reasons, both structural:
2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`** 2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`**
contract: re-allocating in the handler after an establisher-side contract: re-allocating in the handler after an establisher-side
allocate would spawn the session target twice (the first handle's allocate would spawn the session target twice (the first handle's
kill-guard would fire on its drop — a session killed before it kill-guard would fire on its drop: a session killed before it
started), and leaking the establisher-side handle would leak the started), and leaking the establisher-side handle would leak the
live target. live target.
So `backend.allocate` runs where it always did — inside So `backend.allocate` runs where it always did (inside
`drive_session_pre_negotiated`'s `validate_and_allocate` — and `drive_session_pre_negotiated`'s `validate_and_allocate`), and
`allocate_failed` remains a post-open `0x00`-prefixed negotiation `allocate_failed` remains a post-open `0x00`-prefixed negotiation
error frame, surfaced consumer-side as error frame, surfaced consumer-side as
[`TtySessionError::NegotiationRejected`] via the same peek the direct [`TtySessionError::NegotiationRejected`] via the same peek the direct
@@ -114,26 +136,80 @@ no-establisher registration still compiles against alkcall's API and
must not silently EOF), and the R4 seam test pins the handler-side must not silently EOF), and the R4 seam test pins the handler-side
frame. frame.
### 2A. Amendment (alkcall 0.6.0): allocation moves into the establisher
alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the
reserved field: `Establishment { plan: Option<ChannelPlan> }` with
`ChannelPlan = Arc<dyn Any + Send + Sync>` (typed-opaque, wrapper
threaded to the `OpenHandler`'s new second parameter). §2's blocker 1
is gone; the double-`allocate` hazard (blocker 2) is structurally
absent: one allocate per open, whose result the handler consumes.
The amended shape:
1. **The establisher allocates.** After the validation sequence (§1),
it runs `backend.allocate(&params)`. Failure maps to
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"`:
the target refused or the backend lacked capacity; one string
message, no nuance, the same granularity the direct path's
`allocate_failed` frame always had). The
`channel:open_failed` `ErrorDefinition` on `tty_open_spec()`
therefore declares four reachable reasons (`dial_failed`,
`unknown_resource`, `handler_error`, `timeout`).
2. **The handle crosses via the plan.** `TtyHandle` is not `Sync`
(boxed dyn streams), so it cannot be the `ChannelPlan` directly.
The establisher wraps it in a private `AllocatedHandle` one-shot
slot (`Mutex<Option<TtyHandle>>`), created **per establisher
invocation**, one per open. The wrapper moves this open's plan to this
open's handler (alkcall's concurrent-same-resource test pins that
plans are never shared), the handler `take()`s it exactly once, and
establisher-before-handler ordering is alkcall's wrapper guarantee
(ADR-049 §1). The shared-slot race §2 described is unreachable by
construction: there is no shared slot.
3. **The handler pumps the pre-allocated handle.** With a plan it
calls `drive_session_pre_allocated` (new `pub(crate)` driver: the
pumps directly, no validation pass). With `plan: None` (a
no-establisher registration), it falls back to
`drive_session_pre_negotiated`, whose inline validate-and-allocate
keeps the full in-band error-frame vocabulary. That fallback is
defense-in-depth for the assembly-layer freedom to register without
an establisher, not a registered-producer path.
4. **Consumer surface.** `TtySession::open_via_channels` sees
allocation failure as
`ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })`, a typed call error, branchable and retryable
like every other establishment class. The `0x00` peek on the
channel stream becomes a formality that no registered producer
ever exercises; the pinned test flipped with the contract
(`allocate_failure_fails_open_as_dial_failed` in `testing.rs`,
plus the establisher unit gate `establisher_allocates_and_maps_
failure_to_dial_failed` in `channels.rs`).
The R-02 lifetime contract (the handler's `JoinHandle` tracks the
data-plane lifetime) was already alktty's shape (the open handler
awaits the driver inline in its spawned task), so the amendment adds
no lifetime changes.
### 3. The direct-ALPN path is unchanged ### 3. The direct-ALPN path is unchanged
`TtyAdapter::handle` / `drive_session` keep the wire-frame `TtyAdapter::handle` / `drive_session` keep the wire-frame
negotiation (ADR-001) and the full in-band error-frame vocabulary negotiation (ADR-001) and the full in-band error-frame vocabulary
(`unknown_backend`, `malformed_negotiation`, `allocate_failed`, (`unknown_backend`, `malformed_negotiation`, `allocate_failed`,
`forbidden`). Two transports, two contracts — the direct path has no `forbidden`). Two transports, two contracts: the direct path has no
open op to fail, and its `0x00`-peek disambiguation is the ADR-001 open op to fail, and its `0x00`-peek disambiguation is the ADR-001
wire-stable contract. The `enforce_scope` split from ADR-009 is wire-stable contract. The `enforce_scope` split from ADR-009 is
unchanged (`true` direct, `false` channels — the registry's unchanged (`true` direct, `false` channels; the registry's
`AccessControl` is the channels scope gate). `AccessControl` is the channels scope gate).
### 4. Spec enrichment (review 006 E-02 + ADR-016) ### 4. Spec enrichment (review 006 E-02 + ADR-016)
`tty_open_spec()` gains: `tty_open_spec()` gains:
- `description` — disclosed by `services/list` (the open op describes - `description`, disclosed by `services/list` (the open op describes
itself; the produced resource set stays OQ-40's deferred itself; the produced resource set stays OQ-40's deferred
`channel/resources/subscribe` shape). `channel/resources/subscribe` shape).
- An `ErrorDefinition` for `channel:open_failed` with the - An `ErrorDefinition` for `channel:open_failed` with the
`details.reason` enum — so `services/schema` discloses the `details.reason` enum, so `services/schema` discloses the
establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN
crates' open-op specs gain matching `ErrorDefinition` entries so crates' open-op specs gain matching `ErrorDefinition` entries so
`services/schema` discloses the failure contract"). `services/schema` discloses the failure contract").
@@ -149,14 +225,14 @@ unchanged (`true` direct, `false` channels — the registry's
| malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
| unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` | | unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` |
| ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
| **allocation failure** (amended, §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** |
| establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` | | establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` |
| ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` | | ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` |
| channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` | | channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` |
| **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** |
`TtySessionError::ChannelsOpen` now carries alkcall's typed `TtySessionError::ChannelsOpen` now carries alkcall's typed
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened `ChannelOpenError` verbatim (`#[from]`) instead of a flattened
`String` — the N-1 fix applied at alktty's layer, so consumers branch `String`, the N-1 fix applied at alktty's layer, so consumers branch
on `establishment_reason()` without unwrapping strings. on `establishment_reason()` without unwrapping strings.
## Consequences ## Consequences
@@ -166,11 +242,13 @@ on `establishment_reason()` without unwrapping strings.
- The phantom-channel workaround is retired: a semantically invalid - The phantom-channel workaround is retired: a semantically invalid
open no longer allocates → succeeds → in-band-fails. Ledger, policy open no longer allocates → succeeds → in-band-fails. Ledger, policy
count, and manager state balance on every rejection. count, and manager state balance on every rejection.
- Retry policy / UX can branch: `unknown_resource` (bad config — don't - Retry policy / UX can branch: `unknown_resource` (bad config, don't
retry), `timeout` (maybe retry), `allocate_failed` (capacity — retry), `timeout` (maybe retry), `dial_failed` (capacity/target
still distinguishable in-band). refused, maybe retry; amended §2A, was the in-band
- The consumer's `open_via_channels` failure is a typed call error — `allocate_failed`).
no peeking at the data stream for anything but `allocate_failed`. - The consumer's `open_via_channels` failure is a typed call error;
no peeking at the data stream for anything but the (now
unreachable-from-registered-producers) error-frame formality.
**Negative:** **Negative:**
@@ -180,37 +258,43 @@ on `establishment_reason()` without unwrapping strings.
direct path's failure surface is unchanged. direct path's failure surface is unchanged.
- The `channel:open_failed` reason codes the establisher emits join - The `channel:open_failed` reason codes the establisher emits join
the wire-stable error set (ADR-049's one-way door). the wire-stable error set (ADR-049's one-way door).
- `allocate_failed` remains a second, in-band failure shape on the - **Amendment (0.3.0):** allocation failure changed shape again:
channels path — one residual asymmetry, pinned and documented until `NegotiationRejected{ "allocate_failed" }` (in-band) →
`Establishment` gains a payload. `ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })`. The direct path's `allocate_failed` frame is
unchanged (two transports, two contracts).
## Door type ## Door type
**One-way (wire-visible).** The establisher mapping fixes **One-way (wire-visible).** The establisher mapping fixes
`details.reason` values for TTY's channels path — consumers branch on `details.reason` values for TTY's channels path: consumers branch on
`unknown_resource` / `handler_error` / `timeout`, so re-mapping a `unknown_resource` / `handler_error` / `timeout`, so re-mapping a
failure class between reasons is a peer-breaking change. The failure class between reasons is a peer-breaking change. The
establisher's internal validation order is a two-way door establisher's internal validation order is a two-way door
(implementation detail behind the fixed surface). ADR-009's (implementation detail behind the fixed surface). ADR-009's
"two-way for now" negotiation-location decision is unaffected — this "two-way for now" negotiation-location decision is unaffected (this
ADR moves failure *reporting*, not the negotiation *location*. ADR moves failure *reporting*, not the negotiation *location*).
## References ## References
- alkcall ADR-049 (the establisher, `channel:open_failed`, the typed - alkcall ADR-049 (the establisher, `channel:open_failed`, the typed
client error — the upstream capability this ADR adopts) client error; the upstream capability this ADR adopts)
- alkcall review 006 (E-01 the establishment gap; N-1 the typed - alkcall review 006 (E-01 the establishment gap; N-1 the typed
client error; E-02 the discovery enrichment this ADR's spec client error; E-02 the discovery enrichment this ADR's spec
enrichment adopts) enrichment adopts)
- ADR-009 (the open op's `input` is the negotiation; the R4 - ADR-009 (the open op's `input` is the negotiation; the R4
amendment's in-band error frames — shrunk to `allocate_failed` by amendment's in-band error frames, shrunk to `allocate_failed` by
this ADR) this ADR)
- ADR-005 (kill-on-`Drop` — why establisher-side allocation is - ADR-005 (kill-on-`Drop`: why a *second* establisher-side allocate
unsound) would be unsound; avoided by construction in the §2A amendment,
- ADR-001 (the §5 `0x00` disambiguation — retained for the direct one allocate per open, consumed by the handler)
path and the channels `allocate_failed` frame) - ADR-001 (the §5 `0x00` disambiguation, retained for the direct
path; the channels-path peek is now a formality)
- ADR-002 (backend selection/allocation ownership) - ADR-002 (backend selection/allocation ownership)
- `src/channels.rs` — `make_tty_establisher`, `make_tty_open_handler`, - alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment`
`tty_open_spec` plan payload; the upstream capability this ADR's §2A adopts)
- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels` - `src/channels.rs`: `make_tty_establisher`, `AllocatedHandle`,
- `src/testing.rs` — the pinned `allocate_failed`-stays-in-band test `make_tty_open_handler`, `tty_open_spec`
- `src/session.rs`: `TtySessionError::ChannelsOpen`, `open_via_channels`
- `src/testing.rs`: the pinned `allocate_failure_fails_open_as_dial_failed`
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`)
+58 -58
View File
@@ -6,7 +6,7 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
last_updated: 2026-09-05 last_updated: 2026-09-05
--- ---
# alktty — Overview # alktty: Overview
The terminal session protocol crate: a `ProtocolHandler` on `alk/tty` The terminal session protocol crate: a `ProtocolHandler` on `alk/tty`
that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON
@@ -24,18 +24,18 @@ ALPN on the shared endpoint and implements the `ProtocolHandler` trait
(alknet ADR-002, alknet ADR-007). The `TtyAdapter` receives a (alknet ADR-002, alknet ADR-007). The `TtyAdapter` receives a
`Connection`, accepts one bidi stream per terminal session, reads a `Connection`, accepts one bidi stream per terminal session, reads a
single JSON negotiation frame, switches to a raw chunk format, and single JSON negotiation frame, switches to a raw chunk format, and
pumps bytes bidirectionally for the life of the session — pumps bytes bidirectionally for the life of the session,
backend-agnostic. backend-agnostic.
The guiding insight that shapes the crate: The guiding insight that shapes the crate:
> A terminal session is not an SSH concern, or a Docker concern — it is > A terminal session is not an SSH concern, or a Docker concern; it is
> a terminal concern. SSH and Docker are just two backends that can > a terminal concern. SSH and Docker are just two backends that can
> allocate a PTY. > allocate a PTY.
The alknet-docker POC proved that the hard part of interactive attach — The alknet-docker POC proved that the hard part of interactive attach
bidirectional byte pumping over a framed stream with a 1-byte (bidirectional byte pumping over a framed stream with a 1-byte
stream-type multiplexer — is the same problem regardless of whether the stream-type multiplexer) is the same problem regardless of whether the
backend is `bollard::attach_container()` or russh's `pty_request`. The backend is `bollard::attach_container()` or russh's `pty_request`. The
POC's raw chunk format is the seed of alktty's wire format. alktty POC's raw chunk format is the seed of alktty's wire format. alktty
extracts that pattern into its own crate and ALPN; the backends (Docker, extracts that pattern into its own crate and ALPN; the backends (Docker,
@@ -53,7 +53,7 @@ consumers. A hub that runs agent workspaces in containers wires
sessions over `alk/tty`. A coordinator that runs `cargo test` remotely sessions over `alk/tty`. A coordinator that runs `cargo test` remotely
wires `LocalTtyBackend` (pipe mode) and gets the runner pattern (a wires `LocalTtyBackend` (pipe mode) and gets the runner pattern (a
process whose stdin/stdout/stderr/exit-code stream over a framed bidi process whose stdin/stdout/stderr/exit-code stream over a framed bidi
connection) — the same shape as GitHub/Gitea Actions runners, just over connection), the same shape as GitHub/Gitea Actions runners, just over
alk's transport instead of HTTP polling. A browser terminal (xterm.js alk's transport instead of HTTP polling. A browser terminal (xterm.js
over WebTransport, when WebTransport revives) connects to `alk/tty` over WebTransport, when WebTransport revives) connects to `alk/tty`
directly and gets raw bytes without implementing SSH or the call directly and gets raw bytes without implementing SSH or the call
@@ -77,9 +77,9 @@ A `alk/tty` bidi stream has two phases (full detail in
length-prefixed JSON frame carrying the terminal parameters, backend length-prefixed JSON frame carrying the terminal parameters, backend
selector, command, and environment. The framing is a 4-byte selector, command, and environment. The framing is a 4-byte
big-endian length prefix + UTF-8 JSON body, self-contained in big-endian length prefix + UTF-8 JSON body, self-contained in
alktty (the format coincides with alkcall's framing by convention; alktty (the format coincides with alkcall's framing by convention;
alktty does not depend on alkcall's internal wire types — see alktty does not depend on alkcall's internal wire types; see
Dependencies below). Dependencies below).
2. **Raw carriage.** After the negotiation frame, the stream switches to 2. **Raw carriage.** After the negotiation frame, the stream switches to
the chunk format (`[stream_type: u8][length: u32 be][payload]`) for the chunk format (`[stream_type: u8][length: u32 be][payload]`) for
@@ -87,7 +87,7 @@ A `alk/tty` bidi stream has two phases (full detail in
1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in 1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in
(client→server, JSON control messages: resize, signal, eof), (client→server, JSON control messages: resize, signal, eof),
4=ctrl-out (server→client, JSON control messages: exit). There is no 4=ctrl-out (server→client, JSON control messages: exit). There is no
`call.responded`/`call.completed` — this is not the call protocol; `call.responded`/`call.completed`; this is not the call protocol;
the raw-carriage byte pump is its own wire format after the single the raw-carriage byte pump is its own wire format after the single
JSON negotiation frame. JSON negotiation frame.
@@ -101,12 +101,12 @@ control-channel split) is in
## Dependencies ## Dependencies
``` ```
alktty (default — wasm-clean) alktty (default; wasm-clean)
├── alkcall::core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl, ├── alkcall::core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
│ OwnershipProvider — alknet ADR-050 for terminal sessions as resources) │ OwnershipProvider; alknet ADR-050 for terminal sessions as resources)
└── (no backend deps — portable_pty, bollard, russh are in the backend crates / `local` feature) └── (no backend deps; portable_pty, bollard, russh are in the backend crates / `local` feature)
alktty (local feature) — non-wasm by design alktty (local feature) is non-wasm by design
└── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread └── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread
``` ```
@@ -115,7 +115,7 @@ only. The negotiation framing is a self-contained ~30-line module in
alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's
`AsyncRead`/`AsyncWrite`). The heavy backend dependencies `AsyncRead`/`AsyncWrite`). The heavy backend dependencies
(`portable_pty`, `bollard`, `russh`) live in the backend crates, not (`portable_pty`, `bollard`, `russh`) live in the backend crates, not
here. alktty does **not** depend on alkcall's internal wire types — see here. alktty does **not** depend on alkcall's internal wire types; see
[ADR-006](decisions/006-negotiation-framing-self-contained.md). [ADR-006](decisions/006-negotiation-framing-self-contained.md).
### Why no alkcall-internal-wire-types dependency ### Why no alkcall-internal-wire-types dependency
@@ -123,9 +123,9 @@ here. alktty does **not** depend on alkcall's internal wire types — see
An earlier draft had alktty depending on alkcall for the An earlier draft had alktty depending on alkcall for the
`FrameFramedReader`/`FrameFramedWriter` "framing utility." A `FrameFramedReader`/`FrameFramedWriter` "framing utility." A
pre-implementation check found this was unsound: `FrameFramedReader`'s pre-implementation check found this was unsound: `FrameFramedReader`'s
`read_frame()` is hardcoded to deserialize `EventEnvelope` — the `read_frame()` is hardcoded to deserialize `EventEnvelope` (the
length-prefix read and the type-specific deserialize are one entangled length-prefix read and the type-specific deserialize are one entangled
call, not a separable utility. alktty's negotiation payload is a call, not a separable utility). alktty's negotiation payload is a
`NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not `NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not
exist in a usable form. alktty implements its own framing (the format exist in a usable form. alktty implements its own framing (the format
coincides with alkcall's by convention; the implementations are coincides with alkcall's by convention; the implementations are
@@ -135,7 +135,7 @@ domain abstraction worth a cross-crate dependency. See
full decision and alknet ADR-003 Amendment 2 for the dependency-edge full decision and alknet ADR-003 Amendment 2 for the dependency-edge
clarification. clarification.
alktty stays lean — it has no `portable_pty` (default), no `bollard`, no alktty stays lean: it has no `portable_pty` (default), no `bollard`, no
`russh`, no alkcall-internal-wire-types. The `TtyBackend` implementations `russh`, no alkcall-internal-wire-types. The `TtyBackend` implementations
are opaque `Arc<dyn TtyBackend>` from the adapter's perspective: are opaque `Arc<dyn TtyBackend>` from the adapter's perspective:
constructed by the assembly layer at startup, stored in the adapter's constructed by the assembly layer at startup, stored in the adapter's
@@ -145,18 +145,18 @@ backend map, dispatched by the `backend` field of the negotiation frame.
| ALPN | Handler | Transport | Browser? | | ALPN | Handler | Transport | Browser? |
|------|---------|-----------|----------| |------|---------|-----------|----------|
| `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives — alknet ADR-040 parked) | | `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives; alknet ADR-040 parked) |
`alk/tty` is a custom ALPN per the alknet ADR-006 `alk/<name>` convention `alk/tty` is a custom ALPN per the alknet ADR-006 `alk/<name>` convention
(renamed from `alknet/<name>` in alkcall 0.1.1). The `TtyAdapter` (renamed from `alknet/<name>` in alkcall 0.1.1). The `TtyAdapter`
registers for it; the endpoint's `HandlerRegistry` maps `alk/tty` to the registers for it; the endpoint's `HandlerRegistry` maps `alk/tty` to the
adapter instance. One ALPN per connection (alknet ADR-006); within a adapter instance. One ALPN per connection (alknet ADR-006); within a
connection, multiple bidi streams carry independent sessions (one connection, multiple bidi streams carry independent sessions (one
session per stream — see [tty-adapter.md](tty-adapter.md)). session per stream; see [tty-adapter.md](tty-adapter.md)).
The browser terminal case: a browser (xterm.js) connects via WebTransport The browser terminal case: a browser (xterm.js) connects via WebTransport
to `alk/tty` and gets raw bytes. The browser doesn't need to implement to `alk/tty` and gets raw bytes. The browser doesn't need to implement
SSH or the call protocol for the terminal use case — only if it wants SSH or the call protocol for the terminal use case, only if it wants
SSH-specific features (port forwarding, SFTP). This is a cleaner browser SSH-specific features (port forwarding, SFTP). This is a cleaner browser
story than "run a WASM SSH client." WebTransport is deferred per story than "run a WASM SSH client." WebTransport is deferred per
alknet ADR-044; when it revives, the `alk/tty` ALPN is reachable over alknet ADR-044; when it revives, the `alk/tty` ALPN is reachable over
@@ -178,26 +178,26 @@ The decomposition principle: the trait lives where the types live
live. live.
``` ```
alktty (default — lean, no portable_pty, no bollard, no russh) alktty (default; lean, no portable_pty, no bollard, no russh)
├── TtyBackend trait (the contract — ADR-002) ├── TtyBackend trait (the contract, ADR-002)
├── TtyHandle, TtyControl (the handle shape backends produce) ├── TtyHandle, TtyControl (the handle shape backends produce)
├── TtyParams, TerminalParams (the allocation request) ├── TtyParams, TerminalParams (the allocation request)
├── TtyAdapter (ProtocolHandler on alk/tty — session lifecycle) ├── TtyAdapter (ProtocolHandler on alk/tty; session lifecycle)
├── wire format (ChunkReader/ChunkWriter, ControlMessage — ADR-001) ├── wire format (ChunkReader/ChunkWriter, ControlMessage; ADR-001)
└── negotiation framing (self-contained ~30-line module; format coincides └── negotiation framing (self-contained ~30-line module; format coincides
with alkcall's by convention — ADR-006) with alkcall's by convention, ADR-006)
alktty (local feature module — ADR-003; folded in from the old alktty (local feature module, ADR-003; folded in from the old
alknet-tty-local sibling crate) alknet-tty-local sibling crate)
├── LocalTtyBackend (impl TtyBackend — portable_pty for PTY, std::process for pipe) ├── LocalTtyBackend (impl TtyBackend: portable_pty for PTY, std::process for pipe)
├── portable_pty dependency (PTY allocation — the heavy dep, here not in alktty default) ├── portable_pty dependency (PTY allocation, the heavy dep, here not in alktty default)
└── libc (signal forwarding — REQ-TTY-02, Unix only) └── libc (signal forwarding, REQ-TTY-02, Unix only)
alknet-docker (or alktty-docker adapter — future crate, out of scope here) alknet-docker (or alktty-docker adapter, future crate, out of scope here)
└── DockerTtyBackend (impl TtyBackend — wraps bollard::attach_container / exec with tty:true) └── DockerTtyBackend (impl TtyBackend; wraps bollard::attach_container / exec with tty:true)
alknet-ssh (future crate — out of scope here) alknet-ssh (future crate, out of scope here)
└── SshTtyBackend (impl TtyBackend — wraps russh pty_request + shell_request/exec_request) └── SshTtyBackend (impl TtyBackend; wraps russh pty_request + shell_request/exec_request)
``` ```
alktty never sees `portable_pty` (default), `bollard`, or `russh`. The alktty never sees `portable_pty` (default), `bollard`, or `russh`. The
@@ -207,7 +207,7 @@ transport dependencies. The local backend's module placement (folded
into alktty behind a `local` feature, resolving the alknet cyclic-dep into alktty behind a `local` feature, resolving the alknet cyclic-dep
workaround) is decided in [ADR-003](decisions/003-local-backend-placement.md); workaround) is decided in [ADR-003](decisions/003-local-backend-placement.md);
the docker and SSH backends are future crates (out of scope for this the docker and SSH backends are future crates (out of scope for this
spec set — see [tty-backend.md](tty-backend.md) §"Backend implementations" spec set; see [tty-backend.md](tty-backend.md) §"Backend implementations"
for where they live). for where they live).
## Feature Gates ## Feature Gates
@@ -219,54 +219,54 @@ default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"] local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
``` ```
- `default` — the wire format, `TtyAdapter`, and the `TtyBackend` trait. - `default`: the wire format, `TtyAdapter`, and the `TtyBackend` trait.
No backend implementations; the assembly layer registers backends No backend implementations; the assembly layer registers backends
from their own crates. A docker-only or ssh-only deployment uses the from their own crates. A docker-only or ssh-only deployment uses the
default features and depends on `alknet-docker` / `alknet-ssh` (or default features and depends on `alknet-docker` / `alknet-ssh` (or
their own backend crate) directly. **The default crate compiles to their own backend crate) directly. **The default crate compiles to
`wasm32-unknown-unknown`** — this is what makes the downstream `wasm32-unknown-unknown`**; this is what makes the downstream
TS/Python adapter story work (a wasm-compiled alktty is the protocol TS/Python adapter story work (a wasm-compiled alktty is the protocol
layer for a sandboxed adapter). layer for a sandboxed adapter).
- `local` — enables `alktty::local::LocalTtyBackend`, pulling in - `local` enables `alktty::local::LocalTtyBackend`, pulling in
`portable_pty` (PTY mode) and `tokio::process` (pipe mode). A consumer `portable_pty` (PTY mode) and `tokio::process` (pipe mode). A consumer
that wants the local backend (terminal or runner) enables this that wants the local backend (terminal or runner) enables this
feature. Inherently non-wasm — `portable-pty` + `tokio::process` need feature. Inherently non-wasm (`portable-pty` + `tokio::process` need
a real OS. a real OS).
The local backend's `portable_pty` dependency is the heavy dep that The local backend's `portable_pty` dependency is the heavy dep that
motivates the feature gate — a docker-only deployment should not pull in motivates the feature gate: a docker-only deployment should not pull in
PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md). PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md).
## Architecture (component pointers) ## Architecture (component pointers)
- **[tty-wire.md](tty-wire.md)** — the wire format: the negotiation - **[tty-wire.md](tty-wire.md)**: the wire format; the negotiation
frame (JSON carriage, self-contained length-prefixed framing), the frame (JSON carriage, self-contained length-prefixed framing), the
raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the
five stream types, the control channel (split into `STREAM_CTRL_IN` five stream types, the control channel (split into `STREAM_CTRL_IN`
/ `STREAM_CTRL_OUT` halves, JSON control messages), sentinels, and / `STREAM_CTRL_OUT` halves, JSON control messages), sentinels, and
the fixed-channel-set rationale. the fixed-channel-set rationale.
- **[tty-bast.md](tty-bast.md)** — the BAST (Binary Abstract Syntax - **[tty-bast.md](tty-bast.md)**: the BAST (Binary Abstract Syntax
Tree) document for the binary framing layer of the `alk/tty` wire Tree) document for the binary framing layer of the `alk/tty` wire
format (the 5-byte chunk header and the negotiation frame's 4-byte format (the 5-byte chunk header and the negotiation frame's 4-byte
length prefix); a normative JSON spec conforming to the BAST length prefix); a normative JSON spec conforming to the BAST
meta-schema at `https://alk.dev/bast/v1/schema`, validatable by any meta-schema at `https://alk.dev/bast/v1/schema`, validatable by any
JSON Schema Draft 2020-12 validator. The JSON payloads JSON Schema Draft 2020-12 validator. The JSON payloads
(`NegotiateRequest`, `ControlMessage`, `TerminalParams`) are (`NegotiateRequest`, `ControlMessage`, `TerminalParams`) are
specified in `tty-wire.md` and the Rust source, not in the BAST — specified in `tty-wire.md` and the Rust source, not in the BAST;
BAST describes binary layouts, not JSON shapes. BAST describes binary layouts, not JSON shapes.
- **[tty-backend.md](tty-backend.md)** — the `TtyBackend` trait, - **[tty-backend.md](tty-backend.md)**: the `TtyBackend` trait,
`TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between `TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between
the wire-format adapter and the backends. Carries REQ-TTY-01 the wire-format adapter and the backends. Carries REQ-TTY-01
(backends need not be natively async; the bridging pattern is a (backends need not be natively async; the bridging pattern is a
documented strategy). Notes where the docker/SSH backend crates live documented strategy). Notes where the docker/SSH backend crates live
(future, out of scope here). (future, out of scope here).
- **[tty-adapter.md](tty-adapter.md)** — the `TtyAdapter` - **[tty-adapter.md](tty-adapter.md)**: the `TtyAdapter`
(`ProtocolHandler` on `alk/tty`): the session lifecycle, the (`ProtocolHandler` on `alk/tty`): the session lifecycle, the
three-pump bidirectional driver (stdout→client, client→backend, three-pump bidirectional driver (stdout→client, client→backend,
exit→exit-chunk), negotiation errors, the exit-chunk ordering exit→exit-chunk), negotiation errors, the exit-chunk ordering
(ADR-004), access control (terminal sessions as runtime-spawned (ADR-004), access control (terminal sessions as runtime-spawned
resources per alknet ADR-050), session-cancel cleanup (ADR-005). resources per alknet ADR-050), session-cancel cleanup (ADR-005).
- **[tty-local.md](tty-local.md)** — the `local` feature module: - **[tty-local.md](tty-local.md)**: the `local` feature module:
`LocalTtyBackend` via `portable_pty` (PTY mode) and `LocalTtyBackend` via `portable_pty` (PTY mode) and
`tokio::process::Command` (pipe/runner mode). Carries REQ-TTY-02 `tokio::process::Command` (pipe/runner mode). Carries REQ-TTY-02
(signal forwarding to the foreground process group). The (signal forwarding to the foreground process group). The
@@ -291,27 +291,27 @@ PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md).
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object. - **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
- **OQ-44** (deferred(scope)): Terminal modes (TTY modes). - **OQ-44** (deferred(scope)): Terminal modes (TTY modes).
- **OQ-45** (resolved): Flow control for high-throughput stdout — no - **OQ-45** (resolved): Flow control for high-throughput stdout (no
application-level windowing; QUIC per-stream flow control is the application-level windowing; QUIC per-stream flow control is the
backpressure mechanism. backpressure mechanism).
- **OQ-46** (deferred(scope)): Runner API surface. - **OQ-46** (deferred(scope)): Runner API surface.
- **OQ-47** (resolved): Stdin closure canonical signal. - **OQ-47** (resolved): Stdin closure canonical signal.
## References ## References
- alknet ADR-001 — ALPN-based dispatch - alknet ADR-001: ALPN-based dispatch
- alknet ADR-002 — ProtocolHandler trait - alknet ADR-002: ProtocolHandler trait
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition - alknet ADR-003 + Amendments 1 & 2: crate decomposition
(no-handler-depends-on-another-handler; alktty depends on alkcall (no-handler-depends-on-another-handler; alktty depends on alkcall
only; backends depend on alktty for the trait) only; backends depend on alktty for the trait)
- alknet ADR-006 — `alk/<name>` ALPN convention; one ALPN per - alknet ADR-006: `alk/<name>` ALPN convention; one ALPN per
connection; new ALPN for incompatible versions connection; new ALPN for incompatible versions
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives- - alknet ADR-007: `Connection`, `accept_bi`, the handler-receives-
Connection pattern Connection pattern
- alknet ADR-050 — dynamic resource ownership (terminal sessions as - alknet ADR-050: dynamic resource ownership (terminal sessions as
runtime-spawned resources; the adapter's access-control shape runtime-spawned resources; the adapter's access-control shape
declares against this model) declares against this model)
- `/workspace/@alkdev/alknet/docs/architecture/decisions/` — the alknet - `/workspace/@alkdev/alknet/docs/architecture/decisions/`: the alknet
originals of the ADRs ported here as 001..008, plus the alknet ADRs originals of the ADRs ported here as 001..008, plus the alknet ADRs
referenced by alknet number above (which are not ported into alktty's referenced by alknet number above (which are not ported into alktty's
ADR range because they are not tty-specific) ADR range because they are not tty-specific)
+66 -62
View File
@@ -5,7 +5,7 @@ status: draft (ported from alknet 2026-08-17; alknet/tty → alk/tty,
last_updated: 2026-09-05 last_updated: 2026-09-05
--- ---
# alktty — TtyAdapter and Session Lifecycle # alktty: TtyAdapter and Session Lifecycle
The `TtyAdapter` is the `ProtocolHandler` for `alk/tty`: it receives a The `TtyAdapter` is the `ProtocolHandler` for `alk/tty`: it receives a
`Connection`, accepts bidi streams, reads the negotiation frame, selects `Connection`, accepts bidi streams, reads the negotiation frame, selects
@@ -22,7 +22,7 @@ alknet ADR-007 to receive a `Connection`) on ALPN `alk/tty` (alknet
ADR-006). It holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at ADR-006). It holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at
construction (ADR-002 §5). Its `handle()` method accepts the connection construction (ADR-002 §5). Its `handle()` method accepts the connection
and loops `connection.accept_bi()`, dispatching each bidi stream to a and loops `connection.accept_bi()`, dispatching each bidi stream to a
session. One `alk/tty` connection hosts multiple terminal sessions — one session. One `alk/tty` connection hosts multiple terminal sessions, one
session per bidi stream (DP-6, decided in the alknet research; matches session per bidi stream (DP-6, decided in the alknet research; matches
the call protocol's one-operation-per-stream model). the call protocol's one-operation-per-stream model).
@@ -58,10 +58,10 @@ impl ProtocolHandler for TtyAdapter {
} }
``` ```
The `drive_session` function is the per-stream session driver — the The `drive_session` function is the per-stream session driver: the
counterpart to the POC's `session::drive_session`, generalized from the counterpart to the POC's `session::drive_session`, generalized from the
local PTY backend to the `TtyBackend` trait. It is also the function the local PTY backend to the `TtyBackend` trait. It is also the function the
channels path reuses — per [ADR-008](decisions/008-channels-pure-channel-multiplexing.md), channels path reuses; per [ADR-008](decisions/008-channels-pure-channel-multiplexing.md),
TTY always uses its 5-byte format, so the same driver runs in TTY always uses its 5-byte format, so the same driver runs in
both direct `alk/tty` and channels `alk/channels` modes; only the both direct `alk/tty` and channels `alk/channels` modes; only the
`BiStream` source differs. The two paths differ in where the `BiStream` source differs. The two paths differ in where the
@@ -69,7 +69,7 @@ both direct `alk/tty` and channels `alk/channels` modes; only the
direct path reads the wire-frame negotiation (ADR-001 §"Negotiation direct path reads the wire-frame negotiation (ADR-001 §"Negotiation
Frame") and answers failures with in-band error frames; the channels Frame") and answers failures with in-band error frames; the channels
path parses the open op's registry-validated `input` and runs path parses the open op's registry-validated `input` and runs
`drive_session_pre_negotiated` (ADR-009 — no second negotiation `drive_session_pre_negotiated` (ADR-009; no second negotiation
frame), with the establisher rejecting the semantic-failure classes frame), with the establisher rejecting the semantic-failure classes
as `channel:open_failed` before the handler spawns (ADR-010). as `channel:open_failed` before the handler spawns (ADR-010).
@@ -77,7 +77,7 @@ as `channel:open_failed` before the handler spawns (ADR-010).
The adapter is the place where the wire format (ADR-001), the backend The adapter is the place where the wire format (ADR-001), the backend
trait (ADR-002), and the exit-chunk ordering (ADR-004) come together. trait (ADR-002), and the exit-chunk ordering (ADR-004) come together.
Keeping these in one place — the adapter — is what makes the invariants Keeping these in one place (the adapter) is what makes the invariants
enforceable: the wire format's "exit chunk is last" invariant is enforced enforceable: the wire format's "exit chunk is last" invariant is enforced
here, not in the backends (which produce handles, not wire bytes); the here, not in the backends (which produce handles, not wire bytes); the
backend's `exit_code` future is awaited here, not in the backend; the backend's `exit_code` future is awaited here, not in the backend; the
@@ -104,7 +104,7 @@ A `alk/tty` session on one bidi stream proceeds in three phases:
allocated, docker exec failed, SSH channel request rejected), the allocated, docker exec failed, SSH channel request rejected), the
adapter sends a JSON error response and closes the stream. adapter sends a JSON error response and closes the stream.
3. **Raw carriage — the bidirectional pump.** The adapter switches to 3. **Raw carriage: the bidirectional pump.** The adapter switches to
the raw chunk format and pumps three concurrent tasks: the raw chunk format and pumps three concurrent tasks:
- **A. stdout → client**: backend stdout (`TtyHandle.stdout`) → stdout - **A. stdout → client**: backend stdout (`TtyHandle.stdout`) → stdout
@@ -127,7 +127,7 @@ A `alk/tty` session on one bidi stream proceeds in three phases:
A drainer task writes chunks to the client in arrival order. After the A drainer task writes chunks to the client in arrival order. After the
exit chunk is written (task C resolves and the exit chunk drains), exit chunk is written (task C resolves and the exit chunk drains),
the adapter closes the write half — the session ends. the adapter closes the write half; the session ends.
This is the POC's `session::drive_session` pattern, generalized: the POC This is the POC's `session::drive_session` pattern, generalized: the POC
hardcoded the local PTY backend; the adapter dispatches to any hardcoded the local PTY backend; the adapter dispatches to any
@@ -138,24 +138,24 @@ hardcoded the local PTY backend; the adapter dispatches to any
The control channel is split into two halves so it is genuinely The control channel is split into two halves so it is genuinely
bidirectional on the wire: bidirectional on the wire:
- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`, - **`STREAM_CTRL_IN = 3`**: client→server control (`Resize`, `Signal`,
`Eof`). `Eof`).
- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`). - **`STREAM_CTRL_OUT = 4`**: server→client control (`Exit`).
The adapter enforces the direction: The adapter enforces the direction:
- An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation - An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation
(server→client message on the client→server half) — the adapter (server→client message on the client→server half); the adapter
ignores it (the previous single `STREAM_CONTROL = 3` could not ignores it (the previous single `STREAM_CONTROL = 3` could not
distinguish the two directions, so `Exit` from the client was always distinguish the two directions, so `Exit` from the client was always
ignored; the split makes the rejection explicit). ignored; the split makes the rejection explicit).
- A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol - A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol
violation (client→server message on the server→client half) — the violation (client→server message on the server→client half), and the
adapter ignores it (the server never dispatches control messages it adapter ignores it (the server never dispatches control messages it
receives on the server→client half). receives on the server→client half).
- `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a - `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a
protocol violation (the client should not write on the server→client protocol violation (the client should not write on the server→client
half) — the adapter ignores them. half); the adapter ignores them.
The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type
4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing 4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing
@@ -177,31 +177,32 @@ entering raw mode. The error response shape:
On the **direct-ALPN path** this is the failure surface for every On the **direct-ALPN path** this is the failure surface for every
negotiation/allocation failure class (below). On the **channels path** negotiation/allocation failure class (below). On the **channels path**
the semantic classes (unknown backend, malformed negotiation, the semantic classes (unknown backend, malformed negotiation,
ownership denial) are rejected by the establisher before the open ownership denial) **and allocation** are rejected by the establisher
reply and surface as `channel:open_failed` call errors (alkcall 0.5.0 before the open reply and surface as `channel:open_failed` call
ADR-049 / alktty ADR-010) — the in-band frames on that path are errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
defense-in-depth arms plus the one non-migratable class `Establishment` plan payload; ADR-010 §2A), so the in-band frames on
(`allocate_failed`, ADR-010 §2). that path are defense-in-depth arms only (reachable from
no-establisher registrations).
| Error | When | Shape | | Error | When | Shape |
|-------|------|------| |-------|------|------|
| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` | | `unknown_backend` | the `backend` string is not in the adapter's backend map; direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` | | `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation. On the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial; the establisher rejects it as `channel:open_failed` / `handler_error` first, and the handler-side frame is defense-in-depth per ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — on both paths: the establisher cannot carry the allocated handle across to the pump handler (ADR-010 §2), so allocation failure is the one in-band failure class on the channels path | `{"error":"allocate_failed","message":"..."}` | | `allocate_failed` | `backend.allocate()` returned a `TtyError` (the failure surface of the **direct path**). On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload; ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` |
After sending the error response, the adapter closes the write half of After sending the error response, the adapter closes the write half of
the bidi stream. The client reads the error frame and treats stream close the bidi stream. The client reads the error frame and treats stream close
as the failure signal. There is no `call.error` — this is not the call as the failure signal. There is no `call.error`, as this is not the call
protocol; the error is a JSON response in the negotiation framing. protocol; the error is a JSON response in the negotiation framing.
**Framing disambiguation (success vs error).** Both a successful **Framing disambiguation (success vs error).** Both a successful
allocation (raw chunks) and a failed allocation (JSON error frame) begin allocation (raw chunks) and a failed allocation (JSON error frame) begin
with bytes the client must read before knowing which framing applies. with bytes the client must read before knowing which framing applies.
The disambiguation is by the first byte: a JSON error frame's 4-byte The disambiguation is by the first byte: a JSON error frame's 4-byte
big-endian length prefix always starts with `0x00` (error frames MUST big-endian length prefix always starts with `0x00` (error frames MUST
be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is be under 16 MiB, the `MAX_CHUNK_LEN` limit, so the high byte is zero; this is
a wire-format invariant, not an assumption), while a raw chunk's first a wire-format invariant, not an assumption), while a raw chunk's first
byte is a `stream_type`. The server never sends `0` (stdin — byte is a `stream_type`. The server never sends `0` (stdin,
client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so client→server only) or `3` (`STREAM_CTRL_IN`, client→server only), so
the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`); the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`);
`0x00` is unambiguous. The client distinguishes: read the first byte; if `0x00` is unambiguous. The client distinguishes: read the first byte; if
it is `0x00`, interpret the next 4 bytes as a big-endian length prefix it is `0x00`, interpret the next 4 bytes as a big-endian length prefix
@@ -220,13 +221,14 @@ adapter's session driver, not in the backend. The ordering:
1. The stdout pump (task A) drains the backend's stdout to EOF. The 1. The stdout pump (task A) drains the backend's stdout to EOF. The
backend's stdout ends when the process exits and the PTY/pipe buffer backend's stdout ends when the process exits and the PTY/pipe buffer
drains (Unix `Child::wait()` blocks until the child is reaped, which drains (the waiter thread's blocking wait returns when the child is
happens after the child exits and its stdout drains — ADR-004 reaped, which
happens after the child exits and its stdout drains; ADR-004
assumption 1). assumption 1).
2. The exit task (task C) awaits `TtyHandle.exit_code`. The exit resolves 2. The exit task (task C) awaits `TtyHandle.exit_code`. The exit resolves
after the child is reaped (the local backend's waiter thread calls after the child is reaped (the local backend's waiter thread blocks on
`Child::wait()`; docker's `inspect_exec` after the output stream ends; the wait (`libc::waitpid`); docker's `inspect_exec` after the output
SSH's channel close after the process exits). stream ends; SSH's channel close after the process exits).
3. **The adapter waits for *both* the stdout pump to complete (EOF) 3. **The adapter waits for *both* the stdout pump to complete (EOF)
*and* `exit_code` to resolve** before enqueueing the exit chunk. If a *and* `exit_code` to resolve** before enqueueing the exit chunk. If a
backend's stdout outlives the exit resolve (a hypothetical backend backend's stdout outlives the exit resolve (a hypothetical backend
@@ -237,12 +239,12 @@ adapter's session driver, not in the backend. The ordering:
4. After both resolve, the exit chunk (`{"type":"exit","code":N}`) is 4. After both resolve, the exit chunk (`{"type":"exit","code":N}`) is
enqueued on the writer channel. enqueued on the writer channel.
5. The drainer writes the exit chunk to the client. 5. The drainer writes the exit chunk to the client.
6. The adapter closes the write half — the session ends. 6. The adapter closes the write half, and the session ends.
A client reads stdout/stderr/control chunks until it sees the exit A client reads stdout/stderr/control chunks until it sees the exit
chunk, records the exit code, and treats subsequent stream close as the chunk, records the exit code, and treats subsequent stream close as the
session end. The exit chunk is the deterministic completion signal — session end. The exit chunk is the deterministic completion signal, the
the same stopgap property the docker POC validated for logs subscriptions, same stopgap property the docker POC validated for logs subscriptions,
now for any backend. now for any backend.
If `exit_code` resolves with a `TtyError` (the backend couldn't determine If `exit_code` resolves with a `TtyError` (the backend couldn't determine
@@ -267,11 +269,11 @@ revokes. The adapter's access control declares against the ADR-050 model:
process, an SSH backend's channel). The adapter delegates the process, an SSH backend's channel). The adapter delegates the
resource-id extraction to the backend via resource-id extraction to the backend via
`TtyBackend::resource_id(&params)` (ADR-002), which returns `TtyBackend::resource_id(&params)` (ADR-002), which returns
`None` (no pre-existing resource — the session creates its own) or `None` (no pre-existing resource; the session creates its own) or
`Some((kind, id))` (the caller must own this resource). The adapter `Some((kind, id))` (the caller must own this resource). The adapter
checks `OwnershipProvider::owns(identity, kind, id, "tty")` if an checks `OwnershipProvider::owns(identity, kind, id, "tty")` if an
ownership provider is wired and the backend returns `Some`. The ownership provider is wired and the backend returns `Some`. The
adapter does not parse backend-specific JSON itself — the extraction adapter does not parse backend-specific JSON itself; the extraction
is backend-driven, so adding a backend with a new resource shape (e.g., is backend-driven, so adding a backend with a new resource shape (e.g.,
a Kubernetes backend targeting a pod) requires no adapter change. a Kubernetes backend targeting a pod) requires no adapter change.
- **`forwarded_for` for proxied sessions.** A hub that proxies a - **`forwarded_for` for proxied sessions.** A hub that proxies a
@@ -281,7 +283,7 @@ revokes. The adapter's access control declares against the ADR-050 model:
layer. layer.
The tty adapter is a `ProtocolHandler`, not an `OperationSpec`-registered The tty adapter is a `ProtocolHandler`, not an `OperationSpec`-registered
operation — it doesn't go through the call protocol's operation; it doesn't go through the call protocol's
`OperationRegistry::invoke()`. The access-control shape is the adapter's `OperationRegistry::invoke()`. The access-control shape is the adapter's
own (scope-gate + backend-driven ownership check at negotiation), own (scope-gate + backend-driven ownership check at negotiation),
declaring against the ADR-050 model but not consuming declaring against the ADR-050 model but not consuming
@@ -291,12 +293,12 @@ extraction is delegated to the backend via `resource_id()` rather than
a path expression). See alknet ADR-050 §"Specifics" for the model this a path expression). See alknet ADR-050 §"Specifics" for the model this
declares against. declares against.
The concrete choice — the scope name (`tty:open`) and the The concrete choice, the scope name (`tty:open`) and the
check-at-negotiation timing — is a **two-way-door** choice within the check-at-negotiation timing, is a **two-way-door** choice within the
one-way `TtyAdapter` shape. The scope name can be renamed (a one-way `TtyAdapter` shape. The scope name can be renamed (a
deployment-configured scope, not a wire-format constant). The deployment-configured scope, not a wire-format constant). The
resource-id extraction is backend-driven via `resource_id()`, so new resource-id extraction is backend-driven via `resource_id()`, so new
backends with new resource shapes require no adapter change — the backends with new resource shapes require no adapter change; the
generalization is already in place (ADR-002). No ADR is warranted for generalization is already in place (ADR-002). No ADR is warranted for
the scope name; it is a reversible implementation choice, not an the scope name; it is a reversible implementation choice, not an
architectural commitment. architectural commitment.
@@ -307,23 +309,24 @@ architectural commitment.
sessions on that connection are cancelled. Each session's pump tasks sessions on that connection are cancelled. Each session's pump tasks
are dropped (Rust `Drop`); the `TtyHandle` is dropped; the are dropped (Rust `Drop`); the `TtyHandle` is dropped; the
`exit_code` future is dropped without being driven to completion, `exit_code` future is dropped without being driven to completion,
which triggers the backend's cancel-cleanup — the session target is which triggers the backend's cancel-cleanup: the session target is
killed (ADR-005). For the local backend, the `exit_code` future's killed (ADR-005). For the local backend, the `exit_code` future's
`Drop` calls `ChildKiller::kill(SIGHUP)`, the child exits, the `Drop` calls `ChildKiller::kill(SIGHUP)`, the child exits, the
waiter thread's `wait()` reaps it and exits, and the reader/writer waiter thread's blocking wait reaps it and exits, and the
reader/writer
threads exit on channel close. For docker/SSH backends (future), the threads exit on channel close. For docker/SSH backends (future), the
`Drop` issues the backend's kill (container kill / channel close). `Drop` issues the backend's kill (container kill / channel close).
See ADR-005 for the contract and the mechanism. See ADR-005 for the contract and the mechanism.
- **Stream reset**: when a bidi stream is reset mid-session, the - **Stream reset**: when a bidi stream is reset mid-session, the
`ChunkReader` returns a `RawError` (ConnectionClosed or Io). The pump `ChunkReader` returns a `RawError` (ConnectionClosed or Io). The pump
tasks exit; the `TtyHandle` is dropped; the cancel-cleanup runs tasks exit; the `TtyHandle` is dropped; the cancel-cleanup runs
(ADR-005). No exit chunk is sent — the stream is gone, the client (ADR-005). No exit chunk is sent: the stream is gone, the client
that reset it already knows. that reset it already knows.
- **Client cancel**: when the client closes the write half (or sends a - **Client cancel**: when the client closes the write half (or sends a
zero-length stdin chunk / `eof` control chunk), the adapter signals zero-length stdin chunk / `eof` control chunk), the adapter signals
EOF to the backend's stdin and keeps pumping stdout until the backend's EOF to the backend's stdin and keeps pumping stdout until the backend's
stdout ends and the exit resolves. The session completes normally — stdout ends and the exit resolves. The session completes normally
the exit chunk is sent — the client just stopped sending input. This (the exit chunk is sent); the client just stopped sending input. This
is NOT a cancel from the adapter's perspective (the session runs to is NOT a cancel from the adapter's perspective (the session runs to
completion); the cancel-cleanup (ADR-005) is not triggered. The completion); the cancel-cleanup (ADR-005) is not triggered. The
cancel-cleanup is triggered only when the *adapter* drops the handle cancel-cleanup is triggered only when the *adapter* drops the handle
@@ -339,14 +342,14 @@ architectural commitment.
- **One session per bidi stream, multiple streams per connection.** A - **One session per bidi stream, multiple streams per connection.** A
connection hosts multiple sessions (one stream each); the adapter connection hosts multiple sessions (one stream each); the adapter
spawns a `drive_session` task per accepted stream. Sessions are spawns a `drive_session` task per accepted stream. Sessions are
independent — one session's exit doesn't affect another. independent; one session's exit doesn't affect another.
- **Negotiation errors are JSON, not raw chunks.** The error response - **Negotiation errors are JSON, not raw chunks.** The error response
uses the negotiation framing (length-prefixed JSON), not the raw chunk uses the negotiation framing (length-prefixed JSON), not the raw chunk
format. The stream enters raw mode only after a successful allocation. format. The stream enters raw mode only after a successful allocation.
- **The exit chunk is the deterministic completion signal.** A client - **The exit chunk is the deterministic completion signal.** A client
reading to completion sees the exit chunk and knows the process exited reading to completion sees the exit chunk and knows the process exited
with code N. A client that cancels mid-stream (closes the write half) with code N. A client that cancels mid-stream (closes the write half)
won't see the exit chunk — that's correct; a cancelled stream doesn't won't see the exit chunk. That's correct; a cancelled stream doesn't
have a deterministic exit. have a deterministic exit.
- **The adapter triggers backend cleanup by dropping the `TtyHandle` - **The adapter triggers backend cleanup by dropping the `TtyHandle`
(ADR-005).** On connection drop, stream reset, or pump-task panic, the (ADR-005).** On connection drop, stream reset, or pump-task panic, the
@@ -369,37 +372,38 @@ architectural commitment.
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel | | Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs | | Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
| Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) | | Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) |
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures are rejected by the establisher (`channel:open_failed`, alkcall ADR-049); `allocate_failed` stays in-band | | Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures and allocation are rejected/run in the establisher (`channel:open_failed`, alkcall ADR-049; alkcall 0.6 plan payload carries the `TtyHandle`); no in-band frame from a registered producer |
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape | | Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
## Open Questions ## Open Questions
- **OQ-45** (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism. - **OQ-45** (resolved): Flow control for high-throughput stdout (no application-level windowing; QUIC per-stream flow control is the backpressure mechanism).
## References ## References
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire - [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire
format the adapter pumps format the adapter pumps
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the - [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the
backend trait the adapter dispatches to backend trait the adapter dispatches to
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the - [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
exit-chunk ordering the adapter enforces exit-chunk ordering the adapter enforces
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the - [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md): the
cancel-cleanup contract the adapter triggers by dropping the cancel-cleanup contract the adapter triggers by dropping the
`TtyHandle` on session cancel `TtyHandle` on session cancel
- [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) — why - [ADR-008](decisions/008-channels-pure-channel-multiplexing.md): why
the same session driver runs in channels mode the same session driver runs in channels mode
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the - [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md): the
channels path carries no second negotiation frame (the open op's channels path carries no second negotiation frame (the open op's
registry-validated `input` is the negotiation) registry-validated `input` is the negotiation)
- [ADR-010](decisions/010-channels-establisher-migration.md) — the - [ADR-010](decisions/010-channels-establisher-migration.md): the
channels path's semantic failures are establisher call errors channels path's semantic failures are establisher call errors
(`channel:open_failed`); the in-band frames shrink to (`channel:open_failed`); since alkcall 0.6 the establisher also
`allocate_failed` allocates (the `TtyHandle` crosses via the plan payload), so no
- alknet ADR-050 — the ownership model the adapter's access control failure class arrives in-band from a registered producer
- alknet ADR-050: the ownership model the adapter's access control
declares against declares against
- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives- - alknet ADR-007: `Connection`, `accept_bi`, the handler-receives-
Connection pattern Connection pattern
- `src/adapter.rs` — the Rust source this spec documents - `src/adapter.rs`: the Rust source this spec documents
- [tty-wire.md](tty-wire.md) — the wire format details - [tty-wire.md](tty-wire.md): the wire format details
- [tty-backend.md](tty-backend.md) — the backend trait details - [tty-backend.md](tty-backend.md): the backend trait details
+55 -53
View File
@@ -5,7 +5,7 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
last_updated: 2026-08-17 last_updated: 2026-08-17
--- ---
# alktty — TtyBackend Trait and TtyHandle # alktty: TtyBackend Trait and TtyHandle
The `TtyBackend` trait is the inversion point that keeps alktty The `TtyBackend` trait is the inversion point that keeps alktty
decoupled from its backends. alktty defines the trait, the `TtyParams` decoupled from its backends. alktty defines the trait, the `TtyParams`
@@ -24,30 +24,30 @@ terminal/process session. The adapter holds a
adapter reads the negotiation frame, selects the backend by the adapter reads the negotiation frame, selects the backend by the
`backend` field, calls `allocate()`, and pumps the resulting `backend` field, calls `allocate()`, and pumps the resulting
`TtyHandle`'s fields bidirectionally using the chunk format `TtyHandle`'s fields bidirectionally using the chunk format
(ADR-001). The backend does not write to the wire — it produces handles; (ADR-001). The backend does not write to the wire; it produces handles,
the adapter pumps. and the adapter pumps.
Three implementations are contemplated, each in its own crate (the Three implementations are contemplated, each in its own crate (the
no-handler-depends-on-another-handler rule from alknet ADR-003 is no-handler-depends-on-another-handler rule from alknet ADR-003 is
preserved — backends depend on alktty for the trait, alktty doesn't preserved: backends depend on alktty for the trait, alktty doesn't
depend on them): depend on them):
- **`LocalTtyBackend`** (in alktty's `local` feature module, - **`LocalTtyBackend`** (in alktty's `local` feature module,
[ADR-003](decisions/003-local-backend-placement.md)) — wraps [ADR-003](decisions/003-local-backend-placement.md)), which wraps
`portable_pty` for the PTY case and `std::process::Command` with `portable_pty` for the PTY case and `std::process::Command` with
`Stdio::piped()` for the pipe/runner case. See `Stdio::piped()` for the pipe/runner case. See
[tty-local.md](tty-local.md). [tty-local.md](tty-local.md).
- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate - **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate,
— future, out of scope here) — wraps `bollard::attach_container()` for future and out of scope here) wraps `bollard::attach_container()` for
interactive attach or `bollard::exec::start_exec` with `tty: true` for interactive attach or `bollard::exec::start_exec` with `tty: true` for
exec-with-PTY. `control.resize()` calls `bollard::exec::resize_exec` exec-with-PTY. `control.resize()` calls `bollard::exec::resize_exec`
or `bollard::container::resize_container`. stdout/stderr are merged or `bollard::container::resize_container`. stdout/stderr are merged
when `tty: true` (bollard's `LogOutput` on a TTY exec returns when `tty: true` (bollard's `LogOutput` on a TTY exec returns
`StdOut` only), so `TtyHandle.stderr` is `None` for the PTY case. `StdOut` only), so `TtyHandle.stderr` is `None` for the PTY case.
- **`SshTtyBackend`** (in `alknet-ssh` — future, out of scope here) — - **`SshTtyBackend`** (in `alknet-ssh`, future and out of scope here)
wraps russh's `pty_request` + `shell_request` (or `exec_request` with wraps russh's `pty_request` + `shell_request` (or `exec_request` with
a PTY) on a session channel. `channel.into_stream()` gives a PTY) on a session channel. `channel.into_stream()` gives
`(AsyncRead, AsyncWrite)` — the stream *is* the PTY; russh handles `(AsyncRead, AsyncWrite)`; the stream *is* the PTY, and russh handles
kernel PTY allocation on the server side. `control.resize()` sends a kernel PTY allocation on the server side. `control.resize()` sends a
`window_change` channel request; `control.signal()` sends a `signal` `window_change` channel request; `control.signal()` sends a `signal`
channel request. stdout and stderr are merged (PTY property), so channel request. stdout and stderr are merged (PTY property), so
@@ -60,9 +60,9 @@ without re-spec'ing the seam.
## Why ## Why
The guiding insight: **a terminal session is not an SSH concern, or a The guiding insight: **a terminal session is not an SSH concern, or a
Docker concern — it is a terminal concern. SSH and Docker are just two Docker concern; it is a terminal concern. SSH and Docker are just two
backends that can allocate a PTY.** The `TtyBackend` trait is what makes backends that can allocate a PTY.** The `TtyBackend` trait is what makes
that insight load-bearing — alktty owns the wire format and session that insight load-bearing: alktty owns the wire format and session
lifecycle; the backends own PTY allocation. The full rationale (the lifecycle; the backends own PTY allocation. The full rationale (the
inversion point, why the trait is the seam) is in inversion point, why the trait is the seam) is in
[ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) §Context. [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) §Context.
@@ -88,7 +88,7 @@ pub trait TtyBackend: Send + Sync {
/// The pre-existing resource this session targets, for ownership /// The pre-existing resource this session targets, for ownership
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session /// checks (alknet ADR-050). `None` = no pre-existing resource (the session
/// creates its own — local process, SSH channel). `Some((kind, id))` /// creates its own; local process, SSH channel). `Some((kind, id))`
/// = the session targets an existing resource the caller must own /// = the session targets an existing resource the caller must own
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The /// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
/// adapter calls this at negotiation to gate access; the backend /// adapter calls this at negotiation to gate access; the backend
@@ -106,7 +106,7 @@ that allocates a session*; the wire-format pump is backend-agnostic.
The error type for `allocate()` and `exit_code`. `#[non_exhaustive]` so The error type for `allocate()` and `exit_code`. `#[non_exhaustive]` so
new variants are additive (two-way-door extension within the one-way new variants are additive (two-way-door extension within the one-way
trait shape — ADR-002). trait shape; see ADR-002).
```rust ```rust
#[non_exhaustive] #[non_exhaustive]
@@ -123,22 +123,24 @@ pub enum TtyError {
} }
``` ```
- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed - `AllocFailed`: the PTY couldn't be allocated, the docker exec failed
to start, the SSH channel request was rejected. Returned by to start, the SSH channel request was rejected. Returned by
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and `allocate()`; on the direct path the adapter sends
closes (tty-adapter.md §"Negotiation errors"). `{"error":"allocate_failed",...}` and closes (tty-adapter.md
- `WaitFailed` — the backend couldn't reap the child / determine the §"Negotiation errors"); on the channels path the establisher maps it
to `channel:open_failed` / `dial_failed` (ADR-010 §2A).
- `WaitFailed`: the backend couldn't reap the child / determine the
exit code. Returned by the `exit_code` future; the adapter sends exit code. Returned by the `exit_code` future; the adapter sends
`{"type":"exit","code":-1}` (ADR-004 §4). `{"type":"exit","code":-1}` (ADR-004 §4).
- `Io` — an I/O error from a backend's stream/handle. - `Io`: an I/O error from a backend's stream/handle.
- `Backend` — backend-specific error not covered by the above (e.g., a - `Backend`: backend-specific error not covered by the above (e.g., a
bollard API error, a russh protocol error). bollard API error, a russh protocol error).
### `TtyParams` — the allocation request ### `TtyParams`: the allocation request
```rust ```rust
pub struct TtyParams { pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case, /// Terminal parameters. `None` = pipe mode (no PTY, the runner case,
/// ADR-003). `Some` = allocate a PTY with these dimensions. /// ADR-003). `Some` = allocate a PTY with these dimensions.
pub terminal: Option<TerminalParams>, pub terminal: Option<TerminalParams>,
/// Command vector (argv[0] + args). Non-empty. /// Command vector (argv[0] + args). Non-empty.
@@ -161,16 +163,16 @@ pub struct TerminalParams {
pub rows: u16, pub rows: u16,
pub pixel_width: u16, pub pixel_width: u16,
pub pixel_height: u16, pub pixel_height: u16,
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1
} }
``` ```
`terminal: None` is the pipe/runner case — no PTY, separate `terminal: None` is the pipe/runner case (no PTY, separate
stdout/stderr. `terminal: Some` is the PTY case — stdout/stderr merged stdout/stderr). `terminal: Some` is the PTY case (stdout/stderr merged
into the single stdout stream (`TtyHandle.stderr` is `None`), real into the single stdout stream (`TtyHandle.stderr` is `None`), real
terminal semantics (resize, signal delivery to process group). The terminal semantics (resize, signal delivery to process group). The
per-session choice is the backend's branch in `allocate()`, not a per-session choice is the backend's branch in `allocate()`, not a
per-deployment choice — see ADR-003. per-deployment choice (see ADR-003).
### Backend params are opaque ### Backend params are opaque
@@ -178,7 +180,7 @@ per-deployment choice — see ADR-003.
a typed enum. The adapter passes the negotiation frame's a typed enum. The adapter passes the negotiation frame's
backend-specific fields through verbatim; the backend deserializes its backend-specific fields through verbatim; the backend deserializes its
own strongly-typed params struct. alktty has zero knowledge of any own strongly-typed params struct. alktty has zero knowledge of any
backend's params shape — not docker's `container`, not an SSH host backend's params shape: not docker's `container`, not an SSH host
selector, not anything. Each backend defines its own params struct and selector, not anything. Each backend defines its own params struct and
deserializes from `params.backend_params` inside `allocate()`: deserializes from `params.backend_params` inside `allocate()`:
@@ -195,7 +197,7 @@ impl TtyBackend for DockerTtyBackend {
// use p.container ... // use p.container ...
} }
fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> { fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> {
// extract for the ownership check — backend-driven, not adapter-hardcoded // extract for the ownership check: backend-driven, not adapter-hardcoded
let p: DockerBackendParams = serde_json::from_value( let p: DockerBackendParams = serde_json::from_value(
serde_json::Value::Object(params.backend_params.clone()) serde_json::Value::Object(params.backend_params.clone())
).ok()?; ).ok()?;
@@ -207,36 +209,36 @@ impl TtyBackend for DockerTtyBackend {
This is a complete inversion: the *trait* is inverted (backends This is a complete inversion: the *trait* is inverted (backends
implement, alktty doesn't depend on them) and the *params* are implement, alktty doesn't depend on them) and the *params* are
inverted (backends define their own typed shape, alktty doesn't carry inverted (backends define their own typed shape, alktty doesn't carry
it). A new backend crate requires zero changes to alktty — no it). A new backend crate requires zero changes to alktty: no
enum variant to add, no forward-reference type to place, no dependency enum variant to add, no forward-reference type to place, no dependency
edge. See ADR-002 §"Backend params are opaque" for the full rationale edge. See ADR-002 §"Backend params are opaque" for the full rationale
and why the typed-enum alternative (with `SshChannelRef`) was rejected. and why the typed-enum alternative (with `SshChannelRef`) was rejected.
### `TtyHandle` — what a backend produces ### `TtyHandle`: what a backend produces
```rust ```rust
pub struct TtyHandle { pub struct TtyHandle {
/// Stdin writer — bytes the adapter pumps from client stdin chunks. /// Stdin writer: bytes the adapter pumps from client stdin chunks.
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io` /// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
/// one — they are incompatible traits; the tokio stack is the /// one; they are incompatible traits, and the tokio stack is the
/// adapter's runtime). /// adapter's runtime).
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>, pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// Stdout stream — bytes the adapter pumps to client stdout chunks. /// Stdout stream: bytes the adapter pumps to client stdout chunks.
/// Ends when the backend's stdout reaches EOF. /// Ends when the backend's stdout reaches EOF.
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by /// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods). /// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>, pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged /// Stderr stream: `None` for PTY backends (stdout/stderr merged
/// into `stdout`). `Some` for pipe backends (separate streams). /// into `stdout`). `Some` for pipe backends (separate streams).
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>, pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
/// Exit code — a `Future` the adapter awaits. Resolves when the /// Exit code: a `Future` the adapter awaits. Resolves when the
/// process/container/SSH exec exits. The adapter sends the result /// process/container/SSH exec exits. The adapter sends the result
/// as the `{"type":"exit","code":N}` control chunk (ADR-004) and /// as the `{"type":"exit","code":N}` control chunk (ADR-004) and
/// closes the stream. This is `BoxFuture`, not a method on /// closes the stream. This is `BoxFuture`, not a method on
/// `TtyHandle`, so the adapter can `select` between exit and /// `TtyHandle`, so the adapter can `select` between exit and
/// stream-close without coupling to the other fields. (REQ-TTY-01.) /// stream-close without coupling to the other fields. (REQ-TTY-01.)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>, pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can /// Control handle (resize, signal), `Clone` so the adapter can
/// hand it to the spawned control-chunk dispatcher. `None` only /// hand it to the spawned control-chunk dispatcher. `None` only
/// when the backend genuinely has no control path. See OQ-43. /// when the backend genuinely has no control path. See OQ-43.
pub control: Option<TtyControlHandle>, pub control: Option<TtyControlHandle>,
@@ -259,7 +261,7 @@ pub trait TtyControl: Send + Sync {
} }
/// The `Clone`-able handle to a backend's control path. The `TtyControl` /// The `Clone`-able handle to a backend's control path. The `TtyControl`
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) -> /// trait is NOT `Clone` (`Clone` is not object-safe: `fn clone(&self) ->
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability /// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
/// lives on this concrete newtype, which holds the trait object behind an /// lives on this concrete newtype, which holds the trait object behind an
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned /// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
@@ -276,7 +278,7 @@ impl TtyControlHandle {
The trait is kept object-safe by NOT putting `Clone` on it; the `Clone` The trait is kept object-safe by NOT putting `Clone` on it; the `Clone`
newtype (`TtyControlHandle`) holds the trait object behind an `Arc`. The newtype (`TtyControlHandle`) holds the trait object behind an `Arc`. The
POC used a concrete `PtyControl` struct (inherently `Clone` — it held POC used a concrete `PtyControl` struct (inherently `Clone`, since it held
`Arc<Mutex<...>>` fields); this newtype generalizes the POC's shape so a `Arc<Mutex<...>>` fields); this newtype generalizes the POC's shape so a
backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))` backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))`
without the adapter knowing the concrete shape. See OQ-43 for the without the adapter knowing the concrete shape. See OQ-43 for the
@@ -286,7 +288,7 @@ itself.
### REQ-TTY-01: backends are not required to be natively async ### REQ-TTY-01: backends are not required to be natively async
`portable_pty`'s API is blocking `std::io::{Read, Write}` and a blocking `portable_pty`'s API is blocking `std::io::{Read, Write}` and a blocking
`Child::wait()` — there is no async variant. The local-PTY POC bridges `Child::wait()`; there is no async variant. The local-PTY POC bridges
this with three dedicated std threads (reader, writer, waiter) feeding this with three dedicated std threads (reader, writer, waiter) feeding
tokio mpsc/oneshot channels; the async-facing `LocalPty` then exposes tokio mpsc/oneshot channels; the async-facing `LocalPty` then exposes
`mpsc::Receiver<Bytes>` for stdout, `mpsc::Sender<StdinCmd>` for stdin, `mpsc::Receiver<Bytes>` for stdout, `mpsc::Sender<StdinCmd>` for stdin,
@@ -296,8 +298,8 @@ and `oneshot::Receiver<i32>` for exit. This is the same pattern wezterm
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`, The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`BoxFuture`, `TtyControl`) are the **adapter's contract**. A backend may `BoxFuture`, `TtyControl`) are the **adapter's contract**. A backend may
expose blocking handles internally and bridge them to these async-facing expose blocking handles internally and bridge them to these async-facing
types. The bridging pattern — blocking `std::io` on dedicated std threads types. The bridging pattern (blocking `std::io` on dedicated std threads
or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels — or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels)
is a **documented, supported implementation strategy**, not a workaround. is a **documented, supported implementation strategy**, not a workaround.
This resolves the first half of OQ-TTY-01 (the research's open question This resolves the first half of OQ-TTY-01 (the research's open question
@@ -337,8 +339,8 @@ layer chooses what's available.
The SSH backend crate is future work; this spec commits the The SSH backend crate is future work; this spec commits the
trait shape it implements so it can be built against it without trait shape it implements so it can be built against it without
re-spec'ing the seam. The `DockerTtyBackend` is future work in re-spec'ing the seam. The `DockerTtyBackend` is future work in
`alknet-docker` — the natural extension of the alknet-docker POC's `alknet-docker`, the natural extension of the alknet-docker POC's
`drive_attach_raw` — with the trait, it becomes `impl TtyBackend for `drive_attach_raw`; with the trait, it becomes `impl TtyBackend for
DockerTtyBackend`. The `SshTtyBackend` dissolves the alknet-ssh DockerTtyBackend`. The `SshTtyBackend` dissolves the alknet-ssh
research's PTY hedge (DP-5): alknet-ssh's session channel still does research's PTY hedge (DP-5): alknet-ssh's session channel still does
`exec` (structured, JSON carriage, exit code on completion) but `exec` (structured, JSON carriage, exit code on completion) but
@@ -365,10 +367,10 @@ inside alknet-ssh.
produce handles; the adapter pumps. A backend that wrote to the wire produce handles; the adapter pumps. A backend that wrote to the wire
directly would break the wire-format invariants (the exit-chunk directly would break the wire-format invariants (the exit-chunk
ordering, ADR-004). The backend's `exit_code` future resolves and the ordering, ADR-004). The backend's `exit_code` future resolves and the
adapter sends the exit chunk — the backend does not serialize adapter sends the exit chunk; the backend does not serialize
`ControlMessage::Exit`. `ControlMessage::Exit`.
- **PTY backends merge stdout/stderr.** `TtyHandle.stderr` is `None` for - **PTY backends merge stdout/stderr.** `TtyHandle.stderr` is `None` for
the PTY case (kernel PTY property — one output stream from the slave). the PTY case (kernel PTY property: one output stream from the slave).
The adapter pumps only stdout chunks (stream_type 1). Pipe backends The adapter pumps only stdout chunks (stream_type 1). Pipe backends
set `stderr: Some` and the adapter pumps both stdout (stream_type 1) set `stderr: Some` and the adapter pumps both stdout (stream_type 1)
and stderr (stream_type 2) chunks. and stderr (stream_type 2) chunks.
@@ -381,7 +383,7 @@ inside alknet-ssh.
`exit_code` field is a `BoxFuture<'static, Result<i32, TtyError>>` `exit_code` field is a `BoxFuture<'static, Result<i32, TtyError>>`
whose `Drop`-on-cancel (i.e., dropped without being driven to whose `Drop`-on-cancel (i.e., dropped without being driven to
completion) MUST kill the child/container/SSH process. This is a completion) MUST kill the child/container/SSH process. This is a
behavioral contract on the `TtyBackend` trait — the adapter triggers behavioral contract on the `TtyBackend` trait: the adapter triggers
it by dropping the `TtyHandle` on session cancel (connection drop, it by dropping the `TtyHandle` on session cancel (connection drop,
stream reset); the backend wires the kill into the `exit_code` stream reset); the backend wires the kill into the `exit_code`
future's `Drop`. A backend that returns a bare `oneshot::Receiver<i32>` future's `Drop`. A backend that returns a bare `oneshot::Receiver<i32>`
@@ -408,19 +410,19 @@ inside alknet-ssh.
## References ## References
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the - [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the
trait shape decision (this spec is its elaboration) trait shape decision (this spec is its elaboration)
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire - [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire
format the adapter pumps to/from these handles format the adapter pumps to/from these handles
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the - [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
exit-chunk ordering the `exit_code` field feeds into exit-chunk ordering the `exit_code` field feeds into
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the - [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md): the
cancel-cleanup contract on this trait (`exit_code` future's cancel-cleanup contract on this trait (`exit_code` future's
`Drop`-on-cancel kills the session target) `Drop`-on-cancel kills the session target)
- [ADR-003](decisions/003-local-backend-placement.md) — the local - [ADR-003](decisions/003-local-backend-placement.md): the local
backend's placement (folded into alktty behind a `local` feature) backend's placement (folded into alktty behind a `local` feature)
- `src/backend.rs` — the Rust source this spec documents - `src/backend.rs`: the Rust source this spec documents
- [tty-local.md](tty-local.md) — the `LocalTtyBackend` spec (carries - [tty-local.md](tty-local.md): the `LocalTtyBackend` spec (carries
REQ-TTY-02: signal forwarding to the process group) REQ-TTY-02: signal forwarding to the process group)
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes - [tty-adapter.md](tty-adapter.md): the session driver that consumes
these handles these handles
+31 -30
View File
@@ -3,7 +3,7 @@ status: draft
last_updated: 2026-08-17 last_updated: 2026-08-17
--- ---
# alktty — BAST Document for the `alk/tty` Wire Format # alktty: BAST Document for the `alk/tty` Wire Format
This document is the **BAST (Binary Abstract Syntax Tree)** specification This document is the **BAST (Binary Abstract Syntax Tree)** specification
for the binary portions of the `alk/tty` wire format. It is a normative for the binary portions of the `alk/tty` wire format. It is a normative
@@ -24,7 +24,7 @@ and [ADR-006](decisions/006-negotiation-framing-self-contained.md).
The `alk/tty` wire format mixes a small binary framing layer with The `alk/tty` wire format mixes a small binary framing layer with
UTF-8 JSON payloads for the structured parts. Per the BAST format UTF-8 JSON payloads for the structured parts. Per the BAST format
spec, a BAST document describes **binary data layouts** — it is not a spec, a BAST document describes **binary data layouts**; it is not a
format for validating JSON payloads ([`validate_bytes` vs format for validating JSON payloads ([`validate_bytes` vs
`validate_json`](https://alk.dev/bast/v1/spec)). This document `validate_json`](https://alk.dev/bast/v1/spec)). This document
therefore specifies only the binary framing; the JSON shapes are therefore specifies only the binary framing; the JSON shapes are
@@ -36,25 +36,25 @@ The binary portions this BAST covers (both one-way doors per
[ADR-001](decisions/001-wire-format-and-two-carriage.md)): [ADR-001](decisions/001-wire-format-and-two-carriage.md)):
1. **The 5-byte chunk header** (`[stream_type: u8][length: u32 BE] 1. **The 5-byte chunk header** (`[stream_type: u8][length: u32 BE]
[payload]`) — the raw chunk codec in `src/wire.rs`. Five stream [payload]`), the raw chunk codec in `src/wire.rs`. Five stream
types: `STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`, types: `STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`,
`STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. `STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`.
2. **The negotiation frame length prefix** (4-byte BE `u32` followed 2. **The negotiation frame length prefix** (4-byte BE `u32` followed
by `length` bytes of UTF-8 JSON `NegotiateRequest` body) — by `length` bytes of UTF-8 JSON `NegotiateRequest` body),
self-contained per self-contained per
[ADR-006](decisions/006-negotiation-framing-self-contained.md). [ADR-006](decisions/006-negotiation-framing-self-contained.md).
What this BAST deliberately does **not** cover: What this BAST deliberately does **not** cover:
- **`NegotiateRequest` JSON shape** — a UTF-8 JSON object, not a - **`NegotiateRequest` JSON shape**: a UTF-8 JSON object, not a
binary layout. Specified in [tty-wire.md](tty-wire.md) §"Phase 1" binary layout. Specified in [tty-wire.md](tty-wire.md) §"Phase 1"
and implemented by `NegotiateRequest` in `src/negotiation.rs`. and implemented by `NegotiateRequest` in `src/negotiation.rs`.
- **`ControlMessage` JSON shape** — the UTF-8 JSON payloads of - **`ControlMessage` JSON shape**: the UTF-8 JSON payloads of
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` chunks (the `resize`/`signal`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` chunks (the `resize`/`signal`/
`eof`/`exit` variants). Specified in [tty-wire.md](tty-wire.md) `eof`/`exit` variants). Specified in [tty-wire.md](tty-wire.md)
§"Control Channel" and implemented by `ControlMessage` in §"Control Channel" and implemented by `ControlMessage` in
`src/control.rs`. `src/control.rs`.
- **`TerminalParams` JSON shape** — the `tty` field of - **`TerminalParams` JSON shape**: the `tty` field of
`NegotiateRequest`. Specified in [tty-wire.md](tty-wire.md) §"Phase `NegotiateRequest`. Specified in [tty-wire.md](tty-wire.md) §"Phase
1" and implemented by `TerminalParamsWire` in 1" and implemented by `TerminalParamsWire` in
`src/negotiation.rs`. `src/negotiation.rs`.
@@ -67,8 +67,8 @@ struct-encoded binary. The JSON field types (`u16`, `i32`, `String`)
are JSON value types produced and consumed by `serde_json`, not binary are JSON value types produced and consumed by `serde_json`, not binary
field widths. Describing them as BAST structs implied a binary field widths. Describing them as BAST structs implied a binary
encoding that does not exist on the wire and would have misled any encoding that does not exist on the wire and would have misled any
generated validator. The simpler model — BAST for the binary framing, generated validator. The simpler model, BAST for the binary framing
prose + Rust source for the JSON payloads — matches what the wire and prose + Rust source for the JSON payloads, matches what the wire
format actually is. format actually is.
## The BAST document ## The BAST document
@@ -114,7 +114,7 @@ format actually is.
## Annotations ## Annotations
### `ChunkHeader` — binary, exact ### `ChunkHeader`: binary, exact
Maps 1:1 to the on-wire bytes. 5 bytes: `stream_type` as `uint8` (1 Maps 1:1 to the on-wire bytes. 5 bytes: `stream_type` as `uint8` (1
byte), `length` as `uint32` big-endian (4 bytes). This is the 5-byte byte), `length` as `uint32` big-endian (4 bytes). This is the 5-byte
@@ -123,12 +123,12 @@ The Rust types are `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` and `CHUNK_HEADER_LEN = 5` in `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` and `CHUNK_HEADER_LEN = 5` in
`src/wire.rs`. `src/wire.rs`.
### `StreamType` — enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index ### `StreamType`: enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index
The BAST meta-schema specifies that an `enum`'s binary representation The BAST meta-schema specifies that an `enum`'s binary representation
is a `u32` index into `values` (0-based). The `alk/tty` wire format is a `u32` index into `values` (0-based). The `alk/tty` wire format
encodes `stream_type` as a **`uint8`** (1 byte), not a `u32` (4 encodes `stream_type` as a **`uint8`** (1 byte), not a `u32` (4
bytes) — the chunk header is 5 bytes, not 8. This is a deliberate bytes); the chunk header is 5 bytes, not 8. This is a deliberate
deviation: the chunk header needs a 1-byte discriminator, and 4 bytes deviation: the chunk header needs a 1-byte discriminator, and 4 bytes
of padding would double the per-chunk overhead. of padding would double the per-chunk overhead.
@@ -149,9 +149,10 @@ integer→name table for validation and diagnostics.
`stream_type > 4` is a protocol error (`InvalidStreamType`); there is `stream_type > 4` is a protocol error (`InvalidStreamType`); there is
no extension escape hatch in the byte (a 6th channel is a wire-format no extension escape hatch in the byte (a 6th channel is a wire-format
change requiring a new ALPN — [ADR-001](decisions/001-wire-format-and-two-carriage.md)). change requiring a new ALPN; see
[ADR-001](decisions/001-wire-format-and-two-carriage.md)).
### `Chunk` — binary, exact ### `Chunk`: binary, exact
A chunk is the 5-byte `ChunkHeader` followed by `length` bytes of A chunk is the 5-byte `ChunkHeader` followed by `length` bytes of
payload. The `payload` field is `bytes` with `encoding: payload. The `payload` field is `bytes` with `encoding:
@@ -166,16 +167,16 @@ in `src/wire.rs`). The `payload`'s interpretation depends on
- `CtrlIn`/`CtrlOut` (3/4): UTF-8 JSON text (a serialized - `CtrlIn`/`CtrlOut` (3/4): UTF-8 JSON text (a serialized
`ControlMessage`). Control chunks are never zero-length (the JSON `ControlMessage`). Control chunks are never zero-length (the JSON
payload is at least `{}`). The JSON shape is **not** specified by payload is at least `{}`). The JSON shape is **not** specified by
this BAST — see [tty-wire.md](tty-wire.md) §"Control Channel" and this BAST; see [tty-wire.md](tty-wire.md) §"Control Channel" and
`src/control.rs`. `src/control.rs`.
### `NegotiationFrame` — binary, exact (out-of-band for the chunk codec) ### `NegotiationFrame`: binary, exact (out-of-band for the chunk codec)
The negotiation frame is a 4-byte big-endian length prefix + a UTF-8 The negotiation frame is a 4-byte big-endian length prefix + a UTF-8
JSON body. This maps 1:1 to the on-wire bytes: `length` as `uint32` JSON body. This maps 1:1 to the on-wire bytes: `length` as `uint32`
big-endian (4 bytes), `body` as a length-prefixed byte string big-endian (4 bytes), `body` as a length-prefixed byte string
(`length` bytes of UTF-8 JSON). The `maxLength: 16777216` (16 MiB) (`length` bytes of UTF-8 JSON). The `maxLength: 16777216` (16 MiB)
constraint is the same as `MAX_CHUNK_LEN` — error frames MUST be under constraint is the same as `MAX_CHUNK_LEN`: error frames MUST be under
16 MiB so the 4-byte length prefix's high byte is `0x00`, which is 16 MiB so the 4-byte length prefix's high byte is `0x00`, which is
what makes the framing-disambiguation trick sound (first byte `0x00` what makes the framing-disambiguation trick sound (first byte `0x00`
= error/negotiation frame; first byte `1`/`2`/`4` = raw chunk; see = error/negotiation frame; first byte `1`/`2`/`4` = raw chunk; see
@@ -200,22 +201,22 @@ See [ADR-006](decisions/006-negotiation-framing-self-contained.md).
The BAST document above is validatable in two ways: The BAST document above is validatable in two ways:
1. **Structural validation** — run any JSON Schema Draft 2020-12 1. **Structural validation**: run any JSON Schema Draft 2020-12
validator against the BAST meta-schema at validator against the BAST meta-schema at
`https://alk.dev/bast/v1/schema`. This checks whether the BAST is `https://alk.dev/bast/v1/schema`. This checks whether the BAST is
well-formed (correct `kind` strings, required fields present, well-formed (correct `kind` strings, required fields present,
`$ref` targets exist). It does NOT check whether a binary payload `$ref` targets exist). It does NOT check whether a binary payload
conforms to the layout — that is the BAST-native validator's job conforms to the layout; that is the BAST-native validator's job
(see the alktype BAST format spec, (see the alktype BAST format spec,
`/workspace/@alkdev/alktype/docs/architecture/bast-format.md`). `/workspace/@alkdev/alktype/docs/architecture/bast-format.md`).
2. **Drift detection** — a cheap test that parses the BAST document 2. **Drift detection**: a cheap test that parses the BAST document
with `serde_json` and asserts the `StreamType` enum values match with `serde_json` and asserts the `StreamType` enum values match
`wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants (the index of each `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants (the index of each
name in `values` is the integer constant). This catches the common name in `values` is the integer constant). This catches the common
drift case (a new stream_type added to `wire.rs` but not the BAST, drift case (a new stream_type added to `wire.rs` but not the BAST,
or vice versa) without requiring alktype as a dep — just or vice versa) without requiring alktype as a dep (just
`serde_json`, which alktty already has. See the project setup plan's `serde_json`, which alktty already has). See the project setup plan's
"Risk: BAST schema drift" mitigation. "Risk: BAST schema drift" mitigation.
## Design Decisions ## Design Decisions
@@ -228,22 +229,22 @@ The BAST document above is validatable in two ways:
## References ## References
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire - [ADR-001](decisions/001-wire-format-and-two-carriage.md), the wire
format decision this BAST specifies format decision this BAST specifies
- [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the - [ADR-006](decisions/006-negotiation-framing-self-contained.md): the
negotiation framing is self-contained (the `NegotiationFrame` is not negotiation framing is self-contained (the `NegotiationFrame` is not
reused from alkcall) reused from alkcall)
- [tty-wire.md](tty-wire.md) — the prose wire format spec this BAST - [tty-wire.md](tty-wire.md), the prose wire format spec this BAST
formalizes (covers both the binary framing and the JSON payloads) formalizes (covers both the binary framing and the JSON payloads)
- `src/wire.rs` — the runtime chunk codec (`ChunkReader`/`ChunkWriter`, - `src/wire.rs`: the runtime chunk codec (`ChunkReader`/`ChunkWriter`,
`STREAM_*` constants, `MAX_CHUNK_LEN`) this BAST describes `STREAM_*` constants, `MAX_CHUNK_LEN`) this BAST describes
- `src/negotiation.rs` — the `NegotiationReader`/`NegotiationWriter` - `src/negotiation.rs`: the `NegotiationReader`/`NegotiationWriter`
for the `NegotiationFrame`, and `NegotiateRequest` (JSON shape, out for the `NegotiationFrame`, and `NegotiateRequest` (JSON shape, out
of BAST scope) of BAST scope)
- `src/control.rs` — `ControlMessage` (JSON shape, out of BAST scope) - `src/control.rs`: `ControlMessage` (JSON shape, out of BAST scope)
- [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the - [alktype BAST format spec](https://alk.dev/bast/v1/schema), the
normative format spec for BAST documents (the meta-schema this normative format spec for BAST documents (the meta-schema this
document conforms to); see also document conforms to); see also
`/workspace/@alkdev/alktype/docs/architecture/bast-format.md` `/workspace/@alkdev/alktype/docs/architecture/bast-format.md`
- Project setup plan, "Risk: BAST schema drift" — the drift-detection - Project setup plan, "Risk: BAST schema drift": the drift-detection
test mitigation (`docs/plans/project-setup.md`) test mitigation (`docs/plans/project-setup.md`)
+66 -55
View File
@@ -6,10 +6,10 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
last_updated: 2026-08-17 last_updated: 2026-08-17
--- ---
# alktty — Local TTY Backend (`local` feature module) # alktty: Local TTY Backend (`local` feature module)
The local backend: a `TtyBackend` implementation that wraps The local backend: a `TtyBackend` implementation that wraps
`portable_pty` for the PTY case (terminal semantics — resize, signal `portable_pty` for the PTY case (terminal semantics: resize, signal
delivery, escape-sequence handling) and `tokio::process::Command` with delivery, escape-sequence handling) and `tokio::process::Command` with
`Stdio::piped()` for the pipe/runner case (process-streaming without `Stdio::piped()` for the pipe/runner case (process-streaming without
terminal semantics). This document specifies the `LocalTtyBackend`, the terminal semantics). This document specifies the `LocalTtyBackend`, the
@@ -24,12 +24,12 @@ it implements is in [tty-backend.md](tty-backend.md).
(`src/local/`, gated by the `local` cargo feature) and implements (`src/local/`, gated by the `local` cargo feature) and implements
`TtyBackend`. The backend's `allocate()` branches on `TtyParams.terminal`: `TtyBackend`. The backend's `allocate()` branches on `TtyParams.terminal`:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY via - **`terminal: Some(TerminalParams { ... })`**: allocate a real PTY via
`portable_pty::native_pty_system().openpty()`, spawn the command into `portable_pty::native_pty_system().openpty()`, spawn the command into
the slave side, return a `TtyHandle` with merged stdout (stderr is the slave side, return a `TtyHandle` with merged stdout (stderr is
`None` — kernel PTY property) and a real `TtyControl` (resize via `None`, a kernel PTY property) and a real `TtyControl` (resize via
`MasterPty::resize`, signal via `libc::kill(-pgid, sig)`). `MasterPty::resize`, signal via `libc::kill(-pgid, sig)`).
- **`terminal: None`** — pipe mode, the runner case. Spawn the command - **`terminal: None`**: pipe mode, the runner case. Spawn the command
with `Stdio::piped()` for stdin/stdout/stderr, return a `TtyHandle` with `Stdio::piped()` for stdin/stdout/stderr, return a `TtyHandle`
with separate stdout and stderr (stderr is `Some`) and a `TtyControl` with separate stdout and stderr (stderr is `Some`) and a `TtyControl`
whose `resize` is a no-op (no PTY) and `signal` calls whose `resize` is a no-op (no PTY) and `signal` calls
@@ -44,10 +44,10 @@ process group).
The local backend is the simplest backend and the one that enables the The local backend is the simplest backend and the one that enables the
runner pattern: a process whose stdin/stdout/stderr/exit-code stream over runner pattern: a process whose stdin/stdout/stderr/exit-code stream over
a framed bidi connection — the same shape as GitHub/Gitea Actions runners, a framed bidi connection: the same shape as GitHub/Gitea Actions runners,
just over alk's transport instead of HTTP polling. With just over alk's transport instead of HTTP polling. With
`LocalTtyBackend`, the dispatch project (a reverse runner that currently `LocalTtyBackend`, the dispatch project (a reverse runner that currently
requires SSH on the remote end) works without SSH — the endpoint runs requires SSH on the remote end) works without SSH: the endpoint runs
the process directly and streams its I/O back. SSH becomes one transport the process directly and streams its I/O back. SSH becomes one transport
option (for reaching hosts that don't run alk), not a requirement. option (for reaching hosts that don't run alk), not a requirement.
@@ -56,8 +56,8 @@ The PTY case is what makes a terminal a terminal: real resize (via
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), and escape-sequence handling (via `libc::kill(-pgid, sig)`, REQ-TTY-02), and escape-sequence handling
(the kernel PTY's line discipline). Without a PTY, it's a runner (piped (the kernel PTY's line discipline). Without a PTY, it's a runner (piped
process); with a PTY, it's a terminal. The per-session choice process); with a PTY, it's a terminal. The per-session choice
(`TtyParams.terminal`) lets one `LocalTtyBackend` serve both — see (`TtyParams.terminal`) lets one `LocalTtyBackend` serve both (see
ADR-003. ADR-003).
The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking
`std::io` API**, not async. `MasterPty::try_clone_reader()` returns `std::io` API**, not async. `MasterPty::try_clone_reader()` returns
@@ -65,7 +65,7 @@ The wrinkle that drove the Phase 0 POC: `portable_pty` is a **blocking
`Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. The POC was `Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. The POC was
built to discover how that constraint shapes the `TtyBackend` trait built to discover how that constraint shapes the `TtyBackend` trait
(REQ-TTY-01) and the signal-delivery contract (REQ-TTY-02). This spec (REQ-TTY-01) and the signal-delivery contract (REQ-TTY-02). This spec
records both as requirements, not open questions — the POC turned them records both as requirements, not open questions; the POC turned them
into grounded requirements. into grounded requirements.
## Architecture ## Architecture
@@ -89,15 +89,15 @@ pub enum StdinCmd {
} }
``` ```
1. **Reader thread** — blocking reads from `MasterPty::try_clone_reader()` 1. **Reader thread**: blocking reads from `MasterPty::try_clone_reader()`
→ `mpsc::Sender<Bytes>`. The reader loop reads into an 8 KiB buffer, → `mpsc::Sender<Bytes>`. The reader loop reads into an 8 KiB buffer,
copies each chunk to `Bytes`, and `blocking_send`s to the mpsc. On EOF copies each chunk to `Bytes`, and `blocking_send`s to the mpsc. On EOF
(the master reader returns EOF when the slave closes — the child has (the master reader returns EOF when the slave closes; the child has
exited and the OS has drained the PTY buffer), the thread sends a exited and the OS has drained the PTY buffer), the thread sends a
zero-length `Bytes` sentinel (the "drained" signal) and exits. The zero-length `Bytes` sentinel (the "drained" signal) and exits. The
async-facing `TtyHandle.stdout` is the `mpsc::Receiver<Bytes>`, async-facing `TtyHandle.stdout` is the `mpsc::Receiver<Bytes>`,
wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`. wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
2. **Writer thread** — drains an `mpsc::Receiver<StdinCmd>` → blocking 2. **Writer thread**: drains an `mpsc::Receiver<StdinCmd>` → blocking
writes to `MasterPty::take_writer()`. `StdinCmd::Bytes(bytes)` writes writes to `MasterPty::take_writer()`. `StdinCmd::Bytes(bytes)` writes
and flushes; `StdinCmd::Eof` drops the writer (sends EOF to the and flushes; `StdinCmd::Eof` drops the writer (sends EOF to the
slave's stdin) and exits. The async-facing `TtyHandle.stdin` is the slave's stdin) and exits. The async-facing `TtyHandle.stdin` is the
@@ -105,15 +105,24 @@ pub enum StdinCmd {
Unpin>` (an `AsyncWrite` impl that wraps each `write` as a Unpin>` (an `AsyncWrite` impl that wraps each `write` as a
`StdinCmd::Bytes` and `flush` as a no-op; the `mpsc::Sender` is the `StdinCmd::Bytes` and `flush` as a no-op; the `mpsc::Sender` is the
sink). sink).
3. **Waiter thread** — blocking `Child::wait()` → `oneshot::Sender<i32>` 3. **Waiter thread**: blocking wait → `oneshot::Sender<i32>` with the
with the exit code. The async-facing `TtyHandle.exit_code` is a exit code in ADR-004's encoding. On Unix the reap is
`Future` wrapping this `oneshot::Receiver<i32>` PLUS a kill guard `libc::waitpid(pid, ..)` on the direct child, decoded to
holding the `portable_pty::ChildKiller` (see "Cancel-Cleanup `WEXITSTATUS` (normal exit) or `-WTERMSIG` (signal-terminated exit,
(ADR-005)" below). This is the `Future` the adapter awaits (ADR-002 the ADR-004 negative encoding): portable-pty 0.9.0's `ExitStatus`
REQ-TTY-01; ADR-004); its `Drop`-on-cancel kills the child would flatten a signal-terminated child to `code = 1` with the signal
(ADR-005). only as a strsignal name string (review #004); reaping by pid avoids
the conversion entirely, and `std::process::Child`'s `Drop` neither
kills nor reaps, so the two paths never double-reap. `Child::wait()`
remains as the fallback (waitpid failure) and the non-Unix primary.
The async-facing `TtyHandle.exit_code` is a
`Future` wrapping this `oneshot::Receiver<i32>` PLUS a kill guard
holding the `portable_pty::ChildKiller` (see "Cancel-Cleanup
(ADR-005)" below). This is the `Future` the adapter awaits (ADR-002
REQ-TTY-01; ADR-004); its `Drop`-on-cancel kills the child
(ADR-005).
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr — kernel `TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr: a kernel
PTY property, one output stream from the slave). PTY property, one output stream from the slave).
`TtyHandle.control` is a `PtyControl` struct (the POC's concrete type; `TtyHandle.control` is a `PtyControl` struct (the POC's concrete type;
@@ -129,8 +138,8 @@ pub struct PtyControl {
} }
``` ```
`resize()` locks the master and calls `MasterPty::resize(PtySize)` — `resize()` locks the master and calls `MasterPty::resize(PtySize)`,
non-blocking (it issues an `ioctl`). `signal()` — see REQ-TTY-02 below. non-blocking (it issues an `ioctl`). `signal()` (see REQ-TTY-02 below).
### REQ-TTY-02: Signal Forwarding Must Target the Process Group ### REQ-TTY-02: Signal Forwarding Must Target the Process Group
@@ -142,10 +151,10 @@ the **foreground process group**, which (under job-control shells) is the
process group the shell most recently spawned for the foreground job. process group the shell most recently spawned for the foreground job.
`portable_pty` makes the child a session leader (when `portable_pty` makes the child a session leader (when
`controlling_tty = true`, the default — `CommandBuilder::set_controlling_tty(true)`), `controlling_tty = true`, the default, `CommandBuilder::set_controlling_tty(true)`),
so the child's pid *is* its process-group id, and `libc::kill(-pid, sig)` so the child's pid *is* its process-group id, and `libc::kill(-pid, sig)`
(the negative pid) reaches the whole group. The POC's `PtyControl::signal` (the negative pid) reaches the whole group. The POC's `PtyControl::signal`
uses exactly this — `kill(-pgid, sig)` with a fallback to `kill(pid, sig)` uses exactly this: `kill(-pgid, sig)` with a fallback to `kill(pid, sig)`
if the group signal fails (e.g., the child already exited). if the group signal fails (e.g., the child already exited).
The spec records: The spec records:
@@ -175,13 +184,13 @@ future kills the session target.** The local backend implements this for
both PTY and pipe modes. both PTY and pipe modes.
**PTY mode.** `allocate()` obtains a `portable_pty::Child` (with **PTY mode.** `allocate()` obtains a `portable_pty::Child` (with
`wait()`) and a `portable_pty::ChildKiller` (with `kill()`) — the two `wait()`) and a `portable_pty::ChildKiller` (with `kill()`); the two
handles `portable_pty` exposes alongside each other. The `Child` moves handles `portable_pty` exposes alongside each other. The `Child` moves
into the waiter thread (which blocks on `wait()`). The `ChildKiller` into the waiter thread (which blocks on `wait()`). The `ChildKiller`
moves into the `exit_code` future's `Drop` guard, alongside the moves into the `exit_code` future's `Drop` guard, alongside the
`oneshot::Receiver<i32>` from the waiter thread. The future's `poll` `oneshot::Receiver<i32>` from the waiter thread. The future's `poll`
delegates to the oneshot receiver (resolves on natural exit); the delegates to the oneshot receiver (resolves on natural exit); the
future's `Drop` (runs on cancel only — on resolve, the guard is future's `Drop` (runs on cancel only; on resolve, the guard is
disarmed) calls `ChildKiller::kill(SIGHUP)`: disarmed) calls `ChildKiller::kill(SIGHUP)`:
```rust ```rust
@@ -201,10 +210,12 @@ impl Drop for LocalExitFuture {
``` ```
On cancel: the `Drop` kills the child (SIGHUP); the child exits; the On cancel: the `Drop` kills the child (SIGHUP); the child exits; the
waiter thread's `wait()` reaps it and exits (its `oneshot::send` fails waiter thread reaps it (Unix: `libc::waitpid`; see "Waiter thread"
silently — the receiver was dropped with the future, which is expected); above) and exits (its `oneshot::send` fails silently: the receiver was
the reader/writer threads exit on channel close. The child is reaped dropped with the future, which is expected); the reader/writer threads
(no zombie) by the waiter thread's `wait()` returning after the kill. exit on channel close. The child is reaped (no zombie) after the kill;
its SIGHUP death resolves to `-1` (the ADR-004 sentinel collision) on
the no-longer-listened-to channel.
**Pipe mode.** The same pattern with `tokio::process::Child` instead of **Pipe mode.** The same pattern with `tokio::process::Child` instead of
`portable_pty::Child`. The `exit_code` future's `Drop` guard holds the `portable_pty::Child`. The `exit_code` future's `Drop` guard holds the
@@ -222,7 +233,7 @@ This closes the orphaned-process gap the local-PTY POC surfaced: a
child that ignores stdin EOF (a daemon, a long-lived process with no child that ignores stdin EOF (a daemon, a long-lived process with no
stdin reader) is killed when the session is cancelled, not left stdin reader) is killed when the session is cancelled, not left
running. The POC's `LocalPty::exit_code` was a bare running. The POC's `LocalPty::exit_code` was a bare
`oneshot::Receiver<i32>` with no kill guard — an implementer who `oneshot::Receiver<i32>` with no kill guard; an implementer who
copies the POC's shape without the guard violates the contract. See copies the POC's shape without the guard violates the contract. See
ADR-005 for the contract and the trait-level rationale. ADR-005 for the contract and the trait-level rationale.
@@ -230,24 +241,24 @@ ADR-005 for the contract and the trait-level rationale.
`allocate()` spawns the command with `tokio::process::Command` and `allocate()` spawns the command with `tokio::process::Command` and
`Stdio::piped()` for stdin, stdout, and stderr. The async bridge is `Stdio::piped()` for stdin, stdout, and stderr. The async bridge is
simpler than the PTY case — tokio's `Child` provides `AsyncRead` for simpler than the PTY case: tokio's `Child` provides `AsyncRead` for
stdout/stderr and `AsyncWrite` for stdin directly (no std-thread stdout/stderr and `AsyncWrite` for stdin directly (no std-thread
bridge needed). `TtyHandle.stderr` is `Some` (separate streams). The bridge needed). `TtyHandle.stderr` is `Some` (separate streams). The
`exit_code` future is `Child::wait()` (async on tokio's `Child`). `exit_code` future is `Child::wait()` (async on tokio's `Child`).
`TtyHandle.control` is a `PipeControl` whose `resize()` is a no-op `TtyHandle.control` is a `PipeControl` whose `resize()` is a no-op
(no PTY — resize doesn't apply) and `signal()` calls `libc::kill(pid, sig)` (no PTY, so resize doesn't apply) and `signal()` calls `libc::kill(pid, sig)`
on the child's pid. Signal forwarding to the process group is not on the child's pid. Signal forwarding to the process group is not
applicable in pipe mode (there's no session leader / controlling tty); applicable in pipe mode (there's no session leader / controlling tty);
`kill(pid, sig)` reaches the direct child only. If the child has `kill(pid, sig)` reaches the direct child only. If the child has
spawned its own children, they won't receive the signal — this is a spawned its own children, they won't receive the signal; this is a
known limitation of the runner case (a runner that needs known limitation of the runner case (a runner that needs
process-group signal delivery uses the PTY case, not the pipe case). process-group signal delivery uses the PTY case, not the pipe case).
### The Threading/Deadlock Caveat (DP-4, Acknowledged Constraint) ### The Threading/Deadlock Caveat (DP-4, Acknowledged Constraint)
`std::process::Command` with piped stdio can deadlock if stdin writes `std::process::Command` with piped stdio can deadlock if stdin writes
block while stdout/stderr buffers fill — the classic pipe-buffer deadlock. block while stdout/stderr buffers fill (the classic pipe-buffer deadlock).
The fix is concurrent reads on stdout/stderr alongside stdin writes, The fix is concurrent reads on stdout/stderr alongside stdin writes,
which is exactly what the bidirectional pump does (the POC's which is exactly what the bidirectional pump does (the POC's
`drive_attach_raw` runs the two directions as concurrent `drive_attach_raw` runs the two directions as concurrent
@@ -271,13 +282,13 @@ local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-
A consumer that wants the local backend enables `features = ["local"]` A consumer that wants the local backend enables `features = ["local"]`
and gets `alktty::local::LocalTtyBackend`. A consumer that only wants and gets `alktty::local::LocalTtyBackend`. A consumer that only wants
docker/ssh uses the default features and depends on the backend crate docker/ssh uses the default features and depends on the backend crate
directly — no `portable_pty` in the dependency tree. See ADR-003. directly, with no `portable_pty` in the dependency tree. See ADR-003.
The single-crate consolidation resolves the alknet cyclic-dep The single-crate consolidation resolves the alknet cyclic-dep
workaround that motivated the original sibling-crate decision workaround that motivated the original sibling-crate decision
(alknet ADR-054): `alknet-tty-local` depended on `alknet-tty` for the (alknet ADR-054): `alknet-tty-local` depended on `alknet-tty` for the
trait, and ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` trait, and ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
behind a `local` feature — which cargo rejects (a crate cannot behind a `local` feature, which cargo rejects (a crate cannot
re-export from a sibling crate it depends on via an optional dep AND re-export from a sibling crate it depends on via an optional dep AND
have that sibling depend back on it). The workaround in the alknet have that sibling depend back on it). The workaround in the alknet
mono-repo was the assembly-layer pattern (consumer depends on both mono-repo was the assembly-layer pattern (consumer depends on both
@@ -291,16 +302,16 @@ both the original alknet decision and the alktty consolidation.
alktty (local feature) alktty (local feature)
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types) ├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alkcall::core (via alktty's re-export; not direct) ├── alkcall::core (via alktty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, Unix openpty + Windows ConPTY) ├── portable_pty (PTY allocation; the heavy dep, Unix openpty + Windows ConPTY)
├── libc (signal forwarding — REQ-TTY-02, Unix only) ├── libc (signal forwarding: REQ-TTY-02, Unix only)
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite) └── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
``` ```
The `local` feature is inherently non-wasm (`portable-pty` + The `local` feature is inherently non-wasm (`portable-pty` +
`tokio::process` need a real OS); enabling `local` on `tokio::process` need a real OS); enabling `local` on
`wasm32-unknown-unknown` is a build error by design. The default crate `wasm32-unknown-unknown` is a build error by design. The default crate
(no features) stays wasm-clean — see the crate root's `# WASM target` (no features) stays wasm-clean (see the crate root's `# WASM target`
doc comment. doc comment).
## The Runner Pattern ## The Runner Pattern
@@ -310,12 +321,12 @@ research identified. A coordinator sends a negotiation frame with
endpoint runs `cargo test` with piped stdio, streams stdout/stderr chunks endpoint runs `cargo test` with piped stdio, streams stdout/stderr chunks
back, sends `{"type":"exit","code":N}` when it finishes (ADR-004). The back, sends `{"type":"exit","code":N}` when it finishes (ADR-004). The
coordinator gets reliable completion notification (the exit control coordinator gets reliable completion notification (the exit control
chunk + stream close) — no polling, no plugin state. chunk + stream close): no polling, no plugin state.
This is functionally identical to GitHub/Gitea Actions runners, just over This is functionally identical to GitHub/Gitea Actions runners, just over
alk's transport instead of HTTP polling. The dispatch project is a alk's transport instead of HTTP polling. The dispatch project is a
reverse runner that currently requires SSH on the remote end; with reverse runner that currently requires SSH on the remote end; with
`LocalTtyBackend`, the same pattern works without SSH — the endpoint `LocalTtyBackend`, the same pattern works without SSH: the endpoint
runs the process directly. SSH becomes one transport option (for runs the process directly. SSH becomes one transport option (for
reaching hosts that don't run alk), not a requirement. reaching hosts that don't run alk), not a requirement.
@@ -343,18 +354,18 @@ building runner policy into alktty.
A runner that needs process-group signal delivery uses the PTY case. A runner that needs process-group signal delivery uses the PTY case.
- **The pipe-buffer deadlock is handled by the concurrent pump.** The - **The pipe-buffer deadlock is handled by the concurrent pump.** The
adapter's three-pump driver (`tty-adapter.md`) reads stdout/stderr adapter's three-pump driver (`tty-adapter.md`) reads stdout/stderr
concurrently with writing stdin — the POC-validated pattern. No design concurrently with writing stdin (the POC-validated pattern). No design
decision needed; the spec notes it as a known constraint with a known decision needed; the spec notes it as a known constraint with a known
solution. solution.
- **`LocalTtyBackend` takes no constructor dependencies.** Unlike - **`LocalTtyBackend` takes no constructor dependencies.** Unlike
`DockerTtyBackend` (wraps a `bollard::Docker` client) or `DockerTtyBackend` (wraps a `bollard::Docker` client) or
`SshTtyBackend` (wraps an SSH session), the local backend is `SshTtyBackend` (wraps an SSH session), the local backend is
dependency-free at construction — the `portable_pty` system is dependency-free at construction (the `portable_pty` system is
process-global. The assembly layer constructs one `LocalTtyBackend` process-global). The assembly layer constructs one `LocalTtyBackend`
and registers it as `"local"`. and registers it as `"local"`.
- **The `exit_code` future's `Drop`-on-cancel kills the child - **The `exit_code` future's `Drop`-on-cancel kills the child
(ADR-005).** The local backend MUST NOT return a bare (ADR-005).** The local backend MUST NOT return a bare
`oneshot::Receiver<i32>` as `TtyHandle.exit_code` — it must wrap it `oneshot::Receiver<i32>` as `TtyHandle.exit_code`; it must wrap it
in a `Future` whose `Drop` calls `ChildKiller::kill(SIGHUP)` (PTY) or in a `Future` whose `Drop` calls `ChildKiller::kill(SIGHUP)` (PTY) or
`Child::start_kill()` (pipe) when dropped without resolving. An `Child::start_kill()` (pipe) when dropped without resolving. An
implementer who copies the POC's bare `oneshot::Receiver<i32>` shape implementer who copies the POC's bare `oneshot::Receiver<i32>` shape
@@ -377,19 +388,19 @@ building runner policy into alktty.
## References ## References
- [ADR-003](decisions/003-local-backend-placement.md) — the module - [ADR-003](decisions/003-local-backend-placement.md): the module
placement decision (single-crate consolidation) placement decision (single-crate consolidation)
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the - [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the
trait this backend implements; REQ-TTY-01 (the blocking-backend trait this backend implements; REQ-TTY-01 (the blocking-backend
accommodation) accommodation)
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the - [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — - [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md):
the cancel-cleanup contract this backend implements (the `exit_code` the cancel-cleanup contract this backend implements (the `exit_code`
future's `Drop`-on-cancel kills the child via `ChildKiller` / future's `Drop`-on-cancel kills the child via `ChildKiller` /
`start_kill`) `start_kill`)
- `src/local/` — the Rust source this spec documents (`backend.rs`, - `src/local/`: the Rust source this spec documents (`backend.rs`,
`pty.rs`, `pipe.rs`) `pty.rs`, `pipe.rs`)
- [tty-backend.md](tty-backend.md) — the trait this backend implements - [tty-backend.md](tty-backend.md): the trait this backend implements
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes - [tty-adapter.md](tty-adapter.md): the session driver that consumes
this backend's handles this backend's handles
+57 -53
View File
@@ -5,11 +5,11 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
last_updated: 2026-08-17 last_updated: 2026-08-17
--- ---
# alktty — Wire Format # alktty: Wire Format
The wire protocol for `alk/tty`: the negotiation frame (JSON carriage), The wire protocol for `alk/tty`: the negotiation frame (JSON carriage),
the raw chunk codec, the control channel (split into `STREAM_CTRL_IN` / the raw chunk codec, the control channel (split into `STREAM_CTRL_IN` /
`STREAM_CTRL_OUT` halves — Phase 7), and the sentinels. The `STREAM_CTRL_OUT` halves in Phase 7), and the sentinels. The
two-carriage model is decided in [ADR-001](decisions/001-wire-format-and-two-carriage.md); two-carriage model is decided in [ADR-001](decisions/001-wire-format-and-two-carriage.md);
this document specifies what an implementer builds. this document specifies what an implementer builds.
@@ -39,8 +39,8 @@ channel set (five stream types, no negotiation) is an impoverishment of
SSH's channel multiplexer that is the feature: alktty multiplexes *one* SSH's channel multiplexer that is the feature: alktty multiplexes *one*
service (a terminal session) with a fixed channel structure, not service (a terminal session) with a fixed channel structure, not
*arbitrary* services, so the demux is a `match`, not a hash lookup. The *arbitrary* services, so the demux is a `match`, not a hash lookup. The
full rationale — why not JSON for everything, why fixed channel set full rationale (why not JSON for everything, why a fixed channel set
rather than extensible — is in rather than an extensible one) is in
[ADR-001](decisions/001-wire-format-and-two-carriage.md) §Context. [ADR-001](decisions/001-wire-format-and-two-carriage.md) §Context.
## Architecture ## Architecture
@@ -49,11 +49,11 @@ rather than extensible — is in
The client opens a bidi stream (or the server accepts one) and writes a The client opens a bidi stream (or the server accepts one) and writes a
single length-prefixed JSON frame. The framing is a 4-byte big-endian single length-prefixed JSON frame. The framing is a 4-byte big-endian
length prefix + UTF-8 JSON body — a self-contained ~30-line module in length prefix + UTF-8 JSON body, a self-contained ~30-line module in
alktty (read 4-byte length, bounds-check, read N bytes; write the alktty (read 4-byte length, bounds-check, read N bytes; write the
inverse) on tokio's `AsyncRead`/`AsyncWrite`. The format coincides with inverse) on tokio's `AsyncRead`/`AsyncWrite`. The format coincides with
alkcall's `EventEnvelope` framing by convention (both are alkcall's `EventEnvelope` framing by convention (both are
length-prefixed JSON), not by code reuse — alktty does not depend on length-prefixed JSON), not by code reuse; alktty does not depend on
alkcall's internal wire types. The negotiation payload is a tty-specific alkcall's internal wire types. The negotiation payload is a tty-specific
struct (`NegotiateRequest`), not a `call.requested` event. See ADR-001 struct (`NegotiateRequest`), not a `call.requested` event. See ADR-001
§6 and [ADR-006](decisions/006-negotiation-framing-self-contained.md). §6 and [ADR-006](decisions/006-negotiation-framing-self-contained.md).
@@ -80,7 +80,7 @@ The payload shape:
Fields: Fields:
- `carriage` — `"raw"` for terminal sessions (the only carriage in v1). - `carriage`: `"raw"` for terminal sessions (the only carriage in v1).
Selects the post-negotiation byte format. MUST be `"raw"` in v1; any Selects the post-negotiation byte format. MUST be `"raw"` in v1; any
other value (e.g., `"json"`, an unknown carriage, or the field other value (e.g., `"json"`, an unknown carriage, or the field
absent) is a `malformed_negotiation` error and the adapter closes the absent) is a `malformed_negotiation` error and the adapter closes the
@@ -88,19 +88,19 @@ Fields:
structured JSON-only mode for a non-terminal use case) is a v2 structured JSON-only mode for a non-terminal use case) is a v2
addition; in v1 the field is required and must be the literal addition; in v1 the field is required and must be the literal
`"raw"`. `"raw"`.
- `backend` — the backend selector string (`"local"`, `"docker"`, - `backend`: the backend selector string (`"local"`, `"docker"`,
`"ssh"`). The adapter dispatches to the registered `TtyBackend` by this `"ssh"`). The adapter dispatches to the registered `TtyBackend` by this
key (ADR-002 §5). key (ADR-002 §5).
- `tty` — terminal parameters. `null` for the pipe/runner case (no PTY — - `tty`: terminal parameters. `null` for the pipe/runner case (no PTY;
[ADR-003](decisions/003-local-backend-placement.md)). `Some` for the PTY case. The `tty` block maps directly to [ADR-003](decisions/003-local-backend-placement.md)). `Some` for the PTY case. The `tty` block maps directly to
SSH's `pty_request` parameters (term, cols, rows, pixel_width, SSH's `pty_request` parameters (term, cols, rows, pixel_width,
pixel_height, modes) and to docker's `CreateExecOptions { tty: true }`; pixel_height, modes) and to docker's `CreateExecOptions { tty: true }`;
a local backend passes it to `portable_pty::PtySystem::openpty`. The a local backend passes it to `portable_pty::PtySystem::openpty`. The
`modes` field is reserved (OQ-44 — default terminal modes suffice for `modes` field is reserved (OQ-44: default terminal modes suffice for
the current scope). the current scope).
- `cmd` — command vector (argv[0] + args). Non-empty. - `cmd`: command vector (argv[0] + args). Non-empty.
- `cwd` — working directory (`null` = inherit/default). - `cwd`: working directory (`null` = inherit/default).
- `env` — environment variables (empty = inherit). - `env`: environment variables (empty = inherit).
The Rust struct the adapter parses the frame into: The Rust struct the adapter parses the frame into:
@@ -130,7 +130,7 @@ pub struct TerminalParamsWire {
#[serde(default)] #[serde(default)]
pub pixel_height: u16, pub pixel_height: u16,
#[serde(default)] #[serde(default)]
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1
} }
``` ```
@@ -149,11 +149,11 @@ Backend-specific selector fields ride alongside (e.g., `"container":
"abc123"` for docker). The adapter parses the negotiation frame, "abc123"` for docker). The adapter parses the negotiation frame,
extracts the `backend` string, and passes the remaining backend-specific extracts the `backend` string, and passes the remaining backend-specific
fields to the selected backend's `allocate()` as an opaque fields to the selected backend's `allocate()` as an opaque
`serde_json::Map` (ADR-002) — the adapter does not interpret them; the `serde_json::Map` (ADR-002). The adapter does not interpret them; the
backend deserializes its own strongly-typed params struct. backend deserializes its own strongly-typed params struct.
After the negotiation frame, the stream switches to raw chunks. There is After the negotiation frame, the stream switches to raw chunks. There is
no `call.responded`/`call.completed` — this is not the call protocol. no `call.responded`/`call.completed`: this is not the call protocol.
### Phase 2: Raw Chunk Format ### Phase 2: Raw Chunk Format
@@ -161,7 +161,7 @@ no `call.responded`/`call.completed` — this is not the call protocol.
[stream_type: u8][length: u32 be][payload bytes] [stream_type: u8][length: u32 be][payload bytes]
``` ```
- **`stream_type`** (1 byte) — the channel: - **`stream_type`** (1 byte): the channel:
| stream_type | channel | direction | payload | | stream_type | channel | direction | payload |
|-------------|-------------|----------------|---------------------| |-------------|-------------|----------------|---------------------|
@@ -172,7 +172,7 @@ no `call.responded`/`call.completed` — this is not the call protocol.
| 4 | ctrl-out | server→client | JSON control message (`Exit`) | | 4 | ctrl-out | server→client | JSON control message (`Exit`) |
`stream_type > 4` is a protocol error (`InvalidStreamType`). There is `stream_type > 4` is a protocol error (`InvalidStreamType`). There is
no extension escape hatch in the byte — a 6th channel is a wire-format no extension escape hatch in the byte: a 6th channel is a wire-format
change requiring a new ALPN (`alk/tty/v2` per alknet ADR-006), not a change requiring a new ALPN (`alk/tty/v2` per alknet ADR-006), not a
negotiated addition to this format. See ADR-001 §"Fixed channel set, negotiated addition to this format. See ADR-001 §"Fixed channel set,
not extensible." not extensible."
@@ -190,11 +190,11 @@ no `call.responded`/`call.completed` — this is not the call protocol.
violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is
likewise a protocol violation and is ignored). likewise a protocol violation and is ignored).
- **`length`** (4 bytes, big-endian) — payload length in bytes. Max - **`length`** (4 bytes, big-endian): payload length in bytes. Max
16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB 16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB
is a protocol error (`ChunkTooLarge`). is a protocol error (`ChunkTooLarge`).
- **`payload`** (`length` bytes) — the raw bytes (for data channels) or - **`payload`** (`length` bytes): the raw bytes (for data channels) or
UTF-8 JSON (for the control channel). UTF-8 JSON (for the control channel).
The codec is `ChunkReader`/`ChunkWriter` in `src/wire.rs`: The codec is `ChunkReader`/`ChunkWriter` in `src/wire.rs`:
@@ -206,15 +206,15 @@ writes the header and payload. See ADR-001.
Zero-length data chunks are sentinels: Zero-length data chunks are sentinels:
- **Zero-length stdin chunk (stream_type 0, length 0)** — EOF from the - **Zero-length stdin chunk (stream_type 0, length 0)**: EOF from the
client. The server closes the backend's stdin (`ChildStdin::drop` / client. The server closes the backend's stdin (`ChildStdin::drop` /
PTY writer close). This is one of two canonical "stdin done" signals; PTY writer close). This is one of two canonical "stdin done" signals;
the other is a `{"type":"eof"}` control chunk — see OQ-47. the other is a `{"type":"eof"}` control chunk (see OQ-47).
- **Zero-length stdout chunk (stream_type 1, length 0)** — "drained" - **Zero-length stdout chunk (stream_type 1, length 0)**: "drained"
from the server. The backend's stdout stream ended (process exited, from the server. The backend's stdout stream ended (process exited,
container output stream ended, SSH channel closed). This is an container output stream ended, SSH channel closed). This is an
implementation sentinel; the deterministic completion signal is the implementation sentinel; the deterministic completion signal is the
exit control chunk ([ADR-004](decisions/004-exit-code-on-control-chunk.md)), not this sentinel — but the drained exit control chunk ([ADR-004](decisions/004-exit-code-on-control-chunk.md)), not this sentinel, but the drained
sentinel is emitted for symmetry with the docker POC's pattern. sentinel is emitted for symmetry with the docker POC's pattern.
Control chunks are never zero-length (the JSON payload is at least Control chunks are never zero-length (the JSON payload is at least
@@ -224,8 +224,8 @@ Control chunks are never zero-length (the JSON payload is at least
The control channel is split into two halves (Phase 7): The control channel is split into two halves (Phase 7):
- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control. - **`STREAM_CTRL_IN` (stream_type 3)**: client→server control.
- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control. - **`STREAM_CTRL_OUT` (stream_type 4)**: server→client control.
Each half carries JSON payloads tagged by `type`. The schema is the Each half carries JSON payloads tagged by `type`. The schema is the
`ControlMessage` enum (`src/control.rs`): `ControlMessage` enum (`src/control.rs`):
@@ -259,7 +259,7 @@ The adapter enforces the direction: an `Exit` arriving on
`STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a `STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a
`Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a
protocol violation (the adapter ignores it). The split makes the protocol violation (the adapter ignores it). The split makes the
control channel genuinely bidirectional on the wire — the previous control channel genuinely bidirectional on the wire: the previous
single `STREAM_CONTROL = 3` was documented as "bidirectional" but the single `STREAM_CONTROL = 3` was documented as "bidirectional" but the
adapter had to ignore `Exit` from the client because the two directions adapter had to ignore `Exit` from the client because the two directions
were indistinguishable on the same stream_type. were indistinguishable on the same stream_type.
@@ -267,19 +267,23 @@ were indistinguishable on the same stream_type.
**Signal names.** `name` is an uppercase string. The supported set (per **Signal names.** `name` is an uppercase string. The supported set (per
`signal_from_name` in `src/control.rs`): `HUP`, `INT`, `QUIT`, `TERM`, `signal_from_name` in `src/control.rs`): `HUP`, `INT`, `QUIT`, `TERM`,
`KILL`, `USR1`, `USR2`, `TSTP`, `CONT`. Unknown names fall back to the `KILL`, `USR1`, `USR2`, `TSTP`, `CONT`. Unknown names fall back to the
backend's default kill (see [tty-local.md](tty-local.md) REQ-TTY-02 — backend's default kill (see [tty-local.md](tty-local.md) REQ-TTY-02:
`portable_pty`'s `ChildKiller::kill` sends SIGHUP). `portable_pty`'s `ChildKiller::kill` sends SIGHUP).
**Exit code.** `code` is `i32` (matches `std::process::ExitStatus::code()`; **Exit code.** `code` is `i32`: `ExitStatus::code()` on normal exit; on
negative values are signal-terminated, e.g., -9 for SIGKILL on Unix). The Unix, the negative signal number on signal-terminated exit; this is the
exit chunk is the last control chunk before stream close (ADR-004). crate's own convention, not `ExitStatus::code()`'s, which returns `None`
for signal termination. `-1` is the adapter's "could not determine" sentinel
and (via the `-N` encoding) the SIGHUP value; the SIGHUP/sentinel
collision is a documented edge case (ADR-004). The exit chunk is the
last control chunk before stream close (ADR-004).
**Extensibility.** The `type` tag is the extension seam: new control **Extensibility.** The `type` tag is the extension seam: new control
message types are added by extending the tagged enum. Unknown `type` message types are added by extending the tagged enum. Unknown `type`
values are **ignored** (not a protocol error) so that a newer client values are **ignored** (not a protocol error) so that a newer client
sending a control message an older server doesn't recognize degrades sending a control message an older server doesn't recognize degrades
gracefully rather than tearing down the session. This is a two-way-door gracefully rather than tearing down the session. This is a two-way-door
extension point within the one-way-door wire format (ADR-001) — adding a extension point within the one-way-door wire format (ADR-001): adding a
control message type is additive; changing the meaning of an existing control message type is additive; changing the meaning of an existing
type is not. type is not.
@@ -288,12 +292,12 @@ type is not.
Two signals both close the client's stdin: Two signals both close the client's stdin:
1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`) 1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`)
— explicit, recommended. Tells the server to close the backend's is explicit and recommended. Tells the server to close the backend's
stdin (`ChildStdin::drop` / PTY writer close). The client may still stdin (`ChildStdin::drop` / PTY writer close). The client may still
want to receive remaining stdout + the exit code, so the server does want to receive remaining stdout + the exit code, so the server does
not tear down the session on eof — it just closes stdin and keeps not tear down the session on eof; it just closes stdin and keeps
pumping output. pumping output.
2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker 2. **Zero-length stdin chunk** (stream_type 0, length 0), the docker
POC's sentinel. Accepted for compatibility with that pattern. POC's sentinel. Accepted for compatibility with that pattern.
The spec recommends `eof` for explicitness (it's a control message, not The spec recommends `eof` for explicitness (it's a control message, not
@@ -302,14 +306,14 @@ a data-length hack), but both are accepted. See OQ-47.
### Connection vs Stream ### Connection vs Stream
A `Connection` (alknet ADR-007) can open/accept multiple bidi streams. One A `Connection` (alknet ADR-007) can open/accept multiple bidi streams. One
`alk/tty` connection hosts multiple terminal sessions — one session per `alk/tty` connection hosts multiple terminal sessions, one session per
bidi stream (DP-6, decided in the alknet research). This matches the call bidi stream (DP-6, decided in the alknet research). This matches the call
protocol's model (one operation per stream, multiple operations per protocol's model (one operation per stream, multiple operations per
connection) and is the natural fit for QUIC's stream multiplexing. A connection) and is the natural fit for QUIC's stream multiplexing. A
coordinator opens one connection to an endpoint and launches multiple coordinator opens one connection to an endpoint and launches multiple
sessions (one stream each) for parallel tasks. The `TtyAdapter::handle` sessions (one stream each) for parallel tasks. The `TtyAdapter::handle`
accepts the connection and loops `accept_bi`, dispatching each stream to accepts the connection and loops `accept_bi`, dispatching each stream to
a session — see [tty-adapter.md](tty-adapter.md). a session (see [tty-adapter.md](tty-adapter.md)).
## Constraints ## Constraints
@@ -322,13 +326,13 @@ a session — see [tty-adapter.md](tty-adapter.md).
`STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces `STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces
the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on
`STREAM_CTRL_OUT` is ignored. The split is what makes the control `STREAM_CTRL_OUT` is ignored. The split is what makes the control
channel genuinely bidirectional on the wire — the previous single channel genuinely bidirectional on the wire: the previous single
`STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
had to ignore `Exit` from the client because the two directions were had to ignore `Exit` from the client because the two directions were
indistinguishable on the same stream_type. indistinguishable on the same stream_type.
- **No windowing.** The chunk format has no flow-control window; QUIC's - **No windowing.** The chunk format has no flow-control window; QUIC's
per-stream flow control is the backpressure mechanism (OQ-45 resolved: per-stream flow control is the backpressure mechanism (OQ-45 resolved:
the backpressure chain is complete by construction — QUIC flow control the backpressure chain is complete by construction; QUIC flow control
→ bounded drainer channel → bounded stdout channel → OS pipe/PTY → bounded drainer channel → bounded stdout channel → OS pipe/PTY
buffer → process `write()` blocks; no unbounded buffer breaks the buffer → process `write()` blocks; no unbounded buffer breaks the
chain). The reversal path, if ever needed, is an additive chain). The reversal path, if ever needed, is an additive
@@ -337,16 +341,16 @@ a session — see [tty-adapter.md](tty-adapter.md).
- **No negotiation round-trip.** The client writes the negotiation frame - **No negotiation round-trip.** The client writes the negotiation frame
and starts sending chunks; the server reads the frame and starts and starts sending chunks; the server reads the frame and starts
pumping. There is no "the server acknowledges the negotiation before pumping. There is no "the server acknowledges the negotiation before
the client sends data" step — QUIC's stream reliability handles the client sends data" step, as QUIC's stream reliability handles
in-order delivery, and the negotiation frame is small (fits in the in-order delivery, and the negotiation frame is small (fits in the
initial flow-control window — ADR-001 assumption 2). initial flow-control window; ADR-001 assumption 2).
- **Negotiation errors are JSON, not chunks.** If the server cannot - **Negotiation errors are JSON, not chunks.** If the server cannot
allocate the session (unknown backend, PTY allocation failed, the allocate the session (unknown backend, PTY allocation failed, the
command is invalid), it sends a JSON error response in the same command is invalid), it sends a JSON error response in the same
length-prefixed framing as the negotiation frame and closes the stream length-prefixed framing as the negotiation frame and closes the stream
without entering raw mode. The error response MUST be under 16 MiB without entering raw mode. The error response MUST be under 16 MiB
(`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte (`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte
is `0x00` — this is what makes the framing-disambiguation trick is `0x00`; this is what makes the framing-disambiguation trick
(first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk; (first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk;
the server never sends `0` (stdin, client→server) or `3` the server never sends `0` (stdin, client→server) or `3`
(`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it (`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it
@@ -366,34 +370,34 @@ a session — see [tty-adapter.md](tty-adapter.md).
## Open Questions ## Open Questions
- **OQ-44** (deferred(scope)): Terminal modes. - **OQ-44** (deferred(scope)): Terminal modes.
- **OQ-45** (resolved): Flow control for high-throughput stdout — no - **OQ-45** (resolved): Flow control for high-throughput stdout (no
application-level windowing; QUIC per-stream flow control is the application-level windowing; QUIC per-stream flow control is the
backpressure mechanism. backpressure mechanism).
- **OQ-47** (resolved): Stdin closure canonical signal. - **OQ-47** (resolved): Stdin closure canonical signal.
## References ## References
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire - [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire
format decision format decision
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the - [ADR-004](decisions/004-exit-code-on-control-chunk.md): the
exit-chunk ordering the control channel carries exit-chunk ordering the control channel carries
- [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the - [ADR-006](decisions/006-negotiation-framing-self-contained.md): the
dependency-edge decision (negotiation framing is self-contained in dependency-edge decision (negotiation framing is self-contained in
alktty) alktty)
- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call - alknet ADR-003 Amendment 2: alktty does not depend on alknet-call
(self-contained framing); see the alknet originals at (self-contained framing); see the alknet originals at
`/workspace/@alkdev/alknet/docs/architecture/decisions/` `/workspace/@alkdev/alknet/docs/architecture/decisions/`
- `src/wire.rs` — the chunk codec (`ChunkReader`/`ChunkWriter`, - `src/wire.rs`: the chunk codec (`ChunkReader`/`ChunkWriter`,
stream_type 0-4) this spec documents stream_type 0-4) this spec documents
- `src/control.rs` — the JSON control schema (`ControlMessage` tagged - `src/control.rs`: the JSON control schema (`ControlMessage` tagged
enum) this spec documents enum) this spec documents
- [tty-bast.md](tty-bast.md) — the BAST (Binary Abstract Syntax Tree) - [tty-bast.md](tty-bast.md): the BAST (Binary Abstract Syntax Tree)
document for the binary framing layer of this wire format (the document for the binary framing layer of this wire format (the
5-byte chunk header and the negotiation frame's 4-byte length 5-byte chunk header and the negotiation frame's 4-byte length
prefix); a normative JSON spec downstream consumers can validate prefix); a normative JSON spec downstream consumers can validate
against. The JSON payloads (`NegotiateRequest`, `ControlMessage`, against. The JSON payloads (`NegotiateRequest`, `ControlMessage`,
`TerminalParams`) are specified in this document and the Rust `TerminalParams`) are specified in this document and the Rust
source, not in the BAST — BAST describes binary layouts, not JSON source, not in the BAST; BAST describes binary layouts, not JSON
shapes shapes
- [tty-adapter.md](tty-adapter.md) — the session lifecycle that consumes - [tty-adapter.md](tty-adapter.md): the session lifecycle that consumes
this wire format this wire format
+350
View File
@@ -0,0 +1,350 @@
# Fuzzing alktty: Plan
**Status:** adopted; implementation in progress (see §8 progress log)
**Date:** 2026-09-28
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
runner, and no-CI policy below are adopted from it wholesale rather than
re-derived).
---
## 1. Decision: fuzz this crate
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5
"Non-surfaces" explicitly defers the downstream parsers to "downstream
crates' fuzz targets": the channels layer carries opaque `Bytes`, and the
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it
owns four parse surfaces fed by arbitrary peers on both transports (direct
ALPN and channels):
| # | Surface | Location | Shape |
|---|---|---|---|
| 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` |
| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199`; the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) |
| 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct |
| 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
Reasons this pays off here, beyond the generic alkcall argument:
1. **Semantic invariants no example test explores adversarially.** The
peek/disambiguation machinery (`peek_stream_type` vs
`read_chunk_after_peek`; both call orders must yield exactly one
chunk) and the cross-codec invariant that makes it sound: an
error-response frame's length-prefix high byte must be `0x00` so a
`ChunkReader` peek distinguishes it from a chunk whose first byte is a
server stream type ∈ {1, 2, 4} (`wire.rs:189-213`, ADR-052 §5). If
`MAX_CHUNK_LEN` ever changes or a stream type is added, that invariant
breaks subtly. A fuzz target spanning both codecs is the cheapest
enforcement that exists.
2. **The alkcall bug class has a plausible analog here.** The duplicate
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
unit test replays destroyed the live channel. Alktty's adapter has the
same shape: take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
session re-registration, handle replacement. A stateful op-sequence
target against the session pump + mock backend is where a real finding
would live.
3. **Cost is low and the playbook is proven.** Mirroring alkcall's
as-built layout is a port, not a design exercise (§5 below).
Zero parser-bug yield is a successful negative result: it converts "we
think the codecs are robust" into a demonstrated property (the quinn
lesson cuts both ways).
**Scope excludes `src/local/`:** PTY/pipe output is local-OS-shaped, not
wire-attacker-shaped. The wire-facing side is entirely the
shared/producer/consumer modules (`wire`, `negotiation`, `control`,
`backend`, `adapter`, `channels`, `session`).
**The wasm invariant is unaffected:** `fuzz/` is host-only (nightly
toolchain, `path` dep on the crate), excluded from publishing, and never
compiled for `wasm32-unknown-unknown`.
## 2. Toolchain decision (adopted from alkcall §4/§7.7)
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
`[workspace]` table (nightly-only bins).
- **The invariant logic lives in `fuzz/shared/`, a stable-toolchain
library crate**, not inline in the target binaries. The `fuzz_target!`
binaries are thin wrappers. This makes corpus replay a plain
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
replays every committed seed through the identical invariant functions
the fuzzer runs, and joins the release verification checklist.
- `fuzz/rust-toolchain.toml` pins `nightly` for the subtree only; the
crate stays stable at MSRV 1.88.
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
deterministic seed generator script committed next to them.
- **No hosted CI**: this repo has the same no-CI policy as alkcall
(§7.9 there), where the gitea host serves git only; verification and
campaigns are manual. Corpus replay is the standing fuzz gate;
campaigns run via the detached runner before releases and after
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
adapter, or the session pump. Do not add workflow files or "when CI
exists" language.
- AFL/bolero/libafl/OSS-Fuzz: not adopted, same reasoning as alkcall §4.
## 3. Operational isolation: campaigns must be detached from the agent session
**This is mandatory, not a nice-to-have.** Agent sessions (opencode) run
commands through a bash tool that spawns processes as children of the
session's own process tree and cgroup. A fuzz campaign is exactly the
workload shape that can kill its own host:
- **OOM-killer shared fate.** The target's worst case *is* unbounded
allocation. libFuzzer's RSS guard is a *soft* poll; a fast single huge
allocation can beat it, and then the kernel OOM killer fires. It picks
the largest-RSS process in the cgroup, which can be the opencode server
hosting the session. Losing that server is recoverable (sessions are
recorded in a db) but hugely derailing and entirely avoidable. An OOM
in a fuzz target must cost the fuzzer, never the session.
- **Fork bombs / CPU saturation:** `-fork=N` workers and unbounded task
spawning in a buggy target can starve the session host.
- **Deadlock hangs:** a wedged target must not hang the session's bash
call.
Three layers, all standard:
1. **libFuzzer soft limits (always on):** `-rss_limit_mb=2048`
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
artifact) before the kernel gets involved. Do **not** use `ulimit -v`
with ASAN (the sanitizer reserves terabytes of virtual address space;
the classic failure is instant `MmapAlloc` death). The ASAN-safe
equivalent is `malloc_limit_mb`.
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs
each input in a short-lived child; a crash/timeout/OOM/leak kills only
that child, the parent records the artifact and keeps going. Also
turns deadlocks into per-input timeouts instead of hung sessions.
3. **Process detachment (mandatory for agent-run campaigns):** never run
fuzzing as a foreground child of the session. `fuzz/run-detached.sh`
wraps `cargo fuzz run` in `setsid` + `nohup` + redirection to a log
file: removed from the session's controlling terminal and signal
relation, survives the session ending, and gives a pollable log
instead of a blocking call.
Polling protocol (never `wait` on the detached process):
```bash
tail -n 50 fuzz/artifacts/<target>-*.log # campaign progress
ls fuzz/artifacts/ # crash-* / oom-* / timeout-* artifacts
pgrep -f "cargo fuzz run <target>" # still running?
```
One session holds one detached campaign per target; log filenames carry
UTC timestamps. `FUZZ_RUNTIME_SECS` overrides the per-campaign budget
(default 600 s).
## 4. Targets
In priority order. Targets 1–3 are the initial scope; target 4 is second
wave, decided after campaigns on 1–3 come back clean (same sequencing as
alkcall §7.4).
| # | Target | Input style | Drives | Invariants |
|---|---|---|---|---|
| 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers |
| 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) |
| 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) |
| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)**: after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)**: at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound |
### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
The consumer disambiguates an error-response frame from a raw chunk by
peeking one byte (`wire.rs:189-213`): server stream types are {1, 2, 4};
a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
neither. Encoded as fuzz assertions:
- For every `error_response_bytes` output and every negotiation frame of
length < `MAX_CHUNK_LEN`, the first byte is `0x00`, so a `ChunkReader`
peek on `[frame bytes…]` never mistakes it for a chunk.
- **Known boundary collision, encoded deliberately:** a frame of length
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]`:
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
inherent edge of the one-byte-peek design; producers only ever send
tiny error frames, so it is accepted, but the assertion pins the
boundary so any future `MAX_CHUNK_LEN` change that widens the
collision gets caught. (`validate_header`'s doc comment at
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
claim holds only for `<` the cap. Worth a one-word fix in the doc
comment if we touch `wire.rs` for any other reason.)
Also encoded (mirroring alkcall §7.7's trailing-byte probe): a valid
negotiation frame with one extra JSON byte appended *and counted in the
length prefix* must fail with a `Json` error, so a future switch to a
tolerant streaming reader is caught.
## 5. Layout (mirrors alkcall §7.7 as-built)
```
fuzz/
├── Cargo.toml alktty-fuzz (nightly-only bins; own [workspace])
├── rust-toolchain.toml pins nightly for this subtree only
├── fuzz_targets/
│ ├── chunk_frame.rs thin fuzz_target! wrapper
│ ├── negotiation_frame.rs thin wrapper
│ ├── control_json.rs thin wrapper
│ └── session_opseq.rs thin wrapper (typed SessionSequence input)
├── shared/ alktty-fuzz-shared, STABLE-toolchain library
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
├── corpus/<target>/ committed seeds
├── gen_fuzz_seeds.py deterministic seed generator
├── run-detached.sh §3 detached runner (CWD-independent)
└── README.md
```
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]`, since
without it cargo auto-discovers `fuzz/shared/` into the main workspace and the
MSRV toolchain tries to build nightly-consumed dev-deps.
- `"fuzz/"` added to the publish `exclude` list.
No `#[cfg(fuzzing)]` exposure expected: `ChunkReader`/`ChunkWriter`,
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
(alkcall needed none of its five either). The one private constant,
`MAX_CHUNK_LEN`, is wire-stable (ADR-001); the shared crate carries its
own copy, and the shape invariants assert the rejection boundary so a
drift is caught. The `session_opseq` harness is fuzz-local for the same
reason `MockBackend`/`TestBackend` are `#[cfg(test)]`-only: a test
backend in `src/` would either leak into the public API or force the
whole mock apparatus behind `#[cfg(any(test, fuzzing))]`; the shared
crate is the fuzz-side home for it (mirrors alkcall, whose `manager_routing`
harness also lives in `fuzz/shared/`).
## 6. Corpus policy
Commit hand-made seeds; gitignore grown corpora and artifacts
(`fuzz/.gitignore` per alkcall's). Seeds per target: valid frames/headers
for every stream type / every control variant, truncation at every prefix
length, `length = 0`, `length = MAX`, `length = MAX + 1`, `length =
u32::MAX`, invalid UTF-8 in JSON, deep-ish nesting, peek-interleave
fixtures, cross-codec error-frame fixtures. The generator script is
deterministic (no randomness) so seeds are reproducible.
## 7. Sequencing
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
alktty's own path, where `TtySession::open_via_channels` and the
channels establisher run on the fixed `ChannelManager`). Full verification set
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
line while touching it. **Status: see §8.**
2. Scaffold `fuzz/` + root workspace table + publish exclude.
3. Targets 1–3 invariant logic in `fuzz/shared/` + thin target bins +
seeds + detached runner + README.
4. Corpus replay green on stable (`cargo test --manifest-path
fuzz/shared/Cargo.toml`); add it to the AGENTS.md verification
checklist.
5. Detached campaigns (10 min per target via `run-detached.sh`), triage
anything found, record results in §8.
6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
**Status: landed; see §8. All four targets are in scope and live.**
## 8. Progress log (append-only across sessions)
- **2026-09-28**: Proposal discussed and adopted; this plan written.
Alkcall dependency bump + fuzz scaffold started.
- **2026-09-28 (later)**: **All of §7 steps 1–5 landed.**
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
all-features 156 tests, clippy stable/wasm, fmt, doc).
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
replay added to the verification checklist; campaign guidance
added.
- `fuzz/` scaffolded exactly as §5: root `[workspace]` table + publish
exclude, `fuzz/rust-toolchain.toml` nightly pin, thin target bins,
invariant logic in `fuzz/shared/` (stable toolchain), 328 committed
seeds via `gen_fuzz_seeds.py`, `run-detached.sh`, README,
`.gitignore`.
- Corpus replay green on stable
(`cargo test --manifest-path fuzz/shared/Cargo.toml`).
- Harness-model corrections the corpus replay caught *before* any
campaign (the alkcall §7.7 pattern repeating): (1) a valid first
chunk with trailing stream bytes is correct reader behavior (the
payload slice is `5..5+len`, not `5..`); (2) the negotiation framing
layer is length-prefix-only: `read_frame` returns raw bytes and the
adapter does the serde parse (`adapter.rs:411`), so
`NegotiationError::Json` is unreachable from this reader (unlike
alkcall's serde-backed envelope reader; the `Json` arm now panics as
"unreachable" and the trailing-byte probe asserts exact-body
consumption instead of a Json error); (3) zero-length negotiation
frames are admitted by this reader (the adapter's parse rejects
them), so the shape assertion was relaxed accordingly.
- **10-min detached campaigns, all three targets: every run exited 0
with an empty artifact directory (no crashes, hangs, OOMs, or
leaks)** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
seconds; the 5-byte-parser ceiling is fully enumerated);
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
budget end, JSON structure exploration); `control_json` 8.6M execs
(cov 2035, still growing). Grown corpus entries were deleted per the
§6 policy; committed seeds unchanged.
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
session.
- **2026-09-28 (later still)**: **§7 step 6 landed: target 4
`session_opseq`. All four targets are now live.**
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
(`#[derive(Arbitrary)]`, 12-op vocabulary: client writes incl.
barrier probes, backend production/EOF, exit resolve/fail/drop,
control messages, bounded reads, yields) + a fuzz-local
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
the harness driving `drive_session_pre_negotiated` over a
`tokio::io::duplex` pair, with a delivery model mirroring §4's
invariants. 12 committed seeds via the deterministic generator
(hand-encoded against the `arbitrary` 1.4.x derive layout: variant
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
gating each `Vec` element; the encoding is pinned by a decode test
asserting the exact ops each seed decodes to).
- Invariants encoded: kill-on-Drop (ADR-005), exit-chunk-is-last
(ADR-004), lossless per-stream FIFO + sentinel semantics, stdin
prefix-losslessness (post-shutdown chunks never delivered), control
dispatch bounds, take-once allocation, teardown termination.
Harness bounds: 128 ops, 4 KiB/chunk, 32 KiB produce/write caps,
unbounded backend stdin (control dispatch stays prompt).
- **Harness-model correction before the campaign** (the alkcall §7.7
pattern repeating, third time): a 60s smoke run crashed on
`ClientWrite` after the session had completed; a failed client
write can mean the *server half of the duplex dropped* because the
session finished (exit resolved → pumps join → exit chunk →
drainer exits → duplex halves drop) while the write was in flight.
The harness now treats any write/shutdown error as "the session
closed": it drains the read side and requires the exit chunk (a
close without it is a premature close, a real bug). No adapter
code changed; the finding was a harness-model gap.
- Corpus replay green on stable (9 tests, incl. the seed-decode
pin). Full checklist green: 113 + 156 all-features tests, clippy
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
check.
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
empty artifact directory and no crashes, hangs, OOMs, or leaks** (all
33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
job (the op-seq target is stateful, with each input running a full tokio
session with bounded reads/yields, so exec/s is inherently tens, not
millions; the value is interleaving exploration). Cov grew
3934 → 4075 edges over the run, still finding coverage at budget
end. Grown corpus excised per the §6 policy; 12 committed seeds
unchanged; corpus replay green after.
- §7 fuzzing plan complete: all four targets live, corpus replay is
the standing gate, campaigns pre-release and on adapter/pump/wire
changes.
## 9. Relationship to existing tests
The existing suite covers valid-input round-trips and documented error
paths (`wire.rs` codec tests, `negotiation.rs` and `tests/negotiation.rs`
frame tests, `control.rs` message tests). Fuzzing adds: random malformed
input exploration (the quinn CVE class), stateful interleavings (target
4), and adversarial exploration of the peek/disambiguation seam that
example-based tests structurally cannot enumerate.
Verification additions once landed:
```bash
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay: the standing fuzz gate
```
…added to the AGENTS.md checklist alongside the existing commands. The
wasm checks (`cargo check --target wasm32-unknown-unknown`,
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`) remain
untouched by fuzz work but must still pass after the root `Cargo.toml`
gains its `[workspace]` table.
+102 -100
View File
@@ -1,14 +1,14 @@
# Project Setup Plan # Project Setup Plan
Status: draft (revised 2026-08-17 to reflect landed upstream changes; Status: draft (revised 2026-08-17 to reflect landed upstream changes;
Phase 4 landed 2026-08-17 — architecture docs + BAST schema + Phase 4 landed 2026-08-17 (architecture docs + BAST schema +
renumbered ADRs; Phase 5 landed 2026-08-17 — tests) renumbered ADRs); Phase 5 landed 2026-08-17 (tests)
Last updated: 2026-08-17 Last updated: 2026-08-17
## Overview ## Overview
Extract `alknet-tty` + `alknet-tty-local` from the alknet mono-repo into a Extract `alknet-tty` + `alknet-tty-local` from the alknet mono-repo into a
standalone `alktty` crate — a producer/consumer protocol crate on top of standalone `alktty` crate: a producer/consumer protocol crate on top of
alkcall channels. This is the first protocol crate built on alkcall and alkcall channels. This is the first protocol crate built on alkcall and
establishes patterns that `alktunnels` and others will follow. establishes patterns that `alktunnels` and others will follow.
@@ -29,7 +29,7 @@ These were "open questions" or "upstream changes needed" in the
2026-08-14 draft and are now resolved. The plan reflects the as-built 2026-08-14 draft and are now resolved. The plan reflects the as-built
shape, not the original intent. shape, not the original intent.
### alkcall 0.1.1 — ALPN rename done ### alkcall 0.1.1: ALPN rename done
- `CHANNELS_ALPN`: `b"alknet/channels"` → `b"alk/channels"` (in - `CHANNELS_ALPN`: `b"alknet/channels"` → `b"alk/channels"` (in
`alkcall/src/channels/adapter.rs`). `alkcall/src/channels/adapter.rs`).
@@ -39,40 +39,40 @@ shape, not the original intent.
- `alkcall` `Cargo.toml` dependency needs updating from `0.1.0` to - `alkcall` `Cargo.toml` dependency needs updating from `0.1.0` to
`0.1.1` (the scaffold still pins `0.1.0`). `0.1.1` (the scaffold still pins `0.1.0`).
### alkcall — `Identity.resources` shape ### alkcall: `Identity.resources` shape
The alknet-core `Identity` had `resources: HashMap<String, String>` (a The alknet-core `Identity` had `resources: HashMap<String, String>` (a
flat resource-type → resource-id map). alkcall's `Identity` (in flat resource-type → resource-id map). alkcall's `Identity` (in
`alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>` — `alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>`;
one resource type can carry multiple resource ids. The ported adapter one resource type can carry multiple resource ids. The ported adapter
tests that build an `Identity` by hand need this shape; the production tests that build an `Identity` by hand need this shape; the production
code only reads `identity.scopes` (the scope-gate at negotiation), so code only reads `identity.scopes` (the scope-gate at negotiation), so
the change is test-side. the change is test-side.
### alkcall — `OwnershipProvider` / `OwnershipStore` signature ### alkcall: `OwnershipProvider` / `OwnershipStore` signature
- `OwnershipProvider::owns(&self, identity, resource_type, - `OwnershipProvider::owns(&self, identity, resource_type,
resource_id, action: &str)` — added an `action` parameter (was 3 resource_id, action: &str)`, which added an `action` parameter (was 3
args, now 4). The adapter's call site args, now 4). The adapter's call site
(`provider.owns(id_ref, kind, &id, "tty")`) already passes an (`provider.owns(id_ref, kind, &id, "tty")`) already passes an
`"tty"` action string, which is the new arity. No change to the `"tty"` action string, which is the new arity. No change to the
production adapter; the mock and test fixtures need the 4-arg shape. production adapter; the mock and test fixtures need the 4-arg shape.
- `OwnershipStore::record(&self, identity, resource_type, - `OwnershipStore::record(&self, identity, resource_type,
resource_id)` — **dropped** the old `action` parameter that resource_id)` **dropped** the old `action` parameter that
alknet-core's variant took. The adapter test's alknet-core's variant took. The adapter test's
`store.record(&owner, "container", "c1").await` matches the new `store.record(&owner, "container", "c1").await` matches the new
arity. (The scaffold's plan previously listed an `OwnershipStore` arity. (The scaffold's plan previously listed an `OwnershipStore`
import that no longer needs an action arg.) import that no longer needs an action arg.)
### alkcall — `AuthContext::anonymous(alpn)` ### alkcall: `AuthContext::anonymous(alpn)`
alkcall added a convenience constructor `AuthContext::anonymous(alpn)` alkcall added a convenience constructor `AuthContext::anonymous(alpn)`
that builds an `AuthContext` with no identity, no fingerprint, no that builds an `AuthContext` with no identity, no fingerprint, no
remote address — only the ALPN. Useful in adapter tests; not used by remote address, just the ALPN. Useful in adapter tests; not used by
the production adapter (which receives the `AuthContext` from the the production adapter (which receives the `AuthContext` from the
dispatch path). dispatch path).
### alktype 0.2.0 — BAST format pivot ### alktype 0.2.0: BAST format pivot
alktype was reworked: the v0.1.0 `AlkType:*` custom-keyword JSON Schema alktype was reworked: the v0.1.0 `AlkType:*` custom-keyword JSON Schema
backend was dropped in favor of a straightforward `kind`-based BAST backend was dropped in favor of a straightforward `kind`-based BAST
@@ -81,7 +81,7 @@ document is a plain JSON file (conforming to a JSON Schema meta-schema)
that describes binary layouts using `kind` strings (`"uint8"`, that describes binary layouts using `kind` strings (`"uint8"`,
`"uint32"`, `"struct"`, etc.) and `$defs`/`$ref` for composition. `"uint32"`, `"struct"`, etc.) and `$defs`/`$ref` for composition.
`alktty` will **not** depend on alktype — the hand-rolled `alktty` will **not** depend on alktype. The hand-rolled
`ChunkReader`/`ChunkWriter` in `wire.rs` already works and is `ChunkReader`/`ChunkWriter` in `wire.rs` already works and is
straightforward. The BAST format matters here only as a normative straightforward. The BAST format matters here only as a normative
**schema document** for the `alk/tty` wire format: a JSON file under **schema document** for the `alk/tty` wire format: a JSON file under
@@ -93,7 +93,7 @@ dependency.
## Crate Structure ## Crate Structure
Single crate (`alktty`) with the local backend gated behind a `local` Single crate (`alktty`) with the local backend gated behind a `local`
feature. This is a "tty-specific monorepo" — one crate, one concern, feature. This is a "tty-specific monorepo": one crate, one concern,
both halves of the original alknet split (`alknet-tty` + both halves of the original alknet split (`alknet-tty` +
`alknet-tty-local`) folded in. `alknet-tty-local`) folded in.
@@ -106,9 +106,9 @@ alktty/
│ ├── control.rs # ControlMessage enum, signal_from_name │ ├── control.rs # ControlMessage enum, signal_from_name
│ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError │ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError
│ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session │ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session
│ ├── session.rs # TtySession — typed consumer client │ ├── session.rs # TtySession (typed consumer client)
│ ├── channels.rs # OpenHandler factory, register_openable helper │ ├── channels.rs # OpenHandler factory, register_openable helper
│ └── local/ # (behind `local` feature — the folded alknet-tty-local) │ └── local/ # (behind `local` feature, the folded alknet-tty-local)
│ ├── mod.rs # LocalTtyBackend re-export │ ├── mod.rs # LocalTtyBackend re-export
│ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe) │ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe)
│ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)]) │ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)])
@@ -136,7 +136,7 @@ alktty/
The old split (`alknet-tty` + `alknet-tty-local`) had a cyclic dependency The old split (`alknet-tty` + `alknet-tty-local`) had a cyclic dependency
problem: `alknet-tty-local` depends on `alknet-tty` for the trait, and problem: `alknet-tty-local` depends on `alknet-tty` for the trait, and
ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a
`local` feature — which cargo rejects. The assembly-layer workaround `local` feature, which cargo rejects. The assembly-layer workaround
(consumer depends on both crates directly) works but is awkward. (consumer depends on both crates directly) works but is awkward.
Single crate with `local` feature solves this cleanly. The Single crate with `local` feature solves this cleanly. The
@@ -149,31 +149,31 @@ original alknet split: the wire/adapter/backend-trait half (from
local backend is small (~1.4k lines), tightly coupled to the trait, local backend is small (~1.4k lines), tightly coupled to the trait,
and the cyclic-dep workaround it required in the alknet mono-repo and the cyclic-dep workaround it required in the alknet mono-repo
doesn't exist in a single crate. A docker or SSH backend would still doesn't exist in a single crate. A docker or SSH backend would still
be a separate crate — those have real external deps (`bollard`, be a separate crate; those have real external deps (`bollard`,
`russh`) and their own resource models. `russh`) and their own resource models.
**WASM target.** The default crate (no `local` feature) MUST compile **WASM target.** The default crate (no `local` feature) MUST compile
to `wasm32-unknown-unknown`. alkcall and alktype both target wasm; to `wasm32-unknown-unknown`. alkcall and alktype both target wasm;
keeping alktty wasm-clean is what makes the downstream TS/Python keeping alktty wasm-clean is what makes the downstream TS/Python
adapter story work — a wasm-compiled alktty is the protocol layer for adapter story work. A wasm-compiled alktty is the protocol layer for
a sandboxed adapter (browser terminal over WebTransport, a Python a sandboxed adapter (browser terminal over WebTransport, a Python
wheel that shells out to a wasm module, etc.). The `local` feature is wheel that shells out to a wasm module, etc.). The `local` feature is
inherently non-wasm (`portable-pty` needs a real OS, `tokio::process` inherently non-wasm (`portable-pty` needs a real OS, `tokio::process`
needs `spawn`), so enabling `local` on wasm is a build error by needs `spawn`), so enabling `local` on wasm is a build error by
design — the local-process backend runs on a real OS (Linux, macOS, design: the local-process backend runs on a real OS (Linux, macOS,
Windows), never in a sandbox. Windows), never in a sandbox.
Concretely, `Cargo.toml` uses `tokio = { default-features = false, Concretely, `Cargo.toml` uses `tokio = { default-features = false,
features = ["rt", "sync", "io-util", "macros"] }` (the wasm-clean features = ["rt", "sync", "io-util", "macros"] }` (the wasm-clean
subset alkcall uses) and `local` adds `tokio/process` + subset alkcall uses) and `local` adds `tokio/process` +
`tokio/rt-multi-thread`. Do NOT use `features = ["full"]` — it pulls `tokio/rt-multi-thread`. Do NOT use `features = ["full"]`; it pulls
in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The
adapter's `tokio::spawn` for per-session pumps is fine on wasm (the adapter's `tokio::spawn` for per-session pumps is fine on wasm (the
wasm tokio runtime supports `spawn`); the `local` module's std wasm tokio runtime supports `spawn`); the `local` module's std
threads and `tokio::process::Command` are the non-wasm parts, and threads and `tokio::process::Command` are the non-wasm parts, and
they're feature-gated. `libc` stays under `cfg(unix)` (it's already they're feature-gated. `libc` stays under `cfg(unix)` (it's already
there for `signal_from_name` and the pipe-mode `kill` path — neither there for `signal_from_name` and the pipe-mode `kill` path, neither
runs on wasm because the only callers are in `local`). of which runs on wasm because the only callers are in `local`).
### 2. Dependency: `alknet-core` → `alkcall::core` ### 2. Dependency: `alknet-core` → `alkcall::core`
@@ -191,7 +191,8 @@ comes from. The trait shape is identical. The `Identity` struct's
reads `identity.scopes`, so the change is test-side. reads `identity.scopes`, so the change is test-side.
`Cargo.toml` pins `alkcall = "0.1.1"` (the scaffold's `0.1.0` pin is `Cargo.toml` pins `alkcall = "0.1.1"` (the scaffold's `0.1.0` pin is
stale — 0.1.1 is the ALPN-rename release; same public API per semver). stale; 0.1.1 is the ALPN-rename release with the same public API per
semver).
### 3. Channels integration (the new part) ### 3. Channels integration (the new part)
@@ -199,11 +200,11 @@ This is where alktty becomes a proper producer/consumer on alkcall.
**ALPN strings (settled).** `alk/tty` (direct) and `alk/channels` **ALPN strings (settled).** `alk/tty` (direct) and `alk/channels`
(multiplexed). The `alknet/<name>` → `alk/<name>` rename landed in (multiplexed). The `alknet/<name>` → `alk/<name>` rename landed in
alkcall 0.1.1 — `CHANNELS_ALPN` is `b"alk/channels"`, the channels alkcall 0.1.1. `CHANNELS_ALPN` is `b"alk/channels"` and the channels
client test uses `"alk/tty"`. No upstream work remains; we just use client test uses `"alk/tty"`. No upstream work remains; we just use
the new strings. the new strings.
**Producer half** — two paths to expose TTY: **Producer half** (two paths to expose TTY):
- **Direct ALPN** (`alk/tty`): `TtyAdapter` implements `ProtocolHandler`. - **Direct ALPN** (`alk/tty`): `TtyAdapter` implements `ProtocolHandler`.
Existing code, just import migration. Used for direct-connect scenarios Existing code, just import migration. Used for direct-connect scenarios
@@ -216,38 +217,38 @@ the new strings.
Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format. 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 The channels layer strips its 8-byte header and hands TTY the payload
transparently. The same `wire.rs` code runs in both modes — only the transparently. The same `wire.rs` code runs in both modes; only the
`BiStream` source differs. `BiStream` source differs.
The channels integration code is small: a `register_openable` helper The channels integration code is small: a `register_openable` helper
function that takes a `ChannelCore`, `OperationRegistry`, and backend function that takes a `ChannelCore`, `OperationRegistry`, and backend
map, and registers the per-ALPN open op (`channels/tty/sub` — map, and registers the per-ALPN open op (`channels/tty/sub`,
consumer-opens). This lives in `src/channels.rs`. The op spec carries consumer-opens). This lives in `src/channels.rs`. The op spec carries
the `channel_open` marker (`ChannelOpenSpec::new("alk/tty")`) and the the `channel_open` marker (`ChannelOpenSpec::new("alk/tty")`) and the
`AccessControl` for the scope-gate + ownership check; the channels `AccessControl` for the scope-gate + ownership check; the channels
wrapper does `check_open` → `open_channel` → spawn the `OpenHandler` wrapper does `check_open` → `open_channel` → spawn the `OpenHandler`
→ respond with `{ channel_id }`. → respond with `{ channel_id }`.
**Consumer half** — `TtySession`: **Consumer half** (the `TtySession`):
A typed client that wraps the wire protocol. Two constructors: A typed client that wraps the wire protocol. Two constructors:
- `TtySession::connect_direct(connection)` — for direct `alk/tty` - `TtySession::connect_direct(connection)`: for direct `alk/tty`
- `TtySession::open_via_channels(client, params)` — opens a channel via - `TtySession::open_via_channels(client, params)`: opens a channel via
`ChannelClient`, negotiates, returns a session handle `ChannelClient`, negotiates, returns a session handle
The session handle exposes: The session handle exposes:
- `send_stdin(bytes)` / `close_stdin()` - `send_stdin(bytes)` / `close_stdin()`
- `recv_stdout()` / `recv_stderr()` — streams - `recv_stdout()` / `recv_stderr()`: streams
- `resize(cols, rows)`, `signal(name)` - `resize(cols, rows)`, `signal(name)`
- `wait()` — awaits exit code - `wait()`: awaits exit code
### 4. Negotiation framing — keep self-contained ### 4. Negotiation framing: keep it self-contained
ADR-057 says the negotiation framing is self-contained (~30 lines, format ADR-057 says the negotiation framing is self-contained (~30 lines, format
coincides with alkcall's by convention). Even though alkcall is now a coincides with alkcall's by convention). Even though alkcall is now a
dependency, we keep the self-contained framing because: dependency, we keep the self-contained framing because:
- The payload is `NegotiateRequest`, not `EventEnvelope` — alkcall's - The payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope` `FrameFramedReader` is hardcoded to deserialize `EventEnvelope`
- The framing is trivial (4-byte BE length prefix + body) - The framing is trivial (4-byte BE length prefix + body)
- Avoids coupling to alkcall's internal wire types for a TTY-specific payload - Avoids coupling to alkcall's internal wire types for a TTY-specific payload
@@ -257,11 +258,11 @@ dependency, we keep the self-contained framing because:
The architecture README in alkcall describes protocol crates as having two The architecture README in alkcall describes protocol crates as having two
halves: halves:
1. **Producer half** — `register_*()` functions that take an 1. **Producer half**: `register_*()` functions that take an
`&mut OperationRegistry` and register ops with handlers. For channels, `&mut OperationRegistry` and register ops with handlers. For channels,
an `OpenHandler` factory. an `OpenHandler` factory.
2. **Consumer half** — a typed client wrapper around `CallConnection` or 2. **Consumer half**: a typed client wrapper around `CallConnection` or
`ChannelClient` that exposes the crate's ops as async methods. `ChannelClient` that exposes the crate's ops as async methods.
We make this explicit in the module structure: We make this explicit in the module structure:
@@ -273,13 +274,14 @@ We make this explicit in the module structure:
The old code had minimal access control (a scope-gate at negotiation). The old code had minimal access control (a scope-gate at negotiation).
With alkcall, we get: With alkcall, we get:
- **Operation-level ACL**: `AccessControl` on `OperationSpec` — scopes, - **Operation-level ACL**: `AccessControl` on `OperationSpec`, covering
resource ownership checks (`required_scopes`, `required_scopes_any`, scopes and resource ownership checks (`required_scopes`,
`resource_type` + `resource_id_path`, `resource_action`) `required_scopes_any`, `resource_type` + `resource_id_path`,
- **Channel lifecycle policy**: `ChannelLifecyclePolicy` — per-identity `resource_action`)
channel caps, open/close hooks (consulted by the `ChannelCore` - **Channel lifecycle policy**: `ChannelLifecyclePolicy` for per-identity
channel caps and open/close hooks (consulted by the `ChannelCore`
wrapper, not by alktty directly) wrapper, not by alktty directly)
- **Ownership**: `OwnershipProvider` — runtime-spawned resource tracking - **Ownership**: `OwnershipProvider` for runtime-spawned resource tracking
(terminal sessions are resources per ADR-050). alkcall's (terminal sessions are resources per ADR-050). alkcall's
`OwnershipProvider::owns` takes an `action: &str` arg (the adapter `OwnershipProvider::owns` takes an `action: &str` arg (the adapter
passes `"tty"`); `OwnershipStore::record` takes no action arg. passes `"tty"`); `OwnershipStore::record` takes no action arg.
@@ -301,7 +303,7 @@ types round-trip. Keeping them as separate artifacts avoids pulling in
alktype as a runtime dep for a codec that's already ~200 lines and alktype as a runtime dep for a codec that's already ~200 lines and
works. If we later want runtime validation against the BAST (e.g., to works. If we later want runtime validation against the BAST (e.g., to
reject malformed chunks at the framing boundary with a generated reject malformed chunks at the framing boundary with a generated
validator), alktype becomes an optional dep — the BAST document is validator), alktype becomes an optional dep; the BAST document is
already there to feed it. already there to feed it.
The `TtyAdapter`'s direct-ALPN path keeps the existing ad-hoc scope The `TtyAdapter`'s direct-ALPN path keeps the existing ad-hoc scope
@@ -309,7 +311,7 @@ check (`has_scope(identity, TTY_OPEN_SCOPE)`) for the scope gate and
optionally consults `OwnershipProvider` for the resource-ownership optionally consults `OwnershipProvider` for the resource-ownership
check. The channels path gets both for free via check. The channels path gets both for free via
`ChannelCore::register_openable`, which wires `AccessControl` into the `ChannelCore::register_openable`, which wires `AccessControl` into the
operation spec — the registry runs the ACL before the wrapper, so the operation spec; the registry runs the ACL before the wrapper, so the
`OpenHandler` only needs to validate params and spawn the protocol. `OpenHandler` only needs to validate params and spawn the protocol.
## Migration Strategy ## Migration Strategy
@@ -333,26 +335,26 @@ operation spec — the registry runs the ACL before the wrapper, so the
4. Confirm the `local` feature already wires `portable-pty` + 4. Confirm the `local` feature already wires `portable-pty` +
`tokio-util` as optional deps (it does in the scaffold). The `tokio-util` as optional deps (it does in the scaffold). The
`local` module's `pty.rs` additionally needs `libc` under `local` module's `pty.rs` additionally needs `libc` under
`cfg(unix)` — already in `[target.'cfg(unix)'.dependencies]`. `cfg(unix)`, which is already in `[target.'cfg(unix)'.dependencies]`.
### Phase 1: Port core types from `alknet-tty` (no new logic) ### Phase 1: Port core types from `alknet-tty` (no new logic)
1. Port `wire.rs` — no `alknet-core` imports; just `std::io`, `tokio::io`, 1. Port `wire.rs`: no `alknet-core` imports; just `std::io`, `tokio::io`,
`bytes`, `thiserror`. Rename crate references in doc comments `bytes`, `thiserror`. Rename crate references in doc comments
(`alknet/tty` → `alk/tty`, `alknet-tty` → `alktty`). (`alknet/tty` → `alk/tty`, `alknet-tty` → `alktty`).
2. Port `control.rs` — no `alknet-core` imports; `serde`, `bytes`, 2. Port `control.rs`: no `alknet-core` imports; `serde`, `bytes`,
`libc` (under `cfg(unix)`). Rename `alknet/tty` → `alk/tty` in `libc` (under `cfg(unix)`). Rename `alknet/tty` → `alk/tty` in
doc comments. doc comments.
3. Port `negotiation.rs` — no `alknet-core` imports; `std::io`, 3. Port `negotiation.rs`: no `alknet-core` imports; `std::io`,
`bytes`, `tokio::io`, `thiserror`, `serde_json`. Rename doc `bytes`, `tokio::io`, `thiserror`, `serde_json`. Rename doc
references. references.
4. Port `backend.rs` — no `alknet-core` imports; `std`, `async_trait`, 4. Port `backend.rs`: no `alknet-core` imports; `std`, `async_trait`,
`bytes`, `futures_core`, `tokio::sync`, `tokio_stream`. Change the `bytes`, `futures_core`, `tokio::sync`, `tokio_stream`. Change the
`BoxFuture` type alias from `Pin<Box<dyn Future + Send>>` to `BoxFuture` type alias from `Pin<Box<dyn Future + Send>>` to
`futures::future::BoxFuture<'static, T>` (functionally identical; `futures::future::BoxFuture<'static, T>` (functionally identical;
the alias matches the alkcall convention and lets us drop the the alias matches the alkcall convention and lets us drop the
`Pin`/`Future` imports). `Pin`/`Future` imports).
5. Port `adapter.rs` — change imports: 5. Port `adapter.rs`; change the imports:
- `alknet_core::auth::{AuthContext, Identity}` → - `alknet_core::auth::{AuthContext, Identity}` →
`alkcall::core::{AuthContext, Identity}` `alkcall::core::{AuthContext, Identity}`
- `alknet_core::ownership::OwnershipProvider` → - `alknet_core::ownership::OwnershipProvider` →
@@ -368,48 +370,48 @@ operation spec — the registry runs the ACL before the wrapper, so the
- `Identity.resources: HashMap<String, Vec<String>>` (was - `Identity.resources: HashMap<String, Vec<String>>` (was
`HashMap<String, String>` in alknet-core). `HashMap<String, String>` in alknet-core).
- `OwnershipStore::record(&self, identity, resource_type, - `OwnershipStore::record(&self, identity, resource_type,
resource_id)` — 3 args, no `action` (alkcall dropped it). resource_id)`: 3 args, no `action` (alkcall dropped it).
- `OwnershipProvider::owns(identity, rt, rid, action)` — 4 args - `OwnershipProvider::owns(identity, rt, rid, action)`: 4 args
(alkcall added `action`). (alkcall added `action`).
### Phase 2: Channels integration (new code) ### Phase 2: Channels integration (new code)
1. Create `src/channels.rs`: 1. Create `src/channels.rs`:
- `register_openable` helper — takes `ChannelCore`, backend map, - `register_openable` helper: takes `ChannelCore`, backend map,
`OperationRegistry`, `AuthContext`; builds the `OperationSpec` `OperationRegistry`, `AuthContext`; builds the `OperationSpec`
for `channels/tty/sub` with `ChannelOpenSpec::new("alk/tty")`, for `channels/tty/sub` with `ChannelOpenSpec::new("alk/tty")`,
`AccessControl` carrying `TTY_OPEN_SCOPE` + resource-type/ownership `AccessControl` carrying `TTY_OPEN_SCOPE` + resource-type/ownership
if a backend declares a `resource_id`; calls if a backend declares a `resource_id`; calls
`ChannelCore::register_openable(spec, open_handler, registry, auth)`. `ChannelCore::register_openable(spec, open_handler, registry, auth)`.
- `TtyOpenHandler` — the `OpenHandler` (an - `TtyOpenHandler`: the `OpenHandler` (an
`Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`) `Arc<dyn Fn(Value, Connection, AuthContext) -> JoinHandle<()>`)
that receives a channel `Connection`, calls `accept_bi()`, runs that receives a channel `Connection`, calls `accept_bi()`, runs
`drive_session` on the resulting `BiStream`. `drive_session` on the resulting `BiStream`.
2. Create `src/session.rs`: 2. Create `src/session.rs`:
- `TtySession` struct — typed consumer client - `TtySession` struct: typed consumer client
- `TtySession::connect_direct(connection)` — direct `alk/tty` - `TtySession::connect_direct(connection)`: direct `alk/tty`
- `TtySession::open_via_channels(client, params)` — opens a channel - `TtySession::open_via_channels(client, params)`: opens a channel
via `ChannelClient::open_channel("channels/tty/sub", params, via `ChannelClient::open_channel("channels/tty/sub", params,
"alk/tty")`, negotiates, returns a session handle "alk/tty")`, negotiates, returns a session handle
- Methods: `send_stdin`, `recv_stdout`, `recv_stderr`, `resize`, - Methods: `send_stdin`, `recv_stdout`, `recv_stderr`, `resize`,
`signal`, `wait` `signal`, `wait`
### Phase 3: Local backend (behind `local` feature) — folded `alknet-tty-local` ### Phase 3: Local backend (behind `local` feature), the folded `alknet-tty-local`
1. Create `src/local/mod.rs` (re-exports `LocalTtyBackend`), 1. Create `src/local/mod.rs` (re-exports `LocalTtyBackend`),
`src/local/backend.rs`, `src/local/pty.rs`, `src/local/pipe.rs`. `src/local/backend.rs`, `src/local/pty.rs`, `src/local/pipe.rs`.
2. Port from `alknet-tty-local/src/`: 2. Port from `alknet-tty-local/src/`:
- `backend.rs` — change `alknet_tty::backend::{...}` → - `backend.rs`: change `alknet_tty::backend::{...}` →
`crate::backend::{...}`. `LocalTtyBackend::allocate` dispatches `crate::backend::{...}`. `LocalTtyBackend::allocate` dispatches
on `params.terminal`: `Some` → `pty::allocate_pty`, `None` → on `params.terminal`: `Some` → `pty::allocate_pty`, `None` →
`pipe::allocate_pipe`. `pipe::allocate_pipe`.
- `pty.rs` — `portable_pty` + 3 std threads (reader/writer/waiter) - `pty.rs`: `portable_pty` + 3 std threads (reader/writer/waiter)
feeding tokio mpsc/oneshot. `#[cfg(unix)]`-only: PTY mode uses feeding tokio mpsc/oneshot. `#[cfg(unix)]`-only: PTY mode uses
`libc::kill(-pgid, sig)` for process-group signal forwarding. `libc::kill(-pgid, sig)` for process-group signal forwarding.
Update `alknet_tty::backend::{BoxFuture, TtyControl, ...}` → Update `alknet_tty::backend::{BoxFuture, TtyControl, ...}` →
`crate::backend::{...}` and `alknet_tty::control::signal_from_name` `crate::backend::{...}` and `alknet_tty::control::signal_from_name`
→ `crate::control::signal_from_name`. → `crate::control::signal_from_name`.
- `pipe.rs` — `tokio::process::Command` + `tokio_util::io::ReaderStream` - `pipe.rs`: `tokio::process::Command` + `tokio_util::io::ReaderStream`
for stdout/stderr. Cross-platform: signal forwarding uses for stdout/stderr. Cross-platform: signal forwarding uses
`libc::kill(pid, sig)` under `cfg(unix)`, falls back to `libc::kill(pid, sig)` under `cfg(unix)`, falls back to
`Child::kill()` on non-Unix. Update `alknet_tty::...` → `crate::...`. `Child::kill()` on non-Unix. Update `alknet_tty::...` → `crate::...`.
@@ -417,11 +419,11 @@ operation spec — the registry runs the ACL before the wrapper, so the
`local` feature in `src/lib.rs`: `local` feature in `src/lib.rs`:
`#[cfg(feature = "local")] pub mod local;` `#[cfg(feature = "local")] pub mod local;`
### Phase 4: Architecture docs + BAST schema — landed 2026-08-17 ### Phase 4: Architecture docs + BAST schema (landed 2026-08-17)
1. Ported the 5 spec docs from `alknet/docs/architecture/crates/tty/` 1. Ported the 5 spec docs from `alknet/docs/architecture/crates/tty/`
into `docs/architecture/` (flat layout, not the `crates/tty/` into `docs/architecture/` (flat layout, not the `crates/tty/`
subpath — this is a single-crate repo now): subpath; this is a single-crate repo now):
`overview.md` (the crate overview; the alknet `README.md` index is `overview.md` (the crate overview; the alknet `README.md` index is
ported as `docs/architecture/README.md`), `tty-wire.md`, ported as `docs/architecture/README.md`), `tty-wire.md`,
`tty-backend.md`, `tty-adapter.md`, `tty-local.md`. Each is renamed `tty-backend.md`, `tty-adapter.md`, `tty-local.md`. Each is renamed
@@ -432,30 +434,30 @@ operation spec — the registry runs the ACL before the wrapper, so the
2. Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093) 2. Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093)
into `docs/architecture/decisions/` renumbered 001..008 in order: into `docs/architecture/decisions/` renumbered 001..008 in order:
- [001](../architecture/decisions/001-wire-format-and-two-carriage.md) - [001](../architecture/decisions/001-wire-format-and-two-carriage.md)
← alknet ADR-052 — wire format + two-carriage model (incl. the ← alknet ADR-052: wire format + two-carriage model (incl. the
Phase 7 control-channel split amendment) Phase 7 control-channel split amendment)
- [002](../architecture/decisions/002-ttybackend-trait-and-ttyhandle.md) - [002](../architecture/decisions/002-ttybackend-trait-and-ttyhandle.md)
← alknet ADR-053 — `TtyBackend` trait + `TtyHandle` ← alknet ADR-053: `TtyBackend` trait + `TtyHandle`
- [003](../architecture/decisions/003-local-backend-placement.md) - [003](../architecture/decisions/003-local-backend-placement.md)
← alknet ADR-054 — local backend placement (records both the ← alknet ADR-054: local backend placement (records both the
alknet sibling-crate decision and the alktty single-crate alknet sibling-crate decision and the alktty single-crate
consolidation behind a `local` feature) consolidation behind a `local` feature)
- [004](../architecture/decisions/004-exit-code-on-control-chunk.md) - [004](../architecture/decisions/004-exit-code-on-control-chunk.md)
← alknet ADR-055 — exit code on a control chunk ← alknet ADR-055: exit code on a control chunk
- [005](../architecture/decisions/005-backend-cleanup-on-session-cancel.md) - [005](../architecture/decisions/005-backend-cleanup-on-session-cancel.md)
← alknet ADR-056 — backend cleanup on session cancel ← alknet ADR-056: backend cleanup on session cancel
- [006](../architecture/decisions/006-negotiation-framing-self-contained.md) - [006](../architecture/decisions/006-negotiation-framing-self-contained.md)
← alknet ADR-057 — self-contained negotiation framing ← alknet ADR-057: self-contained negotiation framing
- [007](../architecture/decisions/007-tty-inside-channels.md) - [007](../architecture/decisions/007-tty-inside-channels.md)
← alknet ADR-077 — TTY inside channels (reversed by 008; kept for ← alknet ADR-077: TTY inside channels (reversed by 008; kept for
historical context with its reversal notice pointing to 008) historical context with its reversal notice pointing to 008)
- [008](../architecture/decisions/008-channels-pure-channel-multiplexing.md) - [008](../architecture/decisions/008-channels-pure-channel-multiplexing.md)
← alknet ADR-093 — channels pure channel multiplexing (reverses ← alknet ADR-093: channels pure channel multiplexing (reverses
007; TTY always uses its 5-byte format) 007; TTY always uses its 5-byte format)
3. Wrote 3. Wrote the BAST (Binary Abstract Syntax Tree) document for the `alk/tty`
[`docs/architecture/tty-bast.md`](../architecture/tty-bast.md) — wire format at
the BAST (Binary Abstract Syntax Tree) document for the `alk/tty` [`docs/architecture/tty-bast.md`](../architecture/tty-bast.md). Conforms
wire format. Conforms to the BAST meta-schema at to the BAST meta-schema at
`https://alk.dev/bast/v1/schema`; validatable by any JSON Schema `https://alk.dev/bast/v1/schema`; validatable by any JSON Schema
Draft 2020-12 validator. Covers: Draft 2020-12 validator. Covers:
- The 5-byte chunk header (`struct` with `endian: "big"`: - The 5-byte chunk header (`struct` with `endian: "big"`:
@@ -467,12 +469,12 @@ operation spec — the registry runs the ACL before the wrapper, so the
that on-wire control payloads are UTF-8 JSON, not BAST's binary that on-wire control payloads are UTF-8 JSON, not BAST's binary
union encoding. union encoding.
- The negotiation frame as a separate `struct` (4-byte BE length - The negotiation frame as a separate `struct` (4-byte BE length
prefix + UTF-8 JSON `NegotiateRequest` body) — annotated as prefix + UTF-8 JSON `NegotiateRequest` body), annotated as
out-of-band for the chunk codec but documented for completeness. out-of-band for the chunk codec but documented for completeness.
- The `StreamType` enum's documented deviation: on-wire encoding is - The `StreamType` enum's documented deviation: on-wire encoding is
`uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes) `uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes);
— the chunk header is 5 bytes, not 8. the chunk header is 5 bytes, not 8.
4. Wrote [`docs/architecture/README.md`](../architecture/README.md) — 4. Wrote [`docs/architecture/README.md`](../architecture/README.md),
the architecture index: documents table, ADR table (with alknet the architecture index: documents table, ADR table (with alknet
origin numbers and port status), key design principles, open origin numbers and port status), key design principles, open
questions, and references. questions, and references.
@@ -488,28 +490,28 @@ split (`STREAM_CTRL_IN` = 3, `STREAM_CTRL_OUT` = 4) is an amendment
inside ADR-001, mirroring alknet (it was not a standalone ADR there inside ADR-001, mirroring alknet (it was not a standalone ADR there
either). `AGENTS.md` was updated to match this mapping. either). `AGENTS.md` was updated to match this mapping.
### Phase 5: Tests — landed 2026-08-17 ### Phase 5: Tests (landed 2026-08-17)
1. Ported existing unit tests from `alknet-tty` (wire, negotiation, 1. Ported existing unit tests from `alknet-tty` (wire, negotiation,
control, adapter) inline in each `src/*.rs` module's control, adapter) inline in each `src/*.rs` module's
`#[cfg(test)] mod tests` — done in Phases 1–2 alongside the `#[cfg(test)] mod tests`, done in Phases 1–2 alongside the
production code (80 lib tests, all passing). production code (80 lib tests, all passing).
2. Ported integration tests from `alknet-tty-local/tests/` into 2. Ported integration tests from `alknet-tty-local/tests/` into
`tests/` at the crate root: `tests/` at the crate root:
- [`tests/common/mod.rs`](../tests/common/mod.rs) — the - [`tests/common/mod.rs`](../tests/common/mod.rs): the
`ClientSide` wire-protocol harness + `spawn_session` helper + `ClientSide` wire-protocol harness + `spawn_session` helper +
`negotiate_pty_json` / `negotiate_pipe_json` builders. Imports `negotiate_pty_json` / `negotiate_pipe_json` builders. Imports
renamed `alknet_core::auth::Identity` → renamed `alknet_core::auth::Identity` →
`alkcall::core::auth::Identity`, `alknet_tty::...` → `alkcall::core::auth::Identity`, `alknet_tty::...` →
`alktty::...`. `alktty::...`.
- [`tests/negotiation.rs`](../tests/negotiation.rs) — 4 - [`tests/negotiation.rs`](../tests/negotiation.rs): 4
negotiation-error scenarios (unknown_backend, negotiation-error scenarios (unknown_backend,
malformed_negotiation ×3, allocate_failed). malformed_negotiation ×3, allocate_failed).
- [`tests/pipe.rs`](../tests/pipe.rs) — 6 pipe-mode scenarios - [`tests/pipe.rs`](../tests/pipe.rs): 6 pipe-mode scenarios
(echo happy path, separate stderr, SIGTERM, cancel cleanup, (echo happy path, separate stderr, SIGTERM, cancel cleanup,
resize no-op, stdout sentinel). The 2 cancel-cleanup / resize no-op, stdout sentinel). The 2 cancel-cleanup /
SIGTERM tests are `#[cfg(unix)]`. SIGTERM tests are `#[cfg(unix)]`.
- [`tests/pty.rs`](../tests/pty.rs) — 8 PTY-mode scenarios - [`tests/pty.rs`](../tests/pty.rs): 8 PTY-mode scenarios
(echo, interactive cat, resize, SIGINT, process-group (echo, interactive cat, resize, SIGINT, process-group
signal, stdin-EOF sentinel, cancel cleanup, signal, stdin-EOF sentinel, cancel cleanup,
exit-chunk-is-last). The 4 signal / cancel-cleanup tests exit-chunk-is-last). The 4 signal / cancel-cleanup tests
@@ -534,7 +536,7 @@ the 80/19 the plan originally recorded).
## Open Questions ## Open Questions
### OQ-1: ALPN strings (RESOLVED — landed in alkcall 0.1.1) ### OQ-1: ALPN strings (RESOLVED, landed in alkcall 0.1.1)
**Decision**: `alk/tty` (direct) and `alk/channels` (multiplexed). The **Decision**: `alk/tty` (direct) and `alk/channels` (multiplexed). The
`alknet/<name>` → `alk/<name>` rename landed in alkcall 0.1.1: `alknet/<name>` → `alk/<name>` rename landed in alkcall 0.1.1:
@@ -552,12 +554,12 @@ correct.
**Decision**: `channels/tty/sub` for consumer-opens, `channels/tty/pub` **Decision**: `channels/tty/sub` for consumer-opens, `channels/tty/pub`
reserved for future producer-opens use case. reserved for future producer-opens use case.
### OQ-3: `TtySession` API — stream vs callback (RESOLVED) ### OQ-3: `TtySession` API, stream vs callback (RESOLVED)
**Decision**: Stream-based (`Stream<Item = Bytes>`). Consistent with the **Decision**: Stream-based (`Stream<Item = Bytes>`). Consistent with the
backend trait's `TtyHandle` shape. backend trait's `TtyHandle` shape.
### OQ-4: `drive_session` — keep the three-pump pattern? (RESOLVED) ### OQ-4: `drive_session`, keep the three-pump pattern? (RESOLVED)
**Decision**: Keep it. It works, it's tested, and ADR-093 means the same **Decision**: Keep it. It works, it's tested, and ADR-093 means the same
code runs in both direct and channels modes. code runs in both direct and channels modes.
@@ -571,7 +573,7 @@ transitive dep.
### OQ-6: BAST schema scope (RESOLVED) ### OQ-6: BAST schema scope (RESOLVED)
**Decision**: Documentation artifact only — `docs/architecture/tty-bast.md`. **Decision**: Documentation artifact only, at `docs/architecture/tty-bast.md`.
alktty does not depend on alktype. The hand-rolled `wire.rs` codec is alktty does not depend on alktype. The hand-rolled `wire.rs` codec is
the runtime; the BAST document is the normative contract for downstream the runtime; the BAST document is the normative contract for downstream
consumers. If runtime validation against the BAST becomes desirable consumers. If runtime validation against the BAST becomes desirable
@@ -598,7 +600,7 @@ of what was pushed.
## Risks and Concerns ## Risks and Concerns
### Risk: alkcall is v0.1.x — API stability ### Risk: alkcall is v0.1.x (API stability)
alkcall is published at 0.1.1. Breaking changes are expected at this alkcall is published at 0.1.1. Breaking changes are expected at this
major-zero stage. The integration surface we use major-zero stage. The integration surface we use
@@ -656,17 +658,17 @@ from the BAST will be wrong.
`serde_json` and asserts the stream-type enum values match `serde_json` and asserts the stream-type enum values match
`wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/
`STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants. Cheap; catches the `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants. Cheap; catches the
common drift case. (Doesn't require alktype as a dep — just common drift case. (Doesn't require alktype as a dep, just
`serde_json`, which we already have.) `serde_json`, which we already have.)
## References ## References
- [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md) - [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md)
- [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs) — `ChannelCore::register_openable`, `OpenHandler` - [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs): `ChannelCore::register_openable`, `OpenHandler`
- [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs) — `ChannelClient::open_channel`, reference integration pattern - [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs): `ChannelClient::open_channel`, reference integration pattern
- [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md) — normative format spec for the `tty-bast.md` document - [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md): normative format spec for the `tty-bast.md` document
- [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/) — port origin (wire, control, negotiation, backend, adapter) - [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/): port origin (wire, control, negotiation, backend, adapter)
- [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/) — port origin (local backend, pty, pipe) - [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/): port origin (local backend, pty, pipe)
- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/) — port origin for the spec docs - [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/): port origin for the spec docs
- [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md) — TTY always uses 5-byte format - [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md): TTY always uses 5-byte format
- [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — reversed, historical context - [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md): reversed, historical context
@@ -0,0 +1,162 @@
---
status: resolved
last_updated: 2026-11-01
---
# Code Review #004 — PTY-path exit-signal fidelity (consumer input from alkssh)
## Purpose
Consumer-input review filed from alkssh 0.1.0 (published 2026-10-31)
under the family's upstream posture (file asks, never work around).
The ask was booked alkssh-side at OQ-10 with the note "recorded … not
tracked here — it constrains alktty"; this review is that filing.
The finding: the `LocalTtyBackend`'s PTY path cannot express ADR-004
decision 1's own exit-status convention for signal-terminated
children. The pipe path conforms; the PTY path reports a signal death
as a plain nonzero code.
## The convention (ADR-004 decision 1)
The exit chunk carries `{"type":"exit","code":N}` with `code` an
`i32` in this crate's exit-status convention — normal exit →
`ExitStatus::code()`; on Unix, **signal-terminated exit → the negative
signal number** (e.g. `-9`), Python's `Popen.returncode` parity. This
is a one-way door: consumers read `code < 0` as "died by signal N"
(also the `-1` sentinel's collision note — ADR-004 decision 1, the
2026-10-31 doc commit `6e33320`).
## The gap
The waiter thread (`src/local/pty.rs`, the "Waiter thread" spawn,
` Child::wait()` → `oneshot<i32>`) converts the reaped status with
`status.exit_code() as i32` — and discards the signal identity:
- portable-pty 0.9.0's `ExitStatus` is
`struct ExitStatus { code: u32, signal: Option<String> }`; a
signal-terminated child is constructed via `with_signal(name)`,
which hardcodes `code = 1` and keeps the signal **only as a
humanized name string** (`signal() -> Option<&str>`). Verified
against the published 0.9.0 source (`lib.rs`, the `ExitStatus` impl).
- So a `SIGTERM`-terminated child yields `exit_code = 1`, the oneshot
carries `1`, and the adapter emits `{"type":"exit","code":1}`. The
consumer cannot distinguish "exited with status 1" from "terminated
by signal 15". ADR-004's own `−15` encoding is unrecoverable on this
path.
Two aggravations worth recording in this crate's own terms:
- The crate's **own** signal features produce signal deaths the PTY
path then flattens: REQ-TTY-02's `signal()` (`libc::kill(-pgid,
sig)`) and ADR-056's cancel-cleanup `ChildKiller::kill()` (SIGHUP)
both end in signal-terminated reaps that the waiter reports as plain
`1`.
- The pipe path already conforms (ADR-004 decision 1's
`ExitStatusExt::signal()` reference; `src/local/pipe.rs::
exit_code_from`) — the two backends disagree on the same convention.
## Consumer consequence (alkssh, the vanilla-SSH front door)
SSH reports a signal death as `exit-signal(name)` + `core_dumped`,
distinct from `exit-status(n)`. alkssh's derivation contract (its
`mapping.md` §"Exit chunk → SSH exit reporting"): `code ≥ 0` →
`exit-status(N)`; negative `−N` on Unix → `exit-signal(name)` via a
server-side signal-number→name table; `−1` → `exit-status(255)`
(never a signal claim — the sentinel collides with SIGHUP;
`core_dumped` never asserted). The contract was verified live on the
pipe path (`−15` → `exit-signal("TERM")`, alkssh POC-3 §D.2).
On the PTY path, that same session reports `exit-status(1)` — while
vanilla `sshd` for the identical child reports
`exit-signal(15)`. Fail-closed, honest, and never a wrong claim — but
a fidelity gap versus both the family convention and vanilla sshd
parity, in the crate's first published consumer's primary path
(`ssh host` interactive / `ssh host "cmd"` every day).
alkssh holds no alkssh-side workaround and adapts automatically with
**no contract change** the day the convention holds on the PTY path:
its mapping table consumes ADR-004's negative code verbatim. Nothing
in alkssh needs to change for this to be worth fixing; the
justification is tty/pty-grounded independently (see below).
## Why this is also an in-crate issue, not only alkssh's
The negative-code convention is this crate's documented, one-way
wire shape (ADR-004), and the crate advertises `Popen.returncode`
parity in the ADR itself. The PTY is the backend where signal
identity is the most semantically meaningful (shells, editors, and
every long-running session dies by signal at least sometimes), and
the backend's own signal-forwarding capabilities (REQ-TTY-02, the
resize/signal control handle) make the flattened report internally
inconsistent: the feature exists to deliver a signal, the exit report
cannot say a signal happened.
## What a resolution decides (framing, not prescription)
1. Whether the PTY backend conforms to ADR-004's negative-code
convention (the pipe path's precedent) — in which case the waiter
needs the signal identity, which portable-pty 0.9.0 does expose
(as a name string, requiring a name → Unix signum mapping at the
alktty side, or an upstream ask for a numeric form).
2. Or whether the PTY path's flattening is ruled the documented
posture — which would need its own ADR, since it narrows ADR-004
decision 1 per-backend (a change to a wire-visible convention,
one-way from alkssh's consumer).
3. Whatever widens what the exit chunk carries (e.g. a separate
signal field instead of the negative encoding) is
alkssh-irrelevant-by-design — alkssh OQ-10 resolved that no
widening was warranted from the consumer seat — and would ripple
`ControlMessage::Exit`'s parsed shape regardless. Not asked for.
## References
- ADR-004 (the exit-code-on-control-chunk convention; decision 1's
negative encoding and the `-1` sentinel note)
- `src/local/pty.rs` (the waiter thread; `exit_code() as i32`),
`src/local/pipe.rs::exit_code_from` (the conforming pipe path)
- portable-pty 0.9.0 `src/lib.rs` — `ExitStatus { code, signal }`,
`with_signal` (code hardcoded 1, signal as name string)
- alkssh OQ-10
(`/workspace/@alkdev/alkssh/docs/architecture/open-questions.md`,
closed as contract + upstream constraint) — the origin of this
filing; alkssh `docs/architecture/mapping.md` §"Exit chunk → SSH
exit reporting" (the derivation contract this gap constrains)
- alkssh POC-3 §D.2 (pipe-path fidelity proven live; PTY-path limit
verified against portable-pty source)
- alknet ADR-055 (the `-1` sentinel lineage ADR-004 inherits)
## Resolution (2026-11-01)
The finding was verified against the published portable-pty 0.9.0
source and confirmed in full: the waiter's `status.exit_code() as i32`
discarded signal identity; the `From<std::process::ExitStatus>` impl
keeps a signal death only as a strsignal name string with `code = 1`
(`unwrap_or(1)`), while the pipe path conformed.
Resolved **in-crate, without a fork and without an upstream ask**, via
option (1) of the framing above — but through a mechanism the framing
did not list: on Unix the waiter now skips portable-pty's status
conversion entirely and reaps the direct child with
`libc::waitpid(pid, ..)` (`src/local/pty.rs::wait_exit_code`), decoding
`WIFEXITED`/`WEXITSTATUS` (normal exit) and `WIFSIGNALED`/`WTERMSIG`
(signal death → `-N`). This is fully numeric, locale-independent, and
exhaustive (no name→signum table, no strsignal parsing). It relies on
portable-pty's unix backend returning a `std::process::Child` whose
`Drop` neither kills nor reaps — so the two paths never double-reap
(`Child::wait()` remains as the waitpid-failure fallback and the
non-Unix primary).
Behavioral notes recorded alongside:
- Signal deaths from the crate's own features now encode honestly:
`signal("TERM")` → `-15`; the ADR-056 cancel kill (SIGHUP) → `-1`,
which is the documented ADR-004 sentinel collision (now common on the
cancel path, not just the wait-failure path).
- Tests strengthened: PTY-path `SIGINT` → `-2`, `SIGTERM` → `-15`, and
the SIGHUP fallback → `-1` collision is pinned. alkssh needs no
change; its mapping table consumes the negative code verbatim.
Docs updated: ADR-004 decision 1 (both backends' encoding reference),
`tty-local.md` (waiter thread + cancel-cleanup), ADR-005 and
`tty-adapter.md` (mechanism wording).
+31 -31
View File
@@ -18,7 +18,7 @@ leverages:
4. **Task-Driven**: Structured task graphs with dependency analysis 4. **Task-Driven**: Structured task graphs with dependency analysis
5. **Safe Exit**: Always have a way to unblock progress when stuck 5. **Safe Exit**: Always have a way to unblock progress when stuck
6. **Categorical Estimates**: Use risk/scope/impact categories, not time 6. **Categorical Estimates**: Use risk/scope/impact categories, not time
estimates. These are structurally important — upstream failures multiply estimates. These are structurally important: upstream failures multiply
downstream damage regardless of developer type (human or LLM). See the downstream damage regardless of developer type (human or LLM). See the
cost-benefit framework in taskgraph's framework docs. cost-benefit framework in taskgraph's framework docs.
@@ -47,11 +47,11 @@ approaches
**Process**: **Process**:
1. Architect creates the architecture documentation structure: 1. Architect creates the architecture documentation structure:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/overview.md` — package purpose, exports, dependencies - `docs/architecture/overview.md`: package purpose, exports, dependencies
- `docs/architecture/<component>.md` — one focused doc per component/area - `docs/architecture/<component>.md`: one focused doc per component/area
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...) - `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs - `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not 2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
inline rationale) and OQs by number (not inline questions). inline rationale) and OQs by number (not inline questions).
3. Architecture Review validates for ambiguities, risks, and structural issues 3. Architecture Review validates for ambiguities, risks, and structural issues
@@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
- Extract decisions into numbered ADRs in `decisions/` directory - Extract decisions into numbered ADRs in `decisions/` directory
- Centralize open questions in `open-questions.md` with OQ-IDs - Centralize open questions in `open-questions.md` with OQ-IDs
- Maintain `README.md` as the architecture index - Maintain `README.md` as the architecture index
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them - Keep spec documents focused (~500 lines): reference ADRs and OQs, don't inline them
- Redirect exploration work to Research Specialist - Redirect exploration work to Research Specialist
- Iterate based on review feedback - Iterate based on review feedback
**Deliverables**: **Deliverables**:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md` — focused spec documents - `docs/architecture/<component>.md`: focused spec documents
- `docs/architecture/decisions/` — numbered ADR files - `docs/architecture/decisions/`: numbered ADR files
- `docs/architecture/open-questions.md` — centralized OQ tracker - `docs/architecture/open-questions.md`: centralized OQ tracker
--- ---
@@ -179,17 +179,17 @@ sessions.
**Mode**: Primary (manages worktrees and agent sessions) **Mode**: Primary (manages worktrees and agent sessions)
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin. **Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
sessions get the full operation set, spawned implementation sessions get a sessions get the full operation set, spawned implementation sessions get a
limited set (current, notify, status). No mode toggle required. limited set (current, notify, status). No mode toggle required.
**Tools**: **Tools**:
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, - `worktree({action, args})`: spawn, sessions, dashboard, message, abort,
cleanup cleanup
- Bash (opencode CLI for session interaction) - Bash (opencode CLI for session interaction)
- Read (monitor task files) - Read (monitor task files)
- `memory` / `memory_compact` — context management and session history (via - `memory` / `memory_compact`: context management and session history (via
@alkdev/open-memory, when available) @alkdev/open-memory, when available)
**Key Behaviors**: **Key Behaviors**:
@@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required.
**Tools**: **Tools**:
- Read, Write, Edit, Glob, Grep, Bash - Read, Write, Edit, Glob, Grep, Bash
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator - `worktree({action: "notify", ...})`: report progress/blockers to coordinator
- `worktree({action: "current"})` — verify worktree assignment - `worktree({action: "current"})`: verify worktree assignment
- webSearch (documentation lookup) - webSearch (documentation lookup)
- `memory` / `memory_compact` — context management (via @alkdev/open-memory, - `memory` / `memory_compact`: context management (via @alkdev/open-memory,
when available) when available)
**Key Behaviors**: **Key Behaviors**:
@@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction.
### Categorical Estimates ### Categorical Estimates
These fields are structurally important, not optional metadata. They power These fields are structurally important, not optional metadata. They power
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that `taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, commands that
reveal structural problems in the task graph. A task missing `scope`, `risk`, reveal structural problems in the task graph. A task missing `scope`, `risk`,
`impact`, or `level` is a red flag indicating incomplete decomposition. See the `impact`, or `level` is a red flag indicating incomplete decomposition. See the
cost-benefit framework in taskgraph's framework docs for the reasoning. cost-benefit framework in taskgraph's framework docs for the reasoning.
@@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
### Architecture work and the `level` field ### Architecture work and the `level` field
Architecture work uses the same taskgraph format as implementation work — Architecture work uses the same taskgraph format as implementation work;
there is no separate `architecture` level. The `level` enum there is no separate `architecture` level. The `level` enum
(`planning, decomposition, implementation, review, research`) already covers (`planning, decomposition, implementation, review, research`) already covers
architecture activities: architecture activities:
| Architecture activity | `level` | Rationale | | Architecture activity | `level` | Rationale |
|---|---|---| |---|---|---|
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. | | Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable: the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. | | Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit: breaking a large architecture area into decideable units. |
| Architecture review pass | `review` | Direct fit. | | Architecture review pass | `review` | Direct fit. |
| Research / POC before a decision | `research` | Direct fit. | | Research / POC before a decision | `research` | Direct fit. |
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. | | Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
@@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync:
2. **The machine-readable half** (in `tasks/architecture/`): an 2. **The machine-readable half** (in `tasks/architecture/`): an
external-trigger tracker task represents the unblocking condition. It is external-trigger tracker task represents the unblocking condition. It is
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
`level: research` (it is not actionable work — it tracks whether the `level: research` (it is not actionable work; it tracks whether the
external condition has arrived), and its `id` is referenced from the OQ's external condition has arrived), and its `id` is referenced from the OQ's
`Blocked on:` text. This is the surface for the task graph: `taskgraph` `Blocked on:` text. This is the surface for the task graph: `taskgraph`
tools can reason about it, and downstream work that depends on the tools can reason about it, and downstream work that depends on the
@@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
specced), the tracker task is marked `completed` and the OQ transitions from specced), the tracker task is marked `completed` and the OQ transitions from
`deferred(scope)` to `open` (or directly to `resolved` if no architecture `deferred(scope)` to `open` (or directly to `resolved` if no architecture
decision remains). The two halves serve different audiences and use one edge decision remains). The two halves serve different audiences and use one edge
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
add a `blocks:` field; it duplicates the edge and creates a sync hazard. add a `blocks:` field; it duplicates the edge and creates a sync hazard.
## Review Injection ## Review Injection
@@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen:
### Current (open-coordinator plugin) ### Current (open-coordinator plugin)
The Coordinator uses the `worktree` tool from the open-coordinator opencode The Coordinator uses the `worktree` tool from the open-coordinator opencode
plugin. It's a single tool with `{action, args}` dispatch — no separate plugin. It's a single tool with `{action, args}` dispatch; no separate
enable/toggle steps. Role is auto-detected from session state. enable/toggle steps. Role is auto-detected from session state.
``` ```
@@ -563,13 +563,13 @@ a worktree.
When the open-memory plugin is available alongside open-coordinator, the When the open-memory plugin is available alongside open-coordinator, the
coordinator gains: coordinator gains:
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent - `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
sessions spawned from the coordinator sessions spawned from the coordinator
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned - `memory({tool: "messages", args: {sessionId: "..."}})`: read a spawned
session's conversation for debugging session's conversation for debugging
- `memory({tool: "context"})` — check context window usage before long - `memory({tool: "context"})`: check context window usage before long
monitoring sessions monitoring sessions
- `memory_compact()` — proactively compact at natural breakpoints - `memory_compact()`: proactively compact at natural breakpoints
Implementation agents can also use `memory({tool: "context"})` and Implementation agents can also use `memory({tool: "context"})` and
`memory_compact()` to manage their context during long tasks. `memory_compact()` to manage their context during long tasks.
@@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations:
``` ```
State moves from in-process tracking to Postgres `mappings` table. The State moves from in-process tracking to Postgres `mappings` table. The
open-coordinator plugin becomes unnecessary — the hub provides the same open-coordinator plugin becomes unnecessary; the hub provides the same
capabilities as server-side operations accessible from any environment. capabilities as server-side operations accessible from any environment.
## Document Structure ## Document Structure
@@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs) (Links to related specs and ADRs)
``` ```
Numbering starts at 001 within each project. ADRs are stable — once Accepted, Numbering starts at 001 within each project. ADRs are stable: once Accepted,
they don't revert. If superseded, mark the old one and create a new one. they don't revert. If superseded, mark the old one and create a new one.
### Open Questions Format ### Open Questions Format
@@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one.
- **Status**: open | resolved | deferred(scope) | partially resolved - **Status**: open | resolved | deferred(scope) | partially resolved
- **Priority**: high | medium | low - **Priority**: high | medium | low
- **Resolution**: (when resolved) - **Resolution**: (when resolved)
- **Blocked on**: (when deferred — concrete dependency that would unblock) - **Blocked on**: (when deferred: concrete dependency that would unblock)
- **Cross-references**: OQ-NN, ADR-NNN - **Cross-references**: OQ-NN, ADR-NNN
``` ```
+7
View File
@@ -0,0 +1,7 @@
target
corpus/*
!corpus/*/
corpus/*/*
!corpus/*/seed-*
artifacts
coverage
+1330
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "alktty-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
alktty-fuzz-shared = { path = "shared" }
[dependencies.alktty]
path = ".."
[[bin]]
name = "chunk_frame"
path = "fuzz_targets/chunk_frame.rs"
test = false
doc = false
bench = false
[[bin]]
name = "negotiation_frame"
path = "fuzz_targets/negotiation_frame.rs"
test = false
doc = false
bench = false
[[bin]]
name = "control_json"
path = "fuzz_targets/control_json.rs"
test = false
doc = false
bench = false
[[bin]]
name = "session_opseq"
path = "fuzz_targets/session_opseq.rs"
test = false
doc = false
bench = false
[workspace]
+73
View File
@@ -0,0 +1,73 @@
# alktty fuzzing
cargo-fuzz targets for the wire-facing parse surfaces. The design and
operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
`docs/research/fuzzing.md`); this README is the operational cheat-sheet.
## Layout
- `fuzz_targets/`: nightly-only `fuzz_target!` binaries (thin wrappers).
- `shared/` is the stable-toolchain library holding the invariant logic; the
corpus replay tests run here on plain `cargo test`.
- `corpus/<target>/` holds the committed seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py`).
- `artifacts/`: gitignored crash/oom/timeout artifacts + campaign logs.
## Targets
| Target | Drives |
|---|---|
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` |
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend: the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
## Running a campaign (always detached)
Agent sessions must never run fuzzing in the foreground (an OOM in a
target can take down the session host; see docs/plans/fuzzing.md §3).
Use the detached runner:
```bash
fuzz/run-detached.sh chunk_frame
# poll:
tail -n 50 fuzz/artifacts/chunk_frame-*.log
ls fuzz/artifacts/chunk_frame/
pgrep -f "cargo fuzz run chunk_frame"
```
`FUZZ_RUNTIME_SECS=1800 fuzz/run-detached.sh negotiation_frame` for a
longer campaign. The runner pins `-fork=1 -rss_limit_mb=2048
-malloc_limit_mb=2048 -timeout=25` and detaches via `setsid` + `nohup`.
## Corpus replay (the standing fuzz gate)
```bash
cargo test --manifest-path fuzz/shared/Cargo.toml
```
Replays every committed seed through the same invariant functions the
fuzzer runs, on stable, no nightly needed. Part of the release
verification checklist (AGENTS.md).
## Toolchain notes
`rust-toolchain.toml` pins nightly for this subtree only (llvm-tools
required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88.
`cargo fuzz build` must be run with the fuzz dir as CWD or trust the
toolchain file (rustup resolves per directory).
Run campaigns before releases and after touching `src/wire.rs`,
`src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump.
## session_opseq notes
The op-sequence target is inherently slower than the byte-format targets
(each input drives a full tokio session with bounded reads and yields,
which means tens of exec/s per worker, not millions). That is expected: the value is
state-space exploration, not throughput. It uses `tokio::time::timeout`
internally, so the nightly target needs the `--features time` tokio
surface already present via the shared crate's deps; no extra flags.
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
semantics) are the ones unit tests assert individually; the fuzzer
explores interleavings no example test can enumerate.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff. Show more