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/
This commit is contained in:
glm-5.3-flash committed 2026-11-01 13:11:32 +00:00
1 parent e9e01c04e1
commit 58da42a021
39 files changed
+1310 -1295

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
+71 -60
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,13 @@ 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: 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 +39,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 +47,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 +61,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 +108,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 +117,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,90 +134,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; since alkcall 0.6.0 `channel:open_failed`, not an in-band frame; since alkcall 0.6.0
ADR-010 §2A the establisher also allocates — the pump handler ADR-010 §2A the establisher also allocates, and the pump handler
only pumps). Terminal sessions are resources per 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)`
@@ -228,6 +228,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.
@@ -238,27 +249,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 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 The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
seed through the same invariant functions the cargo-fuzz targets run — seed through the same invariant functions the cargo-fuzz targets run,
on stable, no nightly needed. Run detached campaigns on stable, no nightly needed. Run detached campaigns
(`fuzz/run-detached.sh <target>` — never foreground fuzzing) before a (`fuzz/run-detached.sh <target>`, never foreground fuzzing) before a
release and after touching `src/wire.rs`, `src/negotiation.rs`, release and after touching `src/wire.rs`, `src/negotiation.rs`,
`src/control.rs`, the adapter, or the session pump. `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 +
@@ -268,46 +279,46 @@ release and after touching `src/wire.rs`, `src/negotiation.rs`,
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
@@ -320,11 +331,11 @@ release and after touching `src/wire.rs`, `src/negotiation.rs`,
`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.10.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.10.1"` (0.10.1 is the working around them. Pin `alkcall = "0.10.1"` (0.10.1 is the
adoption-window ordering fix: the `adopt_channel` install→drain adoption-window ordering fix: the `adopt_channel` install→drain
window is atomic, so a routed chunk can no longer jump ahead of 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 parked early arrivals or get parked forever past the drain), and bump
deliberately. deliberately.
+64 -64
View File
@@ -6,7 +6,7 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.8.1] - 2026-10-22 ## [0.8.1] - 2026-10-22
The alkcall 0.10.1 adoption: a dependency bump only — no source change 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 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 013, routed from alkgit's wave-2 gate): a chunk routed by the demux
inside the `adopt_channel` install→drain window could be delivered inside the `adopt_channel` install→drain window could be delivered
@@ -15,7 +15,7 @@ byte-stream order) and a related chunk could sit parked forever past
the drain. alktty adopts channels only as a consumer-side client the drain. alktty adopts channels only as a consumer-side client
(`TtySession::open_via_channels`, which opens and then adopts the (`TtySession::open_via_channels`, which opens and then adopts the
session channel), so the fix applies to every channels-path session session channel), so the fix applies to every channels-path session
under multi-process load — the reassembled chunk stream now preserves under multi-process load: the reassembled chunk stream now preserves
wire-arrival order through the adoption window. The surface of the fix wire-arrival order through the adoption window. The surface of the fix
is private to alkcall (`ChannelState` and the `install_locked` path): is private to alkcall (`ChannelState` and the `install_locked` path):
no API, wire-format, or documented-cap change; the behavior only no API, wire-format, or documented-cap change; the behavior only
@@ -26,14 +26,14 @@ at all.
- **`alkcall` requirement updated to `0.10.1`; lockfile bumped from - **`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.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 `0.10` caret family; alkcall 0.10.1 changed no type alktty touches,
— the fix surface is private). alktty's public signatures reference and the fix surface is private). alktty's public signatures reference
alkcall types, so downstream crates composing alktty with the fixed alkcall types, so downstream crates composing alktty with the fixed
alkcall pick this patch bump up on their next `cargo update`. alkcall pick this patch bump up on their next `cargo update`.
## [0.8.0] - 2026-10-19 ## [0.8.0] - 2026-10-19
The alkcall 0.10.0 adoption: a dependency bump only — no source change 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 in alktty. alkcall 0.10.0 is the gateway ownership seam release
(`GatewayDispatch::with_ownership_provider`, the declared (`GatewayDispatch::with_ownership_provider`, the declared
static-fallback opt-in, and the loud unwired-spine default). All of it static-fallback opt-in, and the loud unwired-spine default). All of it
@@ -46,26 +46,26 @@ trait, and alktty's public API surface are unchanged.
### Changed ### Changed
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.10.0.** - **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.10.0.**
alkcall 0.10.0 changed no type that alktty touches: the seam alktty alkcall 0.10.0 changed no type that alktty touches: the seam alktty
exercises (`ChannelCore`/`register_openable_with_establisher`/ exercises (`ChannelCore`/`register_openable_with_establisher`/
`OpenEstablisher`/`OpenHandler`/`Establishment`, `ChannelClient`, `OpenEstablisher`/`OpenHandler`/`Establishment`, `ChannelClient`,
`OwnershipProvider`/`OwnershipStore`/`InMemoryOwnershipStore`) is `OwnershipProvider`/`OwnershipStore`/`InMemoryOwnershipStore`) is
shape-identical to 0.9.0 — the loud unwired-spine default applies shape-identical to 0.9.0; the loud unwired-spine default applies
only to `resource_type`-gated ops served through `GatewayDispatch`, only to `resource_type`-gated ops served through `GatewayDispatch`,
which alktty never registers against. alktty's public signatures which alktty never registers against. alktty's public signatures
reference alkcall types, so downstream crates must now unify on reference alkcall types, so downstream crates must now unify on
alkcall 0.10.0. Minor bump per the 0.x ecosystem-coordination rule 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 (raising the minimum requirement is breaking, hence 0.8.0, not a
patch release). patch release).
- **`alkcall` requirement updated to `0.10.0`; lockfile bumped from - **`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 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 baseline (113 default / 137 all-features); the adoption required no
source change in alktty. source change in alktty.
## [0.7.0] - 2026-10-14 ## [0.7.0] - 2026-10-14
The alkcall 0.9.0 adoption: a dependency bump only — no source change 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 in alktty. alkcall 0.9.0 is the stub-sweep release (the marked-pub open
contract, `channel/control`, `channel/resources/subscribe`, and contract, `channel/control`, `channel/resources/subscribe`, and
`op/register` forward-through are real implementations). All of `op/register` forward-through are real implementations). All of
@@ -73,19 +73,19 @@ alkcall's semver-relevant changes (`ManagerError::InvalidAdopt`, the
`RelayError::PubTypedOpen` removal, the `register_imported` `RelayError::PubTypedOpen` removal, the `register_imported`
`Result` return, `Establishment` losing `Debug`, the new `Result` return, `Establishment` losing `Debug`, the new
`register_openable*` refusal of `Pub`-typed specs) touch surfacing `register_openable*` refusal of `Pub`-typed specs) touch surfacing
alktty never uses — alktty's open op is `Sub`-typed and it only alktty never uses: alktty's open op is `Sub`-typed and it only
constructs `Establishment` (whose `new` is unchanged). The wire format, constructs `Establishment` (whose `new` is unchanged). The wire format,
the direct-ALPN path, the `TtyBackend` trait, and alktty's public API the direct-ALPN path, the `TtyBackend` trait, and alktty's public API
surface are unchanged. surface are unchanged.
### Changed ### Changed
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.9.0.** - **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.9.0.**
alkcall 0.9.0 changed no type that alktty touches: the open-op alkcall 0.9.0 changed no type that alktty touches: the open-op
machinery alktty registers through (`ChannelCore`/ machinery alktty registers through (`ChannelCore`/
`register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler` `register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler`
/`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`, /`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`,
`ChannelOpenError::CallFailed`) is shape-identical to 0.8.1 — the `ChannelOpenError::CallFailed`) is shape-identical to 0.8.1: the
four stub implementations are net-new surfaces on ops alktty does four stub implementations are net-new surfaces on ops alktty does
not exercise, the established-wrapper refusal of `Pub`-typed specs not exercise, the established-wrapper refusal of `Pub`-typed specs
does not apply to alktty's `Sub`-typed open op, and the removed does not apply to alktty's `Sub`-typed open op, and the removed
@@ -93,18 +93,18 @@ surface are unchanged.
signatures reference alkcall types, so downstream crates must now signatures reference alkcall types, so downstream crates must now
unify on alkcall 0.9.0. Minor bump per the 0.x unify on alkcall 0.9.0. Minor bump per the 0.x
ecosystem-coordination rule (raising the minimum requirement is ecosystem-coordination rule (raising the minimum requirement is
breaking — hence 0.7.0, not a patch release). breaking, hence 0.7.0, not a patch release).
- **`alkcall` requirement updated to `0.9.0`; lockfile bumped from - **`alkcall` requirement updated to `0.9.0`; lockfile bumped from
0.8.1 to 0.9.0.** 0.8.1 to 0.9.0.**
## [0.6.0] - 2026-09-28 ## [0.6.0] - 2026-09-28
The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing
parse surfaces, with zero findings in the crate — every crash the parse surfaces, with zero findings in the crate; every crash the
campaigns produced was a harness-model gap, and every fix landed in the campaigns produced was a harness-model gap, and every fix landed in the
harness, not the crate. The `alkcall` requirement also updates to harness, not the crate. The `alkcall` requirement also updates to
0.8.1. No API, wire-format, or behavior change; this release ships no 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, source change in `src/` at all: the deltas are the fuzz workspace,
the dependency bump, and this changelog. Minor bump per the 0.x the dependency bump, and this changelog. Minor bump per the 0.x
ecosystem-coordination rule (alktty's public signatures reference ecosystem-coordination rule (alktty's public signatures reference
alkcall types, so downstream crates must unify on alkcall 0.8.1). alkcall types, so downstream crates must unify on alkcall 0.8.1).
@@ -115,20 +115,20 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1).
`docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook): `docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook):
four targets over the wire-facing surfaces, with the invariant logic four targets over the wire-facing surfaces, with the invariant logic
in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus
replays on stable — the nightly pin is scoped to the subtree via replays on stable; the nightly pin is scoped to the subtree via
`fuzz/rust-toolchain.toml`: `fuzz/rust-toolchain.toml`:
- `chunk_frame` — the 5-byte chunk codec - `chunk_frame`: the 5-byte chunk codec
(`ChunkReader`/`ChunkWriter`), including the (`ChunkReader`/`ChunkWriter`), including the
peek/disambiguation seam (`peek_stream_type` vs peek/disambiguation seam (`peek_stream_type` vs
`read_chunk_after_peek` in both call orders must yield exactly one `read_chunk_after_peek` in both call orders must yield exactly one
chunk). chunk).
- `negotiation_frame` — the 4-byte BE length-prefixed negotiation - `negotiation_frame`: the 4-byte BE length-prefixed negotiation
framing + `NegotiateRequest` JSON parse + `error_response_bytes`, framing + `NegotiateRequest` JSON parse + `error_response_bytes`,
and the cross-codec invariant that an error-response frame's and the cross-codec invariant that an error-response frame's
length-prefix high byte is `0x00` so a `ChunkReader` peek length-prefix high byte is `0x00` so a `ChunkReader` peek
distinguishes it from a chunk (ADR-001 §5). distinguishes it from a chunk (ADR-001 §5).
- `control_json` — `ControlMessage` JSON parse + `signal_from_name`. - `control_json`: `ControlMessage` JSON parse + `signal_from_name`.
- `session_opseq` — the stateful target: a `#[derive(Arbitrary)]` - `session_opseq`, the stateful target: a `#[derive(Arbitrary)]`
12-op sequence drives the real producer session pump 12-op sequence drives the real producer session pump
(`drive_session_pre_negotiated`) over a duplex pair against a (`drive_session_pre_negotiated`) over a duplex pair against a
fuzz-local mock backend, asserting the wire-contract invariants fuzz-local mock backend, asserting the wire-contract invariants
@@ -146,12 +146,12 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1).
same invariant functions the targets run, on stable. Added to the same invariant functions the targets run, on stable. Added to the
verification checklist. verification checklist.
- **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh` - **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh`
(setsid/nohup, fork mode, bounded RSS) — never in the foreground of (setsid/nohup, fork mode, bounded RSS), never in the foreground of
an agent session. an agent session.
### Changed ### Changed
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.1.** - **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.8.1.**
alkcall 0.8.1 is a patch release fixing the duplicate alkcall 0.8.1 is a patch release fixing the duplicate
adopt/open channel-state destruction found by alkcall's own fuzz adopt/open channel-state destruction found by alkcall's own fuzz
campaign; it changed no type alktty touches. Downstream crates must campaign; it changed no type alktty touches. Downstream crates must
@@ -170,20 +170,20 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1).
- 0 findings across all four targets: detached campaigns (10 min each - 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, 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 cov 3934 → 4075 and still growing at budget end) plus a 60s smoke
run on target 4 — no crash/OOM/timeout/leak. run on target 4: no crash/OOM/timeout/leak.
- Corpus replay 9 tests green on stable (340 seeds); crate tests 113 - Corpus replay 9 tests green on stable (340 seeds); crate tests 113
default / 156 all-features; clippy stable + wasm; fmt; doc; wasm default / 156 all-features; clippy stable + wasm; fmt; doc; wasm
check — all pass. check: all pass.
- The three crashes the harness-model smoke runs surfaced were - The three crashes the harness-model smoke runs surfaced were
harness gaps, each fixed in the fuzz harness (the alkcall §7.7 harness gaps, each fixed in the fuzz harness (the alkcall §7.7
pattern): a failed client write can mean the session completed and pattern): a failed client write can mean the session completed and
dropped the server duplex half mid-write — the harness drains and dropped the server duplex half mid-write; the harness drains and
requires the exit chunk on any write/shutdown error (a premature requires the exit chunk on any write/shutdown error (a premature
close is a finding). No adapter code changed. close is a finding). No adapter code changed.
## [0.5.0] - 2026-09-18 ## [0.5.0] - 2026-09-18
The alkcall 0.8.0 adoption: a dependency bump only — no source change 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 in alktty. alkcall 0.8.0 landed review 008's remediation (the
establisher reply projection, flavor-form open-op ids in discovery, the establisher reply projection, flavor-form open-op ids in discovery, the
in-tree `ChannelRelay` + `HubLegImports`/`HubLegTemplate`) plus a in-tree `ChannelRelay` + `HubLegImports`/`HubLegTemplate`) plus a
@@ -194,12 +194,12 @@ trait, and alktty's public API surface are unchanged.
### Changed ### Changed
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.0.** - **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.8.0.**
alkcall 0.8.0 changed no type that alktty touches: the open-op alkcall 0.8.0 changed no type that alktty touches: the open-op
machinery alktty registers through (`ChannelCore`/ machinery alktty registers through (`ChannelCore`/
`register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler` `register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler`
/`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`, /`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`,
`ChannelOpenError::CallFailed`) is shape-identical to 0.7.1 — the `ChannelOpenError::CallFailed`) is shape-identical to 0.7.1: the
new `open_channel_with_reply` / reply-field / relay / hub-leg new `open_channel_with_reply` / reply-field / relay / hub-leg
surfaces are additive and alktty exercises none of them. alktty's surfaces are additive and alktty exercises none of them. alktty's
own open op is the standard-shape name `channels/tty/sub`, so the own open op is the standard-shape name `channels/tty/sub`, so the
@@ -211,13 +211,13 @@ trait, and alktty's public API surface are unchanged.
the 0.x ecosystem-coordination rule. the 0.x ecosystem-coordination rule.
- **`alkcall` requirement updated to `0.8.0`; lockfile bumped from - **`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 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 baseline (113 default / 156 all-features); the adoption required no
source change in alktty. source change in alktty.
## [0.4.1] - 2026-09-10 ## [0.4.1] - 2026-09-10
The MSRV floor becomes honest: `rust-version` raises from 1.85 to 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 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 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; 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 iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0
@@ -229,9 +229,9 @@ update`.
### Changed ### Changed
- **`rust-version` 1.85 → 1.88.** Verified on a real 1.88 toolchain - **`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 — (full test suite, clippy). alktty's own code is 1.85-clean
`cargo check` under a 1.85 toolchain passes with the pre-bump (`cargo check` under a 1.85 toolchain passes with the pre-bump
lockfile — so the raise is dependency-driven only and needed no lockfile), so the raise is dependency-driven only and needed no
clippy fixes. Behavior-identical. clippy fixes. Behavior-identical.
- **`alkcall` requirement updated to `0.7.1`; lockfile bumped from - **`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 0.7.0 to 0.7.1.** Version-requirement-only (0.7.x is already the
@@ -246,7 +246,7 @@ on the channels path. The wire format, the direct-ALPN path, the
### Changed ### Changed
- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.7.0.** - **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.7.0.**
alkcall 0.7.0 changed no type that alktty touches alkcall 0.7.0 changed no type that alktty touches
(`OpenEstablisher`/`OpenHandler`/`Establishment`/`ChannelPlan` are (`OpenEstablisher`/`OpenHandler`/`Establishment`/`ChannelPlan` are
identical to 0.6.0; alktty never constructs `ServingConfig`), but identical to 0.6.0; alktty never constructs `ServingConfig`), but
@@ -256,7 +256,7 @@ on the channels path. The wire format, the direct-ALPN path, the
- **The channels establisher and pump handler take their identity - **The channels establisher and pump handler take their identity
from the per-call `auth` (CF-006 alignment).** `register_openable` from the per-call `auth` (CF-006 alignment).** `register_openable`
no longer captures the install-time identity and overrides the no longer captures the install-time identity and overrides the
establisher/handler `auth.identity` with it — post-alkcall-0.7 the establisher/handler `auth.identity` with it: post-alkcall-0.7, the
per-call context already carries the dispatch-resolved opener (the per-call context already carries the dispatch-resolved opener (the
same identity view the registry's ACL gate checked; the end client, same identity view the registry's ACL gate checked; the end client,
not the hub, on hub-forwarded opens). Behavior-identical in the not the hub, on hub-forwarded opens). Behavior-identical in the
@@ -268,7 +268,7 @@ on the channels path. The wire format, the direct-ALPN path, the
## [0.3.0] - 2026-09-07 ## [0.3.0] - 2026-09-07
The alkcall 0.6.0 adoption (review 007 R-01 — the `Establishment` The alkcall 0.6.0 adoption (review 007 R-01, the `Establishment`
plan payload): backend allocation moves into the channels establisher. plan payload): backend allocation moves into the channels establisher.
Breaking on the channels path's allocation-failure shape; the wire Breaking on the channels path's allocation-failure shape; the wire
format, the direct-ALPN path, and the `TtyBackend` trait are format, the direct-ALPN path, and the `TtyBackend` trait are
@@ -276,11 +276,11 @@ unchanged.
### Changed ### Changed
- **BREAKING — `alkcall` bumped to 0.6.0; channels-path allocation - **BREAKING: `alkcall` bumped to 0.6.0; channels-path allocation
failure changes shape (ADR-010 §2A).** alkcall 0.6.0 filled the failure changes shape (ADR-010 §2A).** alkcall 0.6.0 filled the
reserved `Establishment` field (`plan: Option<ChannelPlan>`, reserved `Establishment` field (`plan: Option<ChannelPlan>`,
review 007 R-01), so `backend.allocate` moved from the pump handler review 007 R-01), so `backend.allocate` moved from the pump handler
into the establisher — the allocated `TtyHandle` crosses to the into the establisher; the allocated `TtyHandle` crosses to the
handler via the plan payload (a private per-open one-shot slot; 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 handle is not `Sync`). Allocation failure now fails the open op
itself: `TtySessionError::ChannelsOpen(CallFailed{ itself: `TtySessionError::ChannelsOpen(CallFailed{
@@ -296,7 +296,7 @@ unchanged.
- Handler closures in `make_tty_open_handler` gain the - Handler closures in `make_tty_open_handler` gain the
`Option<ChannelPlan>` parameter (alkcall 0.6 `OpenHandler` shape); `Option<ChannelPlan>` parameter (alkcall 0.6 `OpenHandler` shape);
a `None` plan (no-establisher registration) falls back to the a `None` plan (no-establisher registration) falls back to the
pre-0.3.0 inline validate-and-allocate — defense-in-depth, keeping pre-0.3.0 inline validate-and-allocate, defense-in-depth, keeping
the in-band error-frame vocabulary for that registration shape. the in-band error-frame vocabulary for that registration shape.
- New tests: establisher unit gate for allocate-in-establisher (plan - New tests: establisher unit gate for allocate-in-establisher (plan
slot carries the live handle; failure maps to `dial_failed`), the slot carries the live handle; failure maps to `dial_failed`), the
@@ -305,7 +305,7 @@ unchanged.
## [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.
@@ -316,15 +316,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
@@ -338,13 +338,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
@@ -355,8 +355,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"`.
@@ -369,7 +369,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.
@@ -378,21 +378,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
@@ -406,9 +406,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;
@@ -422,14 +422,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` +
@@ -439,12 +439,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:
@@ -460,7 +460,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).
@@ -470,7 +470,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
@@ -488,13 +488,13 @@ 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.
+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; amended 2026-09-07 §2A for alkcall 0.6's plan payload — allocation moves into the establisher, no in-band frame from a registered producer) | | [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-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,31 +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 wait (`Child::wait()`; on Unix now the - **Waiter thread** (blocking wait; `Child::wait()`, on Unix now the
`libc::waitpid` reap — ADR-004). This is a syscall that `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
@@ -58,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
@@ -71,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
@@ -90,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
@@ -104,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.
@@ -118,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
@@ -128,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`
@@ -139,7 +139,7 @@ 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 blocking wait returns; the waiter child to exit; the waiter thread's blocking wait returns; the waiter
@@ -155,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.
@@ -169,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
@@ -199,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:**
@@ -240,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.
@@ -264,33 +264,33 @@ seam.
exits; the waiter thread's blocking 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),
@@ -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,22 +8,22 @@ 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): Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0, review 007 R-01):
`Establishment` gained its plan payload, so `allocate` also moved `Establishment` gained its plan payload, so `allocate` also moved
into the establisher — the in-band error-frame path on this channel into the establisher; the in-band error-frame path on this channel
shrinks to nothing from a registered producer (the frames remain the shrinks to nothing from a registered producer (the frames remain the
defense-in-depth fallback for no-establisher registrations and the defense-in-depth fallback for no-establisher registrations and the
direct path). See ADR-010 §2A. direct path). See ADR-010 §2A.
@@ -34,7 +34,7 @@ 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
@@ -45,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
@@ -70,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
@@ -106,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}`).
@@ -132,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
@@ -143,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
@@ -161,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.
@@ -190,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.
@@ -198,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
@@ -207,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,14 +3,14 @@
## 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 **Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2, review
007 R-01):** `Establishment` gained its plan payload 007 R-01):** `Establishment` gained its plan payload
(`ChannelPlan = Arc<dyn Any + Send + Sync>`), removing §2's (`ChannelPlan = Arc<dyn Any + Send + Sync>`), removing §2's
payloadless-`Establishment` blocker. Backend `allocate` moves into payloadless-`Establishment` blocker. Backend `allocate` moves into
@@ -35,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
@@ -43,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 }`
@@ -60,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` |
@@ -81,24 +82,24 @@ 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` | | **allocation failure** (amended, §2A) | `DialFailed` | `dial_failed` |
| establishment deadline | (wrapper) | `timeout` | | establishment deadline | (wrapper) | `timeout` |
`resource_shortage` has no TTY producer path (allocation-capacity `resource_shortage` has no TTY producer path (allocation-capacity
failures map to `dial_failed` — the target refused or the backend failures map to `dial_failed`: the target refused or the backend
lacked capacity; §2A). `HandlerError` covers both the lacked capacity; §2A). `HandlerError` covers both the
malformed-negotiation semantics (the request shape is wrong) and the malformed-negotiation semantics (the request shape is wrong) and the
ACL-outcome ownership denial (an authorization failure, not a dial ACL-outcome ownership denial (an authorization failure, not a dial
failure). 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 > **Amended away (2026-09-07; see §2A).** This section is the
> historical record of the pre-0.3.0 shape; its blocker 1 > historical record of the pre-0.3.0 shape; its blocker 1
> (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049 > (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049
> amendment 2, review 007 R-01). Blocker 2 (double-`allocate` > amendment 2, review 007 R-01). Blocker 2 (double-`allocate`
> unsoundness) is avoided by construction in the amended shape: the > unsoundness) is avoided by construction in the amended shape: the
> establisher's handle *is* the handle the handler pumps — there is no > establisher's handle *is* the handle the handler pumps; there is no
> second allocate to be unsound about. > 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
@@ -113,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
@@ -139,18 +140,18 @@ frame.
alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the
reserved field: `Establishment { plan: Option<ChannelPlan> }` with reserved field: `Establishment { plan: Option<ChannelPlan> }` with
`ChannelPlan = Arc<dyn Any + Send + Sync>` — typed-opaque, wrapper `ChannelPlan = Arc<dyn Any + Send + Sync>` (typed-opaque, wrapper
threaded to the `OpenHandler`'s new second parameter. §2's blocker 1 threaded to the `OpenHandler`'s new second parameter). §2's blocker 1
is gone; the double-`allocate` hazard (blocker 2) is structurally is gone; the double-`allocate` hazard (blocker 2) is structurally
absent — one allocate per open, whose result the handler consumes. absent: one allocate per open, whose result the handler consumes.
The amended shape: The amended shape:
1. **The establisher allocates.** After the validation sequence (§1), 1. **The establisher allocates.** After the validation sequence (§1),
it runs `backend.allocate(&params)`. Failure maps to it runs `backend.allocate(&params)`. Failure maps to
`EstablishmentError::DialFailed` (`details.reason == "dial_failed"` `EstablishmentError::DialFailed` (`details.reason == "dial_failed"`:
— the target refused or the backend lacked capacity; one string the target refused or the backend lacked capacity; one string
message, no nuance — the same granularity the direct path's message, no nuance, the same granularity the direct path's
`allocate_failed` frame always had). The `allocate_failed` frame always had). The
`channel:open_failed` `ErrorDefinition` on `tty_open_spec()` `channel:open_failed` `ErrorDefinition` on `tty_open_spec()`
therefore declares four reachable reasons (`dial_failed`, therefore declares four reachable reasons (`dial_failed`,
@@ -159,7 +160,7 @@ The amended shape:
(boxed dyn streams), so it cannot be the `ChannelPlan` directly. (boxed dyn streams), so it cannot be the `ChannelPlan` directly.
The establisher wraps it in a private `AllocatedHandle` one-shot The establisher wraps it in a private `AllocatedHandle` one-shot
slot (`Mutex<Option<TtyHandle>>`), created **per establisher slot (`Mutex<Option<TtyHandle>>`), created **per establisher
invocation** — per open. The wrapper moves this open's plan to this invocation**, one per open. The wrapper moves this open's plan to this
open's handler (alkcall's concurrent-same-resource test pins that open's handler (alkcall's concurrent-same-resource test pins that
plans are never shared), the handler `take()`s it exactly once, and plans are never shared), the handler `take()`s it exactly once, and
establisher-before-handler ordering is alkcall's wrapper guarantee establisher-before-handler ordering is alkcall's wrapper guarantee
@@ -167,8 +168,8 @@ The amended shape:
construction: there is no shared slot. construction: there is no shared slot.
3. **The handler pumps the pre-allocated handle.** With a plan it 3. **The handler pumps the pre-allocated handle.** With a plan it
calls `drive_session_pre_allocated` (new `pub(crate)` driver: the calls `drive_session_pre_allocated` (new `pub(crate)` driver: the
pumps directly, no validation pass). With `plan: None` — a pumps directly, no validation pass). With `plan: None` (a
no-establisher registration — it falls back to no-establisher registration), it falls back to
`drive_session_pre_negotiated`, whose inline validate-and-allocate `drive_session_pre_negotiated`, whose inline validate-and-allocate
keeps the full in-band error-frame vocabulary. That fallback is keeps the full in-band error-frame vocabulary. That fallback is
defense-in-depth for the assembly-layer freedom to register without defense-in-depth for the assembly-layer freedom to register without
@@ -176,7 +177,7 @@ The amended shape:
4. **Consumer surface.** `TtySession::open_via_channels` sees 4. **Consumer surface.** `TtySession::open_via_channels` sees
allocation failure as allocation failure as
`ChannelsOpen(CallFailed{ channel:open_failed, reason: `ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })` — a typed call error, branchable and retryable dial_failed })`, a typed call error, branchable and retryable
like every other establishment class. The `0x00` peek on the like every other establishment class. The `0x00` peek on the
channel stream becomes a formality that no registered producer channel stream becomes a formality that no registered producer
ever exercises; the pinned test flipped with the contract ever exercises; the pinned test flipped with the contract
@@ -185,8 +186,8 @@ The amended shape:
failure_to_dial_failed` in `channels.rs`). failure_to_dial_failed` in `channels.rs`).
The R-02 lifetime contract (the handler's `JoinHandle` tracks the The R-02 lifetime contract (the handler's `JoinHandle` tracks the
data-plane lifetime) was already alktty's shape — the open handler data-plane lifetime) was already alktty's shape (the open handler
awaits the driver inline in its spawned task — so the amendment adds awaits the driver inline in its spawned task), so the amendment adds
no lifetime changes. no lifetime changes.
### 3. The direct-ALPN path is unchanged ### 3. The direct-ALPN path is unchanged
@@ -194,21 +195,21 @@ no lifetime changes.
`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").
@@ -224,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 })`** | | **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 })` |
`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
@@ -241,11 +242,11 @@ 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), `dial_failed` (capacity/target retry), `timeout` (maybe retry), `dial_failed` (capacity/target
refused — maybe retry; amended §2A, was the in-band refused, maybe retry; amended §2A, was the in-band
`allocate_failed`). `allocate_failed`).
- The consumer's `open_via_channels` failure is a typed call error — - The consumer's `open_via_channels` failure is a typed call error;
no peeking at the data stream for anything but the (now no peeking at the data stream for anything but the (now
unreachable-from-registered-producers) error-frame formality. unreachable-from-registered-producers) error-frame formality.
@@ -257,7 +258,7 @@ 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).
- **Amendment (0.3.0):** allocation failure changed shape again — - **Amendment (0.3.0):** allocation failure changed shape again:
`NegotiationRejected{ "allocate_failed" }` (in-band) → `NegotiationRejected{ "allocate_failed" }` (in-band) →
`ChannelsOpen(CallFailed{ channel:open_failed, reason: `ChannelsOpen(CallFailed{ channel:open_failed, reason:
dial_failed })`. The direct path's `allocate_failed` frame is dial_failed })`. The direct path's `allocate_failed` frame is
@@ -266,34 +267,34 @@ on `establishment_reason()` without unwrapping strings.
## 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 a *second* establisher-side allocate - ADR-005 (kill-on-`Drop`: why a *second* establisher-side allocate
would be unsound; avoided by construction in the §2A amendment — would be unsound; avoided by construction in the §2A amendment,
one allocate per open, consumed by the handler) one allocate per open, consumed by the handler)
- ADR-001 (the §5 `0x00` disambiguation — retained for the direct - ADR-001 (the §5 `0x00` disambiguation, retained for the direct
path; the channels-path peek is now a formality) path; the channels-path peek is now a formality)
- ADR-002 (backend selection/allocation ownership) - ADR-002 (backend selection/allocation ownership)
- alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment` - alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment`
plan payload — the upstream capability this ADR's §2A adopts) plan payload; the upstream capability this ADR's §2A adopts)
- `src/channels.rs` — `make_tty_establisher`, `AllocatedHandle`, - `src/channels.rs`: `make_tty_establisher`, `AllocatedHandle`,
`make_tty_open_handler`, `tty_open_spec` `make_tty_open_handler`, `tty_open_spec`
- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels` - `src/session.rs`: `TtySessionError::ChannelsOpen`, `open_via_channels`
- `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed` - `src/testing.rs`: the pinned `allocate_failure_fails_open_as_dial_failed`
test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`) 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)
+50 -50
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
@@ -180,29 +180,29 @@ the semantic classes (unknown backend, malformed negotiation,
ownership denial) **and allocation** are rejected by the establisher ownership denial) **and allocation** are rejected by the establisher
before the open reply and surface as `channel:open_failed` call before the open reply and surface as `channel:open_failed` call
errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's
`Establishment` plan payload — ADR-010 §2A) — the in-band frames on `Establishment` plan payload; ADR-010 §2A), so the in-band frames on
that path are defense-in-depth arms only (reachable from that path are defense-in-depth arms only (reachable from
no-establisher registrations). 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` — 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":"..."}` | | `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
@@ -223,7 +223,7 @@ adapter's session driver, not in the backend. The ordering:
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 (the waiter thread's blocking wait returns when the child is drains (the waiter thread's blocking wait returns when the child is
reaped, which reaped, which
happens after the child exits and its stdout drains — ADR-004 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 blocks on after the child is reaped (the local backend's waiter thread blocks on
@@ -239,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
@@ -269,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
@@ -283,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
@@ -293,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.
@@ -309,7 +309,7 @@ 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 blocking wait reaps it and exits, and the waiter thread's blocking wait reaps it and exits, and the
@@ -320,13 +320,13 @@ architectural commitment.
- **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
@@ -342,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
@@ -377,33 +377,33 @@ architectural commitment.
## 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`); since alkcall 0.6 the establisher also (`channel:open_failed`); since alkcall 0.6 the establisher also
allocates (the `TtyHandle` crosses via the plan payload), so no allocates (the `TtyHandle` crosses via the plan payload), so no
failure class arrives in-band from a registered producer failure class arrives in-band from a registered producer
- alknet ADR-050 — the ownership model the adapter's access control - 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
+51 -51
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,24 +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()`; on the direct path the adapter sends `allocate()`; on the direct path the adapter sends
`{"error":"allocate_failed",...}` and closes (tty-adapter.md `{"error":"allocate_failed",...}` and closes (tty-adapter.md
§"Negotiation errors"); on the channels path the establisher maps it §"Negotiation errors"); on the channels path the establisher maps it
to `channel:open_failed` / `dial_failed` (ADR-010 §2A). to `channel:open_failed` / `dial_failed` (ADR-010 §2A).
- `WaitFailed` — the backend couldn't reap the child / determine the - `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.
@@ -163,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
@@ -180,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()`:
@@ -197,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()?;
@@ -209,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>,
@@ -261,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
@@ -278,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
@@ -288,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,
@@ -298,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
@@ -339,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
@@ -367,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.
@@ -383,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>`
@@ -410,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`)
+54 -54
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,14 +267,14 @@ 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`: `ExitStatus::code()` on normal exit; on **Exit code.** `code` is `i32`: `ExitStatus::code()` on normal exit; on
Unix, the negative signal number on signal-terminated exit — the crate's Unix, the negative signal number on signal-terminated exit; this is the
own convention, not `ExitStatus::code()`'s, which returns `None` for crate's own convention, not `ExitStatus::code()`'s, which returns `None`
signal termination. `-1` is the adapter's "could not determine" sentinel for signal termination. `-1` is the adapter's "could not determine" sentinel
and (via the `-N` encoding) the SIGHUP value — the SIGHUP/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 collision is a documented edge case (ADR-004). The exit chunk is the
last control chunk before stream close (ADR-004). last control chunk before stream close (ADR-004).
@@ -283,7 +283,7 @@ 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.
@@ -292,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
@@ -306,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
@@ -326,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
@@ -341,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
@@ -370,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
+41 -41
View File
@@ -1,6 +1,6 @@
# Fuzzing alktty: Plan # Fuzzing alktty: Plan
**Status:** adopted — implementation in progress (see §8 progress log) **Status:** adopted; implementation in progress (see §8 progress log)
**Date:** 2026-09-28 **Date:** 2026-09-28
**Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the **Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the
alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached
@@ -13,7 +13,7 @@ re-derived).
**Yes.** Alktty is the *named follow-up* in the alkcall research: §5 **Yes.** Alktty is the *named follow-up* in the alkcall research: §5
"Non-surfaces" explicitly defers the downstream parsers to "downstream "Non-surfaces" explicitly defers the downstream parsers to "downstream
crates' fuzz targets" — the channels layer carries opaque `Bytes` and the crates' fuzz targets": the channels layer carries opaque `Bytes`, and the
handler owns sub-stream framing (ADR-035). Alktty is that handler, and it 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 owns four parse surfaces fed by arbitrary peers on both transports (direct
ALPN and channels): ALPN and channels):
@@ -21,7 +21,7 @@ ALPN and channels):
| # | Surface | Location | Shape | | # | 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` | | 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) | | 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 | | 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 | | 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure |
@@ -29,7 +29,7 @@ Reasons this pays off here, beyond the generic alkcall argument:
1. **Semantic invariants no example test explores adversarially.** The 1. **Semantic invariants no example test explores adversarially.** The
peek/disambiguation machinery (`peek_stream_type` vs peek/disambiguation machinery (`peek_stream_type` vs
`read_chunk_after_peek` — both call orders must yield exactly one `read_chunk_after_peek`; both call orders must yield exactly one
chunk) and the cross-codec invariant that makes it sound: an chunk) and the cross-codec invariant that makes it sound: an
error-response frame's length-prefix high byte must be `0x00` so a 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 `ChunkReader` peek distinguishes it from a chunk whose first byte is a
@@ -40,14 +40,14 @@ Reasons this pays off here, beyond the generic alkcall argument:
2. **The alkcall bug class has a plausible analog here.** The duplicate 2. **The alkcall bug class has a plausible analog here.** The duplicate
adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no
unit test replays destroyed the live channel. Alktty's adapter has the unit test replays destroyed the live channel. Alktty's adapter has the
same shape — take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`), same shape: take-once `Mutex<Option<tx>>` slots (`adapter.rs:777-792`),
session re-registration, handle replacement. A stateful op-sequence session re-registration, handle replacement. A stateful op-sequence
target against the session pump + mock backend is where a real finding target against the session pump + mock backend is where a real finding
would live. would live.
3. **Cost is low and the playbook is proven.** Mirroring alkcall's 3. **Cost is low and the playbook is proven.** Mirroring alkcall's
as-built layout is a port, not a design exercise (§5 below). as-built layout is a port, not a design exercise (§5 below).
Zero parser-bug yield is a successful negative result — it converts "we Zero parser-bug yield is a successful negative result: it converts "we
think the codecs are robust" into a demonstrated property (the quinn think the codecs are robust" into a demonstrated property (the quinn
lesson cuts both ways). lesson cuts both ways).
@@ -64,8 +64,8 @@ compiled for `wasm32-unknown-unknown`.
- `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own - `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own
`[workspace]` table (nightly-only bins). `[workspace]` table (nightly-only bins).
- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain - **The invariant logic lives in `fuzz/shared/`, a stable-toolchain
library crate** — not inline in the target binaries. The `fuzz_target!` library crate**, not inline in the target binaries. The `fuzz_target!`
binaries are thin wrappers. This makes corpus replay a plain binaries are thin wrappers. This makes corpus replay a plain
`cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it `cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it
replays every committed seed through the identical invariant functions replays every committed seed through the identical invariant functions
@@ -74,8 +74,8 @@ compiled for `wasm32-unknown-unknown`.
crate stays stable at MSRV 1.88. crate stays stable at MSRV 1.88.
- Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern); - Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern);
deterministic seed generator script committed next to them. deterministic seed generator script committed next to them.
- **No hosted CI** — this repo has the same no-CI policy as alkcall - **No hosted CI**: this repo has the same no-CI policy as alkcall
(§7.9 there): the gitea host serves git only; verification and (§7.9 there), where the gitea host serves git only; verification and
campaigns are manual. Corpus replay is the standing fuzz gate; campaigns are manual. Corpus replay is the standing fuzz gate;
campaigns run via the detached runner before releases and after campaigns run via the detached runner before releases and after
touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the
@@ -91,7 +91,7 @@ session's own process tree and cgroup. A fuzz campaign is exactly the
workload shape that can kill its own host: workload shape that can kill its own host:
- **OOM-killer shared fate.** The target's worst case *is* unbounded - **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. 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 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 the largest-RSS process in the cgroup, which can be the opencode server
hosting the session. Losing that server is recoverable (sessions are hosting the session. Losing that server is recoverable (sessions are
@@ -108,7 +108,7 @@ Three layers, all standard:
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class `-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class
artifact) before the kernel gets involved. Do **not** use `ulimit -v` artifact) before the kernel gets involved. Do **not** use `ulimit -v`
with ASAN (the sanitizer reserves terabytes of virtual address space; with ASAN (the sanitizer reserves terabytes of virtual address space;
the classic failure is instant `MmapAlloc` death) — the ASAN-safe the classic failure is instant `MmapAlloc` death). The ASAN-safe
equivalent is `malloc_limit_mb`. equivalent is `malloc_limit_mb`.
2. **Fork mode as blast-radius containment (default):** `-fork=1` runs 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 each input in a short-lived child; a crash/timeout/OOM/leak kills only
@@ -144,7 +144,7 @@ alkcall §7.4).
| 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 | | 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) | | 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) | | 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 | `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) ### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one)
@@ -154,14 +154,14 @@ a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is
neither. Encoded as fuzz assertions: neither. Encoded as fuzz assertions:
- For every `error_response_bytes` output and every negotiation frame of - For every `error_response_bytes` output and every negotiation frame of
length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader` length < `MAX_CHUNK_LEN`, the first byte is `0x00`, so a `ChunkReader`
peek on `[frame bytes…]` never mistakes it for a chunk. peek on `[frame bytes…]` never mistakes it for a chunk.
- **Known boundary collision, encoded deliberately:** a frame of length - **Known boundary collision, encoded deliberately:** a frame of length
exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` — exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]`:
first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths
admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an
inherent edge of the one-byte-peek design; producers only ever send inherent edge of the one-byte-peek design; producers only ever send
tiny error frames, so it is accepted — but the assertion pins the tiny error frames, so it is accepted, but the assertion pins the
boundary so any future `MAX_CHUNK_LEN` change that widens the boundary so any future `MAX_CHUNK_LEN` change that widens the
collision gets caught. (`validate_header`'s doc comment at collision gets caught. (`validate_header`'s doc comment at
`wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the `wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the
@@ -184,7 +184,7 @@ fuzz/
│ ├── negotiation_frame.rs thin wrapper │ ├── negotiation_frame.rs thin wrapper
│ ├── control_json.rs thin wrapper │ ├── control_json.rs thin wrapper
│ └── session_opseq.rs thin wrapper (typed SessionSequence input) │ └── session_opseq.rs thin wrapper (typed SessionSequence input)
├── shared/ alktty-fuzz-shared — STABLE-toolchain library ├── shared/ alktty-fuzz-shared, STABLE-toolchain library
│ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs │ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs
├── corpus/<target>/ committed seeds ├── corpus/<target>/ committed seeds
├── gen_fuzz_seeds.py deterministic seed generator ├── gen_fuzz_seeds.py deterministic seed generator
@@ -194,12 +194,12 @@ fuzz/
Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required): Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required):
- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without - `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]`, since
it cargo auto-discovers `fuzz/shared/` into the main workspace and the without it cargo auto-discovers `fuzz/shared/` into the main workspace and the
MSRV toolchain tries to build nightly-consumed dev-deps. MSRV toolchain tries to build nightly-consumed dev-deps.
- `"fuzz/"` added to the publish `exclude` list. - `"fuzz/"` added to the publish `exclude` list.
No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`, No `#[cfg(fuzzing)]` exposure expected: `ChunkReader`/`ChunkWriter`,
`NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`, `NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`,
`ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`, `ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`,
and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub` and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub`
@@ -226,8 +226,8 @@ deterministic (no randomness) so seeds are reproducible.
## 7. Sequencing ## 7. Sequencing
1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on 1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on
alktty's own path — `TtySession::open_via_channels` and the channels alktty's own path, where `TtySession::open_via_channels` and the
establisher run on the fixed `ChannelManager`). Full verification set channels establisher run on the fixed `ChannelManager`). Full verification set
including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0" including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0"
line while touching it. **Status: see §8.** line while touching it. **Status: see §8.**
2. Scaffold `fuzz/` + root workspace table + publish exclude. 2. Scaffold `fuzz/` + root workspace table + publish exclude.
@@ -239,13 +239,13 @@ deterministic (no randomness) so seeds are reproducible.
5. Detached campaigns (10 min per target via `run-detached.sh`), triage 5. Detached campaigns (10 min per target via `run-detached.sh`), triage
anything found, record results in §8. anything found, record results in §8.
6. Decide on target 4 (`session_opseq`) after 1–3 are clean. 6. Decide on target 4 (`session_opseq`) after 1–3 are clean.
**Status: landed — see §8. All four targets are in scope and live.** **Status: landed; see §8. All four targets are in scope and live.**
## 8. Progress log (append-only across sessions) ## 8. Progress log (append-only across sessions)
- **2026-09-28** — Proposal discussed and adopted; this plan written. - **2026-09-28**: Proposal discussed and adopted; this plan written.
Alkcall dependency bump + fuzz scaffold started. Alkcall dependency bump + fuzz scaffold started.
- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.** - **2026-09-28 (later)**: **All of §7 steps 1–5 landed.**
- alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 + - alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 +
all-features 156 tests, clippy stable/wasm, fmt, doc). all-features 156 tests, clippy stable/wasm, fmt, doc).
- AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus - AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus
@@ -260,38 +260,38 @@ deterministic (no randomness) so seeds are reproducible.
(`cargo test --manifest-path fuzz/shared/Cargo.toml`). (`cargo test --manifest-path fuzz/shared/Cargo.toml`).
- Harness-model corrections the corpus replay caught *before* any - Harness-model corrections the corpus replay caught *before* any
campaign (the alkcall §7.7 pattern repeating): (1) a valid first campaign (the alkcall §7.7 pattern repeating): (1) a valid first
chunk with trailing stream bytes is correct reader behavior — the chunk with trailing stream bytes is correct reader behavior (the
payload slice is `5..5+len`, not `5..`; (2) the negotiation framing payload slice is `5..5+len`, not `5..`); (2) the negotiation framing
layer is length-prefix-only — `read_frame` returns raw bytes and the layer is length-prefix-only: `read_frame` returns raw bytes and the
adapter does the serde parse (`adapter.rs:411`), so adapter does the serde parse (`adapter.rs:411`), so
`NegotiationError::Json` is unreachable from this reader (unlike `NegotiationError::Json` is unreachable from this reader (unlike
alkcall's serde-backed envelope reader; the `Json` arm now panics as alkcall's serde-backed envelope reader; the `Json` arm now panics as
"unreachable" and the trailing-byte probe asserts exact-body "unreachable" and the trailing-byte probe asserts exact-body
consumption instead of a Json error); (3) zero-length negotiation consumption instead of a Json error); (3) zero-length negotiation
frames are admitted by this reader (the adapter's parse rejects frames are admitted by this reader (the adapter's parse rejects
them) — the shape assertion was relaxed accordingly. them), so the shape assertion was relaxed accordingly.
- **10-min detached campaigns, all three targets: every run exited 0 - **10-min detached campaigns, all three targets: every run exited 0
with an empty artifact directory — no crashes, hangs, OOMs, or with an empty artifact directory (no crashes, hangs, OOMs, or
leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork leaks)** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork
jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within
seconds — the 5-byte-parser ceiling is fully enumerated); seconds; the 5-byte-parser ceiling is fully enumerated);
`negotiation_frame` 3.69M execs (cov 3157, still finding coverage at `negotiation_frame` 3.69M execs (cov 3157, still finding coverage at
budget end — JSON structure exploration); `control_json` 8.6M execs budget end, JSON structure exploration); `control_json` 8.6M execs
(cov 2035, still growing). Grown corpus entries were deleted per the (cov 2035, still growing). Grown corpus entries were deleted per the
§6 policy; committed seeds unchanged. §6 policy; committed seeds unchanged.
- §7 step 6 (target 4 `session_opseq`) remains open for a follow-up - §7 step 6 (target 4 `session_opseq`) remains open for a follow-up
session. session.
- **2026-09-28 (later still)** — **§7 step 6 landed: target 4 - **2026-09-28 (later still)**: **§7 step 6 landed: target 4
`session_opseq`. All four targets are now live.** `session_opseq`. All four targets are now live.**
- `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence` - `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence`
(`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl. (`#[derive(Arbitrary)]`, 12-op vocabulary: client writes incl.
barrier probes, backend production/EOF, exit resolve/fail/drop, barrier probes, backend production/EOF, exit resolve/fail/drop,
control messages, bounded reads, yields) + a fuzz-local control messages, bounded reads, yields) + a fuzz-local
`FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) + `FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) +
the harness driving `drive_session_pre_negotiated` over a the harness driving `drive_session_pre_negotiated` over a
`tokio::io::duplex` pair, with a delivery model mirroring §4's `tokio::io::duplex` pair, with a delivery model mirroring §4's
invariants. 12 committed seeds via the deterministic generator invariants. 12 committed seeds via the deterministic generator
(hand-encoded against the `arbitrary` 1.4.x derive layout — variant (hand-encoded against the `arbitrary` 1.4.x derive layout: variant
tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte
gating each `Vec` element; the encoding is pinned by a decode test gating each `Vec` element; the encoding is pinned by a decode test
asserting the exact ops each seed decodes to). asserting the exact ops each seed decodes to).
@@ -303,22 +303,22 @@ deterministic (no randomness) so seeds are reproducible.
unbounded backend stdin (control dispatch stays prompt). unbounded backend stdin (control dispatch stays prompt).
- **Harness-model correction before the campaign** (the alkcall §7.7 - **Harness-model correction before the campaign** (the alkcall §7.7
pattern repeating, third time): a 60s smoke run crashed on pattern repeating, third time): a 60s smoke run crashed on
`ClientWrite` after the session had completed — a failed client `ClientWrite` after the session had completed; a failed client
write can mean the *server half of the duplex dropped* because the write can mean the *server half of the duplex dropped* because the
session finished (exit resolved → pumps join → exit chunk → session finished (exit resolved → pumps join → exit chunk →
drainer exits → duplex halves drop) while the write was in flight. drainer exits → duplex halves drop) while the write was in flight.
The harness now treats any write/shutdown error as "the session The harness now treats any write/shutdown error as "the session
closed": it drains the read side and requires the exit chunk (a closed": it drains the read side and requires the exit chunk (a
close without it is a premature close — a real bug). No adapter close without it is a premature close, a real bug). No adapter
code changed; the finding was a harness-model gap. code changed; the finding was a harness-model gap.
- Corpus replay green on stable (9 tests, incl. the seed-decode - Corpus replay green on stable (9 tests, incl. the seed-decode
pin). Full checklist green: 113 + 156 all-features tests, clippy pin). Full checklist green: 113 + 156 all-features tests, clippy
stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm
check. check.
- Campaign: **10 min detached via `run-detached.sh`, exited 0 with an - Campaign: **10 min detached via `run-detached.sh`, exited 0 with an
empty artifact directory — no crashes, hangs, OOMs, or leaks** (all 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 33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per
job (the op-seq target is stateful — each input runs a full tokio 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 session with bounded reads/yields, so exec/s is inherently tens, not
millions; the value is interleaving exploration). Cov grew millions; the value is interleaving exploration). Cov grew
3934 → 4075 edges over the run, still finding coverage at budget 3934 → 4075 edges over the run, still finding coverage at budget
@@ -340,7 +340,7 @@ example-based tests structurally cannot enumerate.
Verification additions once landed: Verification additions once landed:
```bash ```bash
cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate 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 …added to the AGENTS.md checklist alongside the existing commands. The
+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
+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
``` ```
+9 -9
View File
@@ -6,12 +6,12 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
## Layout ## Layout
- `fuzz_targets/` — nightly-only `fuzz_target!` binaries (thin wrappers). - `fuzz_targets/`: nightly-only `fuzz_target!` binaries (thin wrappers).
- `shared/` — stable-toolchain library holding the invariant logic; the - `shared/` is the stable-toolchain library holding the invariant logic; the
corpus replay tests run here on plain `cargo test`. corpus replay tests run here on plain `cargo test`.
- `corpus/<target>/` — committed seeds (regenerate with - `corpus/<target>/` holds the committed seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py`). `python3 fuzz/gen_fuzz_seeds.py`).
- `artifacts/` — gitignored crash/oom/timeout artifacts + campaign logs. - `artifacts/`: gitignored crash/oom/timeout artifacts + campaign logs.
## Targets ## Targets
@@ -20,9 +20,9 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) | | `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam | | `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` | | `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) | | `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 ## Running a campaign (always detached)
Agent sessions must never run fuzzing in the foreground (an OOM in a 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). target can take down the session host; see docs/plans/fuzzing.md §3).
@@ -63,11 +63,11 @@ Run campaigns before releases and after touching `src/wire.rs`,
## session_opseq notes ## session_opseq notes
The op-sequence target is inherently slower than the byte-format targets The op-sequence target is inherently slower than the byte-format targets
(each input drives a full tokio session with bounded reads and yields — (each input drives a full tokio session with bounded reads and yields,
tens of exec/s per worker, not millions). That is expected: the value is 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` state-space exploration, not throughput. It uses `tokio::time::timeout`
internally, so the nightly target needs the `--features time` tokio internally, so the nightly target needs the `--features time` tokio
surface already present via the shared crate's deps — no extra flags. surface already present via the shared crate's deps; no extra flags.
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
semantics) are the ones unit tests assert individually; the fuzzer semantics) are the ones unit tests assert individually; the fuzzer
explores interleavings no example test can enumerate. explores interleavings no example test can enumerate.
+25 -25
View File
@@ -10,13 +10,13 @@
//! //!
//! A `alk/tty` session on one bidi stream proceeds in three phases: //! A `alk/tty` session on one bidi stream proceeds in three phases:
//! //!
//! 1. **Negotiation** — read a length-prefixed JSON frame, parse //! 1. **Negotiation**: read a length-prefixed JSON frame, parse
//! `NegotiateRequest`, validate (`carriage == "raw"`, `cmd` non-empty), //! `NegotiateRequest`, validate (`carriage == "raw"`, `cmd` non-empty),
//! look up the `TtyBackend`, run access control, construct `TtyParams`. //! look up the `TtyBackend`, run access control, construct `TtyParams`.
//! Errors → JSON error response in negotiation framing, stream close. //! Errors → JSON error response in negotiation framing, stream close.
//! 2. **Allocation** — `backend.allocate(&params)`. Errors → //! 2. **Allocation**: `backend.allocate(&params)`. Errors →
//! `allocate_failed` JSON error response, stream close. //! `allocate_failed` JSON error response, stream close.
//! 3. **Raw carriage** — three concurrent pumps: //! 3. **Raw carriage**: three concurrent pumps:
//! - **A. stdout → client**: `TtyHandle.stdout` → stdout chunks //! - **A. stdout → client**: `TtyHandle.stdout` → stdout chunks
//! (stream_type 1); a concurrent stderr pump emits stderr chunks //! (stream_type 1); a concurrent stderr pump emits stderr chunks
//! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout //! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout
@@ -48,17 +48,17 @@
//! 3` was documented as "bidirectional" but the adapter ignored `Exit` //! 3` was documented as "bidirectional" but the adapter ignored `Exit`
//! from the client because the two directions were indistinguishable on //! from the client because the two directions were indistinguishable on
//! the same stream_type. The split makes the bidirectionality explicit //! the same stream_type. The split makes the bidirectionality explicit
//! — see `docs/architecture/tty-wire.md` §"Control Channel". //! (see `docs/architecture/tty-wire.md` §"Control Channel").
//! //!
//! # Cancel cleanup (ADR-056) //! # Cancel cleanup (ADR-056)
//! //!
//! On connection drop or stream reset, the pump tasks are dropped, which //! On connection drop or stream reset, the pump tasks are dropped, which
//! drops the `TtyHandle`, which drops the `exit_code` future without //! drops the `TtyHandle`, which drops the `exit_code` future without
//! driving it to completion — the backend's kill-on-`Drop` guard fires //! driving it to completion; the backend's kill-on-`Drop` guard fires
//! and kills the session target. The adapter has no separate kill //! and kills the session target. 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. A client closing the write half (stdin EOF) does NOT //! the backend. A client closing the write half (stdin EOF) does NOT
//! trigger cancel-cleanup — the session runs to completion. //! trigger cancel-cleanup, and the session runs to completion.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -91,7 +91,7 @@ pub const TTY_OPEN_SCOPE: &str = "tty:open";
/// resource-ownership check. `handle()` accepts the connection and loops /// resource-ownership check. `handle()` accepts the connection and loops
/// `accept_bi`, dispatching each bidi stream to a [`drive_session`] task. /// `accept_bi`, dispatching each bidi stream to a [`drive_session`] task.
/// ///
/// 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). Sessions are independent: one session's exit /// per bidi stream (DP-6). Sessions are independent: one session's exit
/// doesn't affect another. /// doesn't affect another.
pub struct TtyAdapter { pub struct TtyAdapter {
@@ -101,7 +101,7 @@ pub struct TtyAdapter {
impl TtyAdapter { impl TtyAdapter {
/// Construct with the given backend map and no ownership provider /// Construct with the given backend map and no ownership provider
/// (scope-gate only — no resource-level ACL). /// (scope-gate only; no resource-level ACL).
pub fn new(backends: HashMap<String, Arc<dyn TtyBackend>>) -> Self { pub fn new(backends: HashMap<String, Arc<dyn TtyBackend>>) -> Self {
Self { Self {
backends: Arc::new(backends), backends: Arc::new(backends),
@@ -170,7 +170,7 @@ fn has_scope(identity: &Option<Identity>, scope: &str) -> bool {
/// ///
/// Shared by the direct path's failure arms and the channels open /// Shared by the direct path's failure arms and the channels open
/// handler (`make_tty_open_handler` writes the same `malformed_negotiation` /// handler (`make_tty_open_handler` writes the same `malformed_negotiation`
/// frame on a `NegotiateRequest` parse failure of the open op's `input` — /// frame on a `NegotiateRequest` parse failure of the open op's `input`;
/// the pre-negotiated driver has no writer to consume, so the handler /// the pre-negotiated driver has no writer to consume, so the handler
/// builds its own over the channel's write half). `pub(crate)` so the /// builds its own over the channel's write half). `pub(crate)` so the
/// channels handler reuses it; not public API. /// channels handler reuses it; not public API.
@@ -195,11 +195,11 @@ pub(crate) async fn send_negotiation_error<W: AsyncWrite + Unpin>(
/// `client_send` / `client_recv` are the two halves of the bidi stream /// `client_send` / `client_recv` are the two halves of the bidi stream
/// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`). /// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`).
/// Returns when the session is complete (exit chunk sent, stream closed) or /// Returns when the session is complete (exit chunk sent, stream closed) or
/// when the stream is reset (cancel-cleanup path — no exit chunk sent). /// when the stream is reset (cancel-cleanup path: no exit chunk sent).
/// ///
/// This is the per-stream session driver — generalized from the POC's /// This is the per-stream session driver, generalized from the POC's
/// `session::drive_session` to the [`TtyBackend`] trait. The negotiation /// `session::drive_session` to the [`TtyBackend`] trait. The negotiation
/// frame is read from the wire (the direct-ALPN path — ADR-052); the /// frame is read from the wire (the direct-ALPN path, ADR-052); the
/// channels path (open-op `input` is the negotiation) uses /// channels path (open-op `input` is the negotiation) uses
/// [`drive_session_pre_negotiated`] instead. /// [`drive_session_pre_negotiated`] instead.
pub async fn drive_session( pub async fn drive_session(
@@ -217,18 +217,18 @@ pub async fn drive_session(
} }
/// Drive a `alk/tty` session whose negotiation already happened out of /// Drive a `alk/tty` session whose negotiation already happened out of
/// band — the channels path (ADR-009). The open op's registry-validated /// band: the channels path (ADR-009). The open op's registry-validated
/// `input` was parsed into `req` by the channels wrapper; the raw-chunk /// `input` was parsed into `req` by the channels wrapper; the raw-chunk
/// data plane starts immediately. No negotiation frame is read from the /// data plane starts immediately. No negotiation frame is read from the
/// wire, and none is written by the client. /// wire, and none is written by the client.
/// ///
/// Validation still runs here (`carriage`/`cmd`/backend lookup) plus the /// Validation still runs here (`carriage`/`cmd`/backend lookup) plus the
/// ADR-050 ownership check — failures go to the client as a negotiation /// ADR-050 ownership check; failures go to the client as a negotiation
/// error frame on the channel stream (the same framing the direct path /// error frame on the channel stream (the same framing the direct path
/// uses, so the consumer's M1 disambiguation read applies unchanged). /// uses, so the consumer's M1 disambiguation read applies unchanged).
/// The `tty:open` scope gate is NOT re-checked: on the channels path the /// The `tty:open` scope gate is NOT re-checked: on the channels path the
/// registry's `AccessControl` enforced it at open time (the identity view /// registry's `AccessControl` enforced it at open time (the identity view
/// the registry checked — resolved from the connection — is the /// the registry checked (resolved from the connection) is the
/// authoritative one; the handler-side identity is only the /// authoritative one; the handler-side identity is only the
/// ownership-check subject). /// ownership-check subject).
pub async fn drive_session_pre_negotiated( pub async fn drive_session_pre_negotiated(
@@ -254,7 +254,7 @@ pub async fn drive_session_pre_negotiated(
} }
/// Drive a `alk/tty` session whose negotiation AND allocation already /// Drive a `alk/tty` session whose negotiation AND allocation already
/// happened out of band — the channels path with an establisher /// happened out of band, namely the channels path with an establisher
/// (alkcall 0.6, ADR-010 as amended). The establisher validated the /// (alkcall 0.6, ADR-010 as amended). The establisher validated the
/// request and ran `backend.allocate`; its `TtyHandle` arrived via the /// request and ran `backend.allocate`; its `TtyHandle` arrived via the
/// establishment plan (`AllocatedHandle`, taken by the open handler). /// establishment plan (`AllocatedHandle`, taken by the open handler).
@@ -289,11 +289,11 @@ pub(crate) async fn drive_session_pre_allocated<W, R>(
/// ///
/// `enforce_scope` gates the `tty:open` scope check: `true` on the direct /// `enforce_scope` gates the `tty:open` scope check: `true` on the direct
/// path (the adapter is the only gate), `false` on the channels path (the /// path (the adapter is the only gate), `false` on the channels path (the
/// registry's `AccessControl` enforced the scope at open time — see /// registry's `AccessControl` enforced the scope at open time; see
/// [`drive_session_pre_negotiated`]). The scope gate runs *first* (review /// [`drive_session_pre_negotiated`]). The scope gate runs *first* (review
/// #003 P5): checking it after the backend lookup would let an /// #003 P5): checking it after the backend lookup would let an
/// authenticated-but-unscoped identity distinguish registered backend /// authenticated-but-unscoped identity distinguish registered backend
/// names (`unknown_backend`) from unregistered ones (`forbidden`) — a /// names (`unknown_backend`) from unregistered ones (`forbidden`): a
/// name-enumeration differential. Gated first, every unscoped request /// name-enumeration differential. Gated first, every unscoped request
/// gets the same `forbidden` regardless of the request body. /// gets the same `forbidden` regardless of the request body.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@@ -458,8 +458,8 @@ where
/// Enforces the exit-chunk-is-last invariant (ADR-055): the exit chunk /// Enforces the exit-chunk-is-last invariant (ADR-055): the exit chunk
/// is enqueued only after the stdout/stderr pumps have finished sending /// is enqueued only after the stdout/stderr pumps have finished sending
/// AND `exit_code` has resolved. The drainer runs concurrently with the /// AND `exit_code` has resolved. The drainer runs concurrently with the
/// pumps so the shared writer channel is always being consumed — see /// pumps so the shared writer channel is always being consumed (see
/// `drain_chunks`. /// `drain_chunks`).
async fn pump_session<W, R>( async fn pump_session<W, R>(
client_write: W, client_write: W,
client_read: R, client_read: R,
@@ -519,7 +519,7 @@ where
drop(writer_tx); drop(writer_tx);
// The client→backend pump outlives the session data plane only until // The client→backend pump outlives the session data plane only until
// the client disconnects; aborting it at session end is tighter — no // the client disconnects; aborting it at session end is tighter: no
// lingering task holding the backend stdin half after the exit chunk. // lingering task holding the backend stdin half after the exit chunk.
input_pump.abort(); input_pump.abort();
@@ -614,7 +614,7 @@ async fn pump_stderr(
/// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the /// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the
/// adapter ignores it. (The previous single `STREAM_CONTROL = 3` /// adapter ignores it. (The previous single `STREAM_CONTROL = 3`
/// couldn't distinguish the two directions, so `Exit` from the client /// couldn't distinguish the two directions, so `Exit` from the client
/// was always ignored — the split makes the rejection explicit.) /// was always ignored; the split makes the rejection explicit.)
/// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol /// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol
/// violation (it's the server→client half); the adapter ignores it. /// violation (it's the server→client half); the adapter ignores it.
async fn pump_client_to_backend<R>( async fn pump_client_to_backend<R>(
@@ -828,7 +828,7 @@ mod tests {
} }
} }
/// `AsyncWrite` adapter over `mpsc::Sender<Bytes>` — the test backend's /// `AsyncWrite` adapter over `mpsc::Sender<Bytes>`, the test backend's
/// stdin sink. On shutdown, drops the sender so the test's /// stdin sink. On shutdown, drops the sender so the test's
/// `stdin_rx` observes EOF (channel close). /// `stdin_rx` observes EOF (channel close).
struct TestStdinSink { struct TestStdinSink {
@@ -877,7 +877,7 @@ mod tests {
/// A kill-guard future wrapping `oneshot::Receiver<Result<i32, TtyError>>`. /// A kill-guard future wrapping `oneshot::Receiver<Result<i32, TtyError>>`.
/// On `Drop`-without-resolve, sets `cancel_dropped` to true (the /// On `Drop`-without-resolve, sets `cancel_dropped` to true (the
/// cancel-cleanup signal for tests — ADR-056). /// cancel-cleanup signal for tests, ADR-056).
struct ExitFuture { struct ExitFuture {
rx: Option<oneshot::Receiver<Result<i32, TtyError>>>, rx: Option<oneshot::Receiver<Result<i32, TtyError>>>,
cancel_dropped: Arc<Mutex<bool>>, cancel_dropped: Arc<Mutex<bool>>,
@@ -1386,7 +1386,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() { async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() {
// Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not // Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not
// `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client // `STREAM_CTRL_IN` (3), the Phase 7 bidirectionality fix. A client
// distinguishing the two halves can route exit vs. control // distinguishing the two halves can route exit vs. control
// without parsing the JSON tag first. // without parsing the JSON tag first.
let (backend, _control, _cancel) = TestBackend::builder().build(); let (backend, _control, _cancel) = TestBackend::builder().build();
+20 -20
View File
@@ -5,7 +5,7 @@
//! (`crate::adapter`) and the backend crates (`alktty`'s own `local` //! (`crate::adapter`) and the backend crates (`alktty`'s own `local`
//! feature module, future `alknet-docker`, `alknet-ssh`). alktty //! feature module, future `alknet-docker`, `alknet-ssh`). alktty
//! defines the trait; the //! defines the trait; the
//! backends implement it. The trait shape is a one-way door — changing it //! backends implement it. The trait shape is a one-way door: changing it
//! after backends exist is a rewrite across crates. The adapter holds a //! after backends exist is a rewrite across crates. The adapter holds a
//! `HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's //! `HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's
//! `backend` string and pumps the `TtyHandle` fields bidirectionally; //! `backend` string and pumps the `TtyHandle` fields bidirectionally;
@@ -32,7 +32,7 @@ use crate::negotiation::{NegotiateRequest, TerminalParamsWire};
/// `futures::future::BoxFuture<'static, T>` to match the alkcall /// `futures::future::BoxFuture<'static, T>` to match the alkcall
/// convention (alkcall pulls in `futures`, so no new transitive dep). /// convention (alkcall pulls in `futures`, so no new transitive dep).
/// Functionally identical to the hand-rolled `Pin<Box<dyn Future + /// Functionally identical to the hand-rolled `Pin<Box<dyn Future +
/// Send + 'static>>` — `futures::future::BoxFuture<'static, T>` is /// Send + 'static>>`: `futures::future::BoxFuture<'static, T>` is
/// exactly that alias. /// exactly that alias.
pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>; pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
@@ -40,7 +40,7 @@ pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
/// [`TtyHandle::exit_code`] future. /// [`TtyHandle::exit_code`] future.
/// ///
/// `#[non_exhaustive]` so new variants are additive (two-way-door extension /// `#[non_exhaustive]` so new variants are additive (two-way-door extension
/// within the one-way trait shape — ADR-053). /// within the one-way trait shape; see ADR-053).
#[non_exhaustive] #[non_exhaustive]
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum TtyError { pub enum TtyError {
@@ -79,13 +79,13 @@ 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,
/// Reserved — OQ-44; backends MUST ignore the content in v1. /// Reserved (OQ-44); backends MUST ignore the content in v1.
pub modes: serde_json::Value, pub modes: serde_json::Value,
} }
/// The allocation request the adapter passes to [`TtyBackend::allocate`]. /// The allocation request the adapter passes to [`TtyBackend::allocate`].
/// ///
/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr — /// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr;
/// ADR-054). `terminal: Some` is PTY mode (stdout/stderr merged into /// ADR-054). `terminal: Some` is PTY mode (stdout/stderr merged into
/// `stdout` by the kernel PTY, real terminal semantics). /// `stdout` by the kernel PTY, real terminal semantics).
/// ///
@@ -95,7 +95,7 @@ pub struct TerminalParams {
/// §"Backend params are opaque." /// §"Backend params are opaque."
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TtyParams { pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` = /// Terminal parameters. `None` = pipe mode (no PTY; ADR-054). `Some` =
/// allocate a PTY with these dimensions. /// 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.
@@ -127,7 +127,7 @@ impl From<TerminalParamsWire> for TerminalParams {
/// Map a wire negotiation frame to the allocation request the backend /// Map a wire negotiation frame to the allocation request the backend
/// consumes. The adapter calls this after parsing the negotiation /// consumes. The adapter calls this after parsing the negotiation
/// carriage; `backend_params` is passed through verbatim (alktty has /// carriage; `backend_params` is passed through verbatim (alktty has
/// zero knowledge of any backend's params shape — ADR-053). Lives here so /// zero knowledge of any backend's params shape; ADR-053). Lives here so
/// the adapter does not hand-roll the mapping. /// the adapter does not hand-roll the mapping.
impl From<NegotiateRequest> for TtyParams { impl From<NegotiateRequest> for TtyParams {
fn from(req: NegotiateRequest) -> Self { fn from(req: NegotiateRequest) -> Self {
@@ -144,25 +144,25 @@ impl From<NegotiateRequest> for TtyParams {
/// What a backend's `allocate()` produces. The adapter pumps these fields /// What a backend's `allocate()` produces. The adapter pumps these fields
/// bidirectionally against the wire format (ADR-052). /// bidirectionally against the wire format (ADR-052).
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 `futures::io` — they /// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io`, as they
/// are incompatible traits; the tokio stack is the adapter's runtime). /// are incompatible traits; the tokio stack is the 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.
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>, pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged into /// Stderr stream: `None` for PTY backends (stdout/stderr merged into
/// `stdout` by the kernel PTY). `Some` for pipe backends (separate /// `stdout` by the kernel PTY). `Some` for pipe backends (separate
/// streams). /// streams).
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>, pub stderr: Option<Pin<Box<dyn Stream<Item = 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 as /// process/container/SSH exec exits. The adapter sends the result as
/// the `{"type":"exit","code":N}` control chunk (ADR-055) and closes /// the `{"type":"exit","code":N}` control chunk (ADR-055) and closes
/// the stream. This is a `BoxFuture`, not a method on `TtyHandle`, so /// the stream. This is a `BoxFuture`, not a method on `TtyHandle`, so
/// the adapter can `select` between exit and stream-close without /// the adapter can `select` between exit and stream-close without
/// coupling to the other fields (REQ-TTY-01). /// coupling to the other fields (REQ-TTY-01).
/// ///
/// # ADR-056 — kill-on-Drop contract /// # ADR-056: kill-on-Drop contract
/// ///
/// Dropping this future without driving it to completion MUST kill the /// Dropping this future without driving it to completion MUST kill the
/// session target (the child process, the docker exec, the SSH /// session target (the child process, the docker exec, the SSH
@@ -182,7 +182,7 @@ pub struct TtyHandle {
/// adapter awaited it to completion). See ADR-056 and `tty-local.md` /// adapter awaited it to completion). See ADR-056 and `tty-local.md`
/// §"Cancel-Cleanup" for the local backend's mechanism. /// §"Cancel-Cleanup" for the local backend's mechanism.
pub exit_code: BoxFuture<Result<i32, TtyError>>, pub exit_code: BoxFuture<Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can hand /// Control handle (resize, signal); `Clone` so the adapter can hand
/// it to the spawned control-chunk dispatcher. `None` only when the /// it to the spawned control-chunk dispatcher. `None` only when the
/// backend genuinely has no control path. See OQ-43. /// backend genuinely has no control path. See OQ-43.
pub control: Option<TtyControlHandle>, pub control: Option<TtyControlHandle>,
@@ -190,7 +190,7 @@ pub struct TtyHandle {
/// Control path for a live terminal session: resize and signal forwarding. /// Control path for a live terminal session: resize and signal forwarding.
/// ///
/// Object-safe (`Send + Sync`, no `Clone` — `Clone` is not object-safe). /// Object-safe (`Send + Sync`, no `Clone`, since `Clone` is not object-safe).
/// The `Clone`-ability lives on the [`TtyControlHandle`] newtype, which /// The `Clone`-ability lives on the [`TtyControlHandle`] newtype, which
/// holds the trait object behind an `Arc`. A backend produces its own /// holds the trait object behind an `Arc`. A backend produces its own
/// control type via `TtyControlHandle::new(Arc::new(MyControl))` without /// control type via `TtyControlHandle::new(Arc::new(MyControl))` without
@@ -209,7 +209,7 @@ pub trait TtyControl: Send + Sync {
} }
/// The `Clone`-able handle to a backend's control path. The /// The `Clone`-able handle to a backend's control path. The
/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe — /// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe;
/// `fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch); /// `fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch);
/// the `Clone`-ability lives on this concrete newtype, which holds the /// the `Clone`-ability lives on this concrete newtype, which holds the
/// trait object behind an `Arc`. The adapter clones the `Arc` to hand a /// trait object behind an `Arc`. The adapter clones the `Arc` to hand a
@@ -244,7 +244,7 @@ impl TtyControlHandle {
/// `HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's /// `HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's
/// `backend` string and dispatches by that key. /// `backend` string and dispatches by that key.
/// ///
/// # REQ-TTY-01 — backends need not be natively async /// # REQ-TTY-01: backends need not be natively async
/// ///
/// The adapter-facing types this trait returns (`AsyncWrite`, /// The adapter-facing types this trait returns (`AsyncWrite`,
/// `Stream<Item = Bytes>`, `BoxFuture`, `TtyControl`) are the **adapter's /// `Stream<Item = Bytes>`, `BoxFuture`, `TtyControl`) are the **adapter's
@@ -260,7 +260,7 @@ impl TtyControlHandle {
/// wrapped in a kill-guard future (exit). The adapter consumes the bridged /// wrapped in a kill-guard future (exit). The adapter consumes the bridged
/// async-facing types and is unaware of the threading. /// async-facing types and is unaware of the threading.
/// ///
/// # ADR-056 — kill-on-Drop contract /// # ADR-056: kill-on-Drop contract
/// ///
/// The [`TtyHandle::exit_code`] future returned by `allocate()` MUST kill /// The [`TtyHandle::exit_code`] future returned by `allocate()` MUST kill
/// the session target when dropped without being driven to completion. See /// the session target when dropped without being driven to completion. See
@@ -274,7 +274,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 (ADR-050). `None` = no pre-existing resource (the session /// checks (ADR-050). `None` = no pre-existing resource (the session
/// creates its own — local process, SSH channel). `Some((kind, id))` /// creates its own: a local process, an 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
@@ -371,7 +371,7 @@ impl TtyBackend for MockBackend {
} }
} }
/// `AsyncWrite` adapter over an `mpsc::Sender<Bytes>` — the mock /// `AsyncWrite` adapter over an `mpsc::Sender<Bytes>`, the mock
/// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort /// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort
/// sends; on a full channel returns `Pending`, on a closed channel returns /// sends; on a full channel returns `Pending`, on a closed channel returns
/// a broken-pipe error. /// a broken-pipe error.
+46 -46
View File
@@ -1,4 +1,4 @@
//! Producer half of the channels integration — the `register_openable` //! Producer half of the channels integration: the `register_openable`
//! helper and the `TtyOpenHandler` factory (ADR-047 §3, amended by //! helper and the `TtyOpenHandler` factory (ADR-047 §3, amended by
//! alkcall ADR-049 / alktty ADR-010 with an establishment phase). //! alkcall ADR-049 / alktty ADR-010 with an establishment phase).
//! //!
@@ -15,10 +15,10 @@
//! `{ channel_id }`. The establisher performs the semantic validation //! `{ channel_id }`. The establisher performs the semantic validation
//! (full `NegotiateRequest` parse, `carriage == "raw"`, backend //! (full `NegotiateRequest` parse, `carriage == "raw"`, backend
//! lookup, ADR-050 ownership check) and fails the open op with //! lookup, ADR-050 ownership check) and fails the open op with
//! `channel:open_failed` on rejection — no phantom channel. The //! `channel:open_failed` on rejection; no phantom channel. The
//! `TtyOpenHandler` receives the channel's `Connection` (data-plane //! `TtyOpenHandler` receives the channel's `Connection` (data-plane
//! ALPN `alk/tty`), calls `accept_bi()` to get the channel's //! ALPN `alk/tty`), calls `accept_bi()` to get the channel's
//! `BiStream`, and runs the session pumps on it — the same three-pump //! `BiStream`, and runs the session pumps on it: the same three-pump
//! session driver as the direct-ALPN path, minus the wire-frame //! session driver as the direct-ALPN path, minus the wire-frame
//! negotiation phase (the open op's `input` is the negotiation, //! negotiation phase (the open op's `input` is the negotiation,
//! ADR-009). Backend allocation runs in the establisher (alkcall 0.6 //! ADR-009). Backend allocation runs in the establisher (alkcall 0.6
@@ -28,7 +28,7 @@
//! //!
//! The access control (scope-gate + ownership) is wired into the //! The access control (scope-gate + ownership) is wired into the
//! `OperationSpec`'s `AccessControl` and enforced by the registry's //! `OperationSpec`'s `AccessControl` and enforced by the registry's
//! `invoke`/`invoke_streaming` before the establisher runs — the //! `invoke`/`invoke_streaming` before the establisher runs; the
//! establisher itself only runs the ADR-050 ownership check (the //! establisher itself only runs the ADR-050 ownership check (the
//! scope gate is the registry's); this is the difference from the //! scope gate is the registry's); this is the difference from the
//! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check; //! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check;
@@ -65,7 +65,7 @@ pub const OP_TTY_OPEN: &str = "channels/tty/sub";
pub const TTY_ALPN: &str = "alk/tty"; pub const TTY_ALPN: &str = "alk/tty";
/// Register the `channels/tty/sub` open op on a per-connection /// Register the `channels/tty/sub` open op on a per-connection
/// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13 — /// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13,
/// per-connection registration). /// per-connection registration).
/// ///
/// The assembly layer (an `alk/channels` acceptor's /// The assembly layer (an `alk/channels` acceptor's
@@ -76,14 +76,14 @@ pub const TTY_ALPN: &str = "alk/tty";
/// `channels/tty/sub` with the `channel_open` marker set /// `channels/tty/sub` with the `channel_open` marker set
/// (`ChannelOpenSpec::new("alk/tty")`), an `AccessControl` carrying /// (`ChannelOpenSpec::new("alk/tty")`), an `AccessControl` carrying
/// the `tty:open` scope gate, and the partial `NegotiateRequest` /// the `tty:open` scope gate, and the partial `NegotiateRequest`
/// input schema (`carriage`/`backend`/`cmd` required — alkcall 0.4 /// input schema (`carriage`/`backend`/`cmd` required, since alkcall 0.4
/// enforces it at dispatch; the full parse and semantic validation /// enforces it at dispatch; the full parse and semantic validation
/// runs in the handler). It then wraps the /// runs in the handler). It then wraps the
/// `TtyOpenHandler` factory and calls /// `TtyOpenHandler` factory and calls
/// [`ChannelCore::register_openable`]. /// [`ChannelCore::register_openable`].
/// ///
/// `backends` is the same backend map the direct-ALPN `TtyAdapter` /// `backends` is the same backend map the direct-ALPN `TtyAdapter`
/// holds — `HashMap<String, Arc<dyn TtyBackend>>` keyed by the /// holds: `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
/// negotiation frame's `backend` string. The assembly layer typically /// negotiation frame's `backend` string. The assembly layer typically
/// shares one backend map between the direct-ALPN adapter and the /// shares one backend map between the direct-ALPN adapter and the
/// channels `register_openable` registration. /// channels `register_openable` registration.
@@ -92,7 +92,7 @@ pub const TTY_ALPN: &str = "alk/tty";
/// resource-ownership check. `None` = scope-gate only. The provider /// resource-ownership check. `None` = scope-gate only. The provider
/// is consulted inside `drive_session_pre_negotiated` (after the /// is consulted inside `drive_session_pre_negotiated` (after the
/// backend is selected and `resource_id` is extracted from /// backend is selected and `resource_id` is extracted from
/// `backend_params`), not by the channels wrapper — the wrapper's /// `backend_params`), not by the channels wrapper; the wrapper's
/// `AccessControl` carries only the scope gate. This mirrors the /// `AccessControl` carries only the scope gate. This mirrors the
/// direct-ALPN `TtyAdapter` shape: the scope gate is enforced once at /// direct-ALPN `TtyAdapter` shape: the scope gate is enforced once at
/// the entry point (here by the registry's `AccessControl`), the /// the entry point (here by the registry's `AccessControl`), the
@@ -101,14 +101,14 @@ pub const TTY_ALPN: &str = "alk/tty";
/// `auth` is the peer's `AuthContext`, the install-time context passed /// `auth` is the peer's `AuthContext`, the install-time context passed
/// through to `register_openable_with_establisher`. The establisher /// through to `register_openable_with_establisher`. The establisher
/// and pump handler read their identity from the per-call context /// and pump handler read their identity from the per-call context
/// alkcall derives (alkcall 0.7, CF-006 corollary) — the /// alkcall derives (alkcall 0.7, CF-006 corollary): the
/// dispatch-resolved opener, the same view the registry's ACL gate /// dispatch-resolved opener, the same view the registry's ACL gate
/// checked. /// checked.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if `ChannelCore::register_openable` rejects the /// Returns an error if `ChannelCore::register_openable` rejects the
/// spec (e.g., the spec has no `channel_open` marker — won't happen /// spec (e.g., the spec has no `channel_open` marker; won't happen
/// with the spec this helper builds, but the underlying call is /// with the spec this helper builds, but the underlying call is
/// fallible). /// fallible).
pub fn register_openable( pub fn register_openable(
@@ -133,7 +133,7 @@ pub fn register_openable(
/// Build the [`OperationSpec`] for `channels/tty/sub`. /// Build the [`OperationSpec`] for `channels/tty/sub`.
/// ///
/// The op is `Sub`-typed (consumer-opens — the initiator opens a /// The op is `Sub`-typed (consumer-opens: the initiator opens a
/// channel and the data plane flows on the channel's `BiStream`, not /// channel and the data plane flows on the channel's `BiStream`, not
/// in the call response stream). The `channel_open` marker is set to /// in the call response stream). The `channel_open` marker is set to
/// `ChannelOpenSpec::new("alk/tty")` so the channels wrapper allocates /// `ChannelOpenSpec::new("alk/tty")` so the channels wrapper allocates
@@ -141,26 +141,26 @@ pub fn register_openable(
/// gate (the same scope the direct-ALPN `TtyAdapter` checks). /// gate (the same scope the direct-ALPN `TtyAdapter` checks).
/// ///
/// The input schema is the shared part of the `NegotiateRequest` shape /// The input schema is the shared part of the `NegotiateRequest` shape
/// (`carriage`/`backend`/`cmd` required — alkcall 0.4 enforces it at /// (`carriage`/`backend`/`cmd` required, enforced at dispatch by
/// dispatch). The schema is deliberately partial: `tty`, `cwd`, `env`, /// alkcall 0.4). The schema is deliberately partial: `tty`, `cwd`, `env`,
/// and backend-specific selector fields are free-form (`additionalProperties` /// and backend-specific selector fields are free-form (`additionalProperties`
/// defaults to `true` in raw JSON Schema), because the /// defaults to `true` in raw JSON Schema), because the
/// `NegotiateRequest` wire shape is owned by `negotiation.rs` and the /// `NegotiateRequest` wire shape is owned by `negotiation.rs` and the
/// backend params are opaque (ADR-053) — duplicating their full schemas /// backend params are opaque (ADR-053); duplicating their full schemas
/// here would create a second definition to drift. The registry check /// here would create a second definition to drift. The registry check
/// catches structurally-broken opens (missing command, wrong types); /// catches structurally-broken opens (missing command, wrong types);
/// the full parse and semantic validation (`carriage == "raw"`, /// the full parse and semantic validation (`carriage == "raw"`,
/// backend lookup, ownership) and backend allocation run in the /// backend lookup, ownership) and backend allocation run in the
/// establisher (ADR-010, as amended for alkcall 0.6 — failures are /// establisher (ADR-010, as amended for alkcall 0.6: failures are
/// `channel:open_failed`, including `allocate_failed` as /// `channel:open_failed`, including `allocate_failed` as
/// `dial_failed`). /// `dial_failed`).
/// ///
/// The op declares an [`ErrorDefinition`] for `channel:open_failed` /// The op declares an [`ErrorDefinition`] for `channel:open_failed`
/// (ADR-016 — establishment failures are disclosed so `services/schema` /// (ADR-016: establishment failures are disclosed so `services/schema`
/// shows the failure contract; reason ∈ `unknown_resource` / /// shows the failure contract; reason ∈ `unknown_resource` /
/// `handler_error` / `timeout`, per the ADR-049 vocabulary — see /// `handler_error` / `timeout`, per the ADR-049 vocabulary, with the
/// `make_tty_establisher` for the mapping) and carries a /// mapping in `make_tty_establisher`) and carries a
/// `description` (review 006 E-02 — discovery enrichment). /// `description` (review 006 E-02, discovery enrichment).
pub fn tty_open_spec() -> OperationSpec { pub fn tty_open_spec() -> OperationSpec {
let open_failed_schema = json!({ let open_failed_schema = json!({
"type": "object", "type": "object",
@@ -222,7 +222,7 @@ pub fn tty_open_spec() -> OperationSpec {
.with_channel_open(ChannelOpenSpec::new(TTY_ALPN)) .with_channel_open(ChannelOpenSpec::new(TTY_ALPN))
} }
/// Build the [`OpenEstablisher`] for `channels/tty/sub` — the awaited /// Build the [`OpenEstablisher`] for `channels/tty/sub`: the awaited
/// establishment phase (alkcall ADR-049 §1, alktty ADR-010). /// establishment phase (alkcall ADR-049 §1, alktty ADR-010).
/// ///
/// The establisher runs the semantic validation that used to be /// The establisher runs the semantic validation that used to be
@@ -230,45 +230,45 @@ pub fn tty_open_spec() -> OperationSpec {
/// full `NegotiateRequest` parse of the registry-schema-validated /// full `NegotiateRequest` parse of the registry-schema-validated
/// `input` (the schema is deliberately partial so the opaque ADR-053 /// `input` (the schema is deliberately partial so the opaque ADR-053
/// backend params pass through), `carriage == "raw"`, non-empty /// backend params pass through), `carriage == "raw"`, non-empty
/// `cmd`, the backend lookup, the ADR-050 ownership check, and — /// `cmd`, the backend lookup, the ADR-050 ownership check, and,
/// since alkcall 0.6 (ADR-049 amendment 2, review 007 R-01) gave /// since alkcall 0.6 (ADR-049 amendment 2, review 007 R-01) gave
/// `Establishment` its plan payload — the backend allocation /// `Establishment` its plan payload, the backend allocation
/// (ADR-010 as amended: the allocated `TtyHandle` crosses to the pump /// (ADR-010 as amended: the allocated `TtyHandle` crosses to the pump
/// handler via the plan). Any rejection resolves /// handler via the plan). Any rejection resolves
/// `Err(EstablishmentError)` and the wrapper replies /// `Err(EstablishmentError)` and the wrapper replies
/// `channel:open_failed` with `details.reason` — the SSH contract: /// `channel:open_failed` with `details.reason` (the SSH contract:
/// the channel never exists opener-side, no `channel_id` is ever /// the channel never exists opener-side, no `channel_id` is ever
/// returned. /// returned).
/// ///
/// The `tty:open` scope gate is NOT checked here — the registry's /// The `tty:open` scope gate is NOT checked here; the registry's
/// `AccessControl` enforced it before the establisher ran. The /// `AccessControl` enforced it before the establisher ran. The
/// establisher's `auth` is the per-call context (alkcall 0.7, CF-006 /// establisher's `auth` is the per-call context (alkcall 0.7, CF-006
/// corollary): its identity is the dispatch-resolved opener — the same /// corollary): its identity is the dispatch-resolved opener (the same
/// identity view the registry's ACL gate checked — and is the /// identity view the registry's ACL gate checked) and is the
/// ownership-check subject (the same split as the old handler-side /// ownership-check subject (the same split as the old handler-side
/// check). /// check).
/// ///
/// Mapping into the ADR-049 reason vocabulary (no new codes — the /// Mapping into the ADR-049 reason vocabulary (no new codes; the
/// vocabulary is alkcall's one-way wire surface): /// vocabulary is alkcall's one-way wire surface):
/// ///
/// - `NegotiateRequest` parse failure, `carriage != "raw"`, empty /// - `NegotiateRequest` parse failure, `carriage != "raw"`, empty
/// `cmd` → [`EstablishmentError::HandlerError`] (`malformed_ /// `cmd` → [`EstablishmentError::HandlerError`] (`malformed_
/// negotiation` semantics — the request shape is wrong). /// negotiation` semantics: the request shape is wrong).
/// - Unknown backend → [`EstablishmentError::UnknownResource`]. /// - Unknown backend → [`EstablishmentError::UnknownResource`].
/// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL /// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL
/// outcome, not a dial outcome; the registry's scope gate stays the /// outcome, not a dial outcome; the registry's scope gate stays the
/// pre-allocation `FORBIDDEN` path). /// pre-allocation `FORBIDDEN` path).
/// - `backend.allocate` failure (ADR-005's `AllocFailed`: PTY /// - `backend.allocate` failure (ADR-005's `AllocFailed`: PTY
/// couldn't be allocated, docker exec failed to start, SSH channel /// couldn't be allocated, docker exec failed to start, SSH channel
/// request rejected) → [`EstablishmentError::DialFailed`] — the /// request rejected) → [`EstablishmentError::DialFailed`] (the
/// target refused or the backend lacked capacity; the review-007 /// target refused or the backend lacked capacity; the review-007
/// verification gate's "allocate in the establisher → /// verification gate's "allocate in the establisher →
/// `allocate_failed` is a call error, no in-band frame" shape. /// `allocate_failed` is a call error, no in-band frame" shape.
/// ///
/// The allocation is handed to the pump handler through the /// The allocation is handed to the pump handler through the
/// establishment plan: a private `AllocatedHandle` slot (the /// establishment plan: a private `AllocatedHandle` slot (the
/// `TtyHandle` itself is not `Sync` — its fields are boxed dyn /// `TtyHandle` itself is not `Sync`: its fields are boxed dyn
/// streams — so it cannot be the `ChannelPlan` directly). The slot is /// streams, so it cannot be the `ChannelPlan` directly). The slot is
/// created per establisher invocation (per open), so the /// created per establisher invocation (per open), so the
/// shared-slot same-resource race alkcall's R-01 test pins is /// shared-slot same-resource race alkcall's R-01 test pins is
/// unreachable by construction; the wrapper moves the plan to this /// unreachable by construction; the wrapper moves the plan to this
@@ -361,23 +361,23 @@ impl AllocatedHandle {
/// Build the [`OpenHandler`] for `channels/tty/sub`. /// Build the [`OpenHandler`] for `channels/tty/sub`.
/// ///
/// The handler receives the open op's `input` — the `NegotiateRequest` /// The handler receives the open op's `input`: the `NegotiateRequest`
/// params, validated (and allocated against) by the establisher before /// params, validated (and allocated against) by the establisher before
/// the handler was spawned (alkcall ADR-049 §1: the establisher is /// the handler was spawned (alkcall ADR-049 §1: the establisher is
/// awaited bounded, before the reply and before the pump handler) — /// awaited bounded, before the reply and before the pump handler),
/// plus the establisher's `plan`: the [`AllocatedHandle`] slot /// plus the establisher's `plan`: the [`AllocatedHandle`] slot
/// carrying the backend-allocated `TtyHandle` (alkcall 0.6 ADR-049 /// carrying the backend-allocated `TtyHandle` (alkcall 0.6 ADR-049
/// amendment 2, review 007 R-01). It re-parses `input`, calls /// amendment 2, review 007 R-01). It re-parses `input`, calls
/// `accept_bi()` to get the channel's [`BiStream`], splits it into /// `accept_bi()` to get the channel's [`BiStream`], splits it into
/// read/write halves (the stdlib `tokio::io::split` idiom — the same /// read/write halves (the stdlib `tokio::io::split` idiom, the same
/// split the direct-ALPN `TtyAdapter::handle` does), and runs the /// split the direct-ALPN `TtyAdapter::handle` does), and runs the
/// pre-allocated session driver on them — the same three-pump session /// pre-allocated session driver on them: the same three-pump session
/// driver as the direct-ALPN path. /// driver as the direct-ALPN path.
/// ///
/// With a plan the handler never allocates: the establisher already /// With a plan the handler never allocates: the establisher already
/// ran `backend.allocate` (ADR-010 as amended), so no negotiation /// ran `backend.allocate` (ADR-010 as amended), so no negotiation
/// error frame is reachable on the registered path. With `plan: None` /// error frame is reachable on the registered path. With `plan: None`
/// (a no-establisher registration — defense-in-depth, the ADR-010 /// (a no-establisher registration: defense-in-depth, the ADR-010
/// pre-0.3.0 shape) the handler re-runs the full inline /// pre-0.3.0 shape) the handler re-runs the full inline
/// validate-and-allocate, whose negotiation error frames /// validate-and-allocate, whose negotiation error frames
/// (`malformed_negotiation`, `allocate_failed`, ...) remain the only /// (`malformed_negotiation`, `allocate_failed`, ...) remain the only
@@ -385,11 +385,11 @@ impl AllocatedHandle {
/// ///
/// The returned task awaits the session driver inline (R-02 lifetime /// The returned task awaits the session driver inline (R-02 lifetime
/// contract): the `JoinHandle`'s completion is the wrapper's teardown /// contract): the `JoinHandle`'s completion is the wrapper's teardown
/// trigger, so the pumps must finish before the task returns — never /// trigger, so the pumps must finish before the task returns; never
/// spawn-and-forget. /// spawn-and-forget.
/// ///
/// The `tty:open` scope gate is enforced by the registry's `AccessControl` /// The `tty:open` scope gate is enforced by the registry's `AccessControl`
/// before this handler runs — the handler does not re-check it. /// before this handler runs; the handler does not re-check it.
fn make_tty_open_handler(backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>) -> OpenHandler { fn make_tty_open_handler(backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>) -> OpenHandler {
Arc::new( Arc::new(
move |input: Value, move |input: Value,
@@ -474,7 +474,7 @@ mod tests {
/// feed `make_tty_open_handler` a hand-built `input` the (partial) /// feed `make_tty_open_handler` a hand-built `input` the (partial)
/// schema would accept but the full `NegotiateRequest` parse rejects /// schema would accept but the full `NegotiateRequest` parse rejects
/// (`cwd` typed as a number). Post-ADR-010 the establisher rejects /// (`cwd` typed as a number). Post-ADR-010 the establisher rejects
/// this class before the handler ever spawns — this test pins the /// this class before the handler ever spawns; this test pins the
/// handler's own parse arm, which must still write a /// handler's own parse arm, which must still write a
/// `0x00`-prefixed `malformed_negotiation` error frame rather than /// `0x00`-prefixed `malformed_negotiation` error frame rather than
/// silently EOFing if that input ever reaches it (e.g. a /// silently EOFing if that input ever reaches it (e.g. a
@@ -718,10 +718,10 @@ mod tests {
} }
} }
/// Allocation runs in the establisher (alkcall 0.6 — the /// Allocation runs in the establisher (alkcall 0.6, with the
/// `TtyHandle` crosses to the pump handler via the `Establishment` /// `TtyHandle` crossing to the pump handler via the `Establishment`
/// plan payload, ADR-010 as amended). Its failure maps to /// plan payload, ADR-010 as amended). Its failure maps to
/// `DialFailed` (`details.reason == "dial_failed"` — the target /// `DialFailed` (`details.reason == "dial_failed"`, the target
/// refused or the backend lacked capacity), and a successful /// refused or the backend lacked capacity), and a successful
/// allocation lands in the plan slot the handler takes. /// allocation lands in the plan slot the handler takes.
#[tokio::test] #[tokio::test]
@@ -776,7 +776,7 @@ mod tests {
/// E-02: the spec carries a description (disclosed by /// E-02: the spec carries a description (disclosed by
/// `services/list`) and declares the `channel:open_failed` error /// `services/list`) and declares the `channel:open_failed` error
/// schema (ADR-016 — the establishment-failure contract is /// schema (ADR-016: the establishment-failure contract is
/// discoverable via `services/schema`). /// discoverable via `services/schema`).
#[test] #[test]
fn tty_open_spec_declares_description_and_open_failed_error() { fn tty_open_spec_declares_description_and_open_failed_error() {
@@ -795,7 +795,7 @@ mod tests {
.expect("reason enum"); .expect("reason enum");
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect(); let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
// The establisher produces these four (resource_shortage has // The establisher produces these four (resource_shortage has
// no TTY producer path — ADR-010; allocation failure maps to // no TTY producer path, ADR-010; allocation failure maps to
// dial_failed, the amended mapping). // dial_failed, the amended mapping).
assert_eq!( assert_eq!(
reasons, reasons,
@@ -954,7 +954,7 @@ mod tests {
/// Sanity that the `MockBackend` we use in the channels tests /// Sanity that the `MockBackend` we use in the channels tests
/// compiles as a `TtyBackend` and resolves to the configured exit /// compiles as a `TtyBackend` and resolves to the configured exit
/// code. Belt-and-suspenders — the backend module already tests /// code. Belt-and-suspenders: the backend module already tests
/// this, but the channels tests depend on it. /// this, but the channels tests depend on it.
#[tokio::test] #[tokio::test]
async fn mock_backend_resolves_configured_exit_code() { async fn mock_backend_resolves_configured_exit_code() {
+6 -6
View File
@@ -7,7 +7,7 @@
//! server→client control (`Exit`). The previous single //! server→client control (`Exit`). The previous single
//! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter //! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter
//! ignored `Exit` from the client because it had no way to distinguish //! ignored `Exit` from the client because it had no way to distinguish
//! the two directions on the same stream_type — see the Phase 7 notes //! the two directions on the same stream_type (see the Phase 7 notes
//! in `docs/architecture/tty-wire.md` (the bidirectionality fix that //! in `docs/architecture/tty-wire.md` (the bidirectionality fix that
//! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3) //! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3)
//! and `STREAM_CTRL_OUT` (4)). //! and `STREAM_CTRL_OUT` (4)).
@@ -30,7 +30,7 @@
//! //!
//! Unknown `type` values: `from_slice` returns a `serde_json::Error`. The //! Unknown `type` values: `from_slice` returns a `serde_json::Error`. The
//! adapter (task `tty/adapter`) ignores that error per the wire spec's //! adapter (task `tty/adapter`) ignores that error per the wire spec's
//! "unknown types are ignored" policy — this keeps the enum exhaustive and //! "unknown types are ignored" policy; this keeps the enum exhaustive and
//! the policy an adapter-level concern, not a schema-level leak. //! the policy an adapter-level concern, not a schema-level leak.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -51,7 +51,7 @@ use serde::{Deserialize, Serialize};
/// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter /// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter
/// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a /// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a
/// protocol violation (the adapter ignores it). The split is what makes /// protocol violation (the adapter ignores it). The split is what makes
/// the control channel genuinely bidirectional — the previous single /// the control channel genuinely bidirectional: the previous single
/// `STREAM_CONTROL = 3` was documented as "bidirectional" but the /// `STREAM_CONTROL = 3` was documented as "bidirectional" but the
/// adapter had to ignore `Exit` from the client because the two /// adapter had to ignore `Exit` from the client because the two
/// directions were indistinguishable on the same stream_type. /// directions were indistinguishable on the same stream_type.
@@ -87,10 +87,10 @@ pub enum ControlMessage {
/// chunk is the last control chunk before stream close (ADR-055). /// chunk is the last control chunk before stream close (ADR-055).
/// `code` is `i32`: `ExitStatus::code()` on normal exit; on Unix, /// `code` is `i32`: `ExitStatus::code()` on normal exit; on Unix,
/// the negative signal number on signal-terminated exit (e.g., /// the negative signal number on signal-terminated exit (e.g.,
/// `-9` for SIGKILL — the crate's convention, not /// `-9` for SIGKILL; the crate's convention, not
/// `ExitStatus::code()`'s, which returns `None` there). `-1` is /// `ExitStatus::code()`'s, which returns `None` there). `-1` is
/// the adapter's best-effort "backend could not determine the /// the adapter's best-effort "backend could not determine the
/// exit code" sentinel (ADR-055 §4) — colliding with the SIGHUP /// exit code" sentinel (ADR-055 §4), colliding with the SIGHUP
/// encoding on the Unix pipe path. /// encoding on the Unix pipe path.
Exit { code: i32 }, Exit { code: i32 },
} }
@@ -117,7 +117,7 @@ impl ControlMessage {
/// (Ctrl-C → `INT`, Ctrl-\ → `QUIT`, Ctrl-Z → `TSTP`). Unknown names /// (Ctrl-C → `INT`, Ctrl-\ → `QUIT`, Ctrl-Z → `TSTP`). Unknown names
/// return `None`; the caller (the local backend) decides whether to /// return `None`; the caller (the local backend) decides whether to
/// ignore or fall back to the backend's default kill /// ignore or fall back to the backend's default kill
/// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP — see /// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP; see
/// tty-local.md REQ-TTY-02). /// tty-local.md REQ-TTY-02).
/// ///
/// Unix-only: the non-Unix path falls back to `ChildKiller::kill` /// Unix-only: the non-Unix path falls back to `ChildKiller::kill`
+5 -5
View File
@@ -3,18 +3,18 @@
//! Producer/consumer protocol crate on top of alkcall channels. Two //! Producer/consumer protocol crate on top of alkcall channels. Two
//! halves (per alkcall's protocol-crate pattern): //! halves (per alkcall's protocol-crate pattern):
//! //!
//! - **Producer half** — [`adapter::TtyAdapter`] (direct `alk/tty` //! - **Producer half**: [`adapter::TtyAdapter`] (direct `alk/tty`
//! ALPN via `ProtocolHandler`) + [`channels`] (registers the //! ALPN via `ProtocolHandler`) + [`channels`] (registers the
//! `channels/tty/sub` open op via `ChannelCore::register_openable` //! `channels/tty/sub` open op via `ChannelCore::register_openable`
//! for the `alk/channels` multiplexed path). //! for the `alk/channels` multiplexed path).
//! - **Consumer half** — [`session::TtySession`] (typed client wrapper //! - **Consumer half**: [`session::TtySession`] (typed client wrapper
//! around the wire protocol, with `connect_direct` and //! around the wire protocol, with `connect_direct` and
//! `open_via_channels` constructors). //! `open_via_channels` constructors).
//! //!
//! Two-carriage wire format (ADR-052): a JSON negotiation frame, then //! Two-carriage wire format (ADR-052): a JSON negotiation frame, then
//! raw chunks (`[stream_type: u8][length: u32 be][payload]`). //! raw chunks (`[stream_type: u8][length: u32 be][payload]`).
//! Backend-agnostic via the [`backend::TtyBackend`] trait (ADR-053). //! Backend-agnostic via the [`backend::TtyBackend`] trait (ADR-053).
//! Depends on alkcall (ADR-057 — the negotiation framing is //! Depends on alkcall (ADR-057: the negotiation framing is
//! self-contained; alkcall's `EventEnvelope` framing is not reused). //! self-contained; alkcall's `EventEnvelope` framing is not reused).
//! //!
//! # WASM target //! # WASM target
@@ -48,7 +48,7 @@
//! //!
//! # Crate-root surface //! # Crate-root surface
//! //!
//! The primary types are re-exported at the crate root — //! The primary types are re-exported at the crate root:
//! [`TtyAdapter`]/[`drive_session`] (producer), [`TtySession`] //! [`TtyAdapter`]/[`drive_session`] (producer), [`TtySession`]
//! (consumer), [`TtyBackend`]/[`TtyHandle`]/[`TtyParams`] (backend //! (consumer), [`TtyBackend`]/[`TtyHandle`]/[`TtyParams`] (backend
//! authors), plus the wire codec ([`Chunk`], [`ChunkReader`], //! authors), plus the wire codec ([`Chunk`], [`ChunkReader`],
@@ -56,7 +56,7 @@
//! [`MAX_CHUNK_LEN`], ...), [`ControlMessage`], the negotiation types //! [`MAX_CHUNK_LEN`], ...), [`ControlMessage`], the negotiation types
//! [`NegotiateRequest`], ...), the channels registration helpers //! [`NegotiateRequest`], ...), the channels registration helpers
//! ([`register_openable`]), and `local::LocalTtyBackend` under the //! ([`register_openable`]), and `local::LocalTtyBackend` under the
//! `local` feature. The module paths below remain the full public surface — //! `local` feature. The module paths below remain the full public surface;
//! the root re-exports are the ergonomic short paths for the common //! the root re-exports are the ergonomic short paths for the common
//! imports. //! imports.
+3 -3
View File
@@ -17,7 +17,7 @@ use async_trait::async_trait;
/// (the runner case, this crate's `pipe` module). One backend serves both /// (the runner case, this crate's `pipe` module). One backend serves both
/// (ADR-054). /// (ADR-054).
/// ///
/// Takes no constructor dependencies — unlike `DockerTtyBackend` (wraps a /// Takes no constructor dependencies: unlike `DockerTtyBackend` (wraps a
/// `bollard::Docker` client) or `SshTtyBackend` (wraps an SSH session), the /// `bollard::Docker` client) or `SshTtyBackend` (wraps an SSH session), the
/// `portable_pty` system is process-global. The assembly layer constructs one /// `portable_pty` system is process-global. The assembly layer constructs one
/// `LocalTtyBackend` and registers it as `"local"`. /// `LocalTtyBackend` and registers it as `"local"`.
@@ -43,9 +43,9 @@ impl TtyBackend for LocalTtyBackend {
/// `params.terminal`. /// `params.terminal`.
/// ///
/// `Some(TerminalParams)` dispatches to [`crate::local::pty::allocate_pty`] /// `Some(TerminalParams)` dispatches to [`crate::local::pty::allocate_pty`]
/// (PTY mode, real terminal semantics — resize, process-group signal /// (PTY mode, real terminal semantics: resize, process-group signal
/// forwarding, merged stdout/stderr). `None` dispatches to /// forwarding, merged stdout/stderr). `None` dispatches to
/// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case — /// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case:
/// separate stdout/stderr, no-op resize, pid-only signal). /// separate stdout/stderr, no-op resize, pid-only signal).
/// ///
/// `backend_params` is ignored: the local backend has no /// `backend_params` is ignored: the local backend has no
+1 -1
View File
@@ -10,7 +10,7 @@
//! non-wasm by design (`portable_pty` + `tokio::process` need a real //! non-wasm by design (`portable_pty` + `tokio::process` need a real
//! OS). The default crate (no features) compiles to //! OS). The default crate (no features) compiles to
//! `wasm32-unknown-unknown`; enabling `local` on a wasm target is a //! `wasm32-unknown-unknown`; enabling `local` on a wasm target is a
//! build error by design — the local-process backend runs on a real //! build error by design: the local-process backend runs on a real
//! OS (Linux, macOS, Windows), never in a sandbox. //! OS (Linux, macOS, Windows), never in a sandbox.
pub mod backend; pub mod backend;
+11 -11
View File
@@ -122,13 +122,13 @@ where
} }
/// Control handle for pipe mode: no-op resize and `libc::kill(pid, sig)` /// Control handle for pipe mode: no-op resize and `libc::kill(pid, sig)`
/// signal forwarding (the runner case — ADR-054). /// signal forwarding (the runner case; ADR-054).
/// ///
/// # Signal scope — no process group (documented limitation) /// # Signal scope: no process group (documented limitation)
/// ///
/// Pipe mode has no session leader / controlling tty, so `signal()` targets /// Pipe mode has no session leader / controlling tty, so `signal()` targets
/// the direct child pid only via `libc::kill(pid, sig)`. If the child has /// the direct child pid only via `libc::kill(pid, sig)`. If the child has
/// spawned its own children, they will NOT receive the signal — this is a /// spawned its own children, they will NOT receive the signal; this is a
/// known limitation of the runner case. A runner that needs process-group /// known limitation of the runner case. A runner that needs process-group
/// signal delivery uses the PTY case, not the pipe case. See /// signal delivery uses the PTY case, not the pipe case. See
/// `tty-local.md` §"Pipe Mode". /// `tty-local.md` §"Pipe Mode".
@@ -149,11 +149,11 @@ impl PipeControl {
impl TtyControl for PipeControl { impl TtyControl for PipeControl {
fn resize(&self, _cols: u16, _rows: u16, _pixel_width: u16, _pixel_height: u16) { fn resize(&self, _cols: u16, _rows: u16, _pixel_width: u16, _pixel_height: u16) {
// No-op — no PTY in pipe mode. // No-op: no PTY in pipe mode.
} }
fn signal(&self, name: &str) { fn signal(&self, name: &str) {
// Targets the direct child pid only (no process group — see the // Targets the direct child pid only (no process group; see the
// type-level doc comment on `PipeControl`). // type-level doc comment on `PipeControl`).
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -197,13 +197,13 @@ impl TtyControl for PipeControl {
/// The `exit_code` future for pipe mode: wraps `tokio::process::Child::wait` /// The `exit_code` future for pipe mode: wraps `tokio::process::Child::wait`
/// plus the ADR-056 kill guard. /// plus the ADR-056 kill guard.
/// ///
/// # ADR-056 — kill-on-Drop contract /// # ADR-056: kill-on-Drop contract
/// ///
/// On cancel (the future is dropped without being driven to completion), /// On cancel (the future is dropped without being driven to completion),
/// `Drop` calls `Child::start_kill()` (SIGKILL); the child exits and the /// `Drop` calls `Child::start_kill()` (SIGKILL); the child exits and the
/// inner `wait` future reaps it. On the happy path, `poll` resolves the /// inner `wait` future reaps it. On the happy path, `poll` resolves the
/// `wait` future, then **disarms** the guard by taking the `Child` out of /// `wait` future, then **disarms** the guard by taking the `Child` out of
/// `self.child` — so the subsequent `Drop` is a no-op. The guard is the /// `self.child`, so the subsequent `Drop` is a no-op. The guard is the
/// spec-compliant mechanism; `kill_on_drop(true)` on the `Command` is a /// spec-compliant mechanism; `kill_on_drop(true)` on the `Command` is a
/// defense-in-depth backstop, not the primary path (the `Child` is moved /// defense-in-depth backstop, not the primary path (the `Child` is moved
/// into the future and the future's `Drop` is the cancel path, not the /// into the future and the future's `Drop` is the cancel path, not the
@@ -283,10 +283,10 @@ impl Drop for PipeExitFuture {
/// Map an `ExitStatus` to the adapter's `i32` exit code: `code()` on normal /// Map an `ExitStatus` to the adapter's `i32` exit code: `code()` on normal
/// exit; on Unix, the negative signal number on signal-terminated exit /// exit; on Unix, the negative signal number on signal-terminated exit
/// (ADR-055 §4's convention — `ExitStatus::code()` returns `None` for /// (ADR-055 §4's convention: `ExitStatus::code()` returns `None` for
/// signal termination, so this crate's encoding is via /// signal termination, so this crate's encoding is via
/// `ExitStatusExt::signal()`). Falls back to `-1` (the adapter's /// `ExitStatusExt::signal()`). Falls back to `-1` (the adapter's
/// "could not determine" sentinel — ADR-055 §4). /// "could not determine" sentinel; ADR-055 §4).
fn exit_code_from(status: ExitStatus) -> i32 { fn exit_code_from(status: ExitStatus) -> i32 {
if let Some(code) = status.code() { if let Some(code) = status.code() {
return code; return code;
@@ -385,7 +385,7 @@ mod tests {
/// Readiness signal (review #001 N4): the child writes `ready` to /// Readiness signal (review #001 N4): the child writes `ready` to
/// the marker file right before `exec sleep`, so the test knows /// the marker file right before `exec sleep`, so the test knows
/// the exec'd process exists — no fixed pre-signal sleep. /// the exec'd process exists; no fixed pre-signal sleep.
async fn wait_marker(marker: &std::path::Path) { async fn wait_marker(marker: &std::path::Path) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop { loop {
@@ -453,7 +453,7 @@ mod tests {
let pid_str = std::fs::read_to_string(&pid_file).expect("pid file written"); let pid_str = std::fs::read_to_string(&pid_file).expect("pid file written");
let pid: i32 = pid_str.trim().parse().expect("pid parses"); let pid: i32 = pid_str.trim().parse().expect("pid parses");
let _ = std::fs::remove_file(&pid_file); let _ = std::fs::remove_file(&pid_file);
// Drop the handle without awaiting exit_code — the ADR-056 guard // Drop the handle without awaiting exit_code; the ADR-056 guard
// must kill the child. // must kill the child.
drop(handle); drop(handle);
// Poll for the child's death (pid_file written before exec; the // Poll for the child's death (pid_file written before exec; the
+29 -29
View File
@@ -6,7 +6,7 @@
//! forwarding (`libc::kill(-pgid, sig)`), and the ADR-056 kill guard on the //! forwarding (`libc::kill(-pgid, sig)`), and the ADR-056 kill guard on the
//! `exit_code` future. //! `exit_code` future.
//! //!
//! # REQ-TTY-01 — blocking→async bridge //! # REQ-TTY-01: blocking→async bridge
//! //!
//! `portable_pty` exposes a blocking `std::io` API //! `portable_pty` exposes a blocking `std::io` API
//! (`MasterPty::try_clone_reader()`, `take_writer()`, `Child::wait()`). //! (`MasterPty::try_clone_reader()`, `take_writer()`, `Child::wait()`).
@@ -14,7 +14,7 @@
//! backend: dedicated std threads feed tokio mpsc/oneshot channels, and //! backend: dedicated std threads feed tokio mpsc/oneshot channels, and
//! the async-facing `TtyHandle` fields are wrappers over those channels. //! the async-facing `TtyHandle` fields are wrappers over those channels.
//! //!
//! # REQ-TTY-02 — signal targets the process group //! # REQ-TTY-02: signal targets the process group
//! //!
//! `portable_pty` spawns the child as a session leader //! `portable_pty` spawns the child as a session leader
//! (`CommandBuilder::set_controlling_tty(true)`, the default), so the //! (`CommandBuilder::set_controlling_tty(true)`, the default), so the
@@ -24,12 +24,12 @@
//! child already exited). Unknown signal names fall back to //! child already exited). Unknown signal names fall back to
//! `ChildKiller::kill` (SIGHUP). See `tty-local.md` §"REQ-TTY-02". //! `ChildKiller::kill` (SIGHUP). See `tty-local.md` §"REQ-TTY-02".
//! //!
//! # ADR-056 — kill-on-Drop guard //! # ADR-056: kill-on-Drop guard
//! //!
//! `LocalExitFuture` wraps the `oneshot::Receiver<i32>` from the waiter //! `LocalExitFuture` wraps the `oneshot::Receiver<i32>` from the waiter
//! thread alongside a `portable_pty::ChildKiller`. On cancel (the future //! thread alongside a `portable_pty::ChildKiller`. On cancel (the future
//! is dropped without being driven to completion), `Drop` calls //! is dropped without being driven to completion), `Drop` calls
//! `ChildKiller::kill()` (SIGHUP) — best-effort, the child may already be //! `ChildKiller::kill()` (SIGHUP); best-effort, the child may already be
//! exiting. On the happy path (the future resolves), `poll` disarms the //! exiting. On the happy path (the future resolves), `poll` disarms the
//! guard (`Option::take()`), so the subsequent `Drop` is a no-op. The //! guard (`Option::take()`), so the subsequent `Drop` is a no-op. The
//! waiter thread reaps the killed child via its blocking wait (Unix: //! waiter thread reaps the killed child via its blocking wait (Unix:
@@ -43,7 +43,7 @@
//! number (ADR-004 decision 1): `signal("TERM")` resolves to `-15`, the //! number (ADR-004 decision 1): `signal("TERM")` resolves to `-15`, the
//! ADR-056 cancel kill (SIGHUP) to `-1`. portable-pty 0.9.0's `ExitStatus` //! ADR-056 cancel kill (SIGHUP) to `-1`. portable-pty 0.9.0's `ExitStatus`
//! would flatten the same death to code `1` with the signal only as a //! would flatten the same death to code `1` with the signal only as a
//! strsignal name string — indistinguishable from a real `exit 1`. The pipe //! strsignal name string, indistinguishable from a real `exit 1`. The pipe
//! path encodes via `std::os::unix::process::ExitStatusExt::signal()` //! path encodes via `std::os::unix::process::ExitStatusExt::signal()`
//! (`pipe.rs::exit_code_from`); both backends now agree on the convention. //! (`pipe.rs::exit_code_from`); both backends now agree on the convention.
//! See `wait_exit_code` and review #004. //! See `wait_exit_code` and review #004.
@@ -53,19 +53,19 @@
//! The three-thread bridge's error arms are documented-unreachable //! The three-thread bridge's error arms are documented-unreachable
//! through the public path, per the review's disposition: //! through the public path, per the review's disposition:
//! //!
//! - **reader `try_clone_reader` failure** — the master fd is open and //! - **reader `try_clone_reader` failure**: the master fd is open and
//! owned by this allocation; `dup` failure requires an exhausted //! owned by this allocation; `dup` failure requires an exhausted
//! fd table, which no test can force deterministically. //! fd table, which no test can force deterministically.
//! - **reader read error** — the `PtyFd` `Read` impl maps `EIO` //! - **reader read error**: the `PtyFd` `Read` impl maps `EIO`
//! (slave closed) to EOF, the only realistic master read error; any //! (slave closed) to EOF, the only realistic master read error; any
//! other error kind has no trigger from the public path. //! other error kind has no trigger from the public path.
//! - **writer `take_writer` failure** — portable-pty 0.9 fails this //! - **writer `take_writer` failure**: portable-pty 0.9 fails this
//! only on the *second* take (`took_writer` flag); the bridge takes //! only on the *second* take (`took_writer` flag); the bridge takes
//! it exactly once. //! it exactly once.
//! - **writer write/flush failure** — the master fd is open and the //! - **writer write/flush failure**: the master fd is open and the
//! writer thread is the sole writer; a write error requires an //! writer thread is the sole writer; a write error requires an
//! externally-closed fd the bridge never observes. //! externally-closed fd the bridge never observes.
//! - **waiter `wait()` failure** — reached only on the waitpid-failure //! - **waiter `wait()` failure**, reached only on the waitpid-failure
//! fallback (see `wait_exit_code`); `std::process::Child::wait` fails //! fallback (see `wait_exit_code`); `std::process::Child::wait` fails
//! only if the child was already reaped (the bridge normally reaps via //! only if the child was already reaped (the bridge normally reaps via
//! `libc::waitpid` and never calls `try_wait`). The ADR-055 §4 //! `libc::waitpid` and never calls `try_wait`). The ADR-055 §4
@@ -136,7 +136,7 @@ impl PtyControl {
} }
impl TtyControl for PtyControl { impl TtyControl for PtyControl {
/// Resize the PTY. Safe to call from the async pump — /// Resize the PTY. Safe to call from the async pump, since
/// `MasterPty::resize` is non-blocking (it issues an `ioctl`). /// `MasterPty::resize` is non-blocking (it issues an `ioctl`).
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) { fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
let size = PtySize { let size = PtySize {
@@ -155,7 +155,7 @@ impl TtyControl for PtyControl {
/// ///
/// On Unix: maps the name to a `libc` signal number via /// On Unix: maps the name to a `libc` signal number via
/// `crate::control::signal_from_name`, then calls `kill(-pgid, sig)` (the /// `crate::control::signal_from_name`, then calls `kill(-pgid, sig)` (the
/// negative pid reaches the whole process group — the child is a /// negative pid reaches the whole process group; the child is a
/// session leader because `set_controlling_tty(true)`, the default). /// session leader because `set_controlling_tty(true)`, the default).
/// Falls back to `kill(pid, sig)` if the group signal fails (e.g. the /// Falls back to `kill(pid, sig)` if the group signal fails (e.g. the
/// child already exited). Unknown names fall back to /// child already exited). Unknown names fall back to
@@ -207,9 +207,9 @@ impl TtyControl for PtyControl {
/// `portable_pty::ChildKiller`. /// `portable_pty::ChildKiller`.
/// ///
/// `poll` delegates to the oneshot receiver (resolves on natural exit). On /// `poll` delegates to the oneshot receiver (resolves on natural exit). On
/// `Ready`, the killer is taken (`Option::take()`) — disarmed — so the /// `Ready`, the killer is taken (`Option::take()`, disarming it) so the
/// subsequent `Drop` is a no-op. On cancel (the future is dropped before /// subsequent `Drop` is a no-op. On cancel (the future is dropped before
/// resolving), `Drop` calls `ChildKiller::kill()` (SIGHUP) — best-effort; /// resolving), `Drop` calls `ChildKiller::kill()` (SIGHUP), best-effort;
/// the child may already be exiting. The waiter thread's blocking wait /// the child may already be exiting. The waiter thread's blocking wait
/// (Unix: `libc::waitpid`) reaps the killed child, so there is no zombie. /// (Unix: `libc::waitpid`) reaps the killed child, so there is no zombie.
/// The contract is "kill on cancel; no-op on resolve." /// The contract is "kill on cancel; no-op on resolve."
@@ -234,7 +234,7 @@ impl Future for LocalExitFuture {
match Pin::new(&mut self.rx).poll(cx) { match Pin::new(&mut self.rx).poll(cx) {
Poll::Pending => Poll::Pending, Poll::Pending => Poll::Pending,
Poll::Ready(Ok(code)) => { Poll::Ready(Ok(code)) => {
// Disarm the kill guard — the child exited naturally. // Disarm the kill guard: the child exited naturally.
self.killer.take(); self.killer.take();
Poll::Ready(Ok(code)) Poll::Ready(Ok(code))
} }
@@ -253,7 +253,7 @@ impl Future for LocalExitFuture {
impl Drop for LocalExitFuture { impl Drop for LocalExitFuture {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(killer) = self.killer.take() { if let Some(killer) = self.killer.take() {
// ADR-056: kill on cancel. Best-effort — the child may already // ADR-056: kill on cancel. Best-effort, since the child may already
// be exiting. SIGHUP is what portable_pty sends on Unix. // be exiting. SIGHUP is what portable_pty sends on Unix.
let mut killer = killer; let mut killer = killer;
if let Err(e) = killer.kill() { if let Err(e) = killer.kill() {
@@ -269,9 +269,9 @@ impl Drop for LocalExitFuture {
/// ///
/// When the channel is full, `poll_write` parks in an in-flight send /// When the channel is full, `poll_write` parks in an in-flight send
/// future stored on the struct (so a re-poll resumes the same send rather /// future stored on the struct (so a re-poll resumes the same send rather
/// than starting a new one — `reserve()` is not `Unpin`). /// than starting a new one; `reserve()` is not `Unpin`).
/// `poll_shutdown` parks the same way (a separate slot, so the EOF send /// `poll_shutdown` parks the same way (a separate slot, so the EOF send
/// cannot be confused with a byte send) — returning `Pending` without a /// cannot be confused with a byte send). Returning `Pending` without a
/// registered waker would strand the EOF forever on a full channel /// registered waker would strand the EOF forever on a full channel
/// (review #003 P8). /// (review #003 P8).
struct StdinSink { struct StdinSink {
@@ -410,19 +410,19 @@ impl tokio::io::AsyncWrite for StdinSink {
/// Wait for the child to terminate and resolve to the ADR-004 exit-status /// Wait for the child to terminate and resolve to the ADR-004 exit-status
/// encoding: `ExitStatus::code()` on normal exit; on Unix, the negative /// encoding: `ExitStatus::code()` on normal exit; on Unix, the negative
/// signal number on signal-terminated exit (e.g. `-15` for SIGTERM — /// signal number on signal-terminated exit (e.g. `-15` for SIGTERM;
/// Python's `Popen.returncode` parity). /// Python's `Popen.returncode` parity).
/// ///
/// On Unix the reap goes through `libc::waitpid(pid, ..)` on the direct /// On Unix the reap goes through `libc::waitpid(pid, ..)` on the direct
/// child pid, not portable-pty's `Child::wait()`: portable-pty 0.9.0 /// child pid, not portable-pty's `Child::wait()`: portable-pty 0.9.0
/// converts a signal-terminated child to `ExitStatus { code: 1, signal: /// converts a signal-terminated child to `ExitStatus { code: 1, signal:
/// Some(name) }` — the signal identity survives only as a strsignal name /// Some(name) }`; the signal identity survives only as a strsignal name
/// string that cannot be mapped back numerically (it is locale-dependent), /// string that cannot be mapped back numerically (it is locale-dependent),
/// and `code = 1` collides with a real `exit 1`. The reaped child is a /// and `code = 1` collides with a real `exit 1`. The reaped child is a
/// direct child of this process (portable-pty's unix backend spawns a /// direct child of this process (portable-pty's unix backend spawns a
/// `std::process::Child`, whose `Drop` neither kills nor reaps), and /// `std::process::Child`, whose `Drop` neither kills nor reaps), and
/// `Child::wait()` is reachable only on the waitpid-failure path — where /// `Child::wait()` is reachable only on the waitpid-failure path (where
/// the child was not reaped — so exactly one of the two paths reaps. /// the child was not reaped), so exactly one of the two paths reaps.
/// ///
/// Non-Unix has no Unix signal semantics; `Child::wait()` → /// Non-Unix has no Unix signal semantics; `Child::wait()` →
/// `exit_code() as i32` matches the convention for normal exits. `-1` is /// `exit_code() as i32` matches the convention for normal exits. `-1` is
@@ -478,7 +478,7 @@ fn wait_exit_code(pid: Option<i32>, child: &mut Box<dyn Child + Send + Sync>) ->
/// `TtyHandle`. /// `TtyHandle`.
/// ///
/// Spawns the child as a session leader with a controlling tty /// Spawns the child as a session leader with a controlling tty
/// (`CommandBuilder::set_controlling_tty(true)` — the default; REQ-TTY-02 /// (`CommandBuilder::set_controlling_tty(true)`, the default; REQ-TTY-02
/// depends on it). Wires the three-thread bridge (reader/writer/waiter), /// depends on it). Wires the three-thread bridge (reader/writer/waiter),
/// the `PtyControl`, and the ADR-056 `LocalExitFuture`. /// the `PtyControl`, and the ADR-056 `LocalExitFuture`.
pub fn allocate_pty( pub fn allocate_pty(
@@ -516,7 +516,7 @@ pub fn allocate_pty(
for (k, v) in env { for (k, v) in env {
builder.env(k, v); builder.env(k, v);
} }
// Session leader + controlling tty — the default. REQ-TTY-02: // Session leader + controlling tty (the default). REQ-TTY-02:
// `kill(-pgid, sig)` reaches the whole group only when the child is a // `kill(-pgid, sig)` reaches the whole group only when the child is a
// session leader, which requires a controlling tty. // session leader, which requires a controlling tty.
builder.set_controlling_tty(true); builder.set_controlling_tty(true);
@@ -684,7 +684,7 @@ mod tests {
/// Readiness signal (review #001 N4): the child writes `ready` to /// Readiness signal (review #001 N4): the child writes `ready` to
/// the marker file right before `exec sleep`, so the test knows /// the marker file right before `exec sleep`, so the test knows
/// the exec'd process exists — no fixed pre-signal sleep. /// the exec'd process exists (no fixed pre-signal sleep).
async fn wait_marker(marker: &std::path::Path) { async fn wait_marker(marker: &std::path::Path) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop { loop {
@@ -802,7 +802,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn signal_reaches_process_group_child() { async fn signal_reaches_process_group_child() {
// bash -c 'echo ready > marker; sleep 60' — sleep is a child of // bash -c 'echo ready > marker; sleep 60', where sleep is a child of
// bash. The group signal must reach sleep too (REQ-TTY-02). bash // bash. The group signal must reach sleep too (REQ-TTY-02). bash
// exits when its child does. // exits when its child does.
let marker = marker_path("pgroup_ready"); let marker = marker_path("pgroup_ready");
@@ -854,7 +854,7 @@ mod tests {
// ADR-056: dropping the TtyHandle (and thus the exit_code future) // ADR-056: dropping the TtyHandle (and thus the exit_code future)
// without awaiting it MUST kill the child. The waiter thread reaps // without awaiting it MUST kill the child. The waiter thread reaps
// the killed child (no zombie). We assert the kill happened by // the killed child (no zombie). We assert the kill happened by
// spawning a second session that completes promptly — i.e., the // spawning a second session that completes promptly, i.e., the
// dropped session's child does not outlive a short grace period // dropped session's child does not outlive a short grace period
// (if it did, the SIGHUP from Drop would not have fired). // (if it did, the SIGHUP from Drop would not have fired).
let handle = allocate_pty( let handle = allocate_pty(
@@ -900,7 +900,7 @@ mod tests {
wait_marker(&marker).await; wait_marker(&marker).await;
// "NOSUCH" is not a known signal name → falls back to // "NOSUCH" is not a known signal name → falls back to
// ChildKiller::kill (SIGHUP). The child should die by SIGHUP, // ChildKiller::kill (SIGHUP). The child should die by SIGHUP,
// which resolves to `-1` — the documented ADR-004 sentinel // which resolves to `-1`, the documented ADR-004 sentinel
// collision (SIGHUP = signal 1). // collision (SIGHUP = signal 1).
control.signal("NOSUCH"); control.signal("NOSUCH");
let code = tokio::time::timeout(std::time::Duration::from_secs(5), handle.exit_code) let code = tokio::time::timeout(std::time::Duration::from_secs(5), handle.exit_code)
@@ -919,7 +919,7 @@ mod tests {
// The signal path's fallback chain (REQ-TTY-02): `kill(-pgid, sig)` // The signal path's fallback chain (REQ-TTY-02): `kill(-pgid, sig)`
// fails once the child (and its group) is gone, then the // fails once the child (and its group) is gone, then the
// `kill(pid, sig)` fallback fails too, and the call degrades to a // `kill(pid, sig)` fallback fails too, and the call degrades to a
// warn + return — no panic, no cascade. A late signal (the client // warn + return: no panic, no cascade. A late signal (the client
// signals after the process already exited) must be safe. // signals after the process already exited) must be safe.
let handle = allocate_pty( let handle = allocate_pty(
term(), term(),
+10 -10
View File
@@ -8,8 +8,8 @@
//! //!
//! The framing is self-contained in alktty (ADR-057): a 4-byte //! The framing is self-contained in alktty (ADR-057): a 4-byte
//! big-endian length prefix + UTF-8 JSON body. The format coincides with //! big-endian length prefix + UTF-8 JSON body. The format coincides with
//! alkcall's `EventEnvelope` framing by convention, not by code reuse //! alkcall's `EventEnvelope` framing by convention, not by code reuse:
//! — alktty does not depend on alkcall's internal wire types. //! alktty does not depend on alkcall's internal wire types.
//! //!
//! # Framing disambiguation //! # Framing disambiguation
//! //!
@@ -22,7 +22,7 @@
//! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the //! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the
//! high byte is zero (a wire-format invariant, not an assumption). //! high byte is zero (a wire-format invariant, not an assumption).
//! - A raw chunk's first byte is a `stream_type`. The server never sends //! - A raw chunk's first byte is a `stream_type`. The server never sends
//! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` — //! `0` (stdin, client→server only) or `3` (`STREAM_CTRL_IN`,
//! client→server only), so the server-sent set is `{1, 2, 4}` //! client→server only), so the server-sent set is `{1, 2, 4}`
//! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous. //! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous.
//! //!
@@ -68,7 +68,7 @@ pub struct NegotiateRequest {
pub carriage: String, pub carriage: String,
/// Backend selector key (`"local"`, `"docker"`, `"ssh"`). /// Backend selector key (`"local"`, `"docker"`, `"ssh"`).
pub backend: String, pub backend: String,
/// `None` = pipe mode (no PTY — ADR-054). `Some` = allocate a PTY with /// `None` = pipe mode (no PTY; ADR-054). `Some` = allocate a PTY with
/// these dimensions. /// these dimensions.
#[serde(default)] #[serde(default)]
pub tty: Option<TerminalParamsWire>, pub tty: Option<TerminalParamsWire>,
@@ -95,7 +95,7 @@ pub struct NegotiateRequest {
/// ///
/// Maps to SSH's `pty_request` parameters, to docker's /// Maps to SSH's `pty_request` parameters, to docker's
/// `CreateExecOptions { tty: true }`, and to `portable_pty::PtySystem::openpty` /// `CreateExecOptions { tty: true }`, and to `portable_pty::PtySystem::openpty`
/// for the local backend. The `modes` field is reserved (OQ-44 — default /// for the local backend. The `modes` field is reserved (OQ-44: default
/// terminal modes suffice for the current scope); backends MUST ignore its /// terminal modes suffice for the current scope); backends MUST ignore its
/// content in v1. /// content in v1.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -114,7 +114,7 @@ pub struct TerminalParamsWire {
/// Pixel height (most terminals don't report this; defaults to 0). /// Pixel height (most terminals don't report this; defaults to 0).
#[serde(default)] #[serde(default)]
pub pixel_height: u16, pub pixel_height: u16,
/// Reserved — OQ-44; backends MUST ignore the content in v1. /// Reserved (OQ-44); backends MUST ignore the content in v1.
#[serde(default)] #[serde(default)]
pub modes: serde_json::Value, pub modes: serde_json::Value,
} }
@@ -122,7 +122,7 @@ pub struct TerminalParamsWire {
/// Errors from the negotiation framing reader/writer. /// Errors from the negotiation framing reader/writer.
/// ///
/// `ConnectionClosed` is returned (rather than `Io`) when `read_frame` hits /// `ConnectionClosed` is returned (rather than `Io`) when `read_frame` hits
/// a clean `UnexpectedEof` reading either the length prefix or the body — /// a clean `UnexpectedEof` reading either the length prefix or the body:
/// the peer closed the stream cleanly rather than failing the transport. /// the peer closed the stream cleanly rather than failing the transport.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum NegotiationError { pub enum NegotiationError {
@@ -217,7 +217,7 @@ impl<R: AsyncRead + Unpin> NegotiationReader<R> {
/// prefix followed by the body, then flushes. /// prefix followed by the body, then flushes.
/// ///
/// For server-side error responses, the body MUST be under 16 MiB /// For server-side error responses, the body MUST be under 16 MiB
/// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00` — /// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00`;
/// this is the wire-format invariant that makes the framing-disambiguation /// this is the wire-format invariant that makes the framing-disambiguation
/// trick sound (see ADR-052 §5). [`Self::write_frame`] does not enforce /// trick sound (see ADR-052 §5). [`Self::write_frame`] does not enforce
/// this; callers building error responses with [`error_response_bytes`] are /// this; callers building error responses with [`error_response_bytes`] are
@@ -251,13 +251,13 @@ impl<W: AsyncWrite + Unpin> NegotiationWriter<W> {
/// Serialize a negotiation error response to JSON bytes. /// Serialize a negotiation error response to JSON bytes.
/// ///
/// Produces `{"error":"<error>","<field>":"<value>",...}` — the /// Produces `{"error":"<error>","<field>":"<value>",...}`, the
/// length-prefixed error frame the server sends when it cannot allocate /// length-prefixed error frame the server sends when it cannot allocate
/// the session (unknown backend, malformed negotiation, allocate failed). /// the session (unknown backend, malformed negotiation, allocate failed).
/// The caller writes the result via [`NegotiationWriter::write_frame`]. /// The caller writes the result via [`NegotiationWriter::write_frame`].
/// ///
/// Error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the high byte /// Error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the high byte
/// of the 4-byte length prefix is `0x00` (framing disambiguation — ADR-052 /// of the 4-byte length prefix is `0x00` (framing disambiguation; ADR-052
/// §5). Realistic error responses are tens of bytes; this invariant holds /// §5). Realistic error responses are tens of bytes; this invariant holds
/// by construction. /// by construction.
pub fn error_response_bytes(error: &str, fields: &[(&str, &str)]) -> serde_json::Result<Vec<u8>> { pub fn error_response_bytes(error: &str, fields: &[(&str, &str)]) -> serde_json::Result<Vec<u8>> {
+53 -54
View File
@@ -1,9 +1,9 @@
//! Consumer half — `TtySession`, the typed client wrapper around the //! Consumer half: `TtySession`, the typed client wrapper around the
//! `alk/tty` wire protocol (per alkcall's protocol-crate pattern). //! `alk/tty` wire protocol (per alkcall's protocol-crate pattern).
//! //!
//! Two constructors: //! Two constructors:
//! //!
//! - [`TtySession::connect_direct`] — for a direct `alk/tty` ALPN //! - [`TtySession::connect_direct`]: for a direct `alk/tty` ALPN
//! connection. The consumer dials the transport (TLS, QUIC, //! connection. The consumer dials the transport (TLS, QUIC,
//! WebSocket), negotiates the `alk/tty` ALPN, and hands the //! WebSocket), negotiates the `alk/tty` ALPN, and hands the
//! `Connection` to `connect_direct`. The session takes ownership of //! `Connection` to `connect_direct`. The session takes ownership of
@@ -11,35 +11,35 @@
//! ADR-065), writes the negotiation frame, and exposes the typed //! ADR-065), writes the negotiation frame, and exposes the typed
//! methods. //! methods.
//! //!
//! - [`TtySession::open_via_channels`] — for the `alk/channels` //! - [`TtySession::open_via_channels`]: for the `alk/channels`
//! multiplexed path. The consumer holds a //! multiplexed path. The consumer holds a
//! [`alkcall::channels::client::ChannelClient`], calls //! [`alkcall::channels::client::ChannelClient`], calls
//! `open_via_channels(client, params)`, which invokes //! `open_via_channels(client, params)`, which invokes
//! `channels/tty/sub` on channel 0, adopts the resulting channel, //! `channels/tty/sub` on channel 0, adopts the resulting channel,
//! builds a `Connection` from the reassembled read half + mux write //! builds a `Connection` from the reassembled read half + mux write
//! half, and runs the typed-methods flow directly in raw chunk mode //! half, and runs the typed-methods flow directly in raw chunk mode
//! (ADR-009: the open op's `params` — validated by the registry's //! (ADR-009: the open op's `params`, validated by the registry's
//! input schema — *are* the negotiation; no second negotiation //! input schema, *are* the negotiation; no second negotiation
//! frame is written on the channel's data stream). Semantic //! frame is written on the channel's data stream). Semantic
//! establishment failures — including allocation failure since //! establishment failures (including allocation failure since
//! alkcall 0.6 — are rejected by the producer's establisher and //! alkcall 0.6) are rejected by the producer's establisher and
//! surface as `channel:open_failed` call errors (alkcall ADR-049 / //! surface as `channel:open_failed` call errors (alkcall ADR-049 /
//! alktty ADR-010, as amended for the 0.6 plan payload; see //! alktty ADR-010, as amended for the 0.6 plan payload; see
//! `open_via_channels`' docs). //! `open_via_channels`' docs).
//! //!
//! The session handle exposes: //! The session handle exposes:
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write //! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`]: write
//! stdin chunks, close stdin (zero-length sentinel). //! stdin chunks, close stdin (zero-length sentinel).
//! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`] — //! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`]:
//! streams of stdout/stderr chunks (`Stream<Item = Bytes>`). //! streams of stdout/stderr chunks (`Stream<Item = Bytes>`).
//! - [`TtySession::resize`] — send a `Resize` control message. //! - [`TtySession::resize`]: send a `Resize` control message.
//! - [`TtySession::signal`] — send a `Signal` control message. //! - [`TtySession::signal`]: send a `Signal` control message.
//! - [`TtySession::wait`] — await the `Exit` control chunk (the //! - [`TtySession::wait`]: await the `Exit` control chunk (the
//! process exit code). //! process exit code).
//! //!
//! The session does NOT re-serialize the negotiation request itself //! The session does NOT re-serialize the negotiation request itself:
//! — the caller passes a `NegotiateRequest` (direct path) or a //! the caller passes a `NegotiateRequest` (direct path) or a
//! `serde_json::Value` (channels path — the open op's `input` is the //! `serde_json::Value` (channels path, where the open op's `input` is the
//! negotiation). The direct path writes the frame and switches to //! negotiation). The direct path writes the frame and switches to
//! raw-chunk mode; the channels path starts in raw-chunk mode. See //! raw-chunk mode; the channels path starts in raw-chunk mode. See
//! ADR-052 for the two-carriage model. //! ADR-052 for the two-carriage model.
@@ -48,7 +48,7 @@
//! //!
//! `TtySession` is wasm-clean (no `tokio::process`, no `std::thread`, //! `TtySession` is wasm-clean (no `tokio::process`, no `std::thread`,
//! no `libc`). The consumer half is exactly the part a browser-side //! no `libc`). The consumer half is exactly the part a browser-side
//! or Python-wasm adapter would use — it runs the wire protocol //! or Python-wasm adapter would use: it runs the wire protocol
//! against a `Connection` the consumer dials (a WebSocket binary //! against a `Connection` the consumer dials (a WebSocket binary
//! stream, a WebTransport bidi stream, etc.). The producer half //! stream, a WebTransport bidi stream, etc.). The producer half
//! (`TtyAdapter` + `register_openable`) runs on a real OS with a //! (`TtyAdapter` + `register_openable`) runs on a real OS with a
@@ -100,7 +100,7 @@ pub enum TtySessionError {
/// `ChannelOpenError` verbatim (alkcall 0.5.0, ADR-049 §4): a /// `ChannelOpenError` verbatim (alkcall 0.5.0, ADR-049 §4): a
/// `CallFailed` variant wraps the wire `CallError`, so a /// `CallFailed` variant wraps the wire `CallError`, so a
/// semantic-establishment failure (unknown backend, malformed /// semantic-establishment failure (unknown backend, malformed
/// negotiation, ownership denial — rejected by the producer's /// negotiation, ownership denial, all rejected by the producer's
/// establisher) surfaces as `channel:open_failed` with /// establisher) surfaces as `channel:open_failed` with
/// `details.reason` branchable via /// `details.reason` branchable via
/// `ChannelOpenError::establishment_reason()`; ACL denial and /// `ChannelOpenError::establishment_reason()`; ACL denial and
@@ -110,7 +110,7 @@ pub enum TtySessionError {
#[error("channels open: {0}")] #[error("channels open: {0}")]
ChannelsOpen(#[from] alkcall::channels::client::ChannelOpenError), ChannelsOpen(#[from] alkcall::channels::client::ChannelOpenError),
/// The channels open op's `params` failed the consumer's local /// The channels open op's `params` failed the consumer's local
/// `NegotiateRequest` parse — the fail-fast check in /// `NegotiateRequest` parse: the fail-fast check in
/// [`TtySession::open_via_channels`] before a channel is allocated. /// [`TtySession::open_via_channels`] before a channel is allocated.
/// (R5: the parse previously surfaced as `NegotiationSerialize`, /// (R5: the parse previously surfaced as `NegotiationSerialize`,
/// a variant whose name and doc describe serializing the /// a variant whose name and doc describe serializing the
@@ -120,9 +120,9 @@ pub enum TtySessionError {
/// The server sent a negotiation error frame (the first frame on /// The server sent a negotiation error frame (the first frame on
/// the stream is a length-prefixed JSON `{"error":"..."}` rather /// the stream is a length-prefixed JSON `{"error":"..."}` rather
/// than a raw chunk). On the channels path this is /// than a raw chunk). On the channels path this is
/// defense-in-depth only — a registered producer rejects every /// defense-in-depth only, because a registered producer rejects every
/// failure class (including allocation, since alkcall 0.6) in the /// failure class (including allocation, since alkcall 0.6) in the
/// establisher as `channel:open_failed` before the reply — but /// establisher as `channel:open_failed` before the reply, but
/// the variant stays reachable (no-establisher registrations, the /// the variant stays reachable (no-establisher registrations, the
/// direct-ALPN path's full in-band vocabulary). /// direct-ALPN path's full in-band vocabulary).
#[error("negotiation rejected: {error}")] #[error("negotiation rejected: {error}")]
@@ -141,12 +141,12 @@ pub enum TtySessionError {
/// The transport refused the session open (the `Connection`'s /// The transport refused the session open (the `Connection`'s
/// `accept_bi` failed on either constructor path). Carries the /// `accept_bi` failed on either constructor path). Carries the
/// upstream error type rather than flattening it into an /// upstream error type rather than flattening it into an
/// `io::Error` (review #003 P15 — additive pre-consumers). /// `io::Error` (review #003 P15, additive pre-consumers).
#[error("session open failed: {0}")] #[error("session open failed: {0}")]
Open(#[from] alkcall::core::StreamError), Open(#[from] alkcall::core::StreamError),
} }
/// A live `alk/tty` session — the typed consumer-side handle. /// A live `alk/tty` session, the typed consumer-side handle.
/// ///
/// Constructed via [`TtySession::connect_direct`] (direct `alk/tty` /// Constructed via [`TtySession::connect_direct`] (direct `alk/tty`
/// ALPN) or [`TtySession::open_via_channels`] (multiplexed over /// ALPN) or [`TtySession::open_via_channels`] (multiplexed over
@@ -217,18 +217,18 @@ impl TtySession {
Self::from_bidi_stream(stream, negotiate).await Self::from_bidi_stream(stream, negotiate).await
} }
/// Open a TTY session via `alk/channels` — the multiplexed path. /// Open a TTY session via `alk/channels`: the multiplexed path.
/// ///
/// The consumer holds a [`ChannelClient`], calls /// The consumer holds a [`ChannelClient`], calls
/// `open_via_channels(client, params)`, which invokes /// `open_via_channels(client, params)`, which invokes
/// `channels/tty/sub` on channel 0, adopts the resulting channel, /// `channels/tty/sub` on channel 0, adopts the resulting channel,
/// builds a `Connection` from the reassembled read half + mux /// builds a `Connection` from the reassembled read half + mux
/// write half, and runs the typed-methods flow directly in raw /// write half, and runs the typed-methods flow directly in raw
/// chunk mode (ADR-009: the open op's `params` — validated by the /// chunk mode (ADR-009: the open op's `params`, validated by the
/// registry's input schema — *are* the negotiation; no second /// registry's input schema, *are* the negotiation; no second
/// negotiation frame is written on the channel's data stream). /// negotiation frame is written on the channel's data stream).
/// ///
/// `params` is the `NegotiateRequest` as a `serde_json::Value` — /// `params` is the `NegotiateRequest` as a `serde_json::Value`:
/// the channels open op takes a `Value`, not a typed struct. The /// the channels open op takes a `Value`, not a typed struct. The
/// session parses it as a `NegotiateRequest` before opening (so a /// session parses it as a `NegotiateRequest` before opening (so a
/// malformed request fails fast, before a channel is allocated) /// malformed request fails fast, before a channel is allocated)
@@ -241,12 +241,12 @@ impl TtySession {
/// establishment failures (a `NegotiateRequest` parse failure of a /// establishment failures (a `NegotiateRequest` parse failure of a
/// schema-valid-but-unparseable params value, `carriage != "raw"`, /// schema-valid-but-unparseable params value, `carriage != "raw"`,
/// empty `cmd`, unknown backend, ownership denial) are rejected by /// empty `cmd`, unknown backend, ownership denial) are rejected by
/// the producer's establisher before the reply — they surface as /// the producer's establisher before the reply; they surface as
/// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose /// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose
/// `CallError` is `channel:open_failed` with /// `CallError` is `channel:open_failed` with
/// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no /// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no
/// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as /// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as
/// amended — allocation moved into the establisher, the allocated /// amended: allocation moved into the establisher, the allocated
/// handle crossing via the `Establishment` plan payload) no /// handle crossing via the `Establishment` plan payload) no
/// failure class arrives in-band on the channels path: allocation /// failure class arrives in-band on the channels path: allocation
/// failure surfaces as `channel:open_failed` with /// failure surfaces as `channel:open_failed` with
@@ -259,7 +259,7 @@ impl TtySession {
) -> Result<Self, TtySessionError> { ) -> Result<Self, TtySessionError> {
// Borrowing deserialize (`&Value` implements `Deserializer`): // Borrowing deserialize (`&Value` implements `Deserializer`):
// fails fast before a channel is allocated, and the owned // fails fast before a channel is allocated, and the owned
// `params` still goes to `open_channel` (review #003 P15 — no // `params` still goes to `open_channel` (review #003 P15, no
// clone). // clone).
use serde::Deserialize as _; use serde::Deserialize as _;
NegotiateRequest::deserialize(&params) NegotiateRequest::deserialize(&params)
@@ -296,7 +296,7 @@ impl TtySession {
/// Like `from_bidi_stream` but takes the channel's `Connection` /// Like `from_bidi_stream` but takes the channel's `Connection`
/// directly (the channels path already has the `Connection` from /// directly (the channels path already has the `Connection` from
/// `Connection::from_source`). The negotiation already happened in /// `Connection::from_source`). The negotiation already happened in
/// the open op (ADR-009) — the stream starts in raw-chunk mode. /// the open op (ADR-009): the stream starts in raw-chunk mode.
async fn from_bidi_stream_via(channel_conn: Connection) -> Result<Self, TtySessionError> { async fn from_bidi_stream_via(channel_conn: Connection) -> Result<Self, TtySessionError> {
let stream = channel_conn.accept_bi().await?; let stream = channel_conn.accept_bi().await?;
let (read, write) = tokio::io::split(stream); let (read, write) = tokio::io::split(stream);
@@ -327,7 +327,7 @@ impl TtySession {
// `{1, 2, 4}` (the server never sends `0` or `3`). If the server // `{1, 2, 4}` (the server never sends `0` or `3`). If the server
// rejected the negotiation, read the error frame and return // rejected the negotiation, read the error frame and return
// `NegotiationRejected`; otherwise hand the peeked reader to the // `NegotiationRejected`; otherwise hand the peeked reader to the
// read pump — `ChunkReader` tracks the peeked byte, so the pump's // read pump; `ChunkReader` tracks the peeked byte, so the pump's
// `read_chunk()` completes that first chunk. // `read_chunk()` completes that first chunk.
match reader.peek_stream_type().await { match reader.peek_stream_type().await {
Ok(0x00) => { Ok(0x00) => {
@@ -345,11 +345,11 @@ impl TtySession {
} }
/// Core inner for the channels path (ADR-009): the negotiation /// Core inner for the channels path (ADR-009): the negotiation
/// already happened in the open op — the stream is already in /// already happened in the open op: the stream is already in
/// raw-chunk mode. The peek still applies as defense-in-depth: a /// raw-chunk mode. The peek still applies as defense-in-depth: a
/// `0x00`-prefixed error frame means the producer rejected the /// `0x00`-prefixed error frame means the producer rejected the
/// session post-open (only reachable from a no-establisher /// session post-open (only reachable from a no-establisher
/// registration — the establisher rejects every semantic class /// registration: the establisher rejects every semantic class
/// before the reply, and allocation runs there too since alkcall /// before the reply, and allocation runs there too since alkcall
/// 0.6 / ADR-010 as amended), while a raw chunk (`stream_type` in /// 0.6 / ADR-010 as amended), while a raw chunk (`stream_type` in
/// `{1, 2, 4}`) is the normal first data. /// `{1, 2, 4}`) is the normal first data.
@@ -379,7 +379,7 @@ impl TtySession {
/// read pump. Shared by `from_halves` and `from_halves_raw`. The /// read pump. Shared by `from_halves` and `from_halves_raw`. The
/// reader may arrive with a peeked first byte (the negotiation /// reader may arrive with a peeked first byte (the negotiation
/// disambiguation peek); `ChunkReader` tracks that state, so the /// disambiguation peek); `ChunkReader` tracks that state, so the
/// pump reads every chunk — including the first — via `read_chunk`. /// pump reads every chunk, including the first, via `read_chunk`.
fn start_pump<R>( fn start_pump<R>(
writer: ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>, writer: ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>,
reader: ChunkReader<R>, reader: ChunkReader<R>,
@@ -404,7 +404,7 @@ impl TtySession {
/// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the /// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the
/// given payload. An empty `bytes` writes a zero-length sentinel /// given payload. An empty `bytes` writes a zero-length sentinel
/// (client stdin EOF — see `tty-wire.md` §"Sentinels"); callers /// (client stdin EOF; see `tty-wire.md` §"Sentinels"); callers
/// that want to signal EOF should use [`Self::close_stdin`] instead, /// that want to signal EOF should use [`Self::close_stdin`] instead,
/// which is explicit. /// which is explicit.
pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> { pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> {
@@ -414,7 +414,7 @@ impl TtySession {
Ok(()) Ok(())
} }
/// Close stdin — send a zero-length stdin chunk (the EOF sentinel) /// Close stdin: send a zero-length stdin chunk (the EOF sentinel)
/// and flush. The server closes the backend's stdin but keeps /// and flush. The server closes the backend's stdin but keeps
/// pumping stdout + the exit chunk (see `tty-wire.md` §"Stdin /// pumping stdout + the exit chunk (see `tty-wire.md` §"Stdin
/// Closure"). /// Closure").
@@ -451,7 +451,7 @@ impl TtySession {
/// Send a `Signal` control message (client→server, /// Send a `Signal` control message (client→server,
/// `STREAM_CTRL_IN`). `name` is an uppercase string from the /// `STREAM_CTRL_IN`). `name` is an uppercase string from the
/// supported set (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`, /// supported set (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`,
/// `USR2`, `TSTP`, `CONT` — see [`crate::control::signal_from_name`]). /// `USR2`, `TSTP`, `CONT`; see [`crate::control::signal_from_name`]).
/// Unknown names are forwarded as-is; the backend decides whether /// Unknown names are forwarded as-is; the backend decides whether
/// to ignore or fall back to its default kill. /// to ignore or fall back to its default kill.
pub async fn signal(&self, name: &str) -> Result<(), TtySessionError> { pub async fn signal(&self, name: &str) -> Result<(), TtySessionError> {
@@ -467,11 +467,11 @@ impl TtySession {
/// Get the stdout stream. Returns a `Stream<Item = Bytes>` that /// Get the stdout stream. Returns a `Stream<Item = Bytes>` that
/// yields stdout chunks as they arrive. The stream ends when the /// yields stdout chunks as they arrive. The stream ends when the
/// server's stdout reaches EOF — the zero-length stdout sentinel /// server's stdout reaches EOF: the zero-length stdout sentinel
/// chunk (`tty-wire.md` §"Sentinels") terminates the stream and is /// chunk (`tty-wire.md` §"Sentinels") terminates the stream and is
/// NOT yielded as an item. /// NOT yielded as an item.
/// ///
/// This consumes the stdout receiver — calling it twice returns /// This consumes the stdout receiver: calling it twice returns
/// an empty stream the second time (the receiver is behind a /// an empty stream the second time (the receiver is behind a
/// `Mutex<Option<...>>` and is taken). /// `Mutex<Option<...>>` and is taken).
pub async fn recv_stdout(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send>> { pub async fn recv_stdout(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send>> {
@@ -488,7 +488,7 @@ impl TtySession {
} }
})); }));
} }
// Already taken — return an empty stream (documented on // Already taken; return an empty stream (documented on
// `recv_stdout`). // `recv_stdout`).
Box::pin(futures::stream::empty()) Box::pin(futures::stream::empty())
} }
@@ -497,7 +497,7 @@ impl TtySession {
/// (stdout/stderr merged into stdout by the kernel PTY), or if /// (stdout/stderr merged into stdout by the kernel PTY), or if
/// already taken. The stream ends when the server's stderr reaches /// already taken. The stream ends when the server's stderr reaches
/// EOF. Stderr has no sentinel on the wire (the adapter's stderr /// EOF. Stderr has no sentinel on the wire (the adapter's stderr
/// pump emits none — only stdout carries the drained sentinel), so /// pump emits none, since only stdout carries the drained sentinel), so
/// this stream ends when the read pump terminates. /// this stream ends when the read pump terminates.
pub async fn recv_stderr(&self) -> Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> { pub async fn recv_stderr(&self) -> Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> {
let mut guard = self.stderr_rx.lock().await; let mut guard = self.stderr_rx.lock().await;
@@ -511,10 +511,10 @@ impl TtySession {
/// code. The exit chunk is the last control chunk before stream /// code. The exit chunk is the last control chunk before stream
/// close (ADR-055). `code` is `i32`: `ExitStatus::code()` on /// close (ADR-055). `code` is `i32`: `ExitStatus::code()` on
/// normal exit; on Unix, the negative signal number on /// normal exit; on Unix, the negative signal number on
/// signal-terminated exit (e.g., `-15` for SIGTERM — the crate's /// signal-terminated exit (e.g., `-15` for SIGTERM; the crate's
/// convention, not `ExitStatus::code()`'s, which returns `None` /// convention, not `ExitStatus::code()`'s, which returns `None`
/// there). `-1` is the adapter's best-effort "backend could not /// there). `-1` is the adapter's best-effort "backend could not
/// determine the exit code" sentinel — colliding with the SIGHUP /// determine the exit code" sentinel, colliding with the SIGHUP
/// encoding on the Unix pipe path (ADR-055 §4). /// encoding on the Unix pipe path (ADR-055 §4).
/// ///
/// Returns [`TtySessionError::NoExitChunk`] if the session ends /// Returns [`TtySessionError::NoExitChunk`] if the session ends
@@ -522,7 +522,7 @@ impl TtySession {
pub async fn wait(&self) -> Result<i32, TtySessionError> { pub async fn wait(&self) -> Result<i32, TtySessionError> {
let mut rx = self.exit_code.clone(); let mut rx = self.exit_code.clone();
// If the pump already resolved before we started waiting, the // If the pump already resolved before we started waiting, the
// watch's current value is `Some(_)` — return it. // watch's current value is `Some(_)`; return it.
{ {
let borrow = rx.borrow(); let borrow = rx.borrow();
if let Some(outcome) = borrow.as_ref() { if let Some(outcome) = borrow.as_ref() {
@@ -615,7 +615,7 @@ where
/// - `STREAM_STDERR` (2) → stderr channel /// - `STREAM_STDERR` (2) → stderr channel
/// - `STREAM_CTRL_OUT` (4) → control message; parses as /// - `STREAM_CTRL_OUT` (4) → control message; parses as
/// `ControlMessage` and, if it's `Exit`, resolves the exit watch /// `ControlMessage` and, if it's `Exit`, resolves the exit watch
/// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3) — client→server only; /// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3), client→server only;
/// the server shouldn't send these, the pump ignores them (with a /// the server shouldn't send these, the pump ignores them (with a
/// debug log) /// debug log)
async fn read_pump<R>( async fn read_pump<R>(
@@ -701,7 +701,7 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use tokio::io::duplex; use tokio::io::duplex;
/// Build a `NegotiateRequest` for tests — minimal valid shape. /// Build a `NegotiateRequest` for tests: minimal valid shape.
fn test_negotiate(backend: &str) -> NegotiateRequest { fn test_negotiate(backend: &str) -> NegotiateRequest {
NegotiateRequest { NegotiateRequest {
carriage: "raw".to_string(), carriage: "raw".to_string(),
@@ -764,7 +764,7 @@ mod tests {
/// A backend whose exit is held back until the test releases it, so /// A backend whose exit is held back until the test releases it, so
/// typed-method writes happen while the session data plane is /// typed-method writes happen while the session data plane is
/// definitively open (the exit-resolves-immediately `MockBackend` /// definitively open (the exit-resolves-immediately `MockBackend`
/// races them against session teardown — review #003 P15's /// races them against session teardown; review #003 P15's
/// `input_pump.abort()` made that race fail loudly). /// `input_pump.abort()` made that race fail loudly).
struct GatedBackend { struct GatedBackend {
release: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>>, release: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>>,
@@ -904,7 +904,7 @@ mod tests {
let len = u32::from_be_bytes(len_buf) as usize; let len = u32::from_be_bytes(len_buf) as usize;
let mut body = vec![0u8; len]; let mut body = vec![0u8; len];
let _ = server.read_exact(&mut body).await; let _ = server.read_exact(&mut body).await;
// Drop `server` — the client's read pump hits EOF. // Drop `server`: the client's read pump hits EOF.
}); });
let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None); let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let session = TtySession::connect_direct(client_conn, test_negotiate("mock")) let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
@@ -1079,14 +1079,13 @@ mod tests {
} }
/// Client→server stream types (0 and 3) arriving from the server are /// Client→server stream types (0 and 3) arriving from the server are
/// protocol violations the read pump ignores — they must be skipped /// protocol violations the read pump ignores; they must be skipped
/// without ending the pump, desynchronizing framing, or disturbing /// without ending the pump, desynchronizing framing, or disturbing
/// the stdout routing. The server interleaves them with real stdout /// the stdout routing. The server interleaves them with real stdout
/// chunks, then sends a well-formed exit; the session should deliver /// chunks, then sends a well-formed exit; the session should deliver
/// exactly the stdout chunks and the exit code. (The first chunk /// exactly the stdout chunks and the exit code. (The first chunk
/// must be a legitimate server→client type: a leading `0x00` byte /// must be a legitimate server→client type: a leading `0x00` byte
/// is the negotiation-error disambiguation marker — ADR-052 §5 — /// is the negotiation-error disambiguation marker (ADR-052 §5) and would surface `NegotiationRejected` before the pump starts.)
/// and would surface `NegotiationRejected` before the pump starts.)
#[tokio::test] #[tokio::test]
async fn read_pump_ignores_client_to_server_stream_types_from_server() { async fn read_pump_ignores_client_to_server_stream_types_from_server() {
let (client, mut server) = duplex(64 * 1024); let (client, mut server) = duplex(64 * 1024);
@@ -1237,13 +1236,13 @@ mod tests {
} }
/// The ADR-049 §5 migration gate: an unknown backend is rejected by /// The ADR-049 §5 migration gate: an unknown backend is rejected by
/// the producer's establisher before the reply — the open op fails /// the producer's establisher before the reply: the open op fails
/// with `channel:open_failed` + `details.reason == "unknown_resource"` /// with `channel:open_failed` + `details.reason == "unknown_resource"`
/// (the SSH "channel never exists opener-side" contract: no /// (the SSH "channel never exists opener-side" contract: no
/// `channel_id`, no session), instead of the pre-0.2.0 post-open /// `channel_id`, no session), instead of the pre-0.2.0 post-open
/// in-band `unknown_backend` negotiation error frame /// in-band `unknown_backend` negotiation error frame
/// (`NegotiationRejected`). The direct-ALPN path keeps the in-band /// (`NegotiationRejected`). The direct-ALPN path keeps the in-band
/// frame (two transports, two contracts — see /// frame (two transports, two contracts; see
/// `connect_direct_returns_negotiation_rejected`). /// `connect_direct_returns_negotiation_rejected`).
#[tokio::test] #[tokio::test]
async fn open_via_channels_surfaces_negotiation_rejected() { async fn open_via_channels_surfaces_negotiation_rejected() {
@@ -1325,7 +1324,7 @@ mod tests {
/// End-to-end with an emitting backend (L2's backend): stdout and /// End-to-end with an emitting backend (L2's backend): stdout and
/// stderr route through the channels data plane to the consumer's /// stderr route through the channels data plane to the consumer's
/// typed streams — the full producer+consumer channels path with /// typed streams: the full producer+consumer channels path with
/// real data. /// real data.
#[tokio::test] #[tokio::test]
async fn open_via_channels_routes_backend_stdout_and_stderr() { async fn open_via_channels_routes_backend_stdout_and_stderr() {
+7 -7
View File
@@ -1,7 +1,7 @@
//! Shared test wiring for the channels-based tests (the producer //! Shared test wiring for the channels-based tests (the producer
//! harness in `channels.rs::tests` and the consumer end-to-end tests //! harness in `channels.rs::tests` and the consumer end-to-end tests
//! in `session.rs::tests` both need the same `ChannelClient` ↔ //! in `session.rs::tests` both need the same `ChannelClient` ↔
//! server wiring). `#[cfg(test)]`-only — never part of the public //! server wiring). `#[cfg(test)]`-only; never part of the public
//! API. //! API.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -67,7 +67,7 @@ pub(crate) async fn wire_client_and_server(
// `resolve_identity` sees it. The // `resolve_identity` sees it. The
// `ChannelsAdapter` builds `channel0_conn` fresh // `ChannelsAdapter` builds `channel0_conn` fresh
// from `channel_source` and does NOT inherit the // from `channel_source` and does NOT inherit the
// outer connection's identity — we set it here // outer connection's identity; we set it here
// so the `AccessControl` scope-gate can check it. // so the `AccessControl` scope-gate can check it.
if let Some(id) = _identity { if let Some(id) = _identity {
let _ = channel0_conn.set_identity(id); let _ = channel0_conn.set_identity(id);
@@ -192,7 +192,7 @@ mod tests {
// The producer writes a 5-byte TTY header (stdout sentinel) // The producer writes a 5-byte TTY header (stdout sentinel)
// almost immediately after the open (MockBackend resolves // almost immediately after the open (MockBackend resolves
// exit right away) — a push-first producer whose first write // exit right away), a push-first producer whose first write
// races the consumer's adopt. Read it: alkcall 0.4.1's // races the consumer's adopt. Read it: alkcall 0.4.1's
// early-arrival park guarantees the first chunks are not lost. // early-arrival park guarantees the first chunks are not lost.
let mut first = [0u8; 5]; let mut first = [0u8; 5];
@@ -209,14 +209,14 @@ mod tests {
/// End-to-end (R4, post-ADR-010): `input` that passes the registry's /// End-to-end (R4, post-ADR-010): `input` that passes the registry's
/// (partial) schema but fails the establisher's full /// (partial) schema but fails the establisher's full
/// `NegotiateRequest` parse — `cwd` typed as a number. The /// `NegotiateRequest` parse (`cwd` typed as a number). The
/// establisher rejects it before the reply: the open op fails with /// establisher rejects it before the reply: the open op fails with
/// `channel:open_failed` + `details.reason == "handler_error"` (the /// `channel:open_failed` + `details.reason == "handler_error"` (the
/// malformed-negotiation mapping), the channel is torn down, and no /// malformed-negotiation mapping), the channel is torn down, and no
/// `channel_id` is ever returned. Uses `ChannelClient::open_channel` /// `channel_id` is ever returned. Uses `ChannelClient::open_channel`
/// directly (the consumer's local fail-fast parse in /// directly (the consumer's local fail-fast parse in
/// `open_via_channels` would reject these params before the open op /// `open_via_channels` would reject these params before the open op
/// runs — R5's fail-fast path). /// runs; R5's fail-fast path).
#[tokio::test] #[tokio::test]
async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() { async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() {
use alkcall::channels::client::ChannelOpenError; use alkcall::channels::client::ChannelOpenError;
@@ -268,8 +268,8 @@ mod tests {
/// longer arrives in-band. `backend.allocate` runs in the /// longer arrives in-band. `backend.allocate` runs in the
/// establisher (the allocated `TtyHandle` crosses to the pump /// establisher (the allocated `TtyHandle` crosses to the pump
/// handler via the `Establishment` plan payload), so its failure /// handler via the `Establishment` plan payload), so its failure
/// fails the open op itself — `channel:open_failed` with /// fails the open op itself (`channel:open_failed` with
/// `details.reason == "dial_failed"` — and no channel survives /// `details.reason == "dial_failed"`), and no channel survives
/// (the SSH contract: no `channel_id`, no `0x00`-prefixed /// (the SSH contract: no `channel_id`, no `0x00`-prefixed
/// in-band frame on a channel that was reported as succeeding). /// in-band frame on a channel that was reported as succeeding).
#[tokio::test] #[tokio::test]
+20 -20
View File
@@ -10,8 +10,8 @@
//! - 0 = stdin (client→server, raw bytes) //! - 0 = stdin (client→server, raw bytes)
//! - 1 = stdout (server→client, raw bytes) //! - 1 = stdout (server→client, raw bytes)
//! - 2 = stderr (server→client, raw bytes) //! - 2 = stderr (server→client, raw bytes)
//! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`]) //! - 3 = ctrl_in (client→server, JSON control message; see [`crate::control`])
//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`]) //! - 4 = ctrl_out (server→client, JSON control message; see [`crate::control`])
//! //!
//! 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: `STREAM_CTRL_IN = 3` carries client→server //! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server
@@ -19,7 +19,7 @@
//! server→client control (exit). The previous single `STREAM_CONTROL = 3` //! server→client control (exit). The previous single `STREAM_CONTROL = 3`
//! was documented as "bidirectional" but the adapter ignored `Exit` from //! was documented as "bidirectional" but the adapter ignored `Exit` from
//! the client because it had no way to distinguish the two directions on //! the client because it had no way to distinguish the two directions on
//! the same stream_type — see the Phase 7 notes in //! the same stream_type. See the Phase 7 notes in
//! `docs/architecture/tty-wire.md` (the bidirectionality fix that //! `docs/architecture/tty-wire.md` (the bidirectionality fix that
//! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3) //! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3)
//! and `STREAM_CTRL_OUT` (4)). //! and `STREAM_CTRL_OUT` (4)).
@@ -27,7 +27,7 @@
//! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF //! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF
//! from the client; a zero-length stdout chunk is "drained" from the //! from the client; a zero-length stdout chunk is "drained" from the
//! server. Control chunks are never zero-length (the JSON payload is at //! server. Control chunks are never zero-length (the JSON payload is at
//! least `{}`). The codec does not special-case sentinels — they are just //! least `{}`). The codec does not special-case sentinels: they are just
//! chunks with `length == 0`; the adapter interprets them. See //! chunks with `length == 0`; the adapter interprets them. See
//! `docs/architecture/tty-wire.md` §"Sentinels". //! `docs/architecture/tty-wire.md` §"Sentinels".
@@ -41,17 +41,17 @@ pub const STREAM_STDIN: u8 = 0;
pub const STREAM_STDOUT: u8 = 1; pub const STREAM_STDOUT: u8 = 1;
/// stderr channel (server→client, raw bytes). /// stderr channel (server→client, raw bytes).
pub const STREAM_STDERR: u8 = 2; pub const STREAM_STDERR: u8 = 2;
/// Control channel, client→server half (JSON control message — /// Control channel, client→server half (JSON control message:
/// `Resize`, `Signal`, `Eof`). /// `Resize`, `Signal`, `Eof`).
pub const STREAM_CTRL_IN: u8 = 3; pub const STREAM_CTRL_IN: u8 = 3;
/// Control channel, server→client half (JSON control message — `Exit`). /// Control channel, server→client half (JSON control message: `Exit`).
pub const STREAM_CTRL_OUT: u8 = 4; pub const STREAM_CTRL_OUT: u8 = 4;
/// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`. /// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`.
pub const CHUNK_HEADER_LEN: usize = 5; pub const CHUNK_HEADER_LEN: usize = 5;
/// Maximum payload length. A larger chunk is a `ChunkTooLarge` protocol /// Maximum payload length. A larger chunk is a `ChunkTooLarge` protocol
/// error. Shared with the negotiation module so error frames (which reuse /// error. Shared with the negotiation module so error frames (which reuse
/// the 4-byte length-prefix framing) stay under 16 MiB — this keeps the /// the 4-byte length-prefix framing) stay under 16 MiB; this keeps the
/// high byte of the length prefix `0x00`, which is what makes the /// high byte of the length prefix `0x00`, which is what makes the
/// framing-disambiguation trick sound (see ADR-052 §5). /// framing-disambiguation trick sound (see ADR-052 §5).
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
@@ -59,7 +59,7 @@ pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
/// Errors from the raw chunk codec. /// Errors from the raw chunk codec.
/// ///
/// `ConnectionClosed` is returned (rather than `Io`) when `read_chunk` /// `ConnectionClosed` is returned (rather than `Io`) when `read_chunk`
/// hits a clean `UnexpectedEof` reading either the header or the payload — /// hits a clean `UnexpectedEof` reading either the header or the payload:
/// the peer closed the stream cleanly rather than failing the transport. /// the peer closed the stream cleanly rather than failing the transport.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RawError { pub enum RawError {
@@ -117,7 +117,7 @@ impl Chunk {
} }
} }
/// A client→server control chunk (stream_type 3) — `Resize`, `Signal`, /// A client→server control chunk (stream_type 3): `Resize`, `Signal`,
/// or `Eof`. /// or `Eof`.
pub fn ctrl_in(bytes: bytes::Bytes) -> Self { pub fn ctrl_in(bytes: bytes::Bytes) -> Self {
Self { Self {
@@ -126,7 +126,7 @@ impl Chunk {
} }
} }
/// A server→client control chunk (stream_type 4) — `Exit`. /// A server→client control chunk (stream_type 4): `Exit`.
pub fn ctrl_out(bytes: bytes::Bytes) -> Self { pub fn ctrl_out(bytes: bytes::Bytes) -> Self {
Self { Self {
stream_type: STREAM_CTRL_OUT, stream_type: STREAM_CTRL_OUT,
@@ -141,7 +141,7 @@ impl Chunk {
/// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the /// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the
/// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]), /// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]),
/// then reads the payload. On a clean `UnexpectedEof` reading either the /// then reads the payload. On a clean `UnexpectedEof` reading either the
/// header or the payload, it returns [`RawError::ConnectionClosed`] — the /// header or the payload, it returns [`RawError::ConnectionClosed`]; the
/// stream ended cleanly, not with a transport error. /// stream ended cleanly, not with a transport error.
/// ///
/// The reader tracks peeked state: after /// The reader tracks peeked state: after
@@ -190,7 +190,7 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
/// consuming the rest of the header. Used by the consumer to /// consuming the rest of the header. Used by the consumer to
/// disambiguate a negotiation error frame (whose 4-byte length prefix /// disambiguate a negotiation error frame (whose 4-byte length prefix
/// starts with `0x00`) from a raw chunk (whose first byte is a /// starts with `0x00`) from a raw chunk (whose first byte is a
/// `stream_type` in `{1, 2, 4}` — the server never sends `0` or `3`). /// `stream_type` in `{1, 2, 4}`; the server never sends `0` or `3`).
/// After peeking, the caller either reads the error frame itself (via /// After peeking, the caller either reads the error frame itself (via
/// [`ChunkReader::into_inner`]) or completes the chunk with /// [`ChunkReader::into_inner`]) or completes the chunk with
/// [`ChunkReader::read_chunk_after_peek`] or /// [`ChunkReader::read_chunk_after_peek`] or
@@ -261,10 +261,10 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
/// Shared header invariant for both codec directions: a `stream_type` in /// Shared header invariant for both codec directions: a `stream_type` in
/// `{0..=4}` and a payload length ≤ [`MAX_CHUNK_LEN`] (which keeps the /// `{0..=4}` and a payload length ≤ [`MAX_CHUNK_LEN`] (which keeps the
/// length prefix's high byte `0x00` — the negotiation-disambiguation /// length prefix's high byte `0x00`, the negotiation-disambiguation
/// invariant, ADR-052 §5). The read path validates peer input (length is /// invariant, ADR-052 §5). The read path validates peer input (length is
/// already a `u32` from the header); the write paths validate caller /// already a `u32` from the header); the write paths validate caller
/// input *before* the length is cast to `u32` — validating the truncated /// input *before* the length is cast to `u32`; validating the truncated
/// value would let a ≥2³² payload wrap past the check and corrupt the /// value would let a ≥2³² payload wrap past the check and corrupt the
/// peer's framing, which is exactly the bug this validation exists to /// peer's framing, which is exactly the bug this validation exists to
/// catch. /// catch.
@@ -291,7 +291,7 @@ fn validate_header(stream_type: u8, length: u64) -> Result<(), RawError> {
/// [`RawError::InvalidStreamType`] / [`RawError::ChunkTooLarge`] instead /// [`RawError::InvalidStreamType`] / [`RawError::ChunkTooLarge`] instead
/// of writing a header the peer cannot frame (a truncated `u32` length /// of writing a header the peer cannot frame (a truncated `u32` length
/// would corrupt the stream). An empty payload writes the same header as /// would corrupt the stream). An empty payload writes the same header as
/// a zero-length payload — every helper takes `&[u8]`-shaped input and /// a zero-length payload. Every helper takes `&[u8]`-shaped input and
/// emits `length == 0` with no payload bytes. /// emits `length == 0` with no payload bytes.
pub struct ChunkWriter<W: AsyncWrite + Unpin> { pub struct ChunkWriter<W: AsyncWrite + Unpin> {
writer: W, writer: W,
@@ -309,7 +309,7 @@ impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
} }
/// Write a chunk: header + payload (if non-empty) + flush. The /// Write a chunk: header + payload (if non-empty) + flush. The
/// chunk's `stream_type` and payload length are validated first — /// chunk's `stream_type` and payload length are validated first:
/// an invalid chunk fails locally with [`RawError::InvalidStreamType`] /// an invalid chunk fails locally with [`RawError::InvalidStreamType`]
/// or [`RawError::ChunkTooLarge`] instead of corrupting the peer's /// or [`RawError::ChunkTooLarge`] instead of corrupting the peer's
/// framing. /// framing.
@@ -483,7 +483,7 @@ mod tests {
} }
/// Write-side validation: a payload over `MAX_CHUNK_LEN` fails with /// Write-side validation: a payload over `MAX_CHUNK_LEN` fails with
/// `ChunkTooLarge` before any header is written — previously the /// `ChunkTooLarge` before any header is written; previously the
/// `as u32` cast would silently write a truncated length and /// `as u32` cast would silently write a truncated length and
/// desynchronize the peer. /// desynchronize the peer.
#[tokio::test] #[tokio::test]
@@ -554,7 +554,7 @@ mod tests {
} }
/// P7 regression: after `peek_stream_type`, plain `read_chunk` must /// P7 regression: after `peek_stream_type`, plain `read_chunk` must
/// complete the already-peeked chunk — not consume a second header /// complete the already-peeked chunk, not consume a second header
/// byte and desynchronize the stream. The old shape silently read /// byte and desynchronize the stream. The old shape silently read
/// the peeked byte's `length` bytes from the *payload* as a header. /// the peeked byte's `length` bytes from the *payload* as a header.
#[tokio::test] #[tokio::test]
@@ -680,7 +680,7 @@ mod tests {
/// the BAST's index→name table matches the on-wire `uint8` values. /// the BAST's index→name table matches the on-wire `uint8` values.
/// Catches the common drift case (a new stream_type added to /// Catches the common drift case (a new stream_type added to
/// `wire.rs` but not the BAST, or vice versa) without requiring /// `wire.rs` but not the BAST, or vice versa) without requiring
/// alktype as a dep — just `serde_json`, which alktty already has. /// alktype as a dep: just `serde_json`, which alktty already has.
/// See the project setup plan's "Risk: BAST schema drift" /// See the project setup plan's "Risk: BAST schema drift"
/// mitigation. /// mitigation.
#[test] #[test]
@@ -727,7 +727,7 @@ mod tests {
assert_eq!( assert_eq!(
values.len(), values.len(),
expected.len(), expected.len(),
"StreamType enum has {} values, wire.rs has {} — drift", "StreamType enum has {} values, wire.rs has {} (drift)",
values.len(), values.len(),
expected.len() expected.len()
); );